From 56ebef0927d449372c51b4cbe975334ab65c63ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 6 May 2026 17:04:42 -0400 Subject: [PATCH] feat(llm): add provider definitions --- packages/llm/DESIGN.provider-plugins.md | 447 ++++++++++++++++++ packages/llm/package.json | 1 + packages/llm/src/index.ts | 6 + packages/llm/src/provider.ts | 25 + packages/llm/src/providers/amazon-bedrock.ts | 9 +- packages/llm/src/providers/anthropic.ts | 9 +- packages/llm/src/providers/azure.ts | 17 +- packages/llm/src/providers/github-copilot.ts | 24 +- packages/llm/src/providers/google.ts | 9 +- .../llm/src/providers/openai-compatible.ts | 23 +- packages/llm/src/providers/openai.ts | 22 +- packages/llm/src/providers/openrouter.ts | 10 +- packages/llm/src/providers/xai.ts | 9 +- packages/llm/test/exports.test.ts | 8 +- packages/opencode/src/provider/llm-bridge.ts | 25 +- .../opencode/test/provider/llm-bridge.test.ts | 33 ++ 16 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 packages/llm/DESIGN.provider-plugins.md create mode 100644 packages/llm/src/provider.ts diff --git a/packages/llm/DESIGN.provider-plugins.md b/packages/llm/DESIGN.provider-plugins.md new file mode 100644 index 0000000000..8e0785a7d2 --- /dev/null +++ b/packages/llm/DESIGN.provider-plugins.md @@ -0,0 +1,447 @@ +# Native Provider Plugin Design + +## Status + +Proposal: make the existing provider module shape explicit as `Provider.Definition`, use it internally for built-ins, and let OpenCode dynamically import third-party packages that export the same definition. + +This should not introduce a second provider abstraction. `Adapter.model(...)` remains the lower-level primitive for turning one adapter route into a model factory. `Provider.Definition` is the uniform provider facade: an ID, a default `model(...)` factory, and optional named APIs such as `chat` or `responses`. + +Do not reuse the existing `models.dev` `npm` field for native routing. That field currently means "AI SDK provider package" and is part of OpenCode's existing fallback path. Add a separate native metadata field instead. + +## Problem + +OpenCode's current provider loading path can import arbitrary AI SDK provider packages because the AI SDK already defines the package contract: + +- Metadata names an npm package like `@ai-sdk/openai`. +- OpenCode imports that package. +- OpenCode finds a `create*` export. +- OpenCode calls the factory with `{ name, apiKey, baseURL, headers, ...options }`. +- The returned object implements the AI SDK model interface. + +The native `@opencode-ai/llm` path has no equivalent package contract yet. A native model cannot be resolved from an npm package name alone because it must know: + +- Which public model factory to call. +- Which model API, if any, should be selected explicitly. +- Which endpoint and base URL rules apply. +- Which auth renderer applies. +- Which provider option namespace and option lowering apply. +- Which model capabilities and limits OpenCode should attach. +- Which provider-specific behavior belongs in code rather than `models.dev` data. + +The current OpenCode bridge therefore uses a local table from AI SDK package identifiers to built-in native provider helpers. That is good enough for migration, but not enough for third-party native providers. + +## Goals + +- Let third parties publish native OpenCode LLM providers as npm packages. +- Make provider packages explicit and type-checkable instead of guessing export names. +- Keep built-in providers and external packages using one self-similar provider interface. +- Reuse `Adapter.model(...)` as the implementation primitive instead of creating a competing model factory abstraction. +- Keep `models.dev` metadata declarative and serializable. +- Keep provider-specific signing, parsing, URL construction, and option lowering in code. +- Preserve the existing AI SDK provider path as a fallback while native support rolls out. +- Support OpenAI-compatible provider families without requiring a new package for every base URL. + +## Non-Goals + +- Do not dynamically import arbitrary packages and guess a `create*` export for native providers. +- Do not encode protocol parsers, auth signing logic, stream framing, or arbitrary functions in `models.dev`. +- Do not make every provider option portable across providers. +- Do not require immediate extraction of every built-in provider into its own package. +- Do not remove the AI SDK path as part of this design. + +## Recommended Shape + +Add a first-class provider definition contract to `@opencode-ai/llm`. A native provider package is simply an npm package that exports a `Provider.Definition`. + +```ts +export interface Definition { + readonly id: ProviderID + readonly model: Factory + readonly apis?: Record +} + +export type ModelFactory = ( + id: string | ModelID, + options?: Options, +) => ModelRef + +export type ModelOptions = Omit + +type AnyModelFactory = (...args: never[]) => ModelRef + +export const make = (definition: DefinitionType) => definition +``` + +The contract is intentionally close to what provider modules already export today: + +- `id`: native provider ID. +- `model`: default model factory. +- `apis`: optional named factories for providers with multiple first-class APIs. + +Provider IDs and model IDs should use the existing branded types from `src/schema.ts`: `ProviderID` and `ModelID`. Public factories may accept `string | ModelID` for ergonomics, but they normalize to branded IDs at the boundary before constructing a `ModelRef`. + +The model factory shape is fixed on purpose: `(id, options) => ModelRef`. Provider-specific differences belong in the options type, not in positional arguments. `Provider.make(...)` preserves each provider's actual option type, including whether options are optional or required. + +`Provider.Definition.model(...)` should usually be implemented with `Adapter.model(...)` or existing protocol helpers. The layers are: + +```text +Protocol + Endpoint + Auth + Framing -> Adapter +Adapter.model(...) -> route-specific model factory +Provider.Definition -> uniform provider facade / package contract +``` + +Adapters are deliberately not part of the provider package contract. They are implementation details owned by the model factories. `Adapter.make(...)` registers runnable adapters when a provider module is loaded, and `Adapter.model(...)` also ensures the selected adapter is registered when a model factory is called. Keeping adapter lists out of `Provider.Definition` avoids a second source of truth. + +Provider packages export a provider definition that is both the dynamic-loading contract and the direct user-facing entry point: + +```ts +import { Provider } from "@opencode-ai/llm/provider" +import * as OpenRouter from "./openrouter" + +export const provider = Provider.make({ + id: ProviderID.make("openrouter"), + model: OpenRouter.model, +}) + +export const model = provider.model +export default provider +``` + +Direct users can consume the definition instead of a separate helper namespace: + +```ts +import OpenRouter from "@opencode-ai/llm-provider-openrouter" + +const model = OpenRouter.model("openai/gpt-4o-mini", { apiKey }) +``` + +Named exports are convenience aliases for users who prefer `import { model } from ...`; they should point back to the provider definition rather than duplicating implementation. + +Providers with multiple public model APIs expose those factories without making OpenCode know provider-specific function names: + +```ts +export const provider = Provider.make({ + id: ProviderID.make("openai"), + model: OpenAI.model, + apis: { + responses: OpenAI.responses, + chat: OpenAI.chat, + }, +}) + +export const model = provider.model +export const responses = provider.apis.responses +export const chat = provider.apis.chat +export default provider +``` + +Direct users can still write `OpenAI.responses(...)` or `OpenAI.chat(...)`, but those helpers should be aliases of the provider definition. The provider definition is the source of truth; dynamic loaders and direct users consume the same object. + +This mirrors the AI SDK OpenAI provider shape: `openai(modelId)` is the default factory, while `openai.responses(modelId)`, `openai.chat(modelId)`, and `openai.completion(modelId)` explicitly select an OpenAI API. + +## OpenCode Resolve Input + +OpenCode still needs to translate `models.dev` and config into provider model options. That translation should live in the OpenCode bridge, not in a separate plugin-only API. + +```ts +type NativeProviderModelInput = Provider.ModelOptions & { + readonly apiID: string + readonly apiURL?: string +} +``` + +Bridge rule: + +```ts +const factory = native.api ? provider.apis?.[native.api] : provider.model +return factory?.(input.apiID, { + ...input.options, + apiKey: input.apiKey, + baseURL: input.apiURL, + headers: input.headers, + capabilities: input.capabilities, + limits: input.limits, + providerOptions: input.providerOptions, +}) +``` + +That keeps provider modules self-similar. Built-ins, external packages, and OpenCode all call the same `model(id, options)` shape. + +## Ideal Usage API + +The public use site should feel like AI SDK's provider objects, but return native `ModelRef` values. + +Default provider API: + +```ts +import { LLM } from "@opencode-ai/llm" +import { OpenAI } from "@opencode-ai/llm/providers" + +const model = OpenAI.model("gpt-5", { + apiKey, + providerOptions: { + openai: { store: false }, + }, +}) + +const request = LLM.request({ + model, + prompt: "Explain this in one paragraph.", +}) +``` + +Explicit provider model API, for providers with more than one first-class API: + +```ts +const responsesModel = OpenAI.apis.responses("gpt-5", { apiKey }) +const chatModel = OpenAI.apis.chat("gpt-4o", { apiKey }) +``` + +Named aliases can exist for ergonomics, but they should be aliases of the provider definition: + +```ts +const responsesModel = OpenAI.responses("gpt-5", { apiKey }) +const chatModel = OpenAI.chat("gpt-4o", { apiKey }) +``` + +Third-party providers should look the same: + +```ts +import Acme from "@acme/opencode-llm-provider" + +const model = Acme.model("acme-large", { + apiKey, + baseURL: "https://llm.acme.test/v1", +}) +``` + +OpenCode's dynamic path should consume the same object the user sees: + +```ts +const provider = await loadProviderDefinition(native.npm) +const create = native.api ? provider.apis?.[native.api] : provider.model +const model = create?.(apiID, options) +``` + +The important invariant: there is no plugin-only shape. The default export from a provider package is the user-facing provider object and the dynamic-loading contract. + +## Metadata + +Keep AI SDK metadata and native metadata separate. + +```json +{ + "npm": "@openrouter/ai-sdk-provider", + "opencode": { + "provider": "openrouter", + "npm": "@opencode-ai/llm-provider-openrouter" + } +} +``` + +For built-in providers, `opencode.npm` can be omitted: + +```json +{ + "npm": "@ai-sdk/openai", + "opencode": { + "provider": "openai" + } +} +``` + +For OpenAI-compatible providers that only need a base URL/profile, use a built-in generic native provider: + +```json +{ + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.example.com/v1", + "opencode": { + "provider": "openai-compatible" + } +} +``` + +Model-level overrides may refine the provider model API without replacing the whole provider: + +```json +{ + "provider": { + "npm": "@ai-sdk/azure", + "opencode": { + "provider": "azure", + "api": "chat" + } + } +} +``` + +Recommended metadata fields: + +```ts +type ModelsDevProviderNative = { + readonly provider: string + readonly npm?: string + readonly api?: string + readonly profile?: string +} +``` + +`provider` selects a native provider definition. `npm` optionally names an external native provider package. `api` selects a named provider API such as `chat` or `responses`. `profile` is a declarative hint that built-in generic providers may use; it is not executable code. + +## Resolution Flow + +OpenCode's native bridge should resolve a model in this order: + +1. Read `model.provider.opencode` if present, otherwise `provider.opencode`. +2. If `opencode.npm` is present, dynamically import that package and validate its default export as a `Provider.Definition`. +3. Otherwise find a built-in plugin by `opencode.provider`. +4. If no native metadata exists, fall back to the temporary compatibility map from AI SDK package names to built-in plugins. +5. Translate OpenCode's `Provider.Info` and `Provider.Model` into provider model options. +6. Select `provider.apis[opencode.api]` when an API is present, otherwise use `provider.model`. +7. Call the selected model factory with `apiID` and model options to get a `ModelRef`. +8. If no provider or model API exists, treat the model as unsupported by the native path and fall back to the AI SDK path. + +The compatibility map should be treated as migration glue, not the long-term source of truth. + +## Built-In Providers + +Built-ins should use the same provider definition contract as external packages. + +```ts +export const openai = Provider.make({ + id: ProviderID.make("openai"), + model: OpenAI.model, + apis: { + responses: OpenAI.responses, + chat: OpenAI.chat, + }, +}) +``` + +`@opencode-ai/llm/providers` can continue exporting helper namespaces for direct users. A new registry module can export plugins: + +```ts +export const builtins = { + openai, + anthropic, + google, + azure, + openrouter, + "openai-compatible": openAICompatible, +} +``` + +This keeps OpenCode's bridge generic while preserving the ergonomic direct API: + +```ts +const model = OpenAI.model("gpt-5", { apiKey }) +``` + +## Package Boundaries + +Keep provider implementations in-tree until the plugin API stabilizes. Extract later where package boundaries provide real value. + +Good extraction candidates: + +- `@opencode-ai/llm-provider-bedrock`: AWS SigV4, event-stream framing, region/profile handling. +- `@opencode-ai/llm-provider-vertex`: Google auth, project/location routing, Gemini and Anthropic variants. +- `@opencode-ai/llm-provider-openrouter`: OpenRouter-specific routing, usage, reasoning, cache, and provider selection fields. +- `@opencode-ai/llm-provider-azure`: Azure resource/deployment URL policy and API-key/AAD auth. + +Keep shared code in `@opencode-ai/llm`: + +- Protocols such as OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, and Bedrock Converse. +- Adapter primitives: `Adapter`, `Endpoint`, `Auth`, `Framing`, `Protocol`. +- Shared OpenAI-compatible profiles and helpers where they are broadly reusable. + +Do not create one package per provider before the API is proven. Start with built-ins implementing the provider definition contract, then extract providers that have enough special logic or dependency weight to justify it. + +## Dynamic Import Contract + +Native provider package loading should be strict. + +Accept: + +```ts +export default Provider.make({ ... }) +``` + +Optionally accept a named export for CommonJS or package-author convenience: + +```ts +export const provider = Provider.make({ ... }) +``` + +Reject packages that only export arbitrary functions like `createOpenAI`. A bare `model` export is useful for direct users, but the dynamic loader needs the full provider definition so it can validate `id` and select named `apis` uniformly. + +Validation should check: + +- `id` is a non-empty string. +- `model` is a function. +- `apis`, when present, is a record of functions. + +Provider definitions should not receive secrets through global state. OpenCode passes `apiKey` or `auth` material explicitly through model options. + +## Option Mapping + +The OpenCode bridge owns translation from OpenCode/models.dev options into provider model options. + +Provider definitions own provider-specific interpretation. + +For example, OpenCode can pass: + +```ts +{ + providerOptions: { + openrouter: { + usage: true, + reasoning: { effort: "high" }, + }, + }, +} +``` + +The OpenRouter provider decides how that becomes payload fields. Models.dev should not know the wire field names beyond declarative provider option defaults. + +## Security And Operational Policy + +Dynamic native plugins execute code. Treat them like current AI SDK provider packages: + +- Only load packages named by user config, local models.dev metadata, or trusted models.dev metadata. +- Keep package installation in the existing npm cache/install mechanism. +- Do not load native plugin packages for the default native path unless native mode is enabled or the provider is explicitly allowlisted. +- Log provider package, version if available, provider ID, and available model APIs. +- Avoid printing secrets in plugin load failures. + +## Migration Plan + +1. Add `Provider.Definition`, `Provider.ModelOptions`, `Provider.ModelFactory`, and `Provider.make` to `@opencode-ai/llm`. +2. Add built-in provider definitions next to existing helper namespaces. +3. Replace OpenCode's native bridge provider table with a registry lookup against built-in plugins. +4. Keep the AI SDK package compatibility map as a fallback while models.dev metadata catches up. +5. Extend OpenCode's models.dev schemas to parse optional `opencode` metadata. +6. Add dynamic import support for `opencode.npm` behind the existing native feature flag. +7. Add deterministic tests for built-in registry resolution, dynamic plugin loading, validation failures, and AI SDK fallback. +8. Update models.dev to emit native metadata for built-in providers. +9. Dogfood external package loading with one provider package before documenting the contract as stable. +10. Extract heavier providers into subpackages only after the contract survives OpenCode integration. + +## Open Questions + +- Should provider `model` return `Effect.Effect` instead of a synchronous value? Synchronous is simpler and matches current helpers, but Vertex/AWS credential discovery may eventually prefer Effect. +- Should `opencode.api` be a generic hint, or should each provider define its own accepted metadata shape? Generic hints are easier for models.dev, but provider-specific metadata is more type-accurate. +- Should external provider packages depend on `@opencode-ai/llm` as a peer dependency to avoid duplicate adapter registries? Probably yes. +- Should the native path allow custom local `file://` plugin packages the same way the AI SDK path does? Probably yes for development and enterprise providers. + +## Recommendation + +Build the native provider definition contract before adding many more one-off bridge mappings. + +Keep the current bridge as migration glue, but make built-ins implement the same `Provider.Definition` contract intended for third-party packages. That gives OpenCode a clean long-term story: + +- AI SDK metadata keeps powering the existing path. +- Native metadata selects native providers. +- Built-ins and external packages use the same interface. +- Provider-specific behavior lives in code, not in `models.dev` data. +- Third-party providers can plug in without OpenCode guessing export names or copying AI SDK's contract by accident. diff --git a/packages/llm/package.json b/packages/llm/package.json index 927b541719..5b806ca758 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -13,6 +13,7 @@ "exports": { ".": "./src/index.ts", "./adapter": "./src/adapter/index.ts", + "./provider": "./src/provider.ts", "./providers": "./src/providers/index.ts", "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index a1d8dbf2df..f76d819f83 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,5 +1,6 @@ export { LLMClient, modelCapabilities, modelLimits, modelRef } from "./adapter/client" export { Auth } from "./adapter/auth" +export { Provider } from "./provider" export type { AdapterModelInput, AdapterRoutedModelInput, @@ -15,3 +16,8 @@ export type { AnyTool, Tool as ToolShape, Tools, ToolSchema } from "./tool" export * as LLM from "./llm" export type { CapabilitiesInput } from "./llm" +export type { + Definition as ProviderDefinition, + ModelFactory as ProviderModelFactory, + ModelOptions as ProviderModelOptions, +} from "./provider" diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts new file mode 100644 index 0000000000..3a6c7e44e8 --- /dev/null +++ b/packages/llm/src/provider.ts @@ -0,0 +1,25 @@ +import type { AdapterModelInput } from "./adapter/client" +import type { ModelID, ModelRef, ProviderID } from "./schema" + +export type ModelOptions = Omit + +export type ModelFactory = ( + id: string | ModelID, + options?: Options, +) => ModelRef + +type AnyModelFactory = (...args: never[]) => ModelRef + +export interface Definition { + readonly id: ProviderID + readonly model: Factory + readonly apis?: Record +} + +export const make = ModelRef + readonly apis?: Record ModelRef> +}>(definition: DefinitionType) => definition + +export * as Provider from "./provider" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index 6a2d84eb66..0744f8ef53 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -1,4 +1,6 @@ import { Adapter, type AdapterModelInput } from "../adapter/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as BedrockConverse from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse" @@ -28,4 +30,9 @@ const converseModel = Adapter.model( }, ) -export const model = (modelID: string, options: ModelOptions = {}) => converseModel({ ...options, id: modelID }) +export const model = (modelID: string | ModelID, options: ModelOptions = {}) => converseModel({ ...options, id: modelID }) + +export const provider = Provider.make({ + id: ProviderID.make("amazon-bedrock"), + model, +}) diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 1dc5718263..4645c0645e 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -1,7 +1,14 @@ import type { AdapterModelInput } from "../adapter/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as AnthropicMessages from "../protocols/anthropic-messages" export const adapters = [AnthropicMessages.adapter] -export const model = (id: string, options: Omit = {}) => +export const model = (id: string | ModelID, options: Omit = {}) => AnthropicMessages.model({ ...options, id }) + +export const provider = Provider.make({ + id: ProviderID.make("anthropic"), + model, +}) diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts index 813a349549..435c6dd384 100644 --- a/packages/llm/src/providers/azure.ts +++ b/packages/llm/src/providers/azure.ts @@ -2,7 +2,8 @@ import { Auth } from "../adapter/auth" import type { ProviderAuthOption } from "../adapter/auth-options" import { Adapter } from "../adapter/client" import type { ModelInput } from "../llm" -import { ProviderID } from "../schema" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" @@ -63,11 +64,19 @@ const mapInput = (input: AzureModelInput) => { const chatModel = Adapter.model(chatAdapter, { provider: id }, { mapInput }) const responsesModel = Adapter.model(responsesAdapter, { provider: id }, { mapInput }) -export const responses = (modelID: string, options: ModelOptions = {}) => responsesModel({ ...options, id: modelID }) +export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => responsesModel({ ...options, id: modelID }) -export const chat = (modelID: string, options: ModelOptions = {}) => chatModel({ ...options, id: modelID }) +export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => chatModel({ ...options, id: modelID }) -export const model = (modelID: string, options: ModelOptions = {}) => { +export const model = (modelID: string | ModelID, options: ModelOptions = {}) => { if (options.useCompletionUrls === true) return chat(modelID, options) return responses(modelID, options) } + +export const provider = Provider.make({ + id, + model, + apis: { responses, chat }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/github-copilot.ts b/packages/llm/src/providers/github-copilot.ts index 29342cc1a7..8bb2c58157 100644 --- a/packages/llm/src/providers/github-copilot.ts +++ b/packages/llm/src/providers/github-copilot.ts @@ -1,6 +1,7 @@ import { Adapter } from "../adapter/client" import type { ModelInput } from "../llm" -import { ProviderID } from "../schema" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" @@ -12,10 +13,11 @@ export type ModelOptions = Omit & { } type CopilotModelInput = ModelOptions & Pick -export const shouldUseResponsesApi = (modelID: string) => { - const match = /^gpt-(\d+)/.exec(modelID) +export const shouldUseResponsesApi = (modelID: string | ModelID) => { + const model = String(modelID) + const match = /^gpt-(\d+)/.exec(model) if (!match) return false - return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") + return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini") } export const adapters = [OpenAIResponses.adapter, OpenAIChat.adapter] @@ -25,7 +27,19 @@ const mapInput = (input: CopilotModelInput) => withOpenAIOptions(input.id, input const chatModel = Adapter.model(OpenAIChat.adapter, { provider: id }, { mapInput }) const responsesModel = Adapter.model(OpenAIResponses.adapter, { provider: id }, { mapInput }) -export const model = (modelID: string, options: ModelOptions = {}) => { +export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => responsesModel({ ...options, id: modelID }) + +export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => chatModel({ ...options, id: modelID }) + +export const model = (modelID: string | ModelID, options: ModelOptions = {}) => { const create = shouldUseResponsesApi(modelID) ? responsesModel : chatModel return create({ ...options, id: modelID }) } + +export const provider = Provider.make({ + id, + model, + apis: { responses, chat }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts index 6defe85f56..ecf8e6b654 100644 --- a/packages/llm/src/providers/google.ts +++ b/packages/llm/src/providers/google.ts @@ -1,7 +1,14 @@ import type { AdapterModelInput } from "../adapter/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as Gemini from "../protocols/gemini" export const adapters = [Gemini.adapter] -export const model = (id: string, options: Omit = {}) => +export const model = (id: string | ModelID, options: Omit = {}) => Gemini.model({ ...options, id }) + +export const provider = Provider.make({ + id: ProviderID.make("google"), + model, +}) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index 6c26c61f49..4917e095cc 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -1,4 +1,5 @@ -import { ProviderID } from "../schema" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import type { OpenAICompatibleChatModelInput } from "../protocols/openai-compatible-chat" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" @@ -7,13 +8,17 @@ export type ModelOptions = Omit & { + readonly provider?: string +} + export type FamilyModelOptions = Omit & { readonly baseURL?: string } export const adapters = [OpenAICompatibleChat.adapter] -export const model = (id: string, options: ModelOptions) => { +export const model = (id: string | ModelID, options: ModelOptions) => { return OpenAICompatibleChat.model({ ...options, id, @@ -27,7 +32,7 @@ const profileBaseURL = (profile: OpenAICompatibleProfile, options: FamilyModelOp throw new Error(`OpenAI-compatible profile ${profile.provider} requires a baseURL`) } -export const profileModel = (profile: OpenAICompatibleProfile, id: string, options: FamilyModelOptions = {}) => +export const profileModel = (profile: OpenAICompatibleProfile, id: string | ModelID, options: FamilyModelOptions = {}) => OpenAICompatibleChat.model({ ...options, id, @@ -36,10 +41,16 @@ export const profileModel = (profile: OpenAICompatibleProfile, id: string, optio capabilities: options.capabilities ?? profile.capabilities, }) -const define = (profile: OpenAICompatibleProfile) => ({ - id: profile.provider, +const define = (profile: OpenAICompatibleProfile) => Provider.make({ + id: ProviderID.make(profile.provider), adapters, - model: (id: string, options: FamilyModelOptions = {}) => profileModel(profile, id, options), + model: (id: string | ModelID, options: FamilyModelOptions = {}) => profileModel(profile, id, options), +}) + +export const provider = Provider.make({ + id: ProviderID.make("openai-compatible"), + adapters, + model: (id: string | ModelID, options: GenericModelOptions) => model(id, { ...options, provider: options.provider ?? "openai-compatible" }), }) export const baseten = define(profiles.baseten) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 8a93389a79..c84ef305ba 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,6 +1,8 @@ import { Auth } from "../adapter/auth" import type { ProviderAuthOption } from "../adapter/auth-options" import type { AdapterModelInput } from "../adapter/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" @@ -9,9 +11,10 @@ export type { OpenAIOptionsInput } from "./openai-options" export const adapters = [OpenAIResponses.adapter, OpenAIChat.adapter] -type OpenAIModelInput = Omit & ProviderAuthOption<"optional"> & { - readonly providerOptions?: OpenAIProviderOptionsInput -} +type OpenAIModelInput = Omit & + ProviderAuthOption<"optional"> & { + readonly providerOptions?: OpenAIProviderOptionsInput + } const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth @@ -20,12 +23,19 @@ const auth = (options: ProviderAuthOption<"optional">) => { .bearer() } -export const responses = (id: string, options: OpenAIModelInput> = {}) => { +export const responses = (id: string | ModelID, options: OpenAIModelInput> = {}) => { return OpenAIResponses.model(withOpenAIOptions(id, { ...options, auth: auth(options) }, { textVerbosity: true })) } -export const chat = (id: string, options: OpenAIModelInput> = {}) => { +export const chat = (id: string | ModelID, options: OpenAIModelInput> = {}) => { return OpenAIChat.model(withOpenAIOptions(id, { ...options, auth: auth(options) })) } -export const model = responses +export const provider = Provider.make({ + id: ProviderID.make("openai"), + model: responses, + apis: { responses, chat }, +}) + +export const model = provider.model +export const apis = provider.apis diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 066528d4b7..4adc9b3e7e 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -3,8 +3,9 @@ import { Adapter, type AdapterModelInput } from "../adapter/client" import { Endpoint } from "../adapter/endpoint" import { Framing } from "../adapter/framing" import { capabilities } from "../llm" +import { Provider } from "../provider" import { Protocol } from "../adapter/protocol" -import type { ProviderOptions } from "../schema" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAIChat from "../protocols/openai-chat" import { isRecord } from "../protocols/shared" @@ -72,4 +73,9 @@ const modelRef = Adapter.model( }, ) -export const model = (id: string, options: ModelOptions = {}) => modelRef({ ...options, id }) +export const model = (id: string | ModelID, options: ModelOptions = {}) => modelRef({ ...options, id }) + +export const provider = Provider.make({ + id: ProviderID.make(profile.provider), + model, +}) diff --git a/packages/llm/src/providers/xai.ts b/packages/llm/src/providers/xai.ts index dfecfc448c..5e4d161929 100644 --- a/packages/llm/src/providers/xai.ts +++ b/packages/llm/src/providers/xai.ts @@ -1,5 +1,7 @@ import { Adapter } from "../adapter/client" import type { ModelInput } from "../llm" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAIResponses from "../protocols/openai-responses" @@ -9,9 +11,14 @@ export const adapters = [OpenAIResponses.adapter] const responsesModel = Adapter.model(OpenAIResponses.adapter, { provider: "xai" }) -export const model = (modelID: string, options: ModelOptions = {}) => +export const model = (modelID: string | ModelID, options: ModelOptions = {}) => responsesModel({ ...options, id: modelID, baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL, }) + +export const provider = Provider.make({ + id: ProviderID.make("xai"), + model, +}) diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 45a57c8a6b..2e82b6570e 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" -import { LLM, LLMClient } from "@opencode-ai/llm" +import { LLM, LLMClient, Provider } from "@opencode-ai/llm" import { Adapter, Protocol } from "@opencode-ai/llm/adapter" +import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" import { OpenAI, OpenAICompatible, OpenRouter } from "@opencode-ai/llm/providers" import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols" @@ -11,6 +12,8 @@ describe("public exports", () => { expect(LLM.request).toBeFunction() expect(LLMClient.Service).toBeFunction() expect(LLMClient.layer).toBeDefined() + expect(Provider.make).toBeFunction() + expect(ProviderSubpath.make).toBe(Provider.make) }) test("adapter barrel exposes adapter-authoring APIs", () => { @@ -20,8 +23,11 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() + expect(OpenAI.provider.model).toBe(OpenAI.model) + expect(OpenAI.apis.responses).toBe(OpenAI.responses) expect(OpenAICompatible.deepseek.model).toBeFunction() expect(OpenRouter.model).toBeFunction() + expect(OpenRouter.provider.model).toBe(OpenRouter.model) expect(GitHubCopilot.model).toBeFunction() }) diff --git a/packages/opencode/src/provider/llm-bridge.ts b/packages/opencode/src/provider/llm-bridge.ts index 2eb4b76f93..32c5f1ab19 100644 --- a/packages/opencode/src/provider/llm-bridge.ts +++ b/packages/opencode/src/provider/llm-bridge.ts @@ -8,7 +8,7 @@ import { type ProviderOptions, type ProtocolID, } from "@opencode-ai/llm" -import { AmazonBedrock, Anthropic, Azure, GitHubCopilot, Google, OpenAI, OpenAICompatible, XAI } from "@opencode-ai/llm/providers" +import { AmazonBedrock, Anthropic, Azure, GitHubCopilot, Google, OpenAI, OpenAICompatible, OpenRouter, XAI } from "@opencode-ai/llm/providers" import * as OpenAICompatibleProfiles from "@opencode-ai/llm/providers/openai-compatible-profile" import { Option, Schema } from "effect" import { isRecord } from "@/util/record" @@ -60,6 +60,21 @@ const openAIOptions = ( ) } +const openRouterOptions = ( + options: Record, + configured: ProviderOptions | undefined = configuredProviderOptions(options), +): ProviderOptions | undefined => { + const openrouter = Object.fromEntries(Object.entries({ + usage: options.usage === true || isRecord(options.usage) ? options.usage : undefined, + reasoning: isRecord(options.reasoning) ? options.reasoning : undefined, + promptCacheKey: stringOption(options, "promptCacheKey") ?? stringOption(options, "prompt_cache_key"), + }).filter((entry) => entry[1] !== undefined)) + return mergeProviderOptions( + configured, + Object.keys(openrouter).length === 0 ? undefined : { openrouter }, + ) +} + const baseURL = (input: Input, options: Record, fallback?: string) => { const configured = stringOption(options, "baseURL") ?? input.model.api.url if (configured) return configured @@ -191,6 +206,14 @@ const PROVIDERS: Record = { ...sharedOptions(input, options, { protocol: "openai-responses", providerOptions: openAIOptions(options) }), }), "@ai-sdk/openai-compatible": openAICompatibleModel, + "@openrouter/ai-sdk-provider": (input, options) => + OpenRouter.model(String(input.model.api.id), { + ...sharedOptions(input, options, { + protocol: "openrouter-chat", + baseURL: baseURL(input, options, OpenRouter.profile.baseURL), + providerOptions: openRouterOptions(options), + }), + }), "@ai-sdk/togetherai": openAICompatibleModel, "@ai-sdk/xai": (input, options) => XAI.model(String(input.model.api.id), sharedOptions(input, options, { protocol: "openai-responses" })), diff --git a/packages/opencode/test/provider/llm-bridge.test.ts b/packages/opencode/test/provider/llm-bridge.test.ts index bb376de9db..fdc9c26d45 100644 --- a/packages/opencode/test/provider/llm-bridge.test.ts +++ b/packages/opencode/test/provider/llm-bridge.test.ts @@ -106,6 +106,39 @@ describe("ProviderLLMBridge", () => { }) }) + test("maps OpenRouter through its provider helper", () => { + const ref = ProviderLLMBridge.toModelRef({ + provider: provider({ + id: ProviderID.make("openrouter"), + key: "openrouter-key", + options: { usage: true, promptCacheKey: "session_123" }, + }), + model: model({ + id: "openrouter/gpt-4o-mini", + apiID: "openai/gpt-4o-mini", + providerID: "openrouter", + npm: "@openrouter/ai-sdk-provider", + options: { reasoning: { effort: "high" } }, + }), + }) + + expect(ref).toMatchObject({ + id: "openai/gpt-4o-mini", + provider: "openrouter", + adapter: "openrouter", + protocol: "openrouter-chat", + baseURL: "https://openrouter.ai/api/v1", + apiKey: "openrouter-key", + providerOptions: { + openrouter: { + usage: true, + reasoning: { effort: "high" }, + promptCacheKey: "session_123", + }, + }, + }) + }) + test("maps GitHub Copilot through its provider helper", () => { const ref = ProviderLLMBridge.toModelRef({ provider: provider({ id: ProviderID.make("github-copilot"), key: "copilot-key" }),