feat(llm): add provider-routed adapter composition

This commit is contained in:
Kit Langton
2026-04-26 13:35:00 -04:00
parent e1c6bf92fb
commit 4e3f678b24
18 changed files with 492 additions and 28 deletions
+1
View File
@@ -110,6 +110,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -0,0 +1,135 @@
import * as LLM from "@opencode-ai/llm/llm"
import { Anthropic } from "@opencode-ai/llm/provider/anthropic"
import { Azure } from "@opencode-ai/llm/provider/azure"
import { GitHubCopilot } from "@opencode-ai/llm/provider/github-copilot"
import { Google } from "@opencode-ai/llm/provider/google"
import { OpenAI } from "@opencode-ai/llm/provider/openai"
import { OpenAICompatibleFamily } from "@opencode-ai/llm/provider/openai-compatible-family"
import { XAI } from "@opencode-ai/llm/provider/xai"
import type { ProviderDefinition, ProviderRoute } from "@opencode-ai/llm/provider-route"
import { ReasoningEfforts, type ModelRef, type Protocol, type ReasoningEffort } from "@opencode-ai/llm/schema"
import { isRecord } from "@/util/record"
import type * as Provider from "./provider"
type Input = {
readonly provider: Provider.Info
readonly model: Provider.Model
}
const PROVIDERS: Record<string, ProviderDefinition> = {
"@ai-sdk/anthropic": Anthropic.provider,
"@ai-sdk/azure": Azure.provider,
"@ai-sdk/baseten": OpenAICompatibleFamily.provider,
"@ai-sdk/cerebras": OpenAICompatibleFamily.provider,
"@ai-sdk/deepinfra": OpenAICompatibleFamily.provider,
"@ai-sdk/fireworks": OpenAICompatibleFamily.provider,
"@ai-sdk/github-copilot": GitHubCopilot.provider,
"@ai-sdk/google": Google.provider,
"@ai-sdk/openai": OpenAI.provider,
"@ai-sdk/openai-compatible": OpenAICompatibleFamily.provider,
"@ai-sdk/togetherai": OpenAICompatibleFamily.provider,
"@ai-sdk/xai": XAI.provider,
}
const REASONING_EFFORTS = new Set<ReasoningEffort>(ReasoningEfforts)
const stringOption = (options: Record<string, unknown>, key: string) => {
const value = options[key]
if (typeof value === "string" && value.trim() !== "") return value
return undefined
}
const recordOption = (options: Record<string, unknown>, key: string): Record<string, string> => {
const value = options[key]
if (!isRecord(value)) return {}
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"))
}
export const route = (
input: Input,
options: Record<string, unknown> = { ...input.provider.options, ...input.model.options },
): ProviderRoute | undefined =>
PROVIDERS[input.model.api.npm]?.route({
modelID: input.model.api.id,
providerID: input.model.providerID,
options,
})
const baseURL = (input: Input, selected: Protocol, options: Record<string, unknown>) => {
const configured = stringOption(options, "baseURL") ?? input.model.api.url
if (configured) return configured
if (selected === "openai-compatible-chat") return OpenAICompatibleFamily.byProvider[input.model.providerID]?.baseURL
return undefined
}
const authHeader = (selected: Protocol, apiKey: string | undefined): Record<string, string> => {
if (!apiKey) return {}
if (selected === "anthropic-messages") return { "x-api-key": apiKey }
if (selected === "gemini") return { "x-goog-api-key": apiKey }
return { authorization: `Bearer ${apiKey}` }
}
const headers = (input: Input, selected: Protocol, options: Record<string, unknown>) => {
const result = {
...authHeader(selected, stringOption(options, "apiKey") ?? input.provider.key),
...recordOption(options, "headers"),
...input.model.headers,
}
return Object.keys(result).length === 0 ? undefined : result
}
const reasoningEfforts = (input: Input) =>
Object.keys(input.model.variants ?? {}).filter((effort): effort is ReasoningEffort =>
REASONING_EFFORTS.has(effort as ReasoningEffort),
)
const capabilities = (input: Input, selected: Protocol) =>
LLM.capabilities({
input: {
text: input.model.capabilities.input.text,
image: input.model.capabilities.input.image,
audio: input.model.capabilities.input.audio,
video: input.model.capabilities.input.video,
pdf: input.model.capabilities.input.pdf,
},
output: {
text: input.model.capabilities.output.text,
reasoning: input.model.capabilities.reasoning,
},
tools: {
calls: input.model.capabilities.toolcall,
streamingInput: selected !== "gemini" && input.model.capabilities.toolcall,
},
cache: {
prompt: ["anthropic-messages", "bedrock-converse"].includes(selected),
contentBlocks: selected === "anthropic-messages",
},
reasoning: {
efforts: reasoningEfforts(input),
summaries: selected === "openai-responses",
encryptedContent: selected === "openai-responses" || selected === "anthropic-messages",
},
})
export const toModelRef = (input: Input): ModelRef | undefined => {
const options = { ...input.provider.options, ...input.model.options }
const selected = route(input, options)
if (!selected) return undefined
return LLM.model({
id: input.model.api.id,
provider: selected.provider,
protocol: selected.protocol,
baseURL: baseURL(input, selected.protocol, options),
headers: headers(input, selected.protocol, options),
capabilities: capabilities(input, selected.protocol),
limits: LLM.limits({ context: input.model.limit.context, output: input.model.limit.output }),
native: {
opencodeProviderID: input.provider.id,
opencodeModelID: input.model.id,
npm: input.model.api.npm,
options,
},
})
}
export * as ProviderLLMBridge from "./llm-bridge"
+2 -7
View File
@@ -25,18 +25,13 @@ import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { GitHubCopilot } from "@opencode-ai/llm/provider/github-copilot"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
const log = Log.create({ service: "provider" })
function shouldUseCopilotResponsesApi(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) return false
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res
@@ -193,7 +188,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
return GitHubCopilot.shouldUseResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}),
@@ -0,0 +1,172 @@
import { describe, expect, test } from "bun:test"
import { ProviderLLMBridge } from "../../src/provider/llm-bridge"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderTest } from "../fake/provider"
import type { Provider } from "../../src/provider"
const model = (input: {
readonly id: string
readonly providerID: string
readonly npm: string
readonly apiID?: string
readonly apiURL?: string
readonly headers?: Record<string, string>
readonly options?: Record<string, unknown>
readonly reasoning?: boolean
readonly toolcall?: boolean
readonly variants?: Provider.Model["variants"]
}): Provider.Model => {
const base = ProviderTest.model()
return ProviderTest.model({
id: ModelID.make(input.id),
providerID: ProviderID.make(input.providerID),
api: { id: input.apiID ?? input.id, url: input.apiURL ?? "", npm: input.npm },
capabilities: {
...base.capabilities,
reasoning: input.reasoning ?? false,
toolcall: input.toolcall ?? true,
},
limit: { context: 128_000, output: 32_000 },
options: input.options ?? {},
headers: input.headers ?? {},
variants: input.variants ?? {},
})
}
const provider = (input: Partial<Provider.Info> & Pick<Provider.Info, "id">) =>
ProviderTest.info({ ...input, models: input.models ?? {} })
describe("ProviderLLMBridge", () => {
test("maps OpenAI-style providers to Responses", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({ id: ProviderID.openai, key: "openai-key" }),
model: model({ id: "gpt-5", providerID: "openai", npm: "@ai-sdk/openai", reasoning: true, variants: { high: {} } }),
})
expect(ref).toMatchObject({
id: "gpt-5",
provider: "openai",
protocol: "openai-responses",
headers: { authorization: "Bearer openai-key" },
limits: { context: 128_000, output: 32_000 },
})
expect(ref?.capabilities.reasoning.efforts).toEqual(["high"])
})
test("maps Anthropic headers and cache capability", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({
id: ProviderID.anthropic,
key: "anthropic-key",
options: { headers: { "anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } },
}),
model: model({ id: "claude-sonnet-4-5", providerID: "anthropic", npm: "@ai-sdk/anthropic" }),
})
expect(ref).toMatchObject({
protocol: "anthropic-messages",
headers: {
"x-api-key": "anthropic-key",
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
},
})
expect(ref?.capabilities.cache).toMatchObject({ prompt: true, contentBlocks: true })
})
test("maps Gemini API keys", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({ id: ProviderID.make("google"), options: { apiKey: "google-key" } }),
model: model({ id: "gemini-2.5-flash", providerID: "google", npm: "@ai-sdk/google" }),
})
expect(ref).toMatchObject({
protocol: "gemini",
headers: { "x-goog-api-key": "google-key" },
})
expect(ref?.capabilities.tools.streamingInput).toBe(false)
})
test("maps known OpenAI-compatible provider families", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({ id: ProviderID.make("togetherai"), options: { apiKey: "together-key" } }),
model: model({
id: "llama",
apiID: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
providerID: "togetherai",
npm: "@ai-sdk/togetherai",
}),
})
expect(ref).toMatchObject({
id: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
provider: "togetherai",
protocol: "openai-compatible-chat",
baseURL: "https://api.together.xyz/v1",
headers: { authorization: "Bearer together-key" },
})
})
test("maps GitHub Copilot through its provider route", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({ id: ProviderID.make("github-copilot"), key: "copilot-key" }),
model: model({ id: "gpt-5", providerID: "github-copilot", npm: "@ai-sdk/github-copilot" }),
})
expect(ref).toMatchObject({
provider: "github-copilot",
protocol: "openai-responses",
headers: { authorization: "Bearer copilot-key" },
})
})
test("maps Azure through its provider route", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({ id: ProviderID.make("azure"), key: "azure-key", options: { useCompletionUrls: true } }),
model: model({ id: "gpt-4.1", providerID: "azure", npm: "@ai-sdk/azure" }),
})
expect(ref).toMatchObject({
provider: "azure",
protocol: "openai-chat",
headers: { authorization: "Bearer azure-key" },
})
})
test("keeps provider and model overrides ahead of defaults", () => {
const ref = ProviderLLMBridge.toModelRef({
provider: provider({
id: ProviderID.make("cerebras"),
key: "cerebras-key",
options: {
baseURL: "https://custom.cerebras.test/v1",
headers: { "X-Cerebras-3rd-Party-Integration": "opencode" },
},
}),
model: model({
id: "cerebras-model",
providerID: "cerebras",
npm: "@ai-sdk/cerebras",
headers: { "x-model-header": "1" },
}),
})
expect(ref).toMatchObject({
protocol: "openai-compatible-chat",
baseURL: "https://custom.cerebras.test/v1",
headers: {
authorization: "Bearer cerebras-key",
"X-Cerebras-3rd-Party-Integration": "opencode",
"x-model-header": "1",
},
})
})
test("leaves undecided provider packages unmapped", () => {
expect(
ProviderLLMBridge.toModelRef({
provider: provider({ id: ProviderID.make("mistral"), key: "mistral-key" }),
model: model({ id: "mistral-large", providerID: "mistral", npm: "@ai-sdk/mistral" }),
}),
).toBeUndefined()
})
})