From 5fb733cd0ddc8b4984d4e2006415de47d216d50b Mon Sep 17 00:00:00 2001 From: Josh Lane Date: Thu, 26 Feb 2026 08:31:27 -0800 Subject: [PATCH 1/3] fix(provider): let anthropic-beta header pass through for Vertex 1M context Registers claude-sonnet-4-6-1m@default and claude-sonnet-4-5-1m@20250929 as explicit model variants in the google-vertex-anthropic provider with headers: { "anthropic-beta": "context-1m-2025-08-07" } and a 1M context limit. Vertex AI honors the anthropic-beta HTTP header and forwards it to the Anthropic backend, enabling the 1M context window. The body param anthropic_beta is silently ignored by Vertex. An earlier implementation stripped the header (which the SDK correctly sets via model.headers) and replaced it with a body param, breaking the feature entirely. The fix is to remove the interceptor and let the header pass through unmodified. Also removes the options.betas extraction and transform.ts betas injection, both of which were based on the same incorrect assumption. --- packages/opencode/src/provider/provider.ts | 41 ++++ .../opencode/test/provider/provider.test.ts | 198 ++++++++++++++++++ 2 files changed, 239 insertions(+) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f1871ddb696..441662b80d4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -759,6 +759,41 @@ 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 }, + } + } + } + } + } + const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null @@ -1084,6 +1119,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..abb18447bc8 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2218,3 +2218,201 @@ 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) + }, + }) +}) + +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") + }, + }) +}) + +test("google-vertex-anthropic accepts betas array 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: { + betas: ["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.betas).toEqual(["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 = {} + if (init.headers && typeof init.headers === "object" && !Array.isArray(init.headers)) { + Object.assign(headers, init.headers) + } + 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 + } +}) From d8a3bad5d0207c7b52c2c29835ab3fa363ef162a Mon Sep 17 00:00:00 2001 From: Josh Lane Date: Tue, 3 Mar 2026 10:02:58 -0800 Subject: [PATCH 2/3] fix(provider): inject beta header for Opus 4.6 Vertex 1M context --- packages/opencode/src/provider/provider.ts | 10 ++++++++++ packages/opencode/test/provider/provider.test.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 441662b80d4..2156d2727f0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -791,6 +791,16 @@ export namespace Provider { } } } + // 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 }, + } + } } } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index abb18447bc8..61e023aab74 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2263,6 +2263,14 @@ test("google-vertex-anthropic registers 1M context variants", async () => { 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", + ) + } }, }) }) From c7acbc6fb23fb2dbfdabfac8931f3115d11ac9ce Mon Sep 17 00:00:00 2001 From: Josh Lane Date: Tue, 3 Mar 2026 10:08:01 -0800 Subject: [PATCH 3/3] test(provider): fix header mock, remove unused test --- .../opencode/test/provider/provider.test.ts | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 61e023aab74..8ac1666da72 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2309,36 +2309,6 @@ test("google-vertex-anthropic accepts custom headers from config", async () => { }) }) -test("google-vertex-anthropic accepts betas array 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: { - betas: ["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.betas).toEqual(["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 @@ -2358,8 +2328,19 @@ test("Vertex 1M model: anthropic-beta header passes through to Vertex unstripped ;(globalThis as any).fetch = async (_input: any, init?: RequestInit) => { if (init?.method === "POST") { const headers: Record = {} - if (init.headers && typeof init.headers === "object" && !Array.isArray(init.headers)) { - Object.assign(headers, init.headers) + 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") {