Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ concurrency:
cancel-in-progress: true

env:
GO_VERSION: "1.26.5"
GO_VERSION: "1.26.6"
GOPRIVATE: "github.com/GrayCodeAI/*"
GONOSUMDB: "github.com/GrayCodeAI/*"
GONOSUMCHECK: "1"
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,50 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:

## [Unreleased]

### Changed — Shared MiMo auth-retry helper (2026-08-16)
- **Deduplicated `doRequestWithMimoAuthRetry`** between the OpenAI and
Anthropic adapters into one `doWithMimoAuthRetry` helper (client/adapters,
next to `mimoAuthHeaders`); the two adapters now differ only in the Bearer
headers they apply to the 401 retry. No behavior change.

### Fixed — Gemini stream request IDs (2026-08-16)
- **Gemini `StreamChat` now propagates the provider request ID.** The client
captured `X-Goog-Request-Id` from the response headers but passed an empty
string to the stream result, so hosts lost the correlation ID on
successful streams (it was only preserved on errors). Both the shared
parser path and the legacy opt-out parser now carry it.

### Fixed — Non-fatal stream diagnostics no longer fail the stream (2026-08-16)
- **Stream health diagnostics are now warnings, not terminal errors.**
`client/core`'s OpenAI stream processor emits end-of-stream diagnostics
(reasoning-only responses, empty responses) as error-type events followed
by the terminal `done` — but the engine mapped *every* error event to
`provider_unavailable`, stopped forwarding, and set `Err()` even though
content had been delivered. Diagnostic events are now marked non-fatal via
the existing `EyrieStreamEvent.Warning` field (additive); the engine
forwards them as `warning` events and still delivers the final
`done`/usage event with `Err()` unset. Genuinely fatal stream errors keep
the previous behavior. The deprecated client continuation helper and the
tracing middleware treat warning-marked events the same way.

### Fixed — Concentrate adapter robustness (2026-08-16)
- **Concentrate Responses client now uses the shared pooled HTTP client**
(`core.NewPooledHTTPClient(core.DefaultTimeout)`) instead of a private
`&http.Client{Timeout: 120s}` literal — long streams are no longer cut off
at 2 minutes and connections reuse the process-wide transport pool like
every other adapter.
- **Concentrate requests are retried via `core.DoWithRetry`** (chat and
stream paths) on 429/500/502/503/529 with backoff and `Retry-After`
support; `SetRetry` previously discarded the config with a comment claiming
the HTTP client handled retries (it never does).
- **Concentrate errors are structured `*core.EyrieError`s** built by
`core.ParseProviderError`/`core.FormatAPIError` (8KB bounded read,
provider/op/status/request-ID preserved), so `IsRetriable()`/`IsAuthError()`
and the engine's error classification work; the captured `X-Request-Id` is
also propagated to stream results.
- **`normalizeToolParams` no longer mutates the caller's tool schema map** —
a shallow copy gets `additionalProperties:false` injected for strict mode.

### Added — Round 3 ecosystem improvements (2026-06-06)
- **Reasoning controls** — `reasoning_effort` and Anthropic extended-thinking
`thinking_budget_tokens` passthrough on `ChatOptions` (omitted when unset).
Expand Down
27 changes: 6 additions & 21 deletions client/adapters/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,27 +629,12 @@ func (c *AnthropicClient) StreamChat(ctx context.Context, messages []core.EyrieM
}

func (c *AnthropicClient) doRequestWithMimoAuthRetry(ctx context.Context, req *http.Request, body []byte) (*http.Response, error) {
resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger)
if err != nil {
return nil, err
}
if !c.useMimoAuth || resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
_ = resp.Body.Close()
req2, err := http.NewRequestWithContext(ctx, req.Method, req.URL.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+c.apiKey)
req2.Header.Set("Anthropic-Version", c.version)
req2.Header.Set("User-Agent", core.UserAgent())
if req.Header.Get("Accept") != "" {
req2.Header.Set("Accept", req.Header.Get("Accept"))
}
req2.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
return core.DoWithRetry(ctx, c.httpClient, req2, c.retry, c.logger)
return doWithMimoAuthRetry(ctx, c.httpClient, c.retry, c.logger, c.useMimoAuth, req, body, func(req2 *http.Request) {
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+c.apiKey)
req2.Header.Set("Anthropic-Version", c.version)
req2.Header.Set("User-Agent", core.UserAgent())
})
}

// Ping checks connectivity to the Anthropic API using a lightweight GET request.
Expand Down
45 changes: 30 additions & 15 deletions client/adapters/concentrate_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ConcentrateResponsesClient struct {
baseURL string
apiKey string
httpClient *http.Client
retry core.RetryConfig
logger *slog.Logger
}

