feat(llm): add compatible and vertex provider entrypoints (#36900)
This commit is contained in:
parent
22334b94c8
commit
4f1298063b
22 changed files with 860 additions and 53 deletions
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
165
packages/llm/test/provider/google-vertex.test.ts
Normal file
165
packages/llm/test/provider/google-vertex.test.ts
Normal 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",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue