refactor(llm): clarify provider resolution

This commit is contained in:
Kit Langton
2026-04-27 22:39:22 -04:00
parent 1cd53b27ec
commit b0be03facd
13 changed files with 202 additions and 134 deletions
+8 -2
View File
@@ -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"
+57
View File
@@ -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<string, unknown>
}
export interface ProviderResolver {
readonly id: ProviderIDType
readonly resolve: (input: ProviderResolveInput) => ProviderResolution | undefined
}
export const make = (
provider: string | ProviderIDType,
protocol: Protocol,
options: Omit<ProviderResolution, "provider" | "protocol"> = {},
): ProviderResolution => ({
provider: ProviderID.make(provider),
protocol,
...options,
})
export const define = (input: ProviderResolver): ProviderResolver => input
export const fixed = (
provider: string | ProviderIDType,
protocol: Protocol,
options: Omit<ProviderResolution, "provider" | "protocol"> = {},
): 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<string, unknown>,
): ProviderResolveInput => ({
modelID: ModelID.make(modelID),
providerID: ProviderID.make(providerID),
options,
})
export * as ProviderResolver from "./provider-resolver"
-42
View File
@@ -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<string, unknown>
}
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<string, unknown>,
): ProviderRouteInput => ({
modelID: ModelID.make(modelID),
providerID: ProviderID.make(providerID),
options,
})
export * as ProviderRoute from "./provider-route"
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+4 -5
View File
@@ -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"
+4 -5
View File
@@ -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"
+2 -2
View File
@@ -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"
@@ -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<string, ProviderFamily> = 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"
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
@@ -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",
})
})
})
+79 -65
View File
@@ -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<string, ProviderDefinition> = {
"@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<string, ProviderResolverShape> = {
"@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<ReasoningEffort>(ReasoningEfforts)
@@ -52,29 +53,29 @@ const recordOption = (options: Record<string, unknown>, key: string): Record<str
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"))
}
export const route = (
export const resolve = (
input: Input,
options: Record<string, unknown> = { ...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<string, unknown>) => {
const baseURL = (input: Input, resolution: ProviderResolution, 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
return resolution.baseURL
}
const authHeader = (selected: Protocol, apiKey: string | undefined): Record<string, string> => {
const authHeader = (auth: ProviderAuth | undefined, 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 }
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<string, unknown>) => {
const headers = (input: Input, resolution: ProviderResolution, options: Record<string, unknown>) => {
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,