341 lines
16 KiB
TypeScript
341 lines
16 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { model } from "@opencode-ai/ai/providers/openai"
|
|
|
|
describe("provider package entrypoints", () => {
|
|
test("semantic API aliases expose the same contract", async () => {
|
|
const modules = await Promise.all([
|
|
import("@opencode-ai/ai/providers/openai"),
|
|
import("@opencode-ai/ai/providers/openai/responses"),
|
|
import("@opencode-ai/ai/providers/openai/chat"),
|
|
import("@opencode-ai/ai/providers/anthropic"),
|
|
import("@opencode-ai/ai/providers/anthropic-compatible"),
|
|
import("@opencode-ai/ai/providers/openai-compatible"),
|
|
import("@opencode-ai/ai/providers/openai-compatible/responses"),
|
|
import("@opencode-ai/ai/providers/amazon-bedrock"),
|
|
import("@opencode-ai/ai/providers/azure"),
|
|
import("@opencode-ai/ai/providers/azure/responses"),
|
|
import("@opencode-ai/ai/providers/azure/chat"),
|
|
import("@opencode-ai/ai/providers/google"),
|
|
import("@opencode-ai/ai/providers/google-vertex"),
|
|
import("@opencode-ai/ai/providers/google-vertex/gemini"),
|
|
import("@opencode-ai/ai/providers/google-vertex/chat"),
|
|
import("@opencode-ai/ai/providers/google-vertex/responses"),
|
|
import("@opencode-ai/ai/providers/google-vertex/messages"),
|
|
import("@opencode-ai/ai/providers/openrouter"),
|
|
import("@opencode-ai/ai/providers/xai"),
|
|
])
|
|
|
|
for (const module of modules) expect(module.model).toBeFunction()
|
|
expect(modules[0].model).toBe(modules[1].model)
|
|
expect(modules[8].model).toBe(modules[9].model)
|
|
expect(modules[12].model).toBe(modules[13].model)
|
|
})
|
|
|
|
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
|
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
|
const XAI = await import("@opencode-ai/ai/providers/xai")
|
|
const settings = {
|
|
apiKey: "fixture",
|
|
baseURL: "https://provider.example.test/v1",
|
|
headers: { "x-application": "opencode" },
|
|
body: { service_tier: "priority" },
|
|
limits: { context: 200_000, output: 64_000 },
|
|
}
|
|
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
|
...settings,
|
|
providerOptions: { openrouter: { usage: true } },
|
|
})
|
|
const xai = XAI.model("grok-4", {
|
|
...settings,
|
|
providerOptions: { openai: { reasoningEffort: "high" } },
|
|
})
|
|
|
|
for (const selected of [openrouter, xai]) {
|
|
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
|
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
|
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
|
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
|
}
|
|
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
|
|
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
|
|
})
|
|
|
|
test("maps package settings onto the executable model", () => {
|
|
const selected = model("gpt-5", {
|
|
apiKey: "fixture",
|
|
baseURL: "https://api.openai.test/v1",
|
|
headers: { "x-application": "opencode" },
|
|
body: { service_tier: "priority" },
|
|
limits: { context: 200_000, output: 64_000 },
|
|
unrelatedInheritedSetting: true,
|
|
})
|
|
|
|
expect(selected.route.id).toBe("openai-responses")
|
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
|
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
|
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
})
|
|
|
|
test("selects transport without changing the semantic API", () => {
|
|
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
|
|
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
|
|
})
|
|
|
|
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
|
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
|
const selected = OpenAICompatibleResponses.model("custom-model", {
|
|
apiKey: "fixture",
|
|
baseURL: "https://responses.example.test/v1",
|
|
provider: "example",
|
|
headers: { "x-application": "opencode" },
|
|
body: { service_tier: "priority" },
|
|
limits: { context: 200_000, output: 64_000 },
|
|
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
|
})
|
|
|
|
expect(String(selected.provider)).toBe("example")
|
|
expect(selected.route.id).toBe("openai-compatible-responses")
|
|
expect(selected.route.endpoint).toMatchObject({
|
|
baseURL: "https://responses.example.test/v1",
|
|
path: "/responses",
|
|
})
|
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
|
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
|
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
expect(selected.route.defaults.providerOptions).toEqual({
|
|
openresponses: { reasoningEffort: "low", store: true },
|
|
})
|
|
})
|
|
|
|
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
|
const AnthropicCompatible = await import("@opencode-ai/ai/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 },
|
|
providerOptions: { anthropic: { effort: "low" } },
|
|
})
|
|
|
|
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 })
|
|
expect(selected.route.defaults.providerOptions).toEqual({ anthropic: { effort: "low" } })
|
|
})
|
|
|
|
test("maps Anthropic provider options onto the executable model", async () => {
|
|
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
|
const selected = Anthropic.model("claude-sonnet-4-6", {
|
|
apiKey: "fixture",
|
|
providerOptions: { anthropic: { thinking: { type: "adaptive" } } },
|
|
})
|
|
|
|
expect(selected.route.defaults.providerOptions).toEqual({
|
|
anthropic: { thinking: { type: "adaptive" } },
|
|
})
|
|
})
|
|
|
|
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
|
const AnthropicCompatible = await import("@opencode-ai/ai/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/ai/providers/anthropic")
|
|
const AnthropicCompatible = await import("@opencode-ai/ai/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",
|
|
organization: "org_123",
|
|
project: "proj_123",
|
|
})
|
|
|
|
expect(selected.route.defaults.headers).toMatchObject({
|
|
"OpenAI-Organization": "org_123",
|
|
"OpenAI-Project": "proj_123",
|
|
})
|
|
})
|
|
|
|
test("selects Azure API entrypoints with the same model contract", async () => {
|
|
const Azure = await import("@opencode-ai/ai/providers/azure")
|
|
const AzureChat = await import("@opencode-ai/ai/providers/azure/chat")
|
|
const AzureResponses = await import("@opencode-ai/ai/providers/azure/responses")
|
|
const settings = {
|
|
apiKey: "fixture",
|
|
resourceName: "opencode-test",
|
|
headers: { "x-application": "opencode" },
|
|
body: { service_tier: "priority" },
|
|
limits: { context: 200_000, output: 64_000 },
|
|
}
|
|
|
|
const responses = AzureResponses.model("deployment", settings)
|
|
const chat = AzureChat.model("deployment", settings)
|
|
|
|
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
|
|
expect(responses.route.id).toBe("azure-openai-responses")
|
|
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
|
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
|
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
|
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
expect(chat.route.id).toBe("azure-openai-chat")
|
|
})
|
|
|
|
test("maps Google package settings onto the Gemini model", async () => {
|
|
const Google = await import("@opencode-ai/ai/providers/google")
|
|
const selected = Google.model("gemini-2.5-flash", {
|
|
apiKey: "fixture",
|
|
baseURL: "https://generativelanguage.test/v1beta",
|
|
headers: { "x-application": "opencode" },
|
|
body: { safetySettings: [] },
|
|
limits: { context: 1_000_000, output: 65_536 },
|
|
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
|
|
})
|
|
|
|
expect(selected.route.id).toBe("gemini")
|
|
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
|
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
|
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
|
expect(selected.route.defaults.providerOptions).toEqual({
|
|
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
|
|
})
|
|
})
|
|
|
|
test("selects Vertex entrypoints with the same model contract", async () => {
|
|
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
|
|
const GoogleVertexGemini = await import("@opencode-ai/ai/providers/google-vertex/gemini")
|
|
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
|
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
|
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
|
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 messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
|
|
accessToken: "fixture",
|
|
location: "global",
|
|
project: "vertex-project",
|
|
})
|
|
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
|
|
accessToken: "fixture",
|
|
location: "global",
|
|
project: "vertex-project",
|
|
})
|
|
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
|
|
accessToken: "fixture",
|
|
location: "global",
|
|
project: "vertex-project",
|
|
})
|
|
|
|
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
|
expect(gemini.route.id).toBe("google-vertex-gemini")
|
|
expect(gemini.route.protocol).toBe("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(messages.route.id).toBe("google-vertex-messages")
|
|
expect(messages.route.protocol).toBe("anthropic-messages")
|
|
expect(messages.route.endpoint.baseURL).toBe(
|
|
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
|
|
)
|
|
expect(chat.route.id).toBe("google-vertex-chat")
|
|
expect(chat.route.protocol).toBe("openai-chat")
|
|
expect(chat.route.endpoint).toMatchObject({
|
|
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
|
path: "/chat/completions",
|
|
})
|
|
expect(responses.route.id).toBe("google-vertex-responses")
|
|
expect(responses.route.protocol).toBe("open-responses")
|
|
expect(responses.route.endpoint).toMatchObject({
|
|
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
|
path: "/responses",
|
|
})
|
|
expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } })
|
|
})
|
|
|
|
test("rejects conflicting Vertex auth settings at runtime", async () => {
|
|
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
|
|
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
|
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
|
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
|
const Providers = await import("@opencode-ai/ai/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(GoogleVertexMessages.model, undefined, [
|
|
"claude-sonnet-4-6",
|
|
{ apiKey: "fixture", project: "vertex-project" },
|
|
]),
|
|
).toThrow("Google Vertex Messages does not support API keys")
|
|
expect(() =>
|
|
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
|
{ apiKey: "fixture", project: "vertex-project" },
|
|
]),
|
|
).toThrow("Google Vertex Messages does not support API keys")
|
|
expect(() =>
|
|
Reflect.apply(GoogleVertexChat.model, undefined, [
|
|
"deepseek-ai/deepseek-v3.2-maas",
|
|
{ apiKey: "fixture", project: "vertex-project" },
|
|
]),
|
|
).toThrow("Google Vertex Chat does not support API keys")
|
|
expect(() =>
|
|
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
|
{ apiKey: "fixture", project: "vertex-project" },
|
|
]),
|
|
).toThrow("Google Vertex Chat does not support API keys")
|
|
expect(() =>
|
|
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
|
"xai/grok-4.20-reasoning",
|
|
{ apiKey: "fixture", project: "vertex-project" },
|
|
]),
|
|
).toThrow("Google Vertex Responses does not support API keys")
|
|
expect(() =>
|
|
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
|
{ apiKey: "fixture", project: "vertex-project" },
|
|
]),
|
|
).toThrow("Google Vertex Responses does not support API keys")
|
|
})
|
|
})
|