Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ description: プロバイダー エントリ、認証、エンドポイント、
| `thinkingBudgetModels?` | `string[]` |整数 `thinking_budget` を使用したチャット モデル。労力は予算の一部にマッピングされます。 |
| `noVisionModels?` | `string[]` |ビジョン サイドカーを通じて送信されるテキストのみのモデル。マッチングでは、Ollama `:size` タグが許容されます。 |
| `escapeBuiltinToolNames?` | `boolean` | Anthropic 互換ゲートウェイの組み込みツール名をエスケープし、返された呼び出しで復元します。 |
| `anthropicEofTolerance?` | `boolean` | `message_stop` 前にストリームが終了しても、可視テキストまたは完全な JSON オブジェクトのツール入力が受信済みの場合に限り完了を許可します(Anthropic 互換ゲートウェイ向け)。デフォルトはオフ。 |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google トランスポート/認証モード。デフォルトは`ai-studio`です。 |
| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。 |
| `location?` | `string` |頂点の位置。環境フォールバックは `GOOGLE_CLOUD_LOCATION` です。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할
| `thinkingBudgetModels?` | `string[]` | 정수 `thinking_budget`를 쓰는 chat 모델입니다. effort는 예산 비율로 매핑됩니다. |
| `noVisionModels?` | `string[]` | vision sidecar로 보내는 텍스트 전용 모델입니다. 일치 판정은 Ollama `:size` 태그도 허용합니다. |
| `escapeBuiltinToolNames?` | `boolean` | Anthropic 호환 게이트웨이를 위해 내장 도구 이름을 이스케이프하고, 반환된 호출에서는 다시 복원합니다. |
| `anthropicEofTolerance?` | `boolean` | `message_stop` 전에 스트림이 끝나도 표시 텍스트 또는 완전한 JSON 객체 툴 입력을 받은 경우에만 완료를 허용합니다(Anthropic 호환 게이트웨이용). 기본값은 꺼짐. |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 전송/인증 모드입니다. 기본값은 `ai-studio`입니다. |
| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. |
| `location?` | `string` | Vertex 위치입니다. 환경 변수 폴백은 `GOOGLE_CLOUD_LOCATION`입니다. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'Anthropic|Messages API|message_stop|content_block_stop|SSE|EOF|tool JSON' \
  docs-site/src/content/docs/reference/adapters.md

Repository: lidge-jun/opencodex

Length of output: 3674


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- adapter documentation ---'
cat -n docs-site/src/content/docs/reference/adapters.md | sed -n '54,72p'

printf '%s\n' '--- provider reference ---'
cat -n docs-site/src/content/docs/reference/configuration/providers.md | sed -n '86,100p'

printf '%s\n' '--- anthropicEofTolerance references ---'
rg -n -C 4 'anthropicEofTolerance|message_stop|content_block_stop|input_json_delta' \
  --glob '!node_modules' --glob '!dist' --glob '!build' .

printf '%s\n' '--- candidate Anthropic adapter files ---'
fd -i 'anthropic' . --type f | head -80

Repository: lidge-jun/opencodex

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Anthropic parser event cases ---'
sed -n '850,1020p' src/adapters/anthropic.ts

printf '%s\n' '--- adapter documentation context ---'
cat -n docs-site/src/content/docs/reference/adapters.md | sed -n '54,70p'

printf '%s\n' '--- required documentation terms ---'
python3 - <<'PY'
from pathlib import Path

doc = Path("docs-site/src/content/docs/reference/adapters.md").read_text()
terms = [
    "Messages API",
    "message_start",
    "content_block_start",
    "content_block_delta",
    "content_block_stop",
    "message_delta",
    "message_stop",
    "error",
    "anthropicEofTolerance",
    "strict",
    "truncation",
    "complete JSON",
]
for term in terms:
    print(f"{term}: {'present' if term in doc else 'missing'}")
PY

Repository: lidge-jun/opencodex

Length of output: 11037


Document Anthropic Messages streaming and EOF behavior.

