diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f1871ddb696..2156d2727f0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -759,6 +759,51 @@ export namespace Provider { const modelsDev = await ModelsDev.get() const database = mapValues(modelsDev, fromModelsDevProvider) + // Vertex AI 1M context window support via the context-1m-2025-08-07 beta flag. + // Vertex reads and forwards the anthropic-beta HTTP header to the Anthropic backend; + // the header is set in model.headers and flows through the SDK automatically. + { + const VERTEX_1M_BETA = "context-1m-2025-08-07" + const VERTEX_1M_MODELS: Array<{ sourceID: string; variantID: string; name: string }> = [ + { + sourceID: "claude-sonnet-4-6@default", + variantID: "claude-sonnet-4-6-1m@default", + name: "Claude Sonnet 4.6 (1M)", + }, + { + sourceID: "claude-sonnet-4-5@20250929", + variantID: "claude-sonnet-4-5-1m@20250929", + name: "Claude Sonnet 4.5 (1M)", + }, + ] + const vertexProvider = database["google-vertex-anthropic"] + if (vertexProvider) { + for (const { sourceID, variantID, name } of VERTEX_1M_MODELS) { + const source = vertexProvider.models[sourceID] + if (source) { + vertexProvider.models[variantID] = { + ...source, + id: variantID, + name, + api: { ...source.api, id: sourceID }, + limit: { ...source.limit, context: 1000000, input: 0 }, + headers: { ...source.headers, "anthropic-beta": VERTEX_1M_BETA }, + } + } + } + // Opus 4.6 already has 1M context in models.dev but still requires the beta + // header on Vertex. Inject directly into the existing model — no separate + // variant needed since the limit is already correct. + const opusModel = vertexProvider.models["claude-opus-4-6@default"] + if (opusModel && !opusModel.headers?.["anthropic-beta"]) { + vertexProvider.models["claude-opus-4-6@default"] = { + ...opusModel, + headers: { ...opusModel.headers, "anthropic-beta": VERTEX_1M_BETA }, + } + } + } + } + const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null @@ -1084,6 +1129,12 @@ export namespace Provider { opts.signal = combined } + // For google-vertex-anthropic, the anthropic-beta header must be sent as an HTTP + // header (not a body param). Vertex AI reads and forwards the anthropic-beta header + // to the Anthropic backend — confirmed by direct API testing. The header is already + // set in model.headers and flows through the SDK's getHeaders() into opts.headers. + // No interception or body injection needed; the header passes through correctly. + // Strip openai itemId metadata following what codex does // Codex uses #[serde(skip_serializing)] on id fields for all item types: // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 0a5aa415131..8ac1666da72 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2218,3 +2218,190 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) }) + +test("google-vertex-anthropic registers 1M context variants", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GOOGLE_CLOUD_PROJECT", "test-project") + }, + fn: async () => { + const providers = await Provider.list() + const vertex = providers["google-vertex-anthropic"] + expect(vertex).toBeDefined() + + // Verify 1M variants are registered for each source model that exists in models.dev. + // The source model may not be present in all environments (e.g. models-snapshot may lag). + const VARIANTS = [ + { + sourceID: "claude-sonnet-4-6@default", + variantID: "claude-sonnet-4-6-1m@default", + name: "Claude Sonnet 4.6 (1M)", + }, + { + sourceID: "claude-sonnet-4-5@20250929", + variantID: "claude-sonnet-4-5-1m@20250929", + name: "Claude Sonnet 4.5 (1M)", + }, + ] + let testedAtLeastOne = false + for (const { sourceID, variantID, name } of VARIANTS) { + if (!vertex.models[sourceID]) continue + testedAtLeastOne = true + const variant = vertex.models[variantID] + expect(variant).toBeDefined() + expect(variant.name).toBe(name) + expect(variant.api.id).toBe(sourceID) + expect(variant.limit.context).toBe(1000000) + expect(variant.headers?.["anthropic-beta"]).toBe("context-1m-2025-08-07") + // Source model context limit must be unchanged + expect(vertex.models[sourceID].limit.context).not.toBe(1000000) + } + expect(testedAtLeastOne).toBe(true) + + // Opus 4.6 already has 1M context in models.dev — verify the beta header + // is injected directly without creating a separate variant. + if (vertex.models["claude-opus-4-6@default"]) { + expect(vertex.models["claude-opus-4-6@default"].headers?.["anthropic-beta"]).toBe( + "context-1m-2025-08-07", + ) + } + }, + }) +}) + +test("google-vertex-anthropic accepts custom headers from config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "google-vertex-anthropic": { + options: { + headers: { + "anthropic-beta": "context-1m-2025-08-07", + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GOOGLE_CLOUD_PROJECT", "test-project") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["google-vertex-anthropic"]).toBeDefined() + expect(providers["google-vertex-anthropic"].options.headers).toBeDefined() + expect(providers["google-vertex-anthropic"].options.headers["anthropic-beta"]).toBe("context-1m-2025-08-07") + }, + }) +}) + + +// Regression: 1M model was returning "prompt is too long: N > 200000 maximum" because +// an earlier implementation stripped the anthropic-beta header and substituted a body +// param (anthropic_beta). Vertex AI honors the HTTP header but ignores the body param. +// The correct fix is to let the header pass through unmodified; the SDK sets it +// automatically from model.headers via createVertexAnthropic's config.headers. +test("Vertex 1M model: anthropic-beta header passes through to Vertex unstripped", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) + }, + }) + + const capturedRequests: Array<{ headers: Record; body: any }> = [] + const origFetch = globalThis.fetch as typeof fetch + + ;(globalThis as any).fetch = async (_input: any, init?: RequestInit) => { + if (init?.method === "POST") { + const headers: Record = {} + const rawHeaders = init.headers + if (rawHeaders instanceof Headers) { + rawHeaders.forEach((value, key) => { + headers[key] = value + }) + } else if (Array.isArray(rawHeaders)) { + for (const [key, value] of rawHeaders as Array<[string, string]>) { + headers[key] = value + } + } else if (rawHeaders && typeof rawHeaders === "object") { + for (const [key, value] of Object.entries(rawHeaders as Record)) { + headers[key] = String(value) + } + } + let body: any = undefined + if (typeof init.body === "string") { + try { + body = JSON.parse(init.body) + } catch {} + } + capturedRequests.push({ headers, body }) + } + return new Response( + JSON.stringify({ + id: "msg_test", + type: "message", + role: "assistant", + content: [{ type: "text", text: "hi" }], + model: "claude-sonnet-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + + try { + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GOOGLE_CLOUD_PROJECT", "test-project") + }, + fn: async () => { + const providers = await Provider.list() + const vertex = providers["google-vertex-anthropic"] + if (!vertex) return + + const model = vertex.models["claude-sonnet-4-6-1m@default"] + if (!model) return // Not in models-snapshot; skip rather than fail + + const language = await Provider.getLanguage(model) + + try { + await (language as any).doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + maxOutputTokens: 10, + }) + } catch { + // Response-parsing errors are expected with a stub response. + } + + const req = capturedRequests.find((r) => r.body !== undefined) + expect(req).toBeDefined() + + // Vertex AI reads the anthropic-beta HTTP header (forwarded to the Anthropic + // backend) and ignores the anthropic_beta body param. The header must be present + // and the body must NOT contain anthropic_beta (no injection happening). + const betaHeader = + req!.headers["anthropic-beta"] ?? req!.headers["Anthropic-Beta"] ?? "" + expect(betaHeader).toContain("context-1m-2025-08-07") + expect(req!.body?.anthropic_beta).toBeUndefined() + }, + }) + } finally { + ;(globalThis as any).fetch = origFetch + } +})