diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index abdecf61..c3a43f26 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -3,7 +3,13 @@ import { writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; -import { Api, KnownProvider, Model, type OpenAICompletionsCompat } from "../src/types.js"; +import { + Api, + type AnthropicMessagesCompat, + KnownProvider, + Model, + type OpenAICompletionsCompat, +} from "../src/types.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -60,6 +66,13 @@ const KIMI_STATIC_HEADERS = { const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); +const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set(["github-copilot:claude-haiku-4.5"]); + +function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { + return EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`) + ? { supportsEagerToolInputStreaming: false } + : undefined; +} function getBedrockBaseUrl(modelId: string): string { return modelId.startsWith("eu.") @@ -582,6 +595,9 @@ async function loadModelsDevData(): Promise[]> { ? "openai-responses" : "openai-completions"; + const anthropicCompat = + api === "anthropic-messages" ? getAnthropicMessagesCompat("github-copilot", modelId) : undefined; + const copilotModel: Model = { id: modelId, name: m.name || modelId, @@ -599,6 +615,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 128000, maxTokens: m.limit?.output || 8192, headers: { ...COPILOT_STATIC_HEADERS }, + ...(anthropicCompat ? { compat: anthropicCompat } : {}), // compat only applies to openai-completions ...(api === "openai-completions" ? { compat: { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 1ac19938..0f8aa6f5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3014,6 +3014,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsEagerToolInputStreaming":false}, reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 698ecffe..3420c18a 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -9,6 +9,7 @@ import type { import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost } from "../models.js"; import type { + AnthropicMessagesCompat, Api, AssistantMessage, CacheRetention, @@ -159,6 +160,15 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type AnthropicThinkingDisplay = "summarized" | "omitted"; +const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; +const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; + +function getAnthropicCompat(model: Model<"anthropic-messages">): Required { + return { + supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true, + }; +} + export interface AnthropicOptions extends StreamOptions { /** * Enable extended thinking. @@ -433,6 +443,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti model, apiKey, options?.interleavedThinking ?? true, + shouldUseFineGrainedToolStreamingBeta(model, context), options?.headers, copilotDynamicHeaders, ); @@ -728,6 +739,7 @@ function createClient( model: Model<"anthropic-messages">, apiKey: string, interleavedThinking: boolean, + useFineGrainedToolStreamingBeta: boolean, optionsHeaders?: Record, dynamicHeaders?: Record, ): { client: Anthropic; isOAuthToken: boolean } { @@ -735,11 +747,14 @@ function createClient( // The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it. const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id); - // Copilot: Bearer auth, selective betas (no fine-grained-tool-streaming) + // Copilot: Bearer auth, selective betas. if (model.provider === "github-copilot") { const betaFeatures: string[] = []; + if (useFineGrainedToolStreamingBeta) { + betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); + } if (needsInterleavedBeta) { - betaFeatures.push("interleaved-thinking-2025-05-14"); + betaFeatures.push(INTERLEAVED_THINKING_BETA); } const client = new Anthropic({ @@ -763,8 +778,11 @@ function createClient( } const betaFeatures: string[] = []; + if (useFineGrainedToolStreamingBeta) { + betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); + } if (needsInterleavedBeta) { - betaFeatures.push("interleaved-thinking-2025-05-14"); + betaFeatures.push(INTERLEAVED_THINKING_BETA); } // OAuth: Bearer auth, Claude Code identity headers @@ -856,7 +874,12 @@ function buildParams( } if (context.tools) { - params.tools = convertTools(context.tools, isOAuthToken, cacheControl); + params.tools = convertTools( + context.tools, + isOAuthToken, + getAnthropicCompat(model).supportsEagerToolInputStreaming, + cacheControl, + ); } // Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6), @@ -1078,9 +1101,14 @@ function convertMessages( return params; } +function shouldUseFineGrainedToolStreamingBeta(model: Model<"anthropic-messages">, context: Context): boolean { + return !!context.tools?.length && !getAnthropicCompat(model).supportsEagerToolInputStreaming; +} + function convertTools( tools: Tool[], isOAuthToken: boolean, + supportsEagerToolInputStreaming: boolean, cacheControl?: CacheControlEphemeral, ): Anthropic.Messages.Tool[] { if (!tools) return []; @@ -1091,7 +1119,7 @@ function convertTools( return { name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name, description: tool.description, - eager_input_streaming: true, + ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}), input_schema: { type: "object", properties: schema.properties ?? {}, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 8710bbd6..bb7950db 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -304,6 +304,18 @@ export interface OpenAIResponsesCompat { sendSessionIdHeader?: boolean; } +/** Compatibility settings for Anthropic Messages-compatible APIs. */ +export interface AnthropicMessagesCompat { + /** + * Whether the provider accepts per-tool `eager_input_streaming`. + * When false, the Anthropic provider omits `tools[].eager_input_streaming` + * and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header + * for tool-enabled requests. + * Default: true. + */ + supportsEagerToolInputStreaming?: boolean; +} + /** * OpenRouter provider routing preferences. * Controls which upstream providers OpenRouter routes requests to. @@ -414,5 +426,7 @@ export interface Model { ? OpenAICompletionsCompat : TApi extends "openai-responses" ? OpenAIResponsesCompat - : never; + : TApi extends "anthropic-messages" + ? AnthropicMessagesCompat + : never; } diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts new file mode 100644 index 00000000..e73a00a0 --- /dev/null +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -0,0 +1,122 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { streamAnthropic } from "../src/providers/anthropic.js"; +import type { Context, Model, Tool } from "../src/types.js"; + +interface CapturedRequest { + headers: IncomingMessage["headers"]; + body: Record; +} + +function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "test-anthropic", + baseUrl, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + compat, + }; +} + +const tool: Tool = { + name: "lookup", + description: "Look up a value", + parameters: Type.Object({ value: Type.String() }), +}; + +function createContext(tools: Tool[] = [tool]): Context { + return { + messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }], + ...(tools.length > 0 ? { tools } : {}), + }; +} + +async function readRequestBody(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; +} + +function writeEmptySseResponse(response: ServerResponse): void { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(); +} + +async function captureAnthropicRequest( + compat: Model<"anthropic-messages">["compat"], + context: Context, +): Promise { + let capturedRequest: CapturedRequest | undefined; + + const server = createServer(async (request, response) => { + capturedRequest = { + headers: request.headers, + body: await readRequestBody(request), + }; + writeEmptySseResponse(response); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + + try { + const stream = streamAnthropic(createModel(`http://127.0.0.1:${address.port}`, compat), context, { + apiKey: "test-key", + cacheRetention: "none", + }); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + if (!capturedRequest) { + throw new Error("Anthropic request was not captured"); + } + return capturedRequest; +} + +function getFirstTool(body: Record): Record { + const tools = body.tools; + if (!Array.isArray(tools) || typeof tools[0] !== "object" || tools[0] === null) { + throw new Error("Expected first tool in request body"); + } + return tools[0] as Record; +} + +describe("Anthropic eager tool input streaming compatibility", () => { + it("sends per-tool eager_input_streaming by default", async () => { + const request = await captureAnthropicRequest(undefined, createContext()); + + expect(getFirstTool(request.body).eager_input_streaming).toBe(true); + expect(request.headers["anthropic-beta"]).toBeUndefined(); + }); + + it("uses the legacy fine-grained tool streaming beta when eager tool input streaming is disabled", async () => { + const request = await captureAnthropicRequest({ supportsEagerToolInputStreaming: false }, createContext()); + + expect(getFirstTool(request.body).eager_input_streaming).toBeUndefined(); + expect(request.headers["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14"); + }); + + it("does not send the legacy fine-grained tool streaming beta when there are no tools", async () => { + const request = await captureAnthropicRequest({ supportsEagerToolInputStreaming: false }, createContext([])); + + expect(request.body.tools).toBeUndefined(); + expect(request.headers["anthropic-beta"]).toBeUndefined(); + }); +}); diff --git a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts new file mode 100644 index 00000000..6e5fe68c --- /dev/null +++ b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts @@ -0,0 +1,143 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { getEnvApiKey } from "../src/env-api-keys.js"; +import { getModels, getProviders } from "../src/models.js"; +import { complete } from "../src/stream.js"; +import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.js"; +import { resolveApiKey } from "./oauth.js"; + +const githubCopilotToken = await resolveApiKey("github-copilot"); + +const echoToolSchema = Type.Object({ + value: Type.String({ description: "The value to echo" }), +}); + +const echoTool: Tool = { + name: "echo_value", + description: "Echo a string value", + parameters: echoToolSchema, +}; + +interface AnthropicEagerE2ECase { + name: string; + provider: KnownProvider; + model: Model<"anthropic-messages">; + apiKey: string | undefined; +} + +function getE2EApiKey(provider: KnownProvider): string | undefined { + if (provider === "github-copilot") { + return githubCopilotToken; + } + return getEnvApiKey(provider); +} + +function getAnthropicMessagesModels(provider: KnownProvider): Model<"anthropic-messages">[] { + const models = getModels(provider) as Model[]; + return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[]; +} + +const anthropicMessagesCases: AnthropicEagerE2ECase[] = getProviders().flatMap((provider) => + getAnthropicMessagesModels(provider).map((model) => ({ + name: `${provider}/${model.id}`, + provider, + model, + apiKey: getE2EApiKey(provider), + })), +); + +function getProbePriority(model: Model<"anthropic-messages">): number { + const modelId = model.id.toLowerCase(); + const cost = model.cost.input + model.cost.output; + let priority = cost; + + // Prefer current Claude 4 Haiku routes when present: they are cheap and avoid + // stale Claude 3.x aliases that can remain in catalogs after upstream removal. + if (modelId.includes("haiku") && (modelId.includes("4-5") || modelId.includes("4.5"))) { + priority -= 1000; + } else if (modelId.includes("claude") && (modelId.includes("4-") || modelId.includes("4."))) { + priority -= 500; + } + + return priority; +} + +function selectOneCasePerProvider(cases: AnthropicEagerE2ECase[]): AnthropicEagerE2ECase[] { + const byProvider = new Map(); + for (const testCase of cases) { + const providerCases = byProvider.get(testCase.provider) ?? []; + providerCases.push(testCase); + byProvider.set(testCase.provider, providerCases); + } + + return Array.from(byProvider.values()).map( + (providerCases) => + providerCases.sort( + (a, b) => getProbePriority(a.model) - getProbePriority(b.model) || a.model.id.localeCompare(b.model.id), + )[0], + ); +} + +const probeCases = selectOneCasePerProvider(anthropicMessagesCases); + +function withEagerToolInputStreaming(model: Model<"anthropic-messages">): Model<"anthropic-messages"> { + return { + ...model, + compat: { + ...model.compat, + supportsEagerToolInputStreaming: true, + }, + }; +} + +async function expectToolEnabledRequestAccepted( + model: Model<"anthropic-messages">, + apiKey: string | undefined, +): Promise { + const options: ProviderStreamOptions = { + apiKey, + maxTokens: 128, + thinkingEnabled: false, + }; + const response = await complete( + model, + { + systemPrompt: "You are a concise assistant. Use tools when useful.", + messages: [ + { + role: "user", + content: "Call echo_value with value set to eager-input-streaming-compat.", + timestamp: Date.now(), + }, + ], + tools: [echoTool], + }, + options, + ); + + expect(response.errorMessage, response.errorMessage).toBeFalsy(); + expect(response.stopReason, response.errorMessage).not.toBe("error"); +} + +describe("Anthropic Messages eager tool input streaming E2E", () => { + it("covers every generated anthropic-messages model", () => { + const expectedModels = getProviders().flatMap((provider) => + getAnthropicMessagesModels(provider).map((model) => `${provider}/${model.id}`), + ); + expect(anthropicMessagesCases.map((testCase) => testCase.name).sort()).toEqual(expectedModels.sort()); + }); + + describe("forced eager_input_streaming probe", () => { + for (const testCase of probeCases) { + const model = withEagerToolInputStreaming(testCase.model); + + it.skipIf(!testCase.apiKey)( + `${testCase.name} accepts forced eager_input_streaming`, + { retry: 2 }, + async () => { + await expectToolEnabledRequestAccepted(model, testCase.apiKey); + }, + ); + } + }); +}); diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 844f2794..43c0e45f 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -11,6 +11,7 @@ Add custom providers and models (Ollama, vLLM, LM Studio, proxies) via `~/.pi/ag - [Model Configuration](#model-configuration) - [Overriding Built-in Providers](#overriding-built-in-providers) - [Per-model Overrides](#per-model-overrides) +- [Anthropic Messages Compatibility](#anthropic-messages-compatibility) - [OpenAI Compatibility](#openai-compatibility) ## Minimal Example @@ -195,7 +196,7 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | `contextWindow` | No | `128000` | Context window size in tokens | | `maxTokens` | No | `16384` | Maximum output tokens | | `cost` | No | all zeros | `{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}` (per million tokens) | -| `compat` | No | provider `compat` | OpenAI compatibility overrides. Merged with provider-level `compat` when both are set. | +| `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. | Current behavior: - `/model` and `--list-models` list entries by model `id`. @@ -269,6 +270,38 @@ Behavior notes: - You can combine provider-level `baseUrl`/`headers` with `modelOverrides`. - If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry. +## Anthropic Messages Compatibility + +For providers or proxies using `api: "anthropic-messages"`, use `compat.supportsEagerToolInputStreaming` to control Anthropic fine-grained tool streaming compatibility. + +By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead. + +```json +{ + "providers": { + "anthropic-proxy": { + "baseUrl": "https://proxy.example.com", + "api": "anthropic-messages", + "apiKey": "ANTHROPIC_PROXY_KEY", + "compat": { + "supportsEagerToolInputStreaming": false + }, + "models": [ + { + "id": "claude-opus-4-7", + "reasoning": true, + "input": ["text", "image"] + } + ] + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. | + ## OpenAI Compatibility For providers with partial OpenAI compatibility, use the `compat` field. diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 40f94255..01955447 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -3,6 +3,7 @@ */ import { + type AnthropicMessagesCompat, type Api, type AssistantMessageEventStream, type Context, @@ -116,7 +117,15 @@ const OpenAIResponsesCompatSchema = Type.Object({ // Reserved for future use }); -const OpenAICompatSchema = Type.Union([OpenAICompletionsCompatSchema, OpenAIResponsesCompatSchema]); +const AnthropicMessagesCompatSchema = Type.Object({ + supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), +}); + +const ProviderCompatSchema = Type.Union([ + OpenAICompletionsCompatSchema, + OpenAIResponsesCompatSchema, + AnthropicMessagesCompatSchema, +]); // Schema for custom model definition // Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.) @@ -138,7 +147,7 @@ const ModelDefinitionSchema = Type.Object({ contextWindow: Type.Optional(Type.Number()), maxTokens: Type.Optional(Type.Number()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), - compat: Type.Optional(OpenAICompatSchema), + compat: Type.Optional(ProviderCompatSchema), }); // Schema for per-model overrides (all fields optional, merged with built-in model) @@ -157,7 +166,7 @@ const ModelOverrideSchema = Type.Object({ contextWindow: Type.Optional(Type.Number()), maxTokens: Type.Optional(Type.Number()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), - compat: Type.Optional(OpenAICompatSchema), + compat: Type.Optional(ProviderCompatSchema), }); type ModelOverride = Static; @@ -167,7 +176,7 @@ const ProviderConfigSchema = Type.Object({ apiKey: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional(Type.String({ minLength: 1 })), headers: Type.Optional(Type.Record(Type.String(), Type.String())), - compat: Type.Optional(OpenAICompatSchema), + compat: Type.Optional(ProviderCompatSchema), authHeader: Type.Optional(Type.Boolean()), models: Type.Optional(Type.Array(ModelDefinitionSchema)), modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)), @@ -237,9 +246,9 @@ function mergeCompat( ): Model["compat"] | undefined { if (!overrideCompat) return baseCompat; - const base = baseCompat as OpenAICompletionsCompat | OpenAIResponsesCompat | undefined; - const override = overrideCompat as OpenAICompletionsCompat | OpenAIResponsesCompat; - const merged = { ...base, ...override } as OpenAICompletionsCompat | OpenAIResponsesCompat; + const base = baseCompat as OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | undefined; + const override = overrideCompat as OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat; + const merged = { ...base, ...override } as OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat; const baseCompletions = base as OpenAICompletionsCompat | undefined; const overrideCompletions = override as OpenAICompletionsCompat; diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 4a0ffc15..c58016ba 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { Api, Context, Model, OpenAICompletionsCompat } from "@mariozechner/pi-ai"; +import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@mariozechner/pi-ai"; import { getApiProvider } from "@mariozechner/pi-ai"; import { getOAuthProvider } from "@mariozechner/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; @@ -432,6 +432,35 @@ describe("ModelRegistry", () => { expect(compat?.cacheControlFormat).toBe("anthropic"); }); + test("compat schema accepts Anthropic eager tool input streaming flag", () => { + writeRawModelsJson({ + demo: { + baseUrl: "https://example.com", + apiKey: "DEMO_KEY", + api: "anthropic-messages", + compat: { + supportsEagerToolInputStreaming: false, + }, + models: [ + { + id: "demo-model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], + }, + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const compat = registry.find("demo", "demo-model")?.compat as AnthropicMessagesCompat | undefined; + + expect(registry.getError()).toBeUndefined(); + expect(compat?.supportsEagerToolInputStreaming).toBe(false); + }); + test("model-level baseUrl overrides provider-level baseUrl for custom models", () => { writeRawModelsJson({ "opencode-go": {