feat(llm): add provider-routed adapter composition
This commit is contained in:
@@ -409,6 +409,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:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LLMResponse, NoAdapterError, PreparedRequest as PreparedRequestSchema }
|
||||
|
||||
interface RuntimeAdapter {
|
||||
readonly id: string
|
||||
readonly provider?: string
|
||||
readonly protocol: Protocol
|
||||
readonly patches: ReadonlyArray<Patch<unknown>>
|
||||
readonly redact: (target: unknown) => unknown
|
||||
@@ -28,6 +29,7 @@ export interface HttpContext {
|
||||
|
||||
export interface Adapter<Draft, Target> {
|
||||
readonly id: string
|
||||
readonly provider?: string
|
||||
readonly protocol: Protocol
|
||||
readonly patches: ReadonlyArray<Patch<Draft>>
|
||||
readonly redact: (target: Target) => unknown
|
||||
@@ -39,6 +41,7 @@ export interface Adapter<Draft, Target> {
|
||||
|
||||
export interface AdapterInput<Draft, Target> {
|
||||
readonly id: string
|
||||
readonly provider?: string
|
||||
readonly protocol: Protocol
|
||||
readonly patches?: ReadonlyArray<Patch<Draft>>
|
||||
readonly redact: (target: Target) => unknown
|
||||
@@ -54,6 +57,19 @@ export interface AdapterDefinition<Draft, Target> extends Adapter<Draft, Target>
|
||||
readonly withPatches: (patches: ReadonlyArray<Patch<Draft>>) => AdapterDefinition<Draft, Target>
|
||||
}
|
||||
|
||||
export interface ComposeInput<Draft, Target> {
|
||||
readonly id: string
|
||||
readonly provider?: string
|
||||
readonly protocol?: Protocol
|
||||
readonly base: Adapter<Draft, Target>
|
||||
readonly patches?: ReadonlyArray<Patch<Draft>>
|
||||
readonly redact?: (target: Target) => unknown
|
||||
readonly prepare?: (request: LLMRequest) => Effect.Effect<Draft, LLMError>
|
||||
readonly validate?: (draft: Draft) => Effect.Effect<Target, LLMError>
|
||||
readonly toHttp?: (target: Target, context: HttpContext) => Effect.Effect<HttpClientRequest.HttpClientRequest, LLMError>
|
||||
readonly parse?: (response: HttpClientResponse.HttpClientResponse) => Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export interface LLMClient {
|
||||
readonly prepare: (request: LLMRequest) => Effect.Effect<PreparedRequest, LLMError>
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError, RequestExecutor.Service>
|
||||
@@ -77,6 +93,7 @@ const normalizeRegistry = (patches: PatchRegistry | ReadonlyArray<AnyPatch> | un
|
||||
export function define<Draft, Target>(input: AdapterInput<Draft, Target>): AdapterDefinition<Draft, Target> {
|
||||
const build = (patches: ReadonlyArray<Patch<Draft>>): AdapterDefinition<Draft, Target> => ({
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
protocol: input.protocol,
|
||||
patches,
|
||||
get runtime() {
|
||||
@@ -94,13 +111,41 @@ export function define<Draft, Target>(input: AdapterInput<Draft, Target>): Adapt
|
||||
return build(input.patches ?? [])
|
||||
}
|
||||
|
||||
export function compose<Draft, Target>(input: ComposeInput<Draft, Target>): AdapterDefinition<Draft, Target> {
|
||||
return define({
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
protocol: input.protocol ?? input.base.protocol,
|
||||
patches: [...input.base.patches, ...(input.patches ?? [])],
|
||||
redact: input.redact ?? input.base.redact,
|
||||
prepare: input.prepare ?? input.base.prepare,
|
||||
validate: input.validate ?? input.base.validate,
|
||||
toHttp: input.toHttp ?? input.base.toHttp,
|
||||
parse: input.parse ?? input.base.parse,
|
||||
})
|
||||
}
|
||||
|
||||
export function client(options: ClientOptions): LLMClient {
|
||||
const registry = normalizeRegistry(options.patches)
|
||||
const adapters = new Map(options.adapters.map((adapter) => [adapter.runtime.protocol, adapter.runtime] as const))
|
||||
const adapters = options.adapters.map((adapter) => adapter.runtime)
|
||||
const providerAdapters = adapters
|
||||
.filter((adapter): adapter is RuntimeAdapter & { readonly provider: string } => adapter.provider !== undefined)
|
||||
.reduce((map, adapter) => {
|
||||
const current = map.get(adapter.provider) ?? new Map<Protocol, RuntimeAdapter>()
|
||||
current.set(adapter.protocol, adapter)
|
||||
return map.set(adapter.provider, current)
|
||||
}, new Map<string, Map<Protocol, RuntimeAdapter>>())
|
||||
const protocolAdapters = new Map(
|
||||
adapters
|
||||
.filter((adapter) => adapter.provider === undefined)
|
||||
.map((adapter) => [adapter.protocol, adapter] as const),
|
||||
)
|
||||
|
||||
const resolveAdapter = (request: LLMRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const adapter = adapters.get(request.model.protocol)
|
||||
const adapter =
|
||||
providerAdapters.get(request.model.provider)?.get(request.model.protocol) ??
|
||||
protocolAdapters.get(request.model.protocol)
|
||||
if (!adapter) return yield* noAdapter(request.model)
|
||||
return adapter
|
||||
})
|
||||
|
||||
@@ -9,7 +9,14 @@ export * as LLM from "./llm"
|
||||
export * as ProviderPatch from "./provider/patch"
|
||||
export * as Schema from "./schema"
|
||||
export { AnthropicMessages } from "./provider/anthropic-messages"
|
||||
export { Anthropic } from "./provider/anthropic"
|
||||
export { Azure } from "./provider/azure"
|
||||
export { Gemini } from "./provider/gemini"
|
||||
export { Google } from "./provider/google"
|
||||
export { GitHubCopilot } from "./provider/github-copilot"
|
||||
export { OpenAI } from "./provider/openai"
|
||||
export { OpenAIChat } from "./provider/openai-chat"
|
||||
export { OpenAICompatibleChat } from "./provider/openai-compatible-chat"
|
||||
export { OpenAIResponses } from "./provider/openai-responses"
|
||||
export { ProviderRoute } from "./provider-route"
|
||||
export { XAI } from "./provider/xai"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Protocol } from "./schema"
|
||||
|
||||
export interface ProviderRoute {
|
||||
readonly provider: string
|
||||
readonly protocol: Protocol
|
||||
}
|
||||
|
||||
export interface ProviderRouteInput {
|
||||
readonly modelID: string
|
||||
readonly providerID: string
|
||||
readonly options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ProviderDefinition {
|
||||
readonly id: string
|
||||
readonly route: (input: ProviderRouteInput) => ProviderRoute | undefined
|
||||
}
|
||||
|
||||
export const make = (provider: string, protocol: Protocol): ProviderRoute => ({ provider, protocol })
|
||||
|
||||
export const define = (input: ProviderDefinition): ProviderDefinition => input
|
||||
|
||||
export const fixed = (provider: string, protocol: Protocol): ProviderDefinition => {
|
||||
const route = make(provider, protocol)
|
||||
return define({ id: provider, route: () => route })
|
||||
}
|
||||
|
||||
export * as ProviderRoute from "./provider-route"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export const provider = ProviderRoute.fixed("anthropic", "anthropic-messages")
|
||||
|
||||
export * as Anthropic from "./anthropic"
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export const id = "azure"
|
||||
|
||||
export const provider = ProviderRoute.define({
|
||||
id,
|
||||
route: (input) => ProviderRoute.make(id, input.options.useCompletionUrls ? "openai-chat" : "openai-responses"),
|
||||
})
|
||||
|
||||
export const route = provider.route
|
||||
|
||||
export * as Azure from "./azure"
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export const id = "github-copilot"
|
||||
|
||||
export const shouldUseResponsesApi = (modelID: string) => {
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const provider = ProviderRoute.define({
|
||||
id,
|
||||
route: (input) => ProviderRoute.make(id, shouldUseResponsesApi(input.modelID) ? "openai-responses" : "openai-chat"),
|
||||
})
|
||||
|
||||
export const route = provider.route
|
||||
|
||||
export * as GitHubCopilot from "./github-copilot"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export const provider = ProviderRoute.fixed("google", "gemini")
|
||||
|
||||
export * as Google from "./google"
|
||||
@@ -4,6 +4,7 @@ import { Adapter } from "../adapter"
|
||||
import { capabilities, model as llmModel, type ModelInput } from "../llm"
|
||||
import { InvalidRequestError, ProviderChunkError, type LLMError, type LLMRequest } from "../schema"
|
||||
import { OpenAIChat, type OpenAIChatTarget } from "./openai-chat"
|
||||
import { families, type ProviderFamily } from "./openai-compatible-family"
|
||||
import { ProviderShared } from "./shared"
|
||||
|
||||
const ADAPTER = "openai-compatible-chat"
|
||||
@@ -19,20 +20,6 @@ export type ProviderFamilyModelInput = Omit<OpenAICompatibleChatModelInput, "pro
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
interface ProviderFamily {
|
||||
readonly provider: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
const families = {
|
||||
baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
|
||||
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
|
||||
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
|
||||
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
|
||||
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
|
||||
togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
|
||||
} as const satisfies Record<string, ProviderFamily>
|
||||
|
||||
const invalid = (message: string) => new InvalidRequestError({ message })
|
||||
|
||||
const isStringRecord = (value: unknown): value is Record<string, string> =>
|
||||
@@ -74,12 +61,10 @@ const mapParseError = (error: LLMError) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const adapter = Adapter.define<OpenAIChatTarget, OpenAIChatTarget>({
|
||||
export const adapter = Adapter.compose<OpenAIChatTarget, OpenAIChatTarget>({
|
||||
id: ADAPTER,
|
||||
base: OpenAIChat.adapter,
|
||||
protocol: "openai-compatible-chat",
|
||||
redact: OpenAIChat.adapter.redact,
|
||||
prepare: OpenAIChat.adapter.prepare,
|
||||
validate: OpenAIChat.adapter.validate,
|
||||
toHttp: (target, context) => toHttp(target, context.request),
|
||||
parse: (response) => OpenAIChat.adapter.parse(response).pipe(Stream.mapError(mapParseError)),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export interface ProviderFamily {
|
||||
readonly provider: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export const families = {
|
||||
baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
|
||||
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
|
||||
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
|
||||
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
|
||||
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
|
||||
togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
|
||||
} as const satisfies Record<string, ProviderFamily>
|
||||
|
||||
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 provider = ProviderRoute.define({
|
||||
id: "openai-compatible",
|
||||
route: (input) => route(input.providerID),
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleFamily from "./openai-compatible-family"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export const provider = ProviderRoute.fixed("openai", "openai-responses")
|
||||
|
||||
export * as OpenAI from "./openai"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProviderRoute } from "../provider-route"
|
||||
|
||||
export const provider = ProviderRoute.fixed("xai", "openai-responses")
|
||||
|
||||
export * as XAI from "./xai"
|
||||
@@ -10,7 +10,8 @@ export const Protocol = Schema.Literals([
|
||||
])
|
||||
export type Protocol = Schema.Schema.Type<typeof Protocol>
|
||||
|
||||
export const ReasoningEffort = Schema.Literals(["none", "minimal", "low", "medium", "high", "xhigh", "max"])
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||
|
||||
export const PatchPhase = Schema.Literals(["request", "prompt", "tool-schema", "target", "stream"])
|
||||
|
||||
@@ -80,6 +80,13 @@ const gemini = Adapter.define<FakeDraft, FakeDraft>({
|
||||
protocol: "gemini",
|
||||
})
|
||||
|
||||
const providerFake = Adapter.compose({
|
||||
id: "provider-fake",
|
||||
provider: "fake-provider",
|
||||
base: fake,
|
||||
prepare: (request) => fake.prepare(request).pipe(Effect.map((draft) => ({ ...draft, body: `provider:${draft.body}` }))),
|
||||
})
|
||||
|
||||
const echoLayer = dynamicResponse(({ text, respond }) =>
|
||||
Effect.succeed(
|
||||
respond(
|
||||
@@ -136,6 +143,15 @@ describe("llm adapter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers provider-specific adapters over protocol fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* client({ adapters: [fake, providerFake] }).prepare(request)
|
||||
|
||||
expect(prepared.adapter).toBe("provider-fake")
|
||||
expect(prepared.target).toEqual({ body: "provider:hello" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("request, prompt, and tool-schema patches run before adapter prepare", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* client({
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user