Skip to content
Open
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
51 changes: 51 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
187 changes: 187 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; 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<string, string> = {}
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<string, string>)) {
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
}
})