refactor(llm): clarify provider resolution
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user