Expand All @@ -35,7 +36,8 @@ func NewConcentrateResponsesClient(apiKey, baseURL string, opts ...core.ClientOp
c := &ConcentrateResponsesClient{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{Timeout: 120 * time.Second},
httpClient: core.NewPooledHTTPClient(core.DefaultTimeout),
retry: core.DefaultRetryConfig(),
logger: slog.Default(),
}
for _, opt := range opts {
Expand Down Expand Up @@ -179,16 +181,19 @@ func (c *ConcentrateResponsesClient) Chat(ctx context.Context, messages []core.E
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", "eyrie-model-catalog/1.0")
httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }

resp, err := c.httpClient.Do(httpReq)
resp, err := core.DoWithRetry(ctx, c.httpClient, httpReq, c.retry, c.logger)
if err != nil {
return nil, fmt.Errorf("concentrate: request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()

requestID := resp.Header.Get("X-Request-Id")

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("concentrate: request failed (%d): %s", resp.StatusCode, string(body))
detail, readErr := core.ParseProviderError(resp.Body)
return nil, core.FormatAPIError("concentrate", "chat", resp.StatusCode, requestID, detail, readErr)
}

var apiResp responsesResponse
Expand Down Expand Up @@ -221,21 +226,24 @@ func (c *ConcentrateResponsesClient) StreamChat(ctx context.Context, messages []
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("User-Agent", "eyrie-model-catalog/1.0")
httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }

resp, err := c.httpClient.Do(httpReq)
resp, err := core.DoWithRetry(streamCtx, c.httpClient, httpReq, c.retry, c.logger)
if err != nil {
cancel()
return nil, fmt.Errorf("concentrate: request failed: %w", err)
return nil, fmt.Errorf("concentrate: stream request failed: %w", err)
}

requestID := resp.Header.Get("X-Request-Id")

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
detail, readErr := core.ParseProviderError(resp.Body)
_ = resp.Body.Close()
cancel()
return nil, fmt.Errorf("concentrate: stream request failed (%d): %s", resp.StatusCode, string(body))
return nil, core.FormatAPIError("concentrate", "stream", resp.StatusCode, requestID, detail, readErr)
}

return c.handleStream(streamCtx, cancel, resp), nil
return c.handleStream(streamCtx, cancel, resp, requestID), nil
}

// Ping checks the health of the Concentrate API.
Expand Down Expand Up @@ -354,6 +362,8 @@ func concentrateToolChoice(choice *core.ToolChoiceOption) interface{} {

// normalizeToolParams ensures tool parameters conform to Concentrate's strict mode
// requirements: additionalProperties must be false at the top level when strict=true.
// The input map is never mutated: a shallow copy is returned so the caller's
// tool definition (which may be reused across requests) stays intact.
// See: https://concentrate.ai/docs/api-reference/endpoint/tool-calling
func normalizeToolParams(params map[string]interface{}) map[string]interface{} {
if params == nil {
Expand All @@ -362,7 +372,12 @@ func normalizeToolParams(params map[string]interface{}) map[string]interface{} {
// Only enforce for object-typed schemas
if t, ok := params["type"]; ok && t == "object" {
if _, has := params["additionalProperties"]; !has {
params["additionalProperties"] = false
normalized := make(map[string]interface{}, len(params)+1)
for k, v := range params {
normalized[k] = v
}
normalized["additionalProperties"] = false
return normalized
}
}
return params
Expand Down Expand Up @@ -517,7 +532,7 @@ type streamEvent struct {
ContentIndex int `json:"content_index,omitempty"`
}

func (c *ConcentrateResponsesClient) handleStream(ctx context.Context, cancel context.CancelFunc, resp *http.Response) *core.StreamResult {
func (c *ConcentrateResponsesClient) handleStream(ctx context.Context, cancel context.CancelFunc, resp *http.Response, requestID string) *core.StreamResult {
events := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer)

go func() {
Expand Down Expand Up @@ -639,7 +654,7 @@ func (c *ConcentrateResponsesClient) handleStream(ctx context.Context, cancel co
}
}()

return llm.NewStreamResult(events, "", cancel)
return llm.NewStreamResult(events, requestID, cancel)
}

func sendConcentrateStreamEvent(ctx context.Context, events chan<- core.EyrieStreamEvent, event core.EyrieStreamEvent) bool {
Expand Down Expand Up @@ -722,7 +737,7 @@ func (c *ConcentrateResponsesClient) SetHTTPClient(hc *http.Client) {

// SetRetry implements core.Configurable.
func (c *ConcentrateResponsesClient) SetRetry(rc core.RetryConfig) {
// Retries handled at HTTP client level
c.retry = rc
}

// SetLogger implements core.Configurable.
Expand Down Expand Up @@ -752,7 +767,7 @@ func (c *ConcentrateResponsesClient) HTTPClient() *http.Client {

// Retry implements core.Configurable.
func (c *ConcentrateResponsesClient) Retry() core.RetryConfig {
return core.RetryConfig{}
return c.retry
}

// Logger implements core.Configurable.
Expand Down
Loading
Loading