docs-site/src/content/docs/reference/adapters.md:54-66 omits the supported SSE lifecycle events and anthropicEofTolerance behavior. Document message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, and error. 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/reference/configuration/providers.md` at line 94,
Update the Anthropic Messages streaming documentation in the adapters reference
to list the supported SSE lifecycle events: message_start, content_block_start,
content_block_delta, content_block_stop, message_delta, message_stop, and error.
Document that strict EOF handling fails truncated streams, while
anthropicEofTolerance permits early completion only after visible text or
complete JSON-object tool input; incomplete or contentless streams must still
fail.

Source: Path instructions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the non-stream repair controlled by this option

When an operator enables anthropicEofTolerance, parseResponse() also changes non-stream behavior by extracting a JSON object from malformed string-valued tool input, but this row—and the matching translated rows—describes only early stream completion. Users therefore cannot tell that the option can alter and complete tool calls on ordinary non-stream requests. Document the repair behavior in every locale or separate it behind a configuration key whose scope matches the documented EOF behavior.

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`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ cross-route credential fallback не существует. Строки API GPT-
| `thinkingBudgetModels?` | `string[]` | Chat-модели, использующие целочисленный `thinking_budget`; effort отображается в долю бюджета. |
| `noVisionModels?` | `string[]` | Text-only-модели, идущие через vision sidecar; при сопоставлении tolerируется тег Ollama вида `:size`. |
| `escapeBuiltinToolNames?` | `boolean` | Экранировать built-in tool name'ы для Anthropic-compatible gateway'ев и восстанавливать их в возвращаемых call'ах. |
| `anthropicEofTolerance?` | `boolean` | Позволяет Anthropic-совместимому шлюзу завершить поток до `message_stop`, только если получен видимый текст или полный JSON-объект аргументов инструмента. По умолчанию выключено. |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Режим транспорта/аутентификации Google. По умолчанию `ai-studio`. |
| `project?` | `string` | Идентификатор проекта Vertex или Antigravity Cloud Code Assist. |
| `location?` | `string` | Локация Vertex; fallback через окружение — `GOOGLE_CLOUD_LOCATION`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、
| `thinkingBudgetModels?` | `string[]` | 使用整数 `thinking_budget` 的 chat 模型;effort 会映射为预算比例。 |
| `noVisionModels?` | `string[]` | 经由视觉 sidecar 发送的纯文本模型;匹配时会容忍 Ollama 的 `:size` 标记。 |
| `escapeBuiltinToolNames?` | `boolean` | 为 Anthropic 兼容网关转义内置工具名,并在返回的调用中恢复。 |
| `anthropicEofTolerance?` | `boolean` | 允许 Anthropic 兼容网关在 `message_stop` 前结束流,仅当已收到可见文本或完整的 JSON 对象工具输入时。默认关闭。 |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 传输/身份验证模式。默认 `ai-studio`。 |
| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 项目 id。 |
| `location?` | `string` | Vertex 位置;环境变量回退为 `GOOGLE_CLOUD_LOCATION`。 |
Expand Down
62 changes: 60 additions & 2 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the size scanned by JSON repair

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 JSON.parse scans synchronously, followed by up to 32 more prefix parses; the initial loop also retains every brace offset. A 4 MiB all-{ input already spends roughly half a second in this path, and inputs near the 32 MiB translator limit can block Bun's event loop for several seconds per request. Cap the repairable input size or replace the repeated suffix parsing with a single bounded top-level scan.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict repair candidates to top-level concatenated objects

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, {}{"outer":{"value":42} is converted to {"value":42} and emitted as a completed tool call even though the second top-level object never closed. Parse top-level object boundaries with string/escape awareness so nested fragments from incomplete payloads cannot become executable arguments.

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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not count empty text deltas as visible output

If a tolerant gateway emits text_delta with text: "" and then closes, this assignment marks the response usable and the EOF branch returns done with an empty answer. That contradicts the stated requirement that EOF without usable content remain a truncation error. Set sawVisibleText only when the delta actually contains visible content.

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 };
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require streamed tool arguments to be a JSON object

When a tolerant tool stream ends with a syntactically valid scalar or array such as null, 42, or [], streamedToolArgumentsParse() returns true and this branch emits tool_call_end followed by done. The capability contract and documentation require a complete JSON object, so these values should remain truncation/protocol errors instead of becoming completed function calls. Validate that the parsed result is non-null, object-shaped, and not an array before accepting EOF.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop after a translator-budget terminal error

When a tolerant stream exceeds the translator budget after opening a tool call, the catch at lines 966-974 emits translation_buffer_limit but execution continues here; if the overflow happened before the fragment was retained, the empty argument buffer is considered valid and the adapter subsequently emits tool_call_end and done. Direct or unguarded consumers therefore receive a successful terminal after a terminal error, potentially completing an empty tool call. Record the failure or return immediately after emitting the budget error so EOF tolerance cannot run.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept completed tool blocks when only message_stop is missing

When a tool-only AgentRouter stream sends valid arguments and content_block_stop but then closes before message_delta/message_stop, the normal block-stop handling has already cleared currentToolCallId; this branch therefore falls through to the truncation error because no text was emitted. This is a cleaner and more complete tool call than the open-tool case accepted above, yet the advertised tolerance still rejects it. Track that a usable tool call completed and allow that state to reach emitDone().

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Comment on lines +1007 to +1008

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject EOF after malformed SSE records

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 done; for example, a cut-off second text_delta silently loses that text while completing the response. Track whether any malformed record was dropped and keep EOF strict in that state rather than accepting a demonstrably damaged stream.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 content_block_stop clears currentToolCallId before Line 1007. The EOF path then reports truncation even though it already emitted tool_call_end. Conversely, streamedToolArgumentsParse("") returns true and also accepts arrays, scalars, and null, so an open tool call without a complete JSON object can incorrectly complete. An empty text_delta also sets sawVisibleText at Line 893.

  • src/adapters/anthropic.ts#L992-L1010: Track completed tool calls separately. At EOF, accept an open tool call only if its trimmed arguments parse to a non-null, non-array JSON object.
  • src/adapters/anthropic.ts#L892-L894: Set the visible-text state only for non-empty text.
  • tests/anthropic-eof-tolerance.test.ts#L57-L104: Add cases for a tool call closed before EOF, missing or non-object tool JSON, and an empty text delta.

Based on PR objectives, only visible text or a complete JSON object may permit early completion.

📍 Affects 2 files
  • src/adapters/anthropic.ts#L992-L1010 (this comment)
  • src/adapters/anthropic.ts#L892-L894
  • tests/anthropic-eof-tolerance.test.ts#L57-L104
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/anthropic.ts` around lines 992 - 1010, Track completed tool
calls separately from currentToolCallId in the EOF handling around the Anthropic
stream flow, and allow early completion only for visible non-empty text or open
tool arguments that parse to a non-null, non-array JSON object; update
src/adapters/anthropic.ts lines 992-1010 accordingly, while preserving
already-emitted tool_call_end state. In src/adapters/anthropic.ts lines 892-894,
set sawVisibleText only when the text delta is non-empty. Add coverage in
tests/anthropic-eof-tolerance.test.ts lines 57-104 for pre-EOF tool closure,
missing or non-object tool JSON, and empty text deltas.

}
} else {
yield { type: "error", message: "upstream stream ended before message_stop — possible truncation" };
}
Expand Down Expand Up @@ -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" });
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/providers/free-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire AgentRouter tolerance into the runtime provider flow

For existing AgentRouter configurations that do not manually add anthropicEofTolerance, this metadata never enables the new behavior: a repo-wide search shows FREE_PROVIDER_DIRECTORY is consumed only by tests, and tests/provider-registry-parity.test.ts:814-830 explicitly keeps it isolated from runtime providers. Because routedProviderConfig() reads the canonical registry and user config instead, AgentRouter streams still use the strict default and report truncation. Add the capability to the canonical provider/derivation flow or otherwise persist it into the actual provider configuration.

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),
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,13 @@ export interface OcxProviderConfig {
thinkingBudgetModels?: string[];
/** Anthropic-compatible gateways that need custom tool names escaped on the wire. */
escapeBuiltinToolNames?: boolean;
/**
* Anthropic-compatible gateways (e.g. AgentRouter) that may close the stream before
* `message_stop`. With this enabled the adapter completes an otherwise-clean EOF only when
* visible text was received or an open tool call has complete JSON-object arguments; all
* other EOFs remain truncation errors. Absent = strict default behavior.
*/
anthropicEofTolerance?: boolean;
/**
* Model ids that do NOT accept image inputs. The proxy gives them "eyes" via the vision sidecar:
* attached images are described by a gpt vision model and replaced with text before the call.
Expand Down
122 changes: 122 additions & 0 deletions tests/anthropic-eof-tolerance.test.ts
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);
});
});
Loading