Skip to content

Commit 8ae1a2a

Browse files
authored
fix: address 11 review findings (7 critical + 4 high-impact) (#38)
* fix(client): route ProviderTypeVertex to VertexClient (Anthropic-on-Vertex) The registry's ProviderTypeVertex case instantiated NewGeminiClient against config.VertexGeminiBaseURL -- a Gemini-on-Vertex URL. The VertexClient defined in vertex.go (Anthropic-on-Vertex, Name() == "anthropic-vertex") was never instantiated, so any user with Provider("vertex") hit Gemini's wire format at a Gemini-on-Vertex endpoint, producing 4xx. The gemini-vertex deployment kind in setup/deployment.go is unchanged; that path is the explicit "Gemini on Vertex" route and is still served by NewGeminiClient + VertexGeminiBaseURL. Adds client/provider_registry_test.go covering: (1) Vertex branch returns *VertexClient with the correct projectID/region/token/baseURL; (2) region defaults to us-central1; (3) missing VERTEX_PROJECT_ID returns a clear error. Closes: C2 in docs/plans/fix-critical-and-high-review.md * fix(credentials): bound keyring wait time + deduplicate Get path The keyringDo helper spawned a goroutine to wrap the non-ctx-aware go-keyring library. On cancellation, the outer select returned ctx.Err() but the inner goroutine continued running fn() to completion. If the keyring daemon was stuck (e.g. user locked the keyring indefinitely), the goroutine leaked forever and the caller waited forever. Refactor: - Add keyringDoWithTimeout(ctx, fn, timeout) with an early return for already-cancelled contexts (no goroutine spawned) and a hard upper bound (keyringWait = 30s) on the wait. The inner goroutine still runs fn to completion (the keyring library does not accept ctx), but the caller is now released within the bounded window. - Move keyringStore.Get onto keyringDo, eliminating the duplicate inline select+goroutine pattern and the same leak in the Get path. Cannot fully eliminate the inner goroutine without switching keyring libraries; the bound is the best practical mitigation. Tests: keyring_platform_test.go covers normal return, already- cancelled (fn not invoked), prompt release on cancel, ctx timeout, hard timeout, and the Get path. Closes: C6 in docs/plans/fix-critical-and-high-review.md * fix(client): opt-in flag for auto-registering dynamic providers A poisoned OPENAI_API_BASE / OPENAI_BASE_URL in a leaked .envrc or shared shell history would silently auto-register a ghost OpenAI- compatible provider and route requests (with the user's OPENAI_API_KEY header) to an attacker-controlled server. The auto-register path in getOrCreateProvider is now gated on EYRIE_ALLOW_DYNAMIC_PROVIDERS (accepts 1/true/yes, any case). Default is deny: unknown provider names return ErrUnknownProvider. When the opt-in is set and registration fires, a slog.Warn line records the provider name, base URL, and the opt-in env var so the behavior is auditable. Explicit RegisterDynamicProvider calls from user code are unaffected. Migration: users who relied on auto-registration (e.g. local LiteLLM, Ollama on a custom port) should set EYRIE_ALLOW_DYNAMIC_PROVIDERS=1. Tests: provider_registry_test.go covers default-deny, opt-in path, opt-in without base URL, the WARN log, and the env var parser. Closes: C7 in docs/plans/fix-critical-and-high-review.md * feat(client): unify Gemini SSE parsing with shared parser Gemini had a bespoke 4KB streamLoop parser while every other provider uses the shared parseSSEStream (64KB initial buffer, 2MB max) + a per-provider processXxxStream function. Bug fixes to SSE parsing didn't reach Gemini, and a single 5KB event could split mid-codepoint or mid-JSON in the 4KB buffer. This commit: - Adds processGeminiStream, modeled on processAnthropicStream and processOpenAIStream - Routes GeminiClient.StreamChat through the new path - Preserves the original Gemini "done with usage" contract (usage and StopReason combined into one done event when both are present in the final chunk) - Adds EYRIE_GEMINI_SHARED_PARSER=0 opt-out flag to revert to the old streamLoop for one release. The old path will be removed once the new path is validated in production. The old streamLoop and processStreamChunk are kept as the opt-out path, with no behavior change when the opt-out is set. Tests: gemini_stream_test.go covers text, tool call, done with usage, empty stream, context cancel, 200KB large event, the feature flag (default + 0 + false + 1), and direct unit tests of processGeminiStream. Plus a sanity check that the new path doesn't clobber the client's HTTP client. Closes: H1 in docs/plans/fix-critical-and-high-review.md * refactor(client): extract buildAnthropicRequest and buildOpenAIRequest Chat and StreamChat in anthropic.go had ~120 lines of duplicate setup: every field (System, Temperature, TopP, TopK, StopSequences, EnableCaching, Tools, Thinking, Metadata, ServiceTier, OutputConfig) was re-applied in both methods. A drift in either (e.g., adding a new field to Chat but forgetting StreamChat) was silent and only caught in production. openai.go had a smaller ~15-line duplicate of the request construction (the body was already extracted via buildRequestBase). Extracted two helpers: - AnthropicClient.buildAnthropicRequest(ctx, messages, opts, stream) - OpenAIClient.buildOpenAIRequest(ctx, messages, opts, stream) Both take a `stream` bool that controls: - the `stream: true` field in the body - the `Accept: text/event-stream` header Net diff: -28 lines in production (60 insertions, 88 deletions) plus 352 lines of new tests. The key test is TestAnthropic_ChatVsStream_SameBody, which captures the request bodies from both methods via a mock server and asserts they're byte-identical (modulo the `stream` field). This catches drift automatically: any future change that diverges between the two methods will fail this test. Other tests cover the error paths (model required, size limit), the GetBody re-readability needed for the MiMo 401 retry, the system-prompt merging behavior, and the same byte-equality + Accept-header assertions for the OpenAI Chat Completions path. Closes: H2 in docs/plans/fix-critical-and-high-review.md * refactor(client): unify Anthropic response parsing across providers The Anthropic response-parsing logic (text / thinking / redacted_thinking / tool_use extraction, plus usage fields including cache tokens and thinking tokens) was duplicated three times: inlined in anthropic.go Chat, inlined in vertex.go Chat, and as a private responseFromAnthropic helper in bedrock.go. A wire-format change (e.g., a new content block type) required three edits; missing one meant a provider silently dropped blocks (e.g., Bedrock might lose thinking content while Anthropic kept it). Extracted parseAnthropicResponse(ar, requestID, orgID) into anthropic.go (next to the anthropicResponse struct definition). All three call sites now use it: - Anthropic: parseAnthropicResponse(ar, requestID, orgID) - Bedrock: parseAnthropicResponse(ar, reqID, "") - Vertex: parseAnthropicResponse(ar, reqID, "") The orgID parameter is explicit: only Anthropic's wire protocol carries the Anthropic-Organization-Id response header; Bedrock and Vertex pass empty. Net production diff: -40 lines. The redacted_thinking safety invariant (never echo the encrypted blob back to the caller) is now enforced in one place and is enforced by TestParseAnthropicResponse_RedactedThinking. Tests: 11 total (3 renamed from TestResponseFromAnthropic_*, now with +orgID assertion, plus 8 new in anthropic_response_test.go covering thinking, redacted_thinking, mixed content, multiple text/tool blocks, bad JSON in tool_use, and total-tokens calculation). Closes: H3 in docs/plans/fix-critical-and-high-review.md * feat(client): wire *EyrieError into provider error paths The structured *EyrieError type was defined in errors.go with IsRetriable/IsAuthError/IsRateLimited helpers, but no provider returned it — every error path used fmt.Errorf with the "eyrie: <provider> API error (status=N, request_id=X)" format. The structured type was dead code. fallback.isRetriableError worked around this by regex-parsing the error message via types.IsTransient and types.ExtractHTTPStatus — fragile (a log-format change would silently misclassify). This commit: - formatAPIError now returns *EyrieError with a new `op` parameter (chat, stream, count_tokens, embedding) - All 12 call sites updated to pass the op - fallback.isRetriableError now uses errors.As(err, &eyrieErr) + eyrieErr.IsRetriable() as the structured path, with the existing regex path as a fallback for legacy errors (transport errors, before the EyrieError migration) - The IsRetriable/IsAuthError/IsRateLimited helpers are no longer dead code Tests: 3 new (ReturnsEyrieError / AuthError / RetriableCodes) plus 4 updated test assertions to match the new "<provider> <op> failed" format. The IsRateLimited / IsRetriable / IsAuthError predicates are now exercised end-to-end. Closes: H4 in docs/plans/fix-critical-and-high-review.md * docs(eyrie): mark fix-critical-and-high-review plan as complete All 8 items (4 critical + 4 high-impact) are committed and ready for review on PR #38. The plan now has a completion summary table at the top with status, commits, and a net diff summary. Closes: docs tracking for the 30/60/90 plan * fix(client): wire inner error into *EyrieError formatAPIError built an *EyrieError but never set the Err field, so EyrieError.Unwrap() always returned nil. That broke the contract needed for errors.Is / errors.As to traverse the underlying cause (e.g. context.Canceled or an io read error from the provider body). Make parseProviderError return the read error alongside the structured detail, plumb it through formatAPIError as a new inner parameter, and assign it to EyrieError.Err. All seven provider call sites (anthropic, openai, azure, vertex, bedrock chat, bedrock stream, embedding) now propagate the read error so downstream code can inspect it with errors.Is. Also gofmt'd bedrock.go: the Chat() return + closing brace at lines 80-81 were orphaned with extra indentation. The gofmt -w change is naturally part of the bedrock.go call-site update and is folded into this commit. Cover the new contract with TestFormatAPIError_InnerErrorUnwrap, which uses io.ErrUnexpectedEOF as the inner cause and asserts both errors.Is traversal and that the inner message is appended to the rendered error string. Refs: PR #38 review issues #1, #3. * docs(client): clarify doWithRetry's transport-layer scope The plan doc claims doWithRetry was migrated to errors.As, but it operates at the transport layer — only seeing transport errors and raw HTTP status codes from httpClient.Do. It cannot receive an *EyrieError because those are constructed by formatAPIError AFTER the retry loop returns. Add a doc comment pointing readers at fallback.go:240-244, where the structured *EyrieError.IsRetriable()/IsAuthError() checks actually live and drive provider rotation. This accurately describes the architecture and prevents future readers from chasing the same red herring. Refs: PR #38 review issue #2. * refactor(client): rename TestFormatAPIError_OmitsRequestIDWhenEmpty The test name "NoHintFallsBackToDetail" no longer matches what the test actually asserts after the EyrieError migration. The test verifies the error string omits "request_id=" when the caller passes an empty requestID, so rename it to match that contract. Refs: PR #38 review issue #4. * fix(credentials): use NewTimer in keyringDoWithTimeout keyringDoWithTimeout used time.After(timeout) inside the select, which allocates a timer that lives in the runtime until the timeout elapses — even when we return early via the result channel or ctx.Done. Under load (many fast keyring calls with short timeouts) this leaks timer entries until each one expires. Switch to time.NewTimer + defer timer.Stop. Stop is safe whether the timer has already fired or not; if it has fired and the runtime hasn't drained the channel yet, Stop drains it transparently. The keyringWait cap stays the same; only the timer lifecycle changes. Refs: PR #38 review issue #5. * chore(ci): apply gofumpt and wrap bare URLs for markdownlint * chore(lint): fix ineffassign and SA9003 in gemini stream and anthropic test
1 parent ca64bb8 commit 8ae1a2a

22 files changed

Lines changed: 2343 additions & 257 deletions

client/anthropic.go

Lines changed: 87 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,50 @@ type anthropicResponse struct {
217217
} `json:"usage"`
218218
}
219219

220+
// parseAnthropicResponse converts a parsed Anthropic Messages API
221+
// response into an EyrieResponse. Shared by Anthropic, Bedrock, and
222+
// Vertex clients (all three receive the same wire format).
223+
//
224+
// Content blocks are extracted per type:
225+
// - "text" → Content (concatenated)
226+
// - "thinking" → Thinking (concatenated)
227+
// - "redacted_thinking" → skipped silently (safety-sensitive reasoning)
228+
// - "tool_use" → appended to ToolCalls with parsed Arguments
229+
//
230+
// requestID is required. orgID is the Anthropic-Organization-Id
231+
// response header (Anthropic-specific; Bedrock and Vertex pass "").
232+
func parseAnthropicResponse(ar anthropicResponse, requestID, orgID string) *EyrieResponse {
233+
var content, thinkingContent string
234+
var toolCalls []ToolCall
235+
for _, block := range ar.Content {
236+
switch block.Type {
237+
case "text":
238+
content += block.Text
239+
case "thinking":
240+
thinkingContent += block.Thinking
241+
case "redacted_thinking":
242+
// Safety-sensitive reasoning — skip silently
243+
continue
244+
case "tool_use":
245+
var args map[string]interface{}
246+
_ = json.Unmarshal(block.Input, &args)
247+
toolCalls = append(toolCalls, ToolCall{ID: block.ID, Name: block.Name, Arguments: args})
248+
}
249+
}
250+
return &EyrieResponse{
251+
Content: content, Thinking: thinkingContent, FinishReason: ar.StopReason, ToolCalls: toolCalls,
252+
RequestID: requestID, OrganizationID: orgID,
253+
Usage: &EyrieUsage{
254+
PromptTokens: ar.Usage.InputTokens,
255+
CompletionTokens: ar.Usage.OutputTokens,
256+
TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens,
257+
CacheCreationTokens: ar.Usage.CacheCreationInputTokens,
258+
CacheReadTokens: ar.Usage.CacheReadInputTokens,
259+
ThinkingTokens: ar.Usage.OutputTokensDetails.ThinkingTokens,
260+
},
261+
}
262+
}
263+
220264
func buildAnthropicMessages(messages []EyrieMessage) ([]map[string]interface{}, string) {
221265
var system string
222266
msgs := make([]map[string]interface{}, 0, len(messages))
@@ -367,21 +411,27 @@ func audioFormatToMediaType(format string) string {
367411
}
368412

369413
// Chat sends a non-streaming message to Anthropic.
370-
// NOTE: Anthropic does not support a native JSON mode (response_format).
371-
// Structured output with Anthropic is achieved via the tool-use pattern
372-
// (defining a tool whose input_schema is your desired output schema).
373-
// This is not implemented here; opts.ResponseFormat is ignored for Anthropic.
374-
// Future work: implement tool-use-based structured output for Anthropic.
375-
func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) {
414+
// buildAnthropicRequest constructs the request body and http.Request
415+
// for both Chat and StreamChat. If stream is true, the body sets
416+
// `stream: true` and the request gets the `Accept: text/event-stream`
417+
// header. Returns the http.Request (with GetBody set for retry) and
418+
// the raw body bytes (needed by doRequestWithMimoAuthRetry for the
419+
// MiMo 401 retry path).
420+
//
421+
// This helper removes ~120 lines of duplication between Chat and
422+
// StreamChat (lines 375-446 and 496-565 in the previous version):
423+
// every field — System, Temperature, TopP, TopK, StopSequences,
424+
// EnableCaching, tools, thinking, metadata, serviceTier,
425+
// outputConfig — was previously re-applied in both methods.
426+
func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages []EyrieMessage, opts ChatOptions, stream bool) (*http.Request, []byte, error) {
376427
messages = SanitizeMessages(messages)
377428
if opts.Model == "" {
378-
return nil, fmt.Errorf("eyrie: model is required for anthropic")
429+
return nil, nil, fmt.Errorf("eyrie: model is required for anthropic")
379430
}
380431
maxTokens := opts.MaxTokens
381432
if maxTokens == 0 {
382433
maxTokens = 4096
383434
}
384-
385435
thinking := resolveThinking(opts)
386436

387437
var body []byte
@@ -391,7 +441,7 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt
391441
allMessages = append([]EyrieMessage{{Role: "system", Content: opts.System}}, allMessages...)
392442
}
393443
tools := convertToAnthropicTools(opts.Tools)
394-
cachedReq := buildAnthropicCachedRequest(allMessages, opts.Model, maxTokens, opts.Temperature, false, tools,
444+
cachedReq := buildAnthropicCachedRequest(allMessages, opts.Model, maxTokens, opts.Temperature, stream, tools,
395445
thinking, resolveToolChoice(opts.ToolChoice), opts.TopP, opts.TopK, opts.StopSequences)
396446
body, _ = json.Marshal(cachedReq)
397447
} else {
@@ -412,6 +462,7 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt
412462
TopP: opts.TopP,
413463
TopK: opts.TopK,
414464
StopSequences: opts.StopSequences,
465+
Stream: stream,
415466
Tools: convertToAnthropicTools(opts.Tools),
416467
ToolChoice: resolveToolChoice(opts.ToolChoice),
417468
Thinking: thinking,
@@ -424,16 +475,32 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt
424475

425476
// Check request size (32 MB limit for Messages API)
426477
if len(body) > maxAnthropicRequestSize {
427-
return nil, fmt.Errorf("eyrie: request size %d bytes exceeds Anthropic limit of %d bytes", len(body), maxAnthropicRequestSize)
478+
return nil, nil, fmt.Errorf("eyrie: request size %d bytes exceeds Anthropic limit of %d bytes", len(body), maxAnthropicRequestSize)
428479
}
429480

430481
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
431482
if err != nil {
432-
return nil, fmt.Errorf("eyrie: failed to create request: %w", err)
483+
return nil, nil, fmt.Errorf("eyrie: failed to create request: %w", err)
433484
}
434485
c.setHeaders(req)
486+
if stream {
487+
req.Header.Set("Accept", "text/event-stream")
488+
}
435489
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
436490

491+
return req, body, nil
492+
}
493+
494+
// NOTE: Anthropic does not support a native JSON mode (response_format).
495+
// Structured output with Anthropic is achieved via the tool-use pattern
496+
// (defining a tool whose input_schema is your desired output schema).
497+
// This is not implemented here; opts.ResponseFormat is ignored for Anthropic.
498+
// Future work: implement tool-use-based structured output for Anthropic.
499+
func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) {
500+
req, body, err := c.buildAnthropicRequest(ctx, messages, opts, false)
501+
if err != nil {
502+
return nil, err
503+
}
437504
c.logger.Debug("anthropic chat", "model", opts.Model, "caching", opts.EnableCaching)
438505

439506
resp, err := c.doRequestWithMimoAuthRetry(ctx, req, body)
@@ -446,44 +513,16 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt
446513
orgID := resp.Header.Get("Anthropic-Organization-Id")
447514

448515
if resp.StatusCode != 200 {
449-
return nil, formatAPIError("anthropic", resp.StatusCode, requestID, parseProviderError(resp.Body))
516+
detail, readErr := parseProviderError(resp.Body)
517+
return nil, formatAPIError("anthropic", "chat", resp.StatusCode, requestID, detail, readErr)
450518
}
451519

452520
var ar anthropicResponse
453521
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
454522
return nil, fmt.Errorf("eyrie: failed to decode anthropic response: %w", err)
455523
}
456524

