diff --git a/bun.lock b/bun.lock index 41884b717c..037d4c86ed 100644 --- a/bun.lock +++ b/bun.lock @@ -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:*", diff --git a/packages/llm/src/adapter.ts b/packages/llm/src/adapter.ts index 297fc55504..51513b5504 100644 --- a/packages/llm/src/adapter.ts +++ b/packages/llm/src/adapter.ts @@ -8,6 +8,7 @@ import { LLMResponse, NoAdapterError, PreparedRequest as PreparedRequestSchema } interface RuntimeAdapter { readonly id: string + readonly provider?: string readonly protocol: Protocol readonly patches: ReadonlyArray> readonly redact: (target: unknown) => unknown @@ -28,6 +29,7 @@ export interface HttpContext { export interface Adapter { readonly id: string + readonly provider?: string readonly protocol: Protocol readonly patches: ReadonlyArray> readonly redact: (target: Target) => unknown @@ -39,6 +41,7 @@ export interface Adapter { export interface AdapterInput { readonly id: string + readonly provider?: string readonly protocol: Protocol readonly patches?: ReadonlyArray> readonly redact: (target: Target) => unknown @@ -54,6 +57,19 @@ export interface AdapterDefinition extends Adapter readonly withPatches: (patches: ReadonlyArray>) => AdapterDefinition } +export interface ComposeInput { + readonly id: string + readonly provider?: string + readonly protocol?: Protocol + readonly base: Adapter + readonly patches?: ReadonlyArray> + readonly redact?: (target: Target) => unknown + readonly prepare?: (request: LLMRequest) => Effect.Effect + readonly validate?: (draft: Draft) => Effect.Effect + readonly toHttp?: (target: Target, context: HttpContext) => Effect.Effect + readonly parse?: (response: HttpClientResponse.HttpClientResponse) => Stream.Stream +} + export interface LLMClient { readonly prepare: (request: LLMRequest) => Effect.Effect readonly stream: (request: LLMRequest) => Stream.Stream @@ -77,6 +93,7 @@ const normalizeRegistry = (patches: PatchRegistry | ReadonlyArray | un export function define(input: AdapterInput): AdapterDefinition { const build = (patches: ReadonlyArray>): AdapterDefinition => ({ id: input.id, + provider: input.provider, protocol: input.protocol, patches, get runtime() { @@ -94,13 +111,41 @@ export function define(input: AdapterInput): Adapt return build(input.patches ?? []) } +export function compose(input: ComposeInput): AdapterDefinition { + 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() + current.set(adapter.protocol, adapter) + return map.set(adapter.provider, current) + }, new Map>()) + 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 }) diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 678b37e72e..ea69b03702 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -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" diff --git a/packages/llm/src/provider-route.ts b/packages/llm/src/provider-route.ts new file mode 100644 index 0000000000..c4b757391e --- /dev/null +++ b/packages/llm/src/provider-route.ts @@ -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 +} + +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" diff --git a/packages/llm/src/provider/anthropic.ts b/packages/llm/src/provider/anthropic.ts new file mode 100644 index 0000000000..8c246ada00 --- /dev/null +++ b/packages/llm/src/provider/anthropic.ts @@ -0,0 +1,5 @@ +import { ProviderRoute } from "../provider-route" + +export const provider = ProviderRoute.fixed("anthropic", "anthropic-messages") + +export * as Anthropic from "./anthropic" diff --git a/packages/llm/src/provider/azure.ts b/packages/llm/src/provider/azure.ts new file mode 100644 index 0000000000..c1f30b8cbc --- /dev/null +++ b/packages/llm/src/provider/azure.ts @@ -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" diff --git a/packages/llm/src/provider/github-copilot.ts b/packages/llm/src/provider/github-copilot.ts new file mode 100644 index 0000000000..5e5992b9d3 --- /dev/null +++ b/packages/llm/src/provider/github-copilot.ts @@ -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" diff --git a/packages/llm/src/provider/google.ts b/packages/llm/src/provider/google.ts new file mode 100644 index 0000000000..e3a13e60a9 --- /dev/null +++ b/packages/llm/src/provider/google.ts @@ -0,0 +1,5 @@ +import { ProviderRoute } from "../provider-route" + +export const provider = ProviderRoute.fixed("google", "gemini") + +export * as Google from "./google" diff --git a/packages/llm/src/provider/openai-compatible-chat.ts b/packages/llm/src/provider/openai-compatible-chat.ts index f4700cdd66..268f31d1e8 100644 --- a/packages/llm/src/provider/openai-compatible-chat.ts +++ b/packages/llm/src/provider/openai-compatible-chat.ts @@ -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 - const invalid = (message: string) => new InvalidRequestError({ message }) const isStringRecord = (value: unknown): value is Record => @@ -74,12 +61,10 @@ const mapParseError = (error: LLMError) => { }) } -export const adapter = Adapter.define({ +export const adapter = Adapter.compose({ 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)), }) diff --git a/packages/llm/src/provider/openai-compatible-family.ts b/packages/llm/src/provider/openai-compatible-family.ts new file mode 100644 index 0000000000..c06116970a --- /dev/null +++ b/packages/llm/src/provider/openai-compatible-family.ts @@ -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 + +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 provider = ProviderRoute.define({ + id: "openai-compatible", + route: (input) => route(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 new file mode 100644 index 0000000000..c456c41eec --- /dev/null +++ b/packages/llm/src/provider/openai.ts @@ -0,0 +1,5 @@ +import { ProviderRoute } from "../provider-route" + +export const provider = ProviderRoute.fixed("openai", "openai-responses") + +export * as OpenAI from "./openai" diff --git a/packages/llm/src/provider/xai.ts b/packages/llm/src/provider/xai.ts new file mode 100644 index 0000000000..db6f583128 --- /dev/null +++ b/packages/llm/src/provider/xai.ts @@ -0,0 +1,5 @@ +import { ProviderRoute } from "../provider-route" + +export const provider = ProviderRoute.fixed("xai", "openai-responses") + +export * as XAI from "./xai" diff --git a/packages/llm/src/schema.ts b/packages/llm/src/schema.ts index f916356d14..4e9d142df4 100644 --- a/packages/llm/src/schema.ts +++ b/packages/llm/src/schema.ts @@ -10,7 +10,8 @@ export const Protocol = Schema.Literals([ ]) export type Protocol = Schema.Schema.Type -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 export const PatchPhase = Schema.Literals(["request", "prompt", "tool-schema", "target", "stream"]) diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index f5785bbbef..0714cb5aa9 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -80,6 +80,13 @@ const gemini = Adapter.define({ 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({ diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ea91bef74b..7eb8207ce1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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:*", diff --git a/packages/opencode/src/provider/llm-bridge.ts b/packages/opencode/src/provider/llm-bridge.ts new file mode 100644 index 0000000000..a666f92bed --- /dev/null +++ b/packages/opencode/src/provider/llm-bridge.ts @@ -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 = { + "@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(ReasoningEfforts) + +const stringOption = (options: Record, key: string) => { + const value = options[key] + if (typeof value === "string" && value.trim() !== "") return value + return undefined +} + +const recordOption = (options: Record, key: string): Record => { + 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 = { ...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) => { + 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 => { + 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) => { + 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" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d9806d139..8ced1a2044 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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 { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { 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: {}, }), diff --git a/packages/opencode/test/provider/llm-bridge.test.ts b/packages/opencode/test/provider/llm-bridge.test.ts new file mode 100644 index 0000000000..9692bbb60f --- /dev/null +++ b/packages/opencode/test/provider/llm-bridge.test.ts @@ -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 + readonly options?: Record + 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 & Pick) => + 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() + }) +})