-
Notifications
You must be signed in to change notification settings - Fork 530
fix(anthropic): complete AgentRouter streams that end before terminal frames #896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | |
| | `thinkingBudgetModels?` | `string[]` | Chat models using integer `thinking_budget`; effort maps to a budget fraction. | | ||
| | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | | ||
| | `escapeBuiltinToolNames?` | `boolean` | Escape built-in tool names for Anthropic-compatible gateways and restore them in returned calls. | | ||
| | `anthropicEofTolerance?` | `boolean` | Let an Anthropic-compatible gateway complete a stream that ends before `message_stop`, only when visible text or a complete JSON-object tool input was received. Off by default. | | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an operator enables AGENTS.md reference: docs-site/AGENTS.md:L7-L10 Useful? React with 👍 / 👎. |
||
| | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | | ||
| | `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | | ||
| | `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -277,14 +277,50 @@ function usableToolUseId(id: unknown): string { | |
| return typeof id === "string" && id.trim() ? id : synthesizeToolUseId(); | ||
| } | ||
|
|
||
| function toolUseArguments(input: unknown): string { | ||
| /** | ||
| * Bound repair for a malformed tool-arguments string under the compatibility profile (#658): | ||
| * a gateway such as AgentRouter can concatenate JSON objects (`{}{"value":42}`). Find the | ||
| * last parseable JSON object by scanning suffixes from each object-open brace and prefixes | ||
| * ending at each object-close brace, bounded so hostile input cannot cost unbounded time. | ||
| */ | ||
| function lastValidJsonObject(input: string, maxCandidates: number): string | undefined { | ||
| const opens: number[] = []; | ||
| const closes: number[] = []; | ||
| for (let i = 0; i < input.length; i++) { | ||
| if (input[i] === "{") opens.push(i); | ||
| else if (input[i] === "}") closes.push(i); | ||
| } | ||
| let tried = 0; | ||
| for (let i = opens.length - 1; i >= 0 && tried < maxCandidates; i--, tried++) { | ||
| const candidate = input.slice(opens[i]); | ||
|
Comment on lines
+294
to
+295
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tolerant non-stream response contains a large malformed tool-input string with many braces, each of up to 32 suffix candidates is a near-full copy that Useful? React with 👍 / 👎. |
||
| try { | ||
| const parsed = JSON.parse(candidate) as unknown; | ||
| if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return candidate; | ||
|
Comment on lines
+294
to
+298
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the final concatenated object is itself truncated but ends with a complete nested object, scanning every opening brace accepts that nested value as the repaired tool arguments. For example, AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } catch { /* keep scanning */ } | ||
| } | ||
| tried = 0; | ||
| for (let i = closes.length - 1; i >= 0 && tried < maxCandidates; i--, tried++) { | ||
| const candidate = input.slice(0, closes[i] + 1); | ||
| try { | ||
| const parsed = JSON.parse(candidate) as unknown; | ||
| if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return candidate; | ||
| } catch { /* keep scanning */ } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function toolUseArguments(input: unknown, lenient = false): string { | ||
| if (typeof input === "string") { | ||
| const trimmed = input.trim(); | ||
| if (!trimmed) return "{}"; | ||
| try { | ||
| JSON.parse(trimmed); | ||
| return trimmed; | ||
| } catch { | ||
| if (lenient) { | ||
| const repaired = lastValidJsonObject(trimmed, 32); | ||
| if (repaired !== undefined) return repaired; | ||
| } | ||
| // A tool call's arguments must be a JSON object. Re-encoding an unparseable string as a | ||
| // JSON *string* is the double-encoding #765 reports: the caller then receives | ||
| // `"get weather"` where an object was required and the tool call is unusable either way. | ||
|
|
@@ -798,6 +834,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti | |
| let pendingUsage: Record<string, number> | undefined; | ||
| let pendingStopReason: string | undefined; | ||
| let emittedDone = false; | ||
| let sawVisibleText = false; | ||
|
|
||
| const emitDone = function* (): Generator<AdapterEvent> { | ||
| if (emittedDone) return; | ||
|
|
@@ -853,6 +890,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti | |
| const delta = data.delta as Record<string, unknown> | undefined; | ||
| if (!delta) break; | ||
| if (delta.type === "text_delta" && typeof delta.text === "string") { | ||
| sawVisibleText = true; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a tolerant gateway emits AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| yield { type: "text_delta", text: delta.text }; | ||
| } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { | ||
| yield { type: "thinking_delta", thinking: delta.thinking }; | ||
|
|
@@ -951,6 +989,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti | |
| usage: usageFromAnthropic(pendingUsage), | ||
| ...(stopReason ? { stopReason } : {}), | ||
| }; | ||
| } else if (provider.anthropicEofTolerance === true) { | ||
| // AgentRouter-style compatibility profile (#658): the upstream can close the stream | ||
| // after valid content without terminal frames. Complete only when visible text was | ||
| // received or an open tool call has complete JSON-object arguments; everything else | ||
| // (incomplete tool JSON, no usable content, transport failure) stays a truncation | ||
| // error, matching the strict default. | ||
| if (currentToolCallId) { | ||
| if (streamedToolArgumentsParse(currentToolCallJson)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tolerant tool stream ends with a syntactically valid scalar or array such as AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| budget.closeCall(currentToolCallId); | ||
| currentToolCallId = ""; | ||
| yield { type: "tool_call_end" }; | ||
| yield* emitDone(); | ||
|
Comment on lines
+999
to
+1003
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tolerant stream exceeds the translator budget after opening a tool call, the catch at lines 966-974 emits AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } else { | ||
| yield { type: "error", message: "upstream stream ended before message_stop — possible truncation" }; | ||
| } | ||
| } else if (sawVisibleText) { | ||
| yield* emitDone(); | ||
|
Comment on lines
+1007
to
+1008
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tool-only AgentRouter stream sends valid arguments and AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎.
Comment on lines
+1007
to
+1008
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tolerant stream emits valid text and its next SSE record is truncated or contains malformed JSON, the parse failure at lines 858-863 is only debug-dropped, so this branch treats the ensuing EOF as clean and emits AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } else { | ||
| yield { type: "error", message: "upstream stream ended before message_stop — possible truncation" }; | ||
|
Comment on lines
+992
to
+1010
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Implement the documented EOF completion predicate. A valid tool call that receives
Based on PR objectives, only visible text or a complete JSON object may permit early completion. 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
| } else { | ||
| yield { type: "error", message: "upstream stream ended before message_stop — possible truncation" }; | ||
| } | ||
|
|
@@ -980,7 +1038,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti | |
| } else if (block.type === "tool_use") { | ||
| const id = usableToolUseId(block.id); | ||
| events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") }); | ||
| events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) }); | ||
| events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input, provider.anthropicEofTolerance === true) }); | ||
| events.push({ type: "tool_call_end" }); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,8 @@ export interface FreeDirectoryProvider { | |
| keyOptional?: boolean; | ||
| models?: string[]; | ||
| liveModels: boolean; | ||
| /** Anthropic-compatible gateways that may close streams before terminal frames. */ | ||
| anthropicEofTolerance?: boolean; | ||
| note?: string; | ||
| googleMode?: "ai-studio" | "vertex"; | ||
| } | ||
|
|
@@ -116,7 +118,7 @@ const CONNECTABLE: Record<string, ConnectableOverride> = { | |
| // `unverified` and drops the shared verification date rather than borrowing it. | ||
| bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", lastVerified: undefined, documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }), | ||
| "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }), | ||
| agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", lastVerified: LAST_VERIFIED, discovery: "live", liveModels: true }, | ||
| agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", lastVerified: LAST_VERIFIED, discovery: "live", liveModels: true, anthropicEofTolerance: true }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For existing AgentRouter configurations that do not manually add AGENTS.md reference: src/AGENTS.md:L18-L18 Useful? React with 👍 / 👎. |
||
| ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }), | ||
| baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }), | ||
| // Verified end-to-end 2026-07-30: /v1/models returns the OpenAI-shaped live catalog (13 models), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic"; | ||
| import { FREE_PROVIDER_DIRECTORY } from "../src/providers/free-directory"; | ||
| import type { AdapterEvent, OcxProviderConfig } from "../src/types"; | ||
| import { withTestTranslatorBudget } from "./helpers/translator-budget"; | ||
|
|
||
| /** | ||
| * #658: AgentRouter's Anthropic-compatible endpoint can close the stream before | ||
| * `content_block_stop`, `message_delta`, and `message_stop`. The default adapter treats | ||
| * that EOF as a fatal truncation; with `anthropicEofTolerance` enabled it may complete | ||
| * only when visible text was received or an open tool call has complete JSON-object | ||
| * arguments. These tests pin the wire behavior; no request reaches agentrouter.org. | ||
| */ | ||
|
|
||
| const createAnthropicAdapter = (...args: Parameters<typeof createAnthropicAdapterProduction>) => | ||
| withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); | ||
|
|
||
| function providerFor(extra: Partial<OcxProviderConfig> = {}): OcxProviderConfig { | ||
| return { | ||
| adapter: "anthropic", | ||
| baseUrl: "https://agentrouter.org", | ||
| apiKey: "test-key", | ||
| authMode: "key", | ||
| ...extra, | ||
| } as OcxProviderConfig; | ||
| } | ||
|
|
||
| const strict = providerFor(); | ||
| const tolerant = providerFor({ anthropicEofTolerance: true }); | ||
|
|
||
| const TRUNCATION = "upstream stream ended before message_stop — possible truncation"; | ||
|
|
||
| function sseResponse(events: string[]): Response { | ||
| return new Response(events.join("\n\n"), { headers: { "content-type": "text/event-stream" } }); | ||
| } | ||
|
|
||
| async function collect(provider: OcxProviderConfig, events: string[]): Promise<AdapterEvent[]> { | ||
| const out: AdapterEvent[] = []; | ||
| for await (const event of createAnthropicAdapter(provider).parseStream(sseResponse(events))) out.push(event); | ||
| return out; | ||
| } | ||
|
|
||
| const textEof = [ | ||
| 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":2}}}', | ||
| 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', | ||
| 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"visible"}}', | ||
| ]; | ||
|
|
||
| function toolEof(partialJson: string, id = "toolu_1"): string[] { | ||
| return [ | ||
| 'event: message_start\ndata: {"type":"message_start","message":{}}', | ||
| `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "tool_use", id, name: "get_weather" } })}`, | ||
| `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: partialJson } })}`, | ||
| ]; | ||
| } | ||
|
|
||
| describe("AgentRouter Anthropic EOF tolerance (#658)", () => { | ||
| test("text EOF completes when anthropicEofTolerance is enabled", async () => { | ||
| const events = await collect(tolerant, textEof); | ||
|
|
||
| expect(events).toContainEqual({ type: "text_delta", text: "visible" }); | ||
| expect(events.at(-1)).toEqual({ type: "done", usage: { inputTokens: 2, outputTokens: 0 } }); | ||
| expect(events.some(event => event.type === "error")).toBe(false); | ||
| }); | ||
|
|
||
| test("the same EOF without the capability stays a truncation error", async () => { | ||
| const events = await collect(strict, textEof); | ||
|
|
||
| expect(events.at(-1)).toEqual({ type: "error", message: TRUNCATION }); | ||
| expect(events.some(event => event.type === "done")).toBe(false); | ||
| }); | ||
|
|
||
| test("a complete tool call at EOF closes and completes", async () => { | ||
| const events = await collect(tolerant, toolEof('{"value":42}')); | ||
|
|
||
| expect(events).toContainEqual({ type: "tool_call_start", id: "toolu_1", name: "get_weather" }); | ||
| expect(events).toContainEqual({ type: "tool_call_delta", arguments: '{"value":42}' }); | ||
| expect(events.at(-1)).toEqual({ type: "done", usage: undefined }); | ||
| expect(events.some(event => event.type === "error")).toBe(false); | ||
| }); | ||
|
|
||
| test("an incomplete tool call at EOF remains a truncation error", async () => { | ||
| const events = await collect(tolerant, toolEof('{"value":')); | ||
|
|
||
| expect(events.at(-1)).toEqual({ type: "error", message: TRUNCATION }); | ||
| expect(events.some(event => event.type === "done" || event.type === "tool_call_end")).toBe(false); | ||
| }); | ||
|
|
||
| test("EOF before any usable content remains a truncation error", async () => { | ||
| const events = await collect(tolerant, [ | ||
| 'event: message_start\ndata: {"type":"message_start","message":{}}', | ||
| ]); | ||
|
|
||
| expect(events.at(-1)).toEqual({ type: "error", message: TRUNCATION }); | ||
| }); | ||
|
|
||
| test("a missing tool_use id gets a stable synthesized id on the tolerant path", async () => { | ||
| const events = await collect(tolerant, toolEof('{"value":42}', "")); | ||
| const start = events.find(event => event.type === "tool_call_start"); | ||
|
|
||
| expect(start?.type).toBe("tool_call_start"); | ||
| expect((start as { id: string }).id).toMatch(/^toolu_[0-9a-f]{24}$/); | ||
| expect(events.at(-1)).toEqual({ type: "done", usage: undefined }); | ||
| }); | ||
|
|
||
| test("non-stream concatenated tool input keeps the last valid object when enabled", async () => { | ||
| const payload = JSON.stringify({ | ||
| content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: '{}{"value":42}' }], | ||
| }); | ||
|
|
||
| const tolerantEvents = await createAnthropicAdapter(tolerant).parseResponse(new Response(payload)); | ||
| expect(tolerantEvents).toContainEqual({ type: "tool_call_delta", arguments: '{"value":42}' }); | ||
|
|
||
| const strictEvents = await createAnthropicAdapter(strict).parseResponse(new Response(payload)); | ||
| expect(strictEvents).toContainEqual({ type: "tool_call_delta", arguments: "{}" }); | ||
| }); | ||
|
|
||
| test("the AgentRouter directory row declares the EOF tolerance capability", () => { | ||
| const row = FREE_PROVIDER_DIRECTORY.find(provider => provider.id === "agentrouter"); | ||
| expect(row?.anthropicEofTolerance).toBe(true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 3674
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 50378
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 11037
Document Anthropic Messages streaming and EOF behavior.
docs-site/src/content/docs/reference/adapters.md:54-66omits the supported SSE lifecycle events andanthropicEofTolerancebehavior. Documentmessage_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop, anderror. State that strict EOF handling fails on truncation, while enabled tolerance allows early completion only after visible text or complete JSON-object tool input; incomplete or contentless streams remain errors.🤖 Prompt for AI Agents
Source: Path instructions