457-
var content, thinkingContent string
458-
var toolCalls []ToolCall
459-
for _, block := range ar.Content {
460-
switch block.Type {
461-
case "text":
462-
content += block.Text
463-
case "thinking":
464-
thinkingContent += block.Thinking
465-
case "redacted_thinking":
466-
// Safety-sensitive reasoning — skip silently
467-
continue
468-
case "tool_use":
469-
var args map[string]interface{}
470-
_ = json.Unmarshal(block.Input, &args)
471-
toolCalls = append(toolCalls, ToolCall{ID: block.ID, Name: block.Name, Arguments: args})
472-
}
473-
}
474-
475-
eyrieResp := &EyrieResponse{
476-
Content: content, Thinking: thinkingContent, FinishReason: ar.StopReason, ToolCalls: toolCalls,
477-
RequestID: requestID, OrganizationID: orgID,
478-
Usage: &EyrieUsage{
479-
PromptTokens: ar.Usage.InputTokens,
480-
CompletionTokens: ar.Usage.OutputTokens,
481-
TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens,
482-
CacheCreationTokens: ar.Usage.CacheCreationInputTokens,
483-
CacheReadTokens: ar.Usage.CacheReadInputTokens,
484-
ThinkingTokens: ar.Usage.OutputTokensDetails.ThinkingTokens,
485-
},
486-
}
525+
eyrieResp := parseAnthropicResponse(ar, requestID, orgID)
487526

