From b0be03facd7f2200db19284a1f07abb4f885a835 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:39:22 -0400 Subject: [PATCH] refactor(llm): clarify provider resolution --- packages/llm/src/index.ts | 10 +- packages/llm/src/provider-resolver.ts | 57 +++++++ packages/llm/src/provider-route.ts | 42 ----- packages/llm/src/provider/amazon-bedrock.ts | 4 +- packages/llm/src/provider/anthropic.ts | 4 +- packages/llm/src/provider/azure.ts | 9 +- packages/llm/src/provider/github-copilot.ts | 9 +- packages/llm/src/provider/google.ts | 4 +- .../src/provider/openai-compatible-family.ts | 11 +- packages/llm/src/provider/openai.ts | 4 +- packages/llm/src/provider/xai.ts | 4 +- packages/llm/test/provider-resolver.test.ts | 34 +++++ packages/opencode/src/provider/llm-bridge.ts | 144 ++++++++++-------- 13 files changed, 202 insertions(+), 134 deletions(-) create mode 100644 packages/llm/src/provider-resolver.ts delete mode 100644 packages/llm/src/provider-route.ts create mode 100644 packages/llm/test/provider-resolver.test.ts diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index d3b0f46d2a..59d4e3db53 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -8,7 +8,13 @@ export * from "./tool-runtime" export * as LLM from "./llm" export * as ProviderPatch from "./provider/patch" export * as Schema from "./schema" -export type { ProviderDefinition, ProviderRoute as ProviderRouteShape, ProviderRouteInput } from "./provider-route" +export type { CapabilitiesInput } from "./llm" +export type { + ProviderAuth, + ProviderResolution, + ProviderResolveInput, + ProviderResolver as ProviderResolverShape, +} from "./provider-resolver" export { AnthropicMessages } from "./provider/anthropic-messages" export { AmazonBedrock } from "./provider/amazon-bedrock" export { Anthropic } from "./provider/anthropic" @@ -22,5 +28,5 @@ export { OpenAIChat } from "./provider/openai-chat" export { OpenAICompatibleChat } from "./provider/openai-compatible-chat" export { OpenAICompatibleFamily } from "./provider/openai-compatible-family" export { OpenAIResponses } from "./provider/openai-responses" -export { ProviderRoute } from "./provider-route" +export { ProviderResolver } from "./provider-resolver" export { XAI } from "./provider/xai" diff --git a/packages/llm/src/provider-resolver.ts b/packages/llm/src/provider-resolver.ts new file mode 100644 index 0000000000..03c4bf66fc --- /dev/null +++ b/packages/llm/src/provider-resolver.ts @@ -0,0 +1,57 @@ +import { ModelID, ProviderID, type Protocol } from "./schema" +import type { ModelID as ModelIDType, ProviderID as ProviderIDType } from "./schema" +import type { CapabilitiesInput } from "./llm" + +export type ProviderAuth = "bearer" | "anthropic-api-key" | "google-api-key" | "none" + +export interface ProviderResolution { + readonly provider: ProviderIDType + readonly protocol: Protocol + readonly baseURL?: string + readonly auth?: ProviderAuth + readonly capabilities?: CapabilitiesInput +} + +export interface ProviderResolveInput { + readonly modelID: ModelIDType + readonly providerID: ProviderIDType + readonly options: Record +} + +export interface ProviderResolver { + readonly id: ProviderIDType + readonly resolve: (input: ProviderResolveInput) => ProviderResolution | undefined +} + +export const make = ( + provider: string | ProviderIDType, + protocol: Protocol, + options: Omit = {}, +): ProviderResolution => ({ + provider: ProviderID.make(provider), + protocol, + ...options, +}) + +export const define = (input: ProviderResolver): ProviderResolver => input + +export const fixed = ( + provider: string | ProviderIDType, + protocol: Protocol, + options: Omit = {}, +): ProviderResolver => { + const resolution = make(provider, protocol, options) + return define({ id: resolution.provider, resolve: () => resolution }) +} + +export const input = ( + modelID: string | ModelIDType, + providerID: string | ProviderIDType, + options: Record, +): ProviderResolveInput => ({ + modelID: ModelID.make(modelID), + providerID: ProviderID.make(providerID), + options, +}) + +export * as ProviderResolver from "./provider-resolver" diff --git a/packages/llm/src/provider-route.ts b/packages/llm/src/provider-route.ts deleted file mode 100644 index 6875a86a57..0000000000 --- a/packages/llm/src/provider-route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ModelID, ProviderID, type Protocol } from "./schema" -import type { ModelID as ModelIDType, ProviderID as ProviderIDType } from "./schema" - -export interface ProviderRoute { - readonly provider: ProviderIDType - readonly protocol: Protocol -} - -export interface ProviderRouteInput { - readonly modelID: ModelIDType - readonly providerID: ProviderIDType - readonly options: Record -} - -export interface ProviderDefinition { - readonly id: ProviderIDType - readonly route: (input: ProviderRouteInput) => ProviderRoute | undefined -} - -export const make = (provider: string | ProviderIDType, protocol: Protocol): ProviderRoute => ({ - provider: ProviderID.make(provider), - protocol, -}) - -export const define = (input: ProviderDefinition): ProviderDefinition => input - -export const fixed = (provider: string | ProviderIDType, protocol: Protocol): ProviderDefinition => { - const route = make(provider, protocol) - return define({ id: route.provider, route: () => route }) -} - -export const input = ( - modelID: string | ModelIDType, - providerID: string | ProviderIDType, - options: Record, -): ProviderRouteInput => ({ - modelID: ModelID.make(modelID), - providerID: ProviderID.make(providerID), - options, -}) - -export * as ProviderRoute from "./provider-route" diff --git a/packages/llm/src/provider/amazon-bedrock.ts b/packages/llm/src/provider/amazon-bedrock.ts index ae0ac3fcfb..20755e8f95 100644 --- a/packages/llm/src/provider/amazon-bedrock.ts +++ b/packages/llm/src/provider/amazon-bedrock.ts @@ -1,5 +1,5 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" -export const provider = ProviderRoute.fixed("amazon-bedrock", "bedrock-converse") +export const resolver = ProviderResolver.fixed("amazon-bedrock", "bedrock-converse", { auth: "bearer" }) export * as AmazonBedrock from "./amazon-bedrock" diff --git a/packages/llm/src/provider/anthropic.ts b/packages/llm/src/provider/anthropic.ts index 8c246ada00..c4d48e9930 100644 --- a/packages/llm/src/provider/anthropic.ts +++ b/packages/llm/src/provider/anthropic.ts @@ -1,5 +1,5 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" -export const provider = ProviderRoute.fixed("anthropic", "anthropic-messages") +export const resolver = ProviderResolver.fixed("anthropic", "anthropic-messages", { auth: "anthropic-api-key" }) export * as Anthropic from "./anthropic" diff --git a/packages/llm/src/provider/azure.ts b/packages/llm/src/provider/azure.ts index 981c0d16f3..ce0515a411 100644 --- a/packages/llm/src/provider/azure.ts +++ b/packages/llm/src/provider/azure.ts @@ -1,13 +1,12 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" import { ProviderID } from "../schema" export const id = ProviderID.make("azure") -export const provider = ProviderRoute.define({ +export const resolver = ProviderResolver.define({ id, - route: (input) => ProviderRoute.make(id, input.options.useCompletionUrls ? "openai-chat" : "openai-responses"), + resolve: (input) => + ProviderResolver.make(id, input.options.useCompletionUrls ? "openai-chat" : "openai-responses", { auth: "bearer" }), }) -export const route = provider.route - export * as Azure from "./azure" diff --git a/packages/llm/src/provider/github-copilot.ts b/packages/llm/src/provider/github-copilot.ts index d1aee79769..351479fd69 100644 --- a/packages/llm/src/provider/github-copilot.ts +++ b/packages/llm/src/provider/github-copilot.ts @@ -1,4 +1,4 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" import { ProviderID } from "../schema" export const id = ProviderID.make("github-copilot") @@ -9,11 +9,10 @@ export const shouldUseResponsesApi = (modelID: string) => { return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } -export const provider = ProviderRoute.define({ +export const resolver = ProviderResolver.define({ id, - route: (input) => ProviderRoute.make(id, shouldUseResponsesApi(input.modelID) ? "openai-responses" : "openai-chat"), + resolve: (input) => + ProviderResolver.make(id, shouldUseResponsesApi(input.modelID) ? "openai-responses" : "openai-chat", { auth: "bearer" }), }) -export const route = provider.route - export * as GitHubCopilot from "./github-copilot" diff --git a/packages/llm/src/provider/google.ts b/packages/llm/src/provider/google.ts index e3a13e60a9..128fb57fba 100644 --- a/packages/llm/src/provider/google.ts +++ b/packages/llm/src/provider/google.ts @@ -1,5 +1,5 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" -export const provider = ProviderRoute.fixed("google", "gemini") +export const resolver = ProviderResolver.fixed("google", "gemini", { auth: "google-api-key" }) export * as Google from "./google" diff --git a/packages/llm/src/provider/openai-compatible-family.ts b/packages/llm/src/provider/openai-compatible-family.ts index 52e0bb95d0..2923ac2d80 100644 --- a/packages/llm/src/provider/openai-compatible-family.ts +++ b/packages/llm/src/provider/openai-compatible-family.ts @@ -1,4 +1,4 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" export interface ProviderFamily { readonly provider: string @@ -18,11 +18,12 @@ export const byProvider: Record = Object.fromEntries( Object.values(families).map((family) => [family.provider, family]), ) -export const route = (provider: string) => ProviderRoute.make(provider, "openai-compatible-chat") +export const resolve = (provider: string) => + ProviderResolver.make(provider, "openai-compatible-chat", { baseURL: byProvider[provider]?.baseURL, auth: "bearer" }) -export const provider = ProviderRoute.define({ - id: ProviderRoute.make("openai-compatible", "openai-compatible-chat").provider, - route: (input) => route(input.providerID), +export const resolver = ProviderResolver.define({ + id: ProviderResolver.make("openai-compatible", "openai-compatible-chat").provider, + resolve: (input) => resolve(input.providerID), }) export * as OpenAICompatibleFamily from "./openai-compatible-family" diff --git a/packages/llm/src/provider/openai.ts b/packages/llm/src/provider/openai.ts index c456c41eec..7a8ec35c24 100644 --- a/packages/llm/src/provider/openai.ts +++ b/packages/llm/src/provider/openai.ts @@ -1,5 +1,5 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" -export const provider = ProviderRoute.fixed("openai", "openai-responses") +export const resolver = ProviderResolver.fixed("openai", "openai-responses", { auth: "bearer" }) export * as OpenAI from "./openai" diff --git a/packages/llm/src/provider/xai.ts b/packages/llm/src/provider/xai.ts index db6f583128..cc672eb3a5 100644 --- a/packages/llm/src/provider/xai.ts +++ b/packages/llm/src/provider/xai.ts @@ -1,5 +1,5 @@ -import { ProviderRoute } from "../provider-route" +import { ProviderResolver } from "../provider-resolver" -export const provider = ProviderRoute.fixed("xai", "openai-responses") +export const resolver = ProviderResolver.fixed("xai", "openai-responses", { auth: "bearer" }) export * as XAI from "./xai" diff --git a/packages/llm/test/provider-resolver.test.ts b/packages/llm/test/provider-resolver.test.ts new file mode 100644 index 0000000000..3203f2771b --- /dev/null +++ b/packages/llm/test/provider-resolver.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { GitHubCopilot, OpenAI, OpenAICompatibleFamily, ProviderResolver } from "../src" + +describe("provider resolver", () => { + test("fixed providers resolve protocol and auth defaults", () => { + expect(OpenAI.resolver.resolve(ProviderResolver.input("gpt-5", "openai", {}))).toMatchObject({ + provider: "openai", + protocol: "openai-responses", + auth: "bearer", + }) + }) + + test("dynamic providers can select protocols from model metadata", () => { + expect(GitHubCopilot.resolver.resolve(ProviderResolver.input("gpt-5", "github-copilot", {}))).toMatchObject({ + provider: "github-copilot", + protocol: "openai-responses", + auth: "bearer", + }) + expect(GitHubCopilot.resolver.resolve(ProviderResolver.input("gpt-5-mini", "github-copilot", {}))).toMatchObject({ + provider: "github-copilot", + protocol: "openai-chat", + auth: "bearer", + }) + }) + + test("OpenAI-compatible families carry provider-specific defaults", () => { + expect(OpenAICompatibleFamily.resolver.resolve(ProviderResolver.input("llama", "togetherai", {}))).toMatchObject({ + provider: "togetherai", + protocol: "openai-compatible-chat", + baseURL: "https://api.together.xyz/v1", + auth: "bearer", + }) + }) +}) diff --git a/packages/opencode/src/provider/llm-bridge.ts b/packages/opencode/src/provider/llm-bridge.ts index f75c79044d..0d1f21a40e 100644 --- a/packages/opencode/src/provider/llm-bridge.ts +++ b/packages/opencode/src/provider/llm-bridge.ts @@ -6,13 +6,14 @@ import { LLM, OpenAI, OpenAICompatibleFamily, - ProviderRoute, + ProviderResolver, ReasoningEfforts, XAI, + type CapabilitiesInput, type ModelRef, - type Protocol, - type ProviderDefinition, - type ProviderRouteShape, + type ProviderAuth, + type ProviderResolution, + type ProviderResolverShape, type ReasoningEffort, } from "@opencode-ai/llm" import { isRecord } from "@/util/record" @@ -23,19 +24,19 @@ type Input = { readonly model: Provider.Model } -const PROVIDERS: Record = { - "@ai-sdk/amazon-bedrock": AmazonBedrock.provider, - "@ai-sdk/anthropic": Anthropic.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 PROVIDERS: Record = { + "@ai-sdk/amazon-bedrock": AmazonBedrock.resolver, + "@ai-sdk/anthropic": Anthropic.resolver, + "@ai-sdk/baseten": OpenAICompatibleFamily.resolver, + "@ai-sdk/cerebras": OpenAICompatibleFamily.resolver, + "@ai-sdk/deepinfra": OpenAICompatibleFamily.resolver, + "@ai-sdk/fireworks": OpenAICompatibleFamily.resolver, + "@ai-sdk/github-copilot": GitHubCopilot.resolver, + "@ai-sdk/google": Google.resolver, + "@ai-sdk/openai": OpenAI.resolver, + "@ai-sdk/openai-compatible": OpenAICompatibleFamily.resolver, + "@ai-sdk/togetherai": OpenAICompatibleFamily.resolver, + "@ai-sdk/xai": XAI.resolver, } const REASONING_EFFORTS = new Set(ReasoningEfforts) @@ -52,29 +53,29 @@ const recordOption = (options: Record, key: string): Record typeof entry[1] === "string")) } -export const route = ( +export const resolve = ( input: Input, options: Record = { ...input.provider.options, ...input.model.options }, -): ProviderRouteShape | undefined => - PROVIDERS[input.model.api.npm]?.route(ProviderRoute.input(input.model.api.id, input.model.providerID, options)) +): ProviderResolution | undefined => + PROVIDERS[input.model.api.npm]?.resolve(ProviderResolver.input(input.model.api.id, input.model.providerID, options)) -const baseURL = (input: Input, selected: Protocol, options: Record) => { +const baseURL = (input: Input, resolution: ProviderResolution, options: Record) => { 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 + return resolution.baseURL } -const authHeader = (selected: Protocol, apiKey: string | undefined): Record => { +const authHeader = (auth: ProviderAuth | undefined, apiKey: string | undefined): Record => { if (!apiKey) return {} - if (selected === "anthropic-messages") return { "x-api-key": apiKey } - if (selected === "gemini") return { "x-goog-api-key": apiKey } + if (auth === "none") return {} + if (auth === "anthropic-api-key") return { "x-api-key": apiKey } + if (auth === "google-api-key") return { "x-goog-api-key": apiKey } return { authorization: `Bearer ${apiKey}` } } -const headers = (input: Input, selected: Protocol, options: Record) => { +const headers = (input: Input, resolution: ProviderResolution, options: Record) => { const result = { - ...authHeader(selected, stringOption(options, "apiKey") ?? input.provider.key), + ...authHeader(resolution.auth, stringOption(options, "apiKey") ?? input.provider.key), ...recordOption(options, "headers"), ...input.model.headers, } @@ -86,48 +87,61 @@ const reasoningEfforts = (input: Input) => 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: { - // Both Anthropic Messages and Bedrock Converse honour positional cache - // markers — Anthropic via `cache_control` on content blocks, Bedrock via - // its `cachePoint` marker block (added to BedrockConverse in 9d7d518ac). - prompt: ["anthropic-messages", "bedrock-converse"].includes(selected), - contentBlocks: ["anthropic-messages", "bedrock-converse"].includes(selected), - }, - reasoning: { - efforts: reasoningEfforts(input), - summaries: selected === "openai-responses", - encryptedContent: selected === "openai-responses" || selected === "anthropic-messages", - }, - }) +const mergeCapabilities = (base: CapabilitiesInput, override: CapabilitiesInput | undefined): CapabilitiesInput => ({ + input: { ...base.input, ...override?.input }, + output: { ...base.output, ...override?.output }, + tools: { ...base.tools, ...override?.tools }, + cache: { ...base.cache, ...override?.cache }, + reasoning: { ...base.reasoning, ...override?.reasoning }, +}) + +const capabilities = (input: Input, resolution: ProviderResolution) => + LLM.capabilities( + mergeCapabilities( + { + 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: resolution.protocol !== "gemini" && input.model.capabilities.toolcall, + }, + cache: { + // Both Anthropic Messages and Bedrock Converse honour positional cache + // markers — Anthropic via `cache_control` on content blocks, Bedrock via + // its `cachePoint` marker block (added to BedrockConverse in 9d7d518ac). + prompt: ["anthropic-messages", "bedrock-converse"].includes(resolution.protocol), + contentBlocks: ["anthropic-messages", "bedrock-converse"].includes(resolution.protocol), + }, + reasoning: { + efforts: reasoningEfforts(input), + summaries: resolution.protocol === "openai-responses", + encryptedContent: resolution.protocol === "openai-responses" || resolution.protocol === "anthropic-messages", + }, + }, + resolution.capabilities, + ), + ) export const toModelRef = (input: Input): ModelRef | undefined => { const options = { ...input.provider.options, ...input.model.options } - const selected = route(input, options) - if (!selected) return undefined + const resolution = resolve(input, options) + if (!resolution) 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), + provider: resolution.provider, + protocol: resolution.protocol, + baseURL: baseURL(input, resolution, options), + headers: headers(input, resolution, options), + capabilities: capabilities(input, resolution), limits: LLM.limits({ context: input.model.limit.context, output: input.model.limit.output }), native: { opencodeProviderID: input.provider.id,