feat(llm): add compatible and vertex provider entrypoints (#36900)

This commit is contained in:
Shoubhit Dash 2026-07-15 22:08:19 +05:30 committed by GitHub
commit 4f1298063b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 860 additions and 53 deletions

View file

@ -5,10 +5,13 @@ import { Auth as RuntimeAuth } from "../src/route/auth"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
import * as Anthropic from "../src/providers/anthropic"
import * as AnthropicCompatible from "../src/providers/anthropic-compatible"
import * as Azure from "../src/providers/azure"
import * as Cloudflare from "../src/providers/cloudflare"
import * as GitHubCopilot from "../src/providers/github-copilot"
import * as Google from "../src/providers/google"
import * as GoogleVertex from "../src/providers/google-vertex"
import * as GoogleVertexAnthropic from "../src/providers/google-vertex-anthropic"
import * as OpenAI from "../src/providers/openai"
import * as OpenAICompatible from "../src/providers/openai-compatible"
import * as OpenRouter from "../src/providers/openrouter"
@ -135,11 +138,57 @@ Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAu
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
AnthropicCompatible.configure({
apiKey: "messages-key",
baseURL: "https://messages.example.com/v1",
provider: "example",
}).model("compatible-model")
// @ts-expect-error Anthropic-compatible providers require a base URL.
AnthropicCompatible.configure({ apiKey: "messages-key" })
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
AnthropicCompatible.model("compatible-model", {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
})
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
// @ts-expect-error Google model selectors only accept model ids.
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
// @ts-expect-error Vertex Gemini config accepts only one auth source.
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Anthropic package settings do not accept API keys.
GoogleVertexAnthropic.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexAnthropic.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
"claude-sonnet-4-6",
)
GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6",
// @ts-expect-error Vertex Anthropic model selectors only accept model ids.
{},
)
GoogleVertexAnthropic.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Anthropic config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
project: "project",
})
AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude")
// @ts-expect-error Bedrock model selectors only accept model ids.
AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude", {})

View file

@ -8,6 +8,7 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/llm/providers/openai/responses"),
import("@opencode-ai/llm/providers/openai/chat"),
import("@opencode-ai/llm/providers/anthropic"),
import("@opencode-ai/llm/providers/anthropic-compatible"),
import("@opencode-ai/llm/providers/openai-compatible"),
import("@opencode-ai/llm/providers/openai-compatible/responses"),
import("@opencode-ai/llm/providers/amazon-bedrock"),
@ -15,11 +16,13 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/llm/providers/azure/responses"),
import("@opencode-ai/llm/providers/azure/chat"),
import("@opencode-ai/llm/providers/google"),
import("@opencode-ai/llm/providers/google-vertex"),
import("@opencode-ai/llm/providers/google-vertex/anthropic"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[7].model).toBe(modules[8].model)
expect(modules[8].model).toBe(modules[9].model)
})
test("maps package settings onto the executable model", () => {
@ -69,6 +72,53 @@ describe("provider package entrypoints", () => {
})
})
test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/llm/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.endpoint).toMatchObject({
baseURL: "https://messages.example.test/v1",
path: "/messages",
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
})
test("requires an Anthropic-compatible base URL at runtime", async () => {
const AnthropicCompatible = await import("@opencode-ai/llm/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow("Anthropic-compatible providers require a baseURL")
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
const Anthropic = await import("@opencode-ai/llm/providers/anthropic")
const AnthropicCompatible = await import("@opencode-ai/llm/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, [
"compatible-model",
{
apiKey: "fixture",
authToken: "token",
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
@ -126,4 +176,64 @@ describe("provider package entrypoints", () => {
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
})
test("selects Vertex entrypoints with the same model contract", async () => {
const GoogleVertex = await import("@opencode-ai/llm/providers/google-vertex")
const GoogleVertexAnthropic = await import("@opencode-ai/llm/providers/google-vertex/anthropic")
const gemini = GoogleVertex.model("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
})
const anthropic = GoogleVertexAnthropic.model("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
expect(gemini.route.id).toBe("google-vertex-gemini")
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(
GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}).route.endpoint.baseURL,
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
expect(anthropic.route.id).toBe("google-vertex-anthropic")
expect(anthropic.route.endpoint.baseURL).toBe(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
)
})
test("rejects conflicting Vertex auth settings at runtime", async () => {
const GoogleVertex = await import("@opencode-ai/llm/providers/google-vertex")
const GoogleVertexAnthropic = await import("@opencode-ai/llm/providers/google-vertex/anthropic")
const Providers = await import("@opencode-ai/llm/providers")
expect(() =>
Reflect.apply(GoogleVertex.model, undefined, [
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexAnthropic.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Anthropic does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexAnthropic.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Anthropic does not support API keys")
})
})

View file

@ -0,0 +1,165 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { GoogleVertex, GoogleVertexAnthropic } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
describe("Google Vertex providers", () => {
it.effect("sends Gemini requests to the global Vertex endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
location: "global",
project: "vertex-project",
}).model("gemini-3.5-flash"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(
"https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(yield* Effect.promise(() => request.json())).toMatchObject({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
})
return input.respond(
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "Hello." }] },
finishReason: "STOP",
},
],
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexAnthropic.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(request.headers.get("anthropic-version")).toBeNull()
const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({
anthropic_version: "vertex-2023-10-16",
messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
stream: true,
})
expect(body).not.toHaveProperty("model")
return input.respond(
sseEvents(
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello." } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("protects the Vertex Anthropic API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model: GoogleVertexAnthropic.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
location: "us-central1",
project: "vertex-project",
}).model("endpoints/1234567890"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(
"https://us-central1-aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/us-central1/endpoints/1234567890:streamGenerateContent?alt=sse",
)
return input.respond(
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "Hello." }] },
finishReason: "STOP",
},
],
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("rejects tuned Gemini models in express mode", () =>
Effect.sync(() => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
}),
)
})