488527
if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil {
489528
return nil, err
@@ -494,68 +533,10 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt
494533

495534
// StreamChat sends a streaming message to Anthropic.
496535
func (c *AnthropicClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) {
497-
messages = SanitizeMessages(messages)
498-
if opts.Model == "" {
499-
return nil, fmt.Errorf("eyrie: model is required for anthropic")
500-
}
501-
maxTokens := opts.MaxTokens
502-
if maxTokens == 0 {
503-
maxTokens = 4096
504-
}
505-
506-
thinking := resolveThinking(opts)
507-
508-
var body []byte
509-
if opts.EnableCaching {
510-
allMessages := messages
511-
if opts.System != "" {
512-
allMessages = append([]EyrieMessage{{Role: "system", Content: opts.System}}, allMessages...)
513-
}
514-
tools := convertToAnthropicTools(opts.Tools)
515-
cachedReq := buildAnthropicCachedRequest(allMessages, opts.Model, maxTokens, opts.Temperature, true, tools,
516-
thinking, resolveToolChoice(opts.ToolChoice), opts.TopP, opts.TopK, opts.StopSequences)
517-
body, _ = json.Marshal(cachedReq)
518-
} else {
519-
msgs, system := buildAnthropicMessages(messages)
520-
if opts.System != "" {
521-
if system != "" {
522-
system = opts.System + "\n\n" + system
523-
} else {
524-
system = opts.System
525-
}
526-
}
527-
reqBody := anthropicRequest{
528-
Model: opts.Model,
529-
MaxTokens: maxTokens,
530-
Messages: msgs,
531-
System: system,
532-
Temperature: opts.Temperature,
533-
TopP: opts.TopP,
534-
TopK: opts.TopK,
535-
StopSequences: opts.StopSequences,
536-
Stream: true,
537-
Tools: convertToAnthropicTools(opts.Tools),
538-
ToolChoice: resolveToolChoice(opts.ToolChoice),
539-
Thinking: thinking,
540-
Metadata: resolveMetadata(opts),
541-
ServiceTier: opts.ServiceTier,
542-
OutputConfig: resolveOutputConfig(opts),
543-
}
544-
body, _ = json.Marshal(reqBody)
545-
}
546-
547-
// Check request size (32 MB limit for Messages API)
548-
if len(body) > maxAnthropicRequestSize {
549-
return nil, fmt.Errorf("eyrie: request size %d bytes exceeds Anthropic limit of %d bytes", len(body), maxAnthropicRequestSize)
550-
}
551-
552-
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
536+
req, body, err := c.buildAnthropicRequest(ctx, messages, opts, true)
553537
if err != nil {
554-
return nil, fmt.Errorf("eyrie: failed to create request: %w", err)
538+
return nil, err
555539
}
556-
c.setHeaders(req)
557-
req.Header.Set("Accept", "text/event-stream")
558-
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
559540

560541
resp, err := c.doRequestWithMimoAuthRetry(ctx, req, body)
561542
if err != nil {
@@ -565,9 +546,9 @@ func (c *AnthropicClient) StreamChat(ctx context.Context, messages []EyrieMessag
565546
requestID := resp.Header.Get("Request-Id")
566547

567548
if resp.StatusCode != 200 {
568-
detail := parseProviderError(resp.Body)
549+
detail, readErr := parseProviderError(resp.Body)
569550
_ = resp.Body.Close()
570-
return nil, formatAPIError("anthropic", resp.StatusCode, requestID, detail)
551+
return nil, formatAPIError("anthropic", "stream", resp.StatusCode, requestID, detail, readErr)
571552
}
572553

573554
streamCtx, cancel := context.WithCancel(ctx)
@@ -690,7 +671,8 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, messages []EyrieMessa
690671
defer func() { _ = resp.Body.Close() }()
691672

692673
if resp.StatusCode != 200 {
693-
return nil, formatAPIError("anthropic", resp.StatusCode, resp.Header.Get("Request-Id"), parseProviderError(resp.Body))
674+
detail, readErr := parseProviderError(resp.Body)
675+
return nil, formatAPIError("anthropic", "count_tokens", resp.StatusCode, resp.Header.Get("Request-Id"), detail, readErr)
694676
}
695677

696678
var result TokenCountResult

0 commit comments

Comments
 (0)