feat(llm): add provider definitions

This commit is contained in:
Kit Langton
2026-05-06 17:04:42 -04:00
parent 73326f5125
commit 56ebef0927
16 changed files with 648 additions and 29 deletions
+447
View File
@@ -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<Factory extends AnyModelFactory = ModelFactory> {
readonly id: ProviderID
readonly model: Factory
readonly apis?: Record<string, AnyModelFactory>
}
export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
id: string | ModelID,
options?: Options,
) => ModelRef
export type ModelOptions = Omit<AdapterModelInput, "id">
type AnyModelFactory = (...args: never[]) => ModelRef
export const make = <DefinitionType extends Definition>(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<ModelRef, LLMError>` 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.
+1
View File
@@ -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",
+6
View File
@@ -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"
+25
View File
@@ -0,0 +1,25 @@
import type { AdapterModelInput } from "./adapter/client"
import type { ModelID, ModelRef, ProviderID } from "./schema"
export type ModelOptions = Omit<AdapterModelInput, "id">
export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
id: string | ModelID,
options?: Options,
) => ModelRef
type AnyModelFactory = (...args: never[]) => ModelRef
export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
readonly id: ProviderID
readonly model: Factory
readonly apis?: Record<string, AnyModelFactory>
}
export const make = <DefinitionType extends {
readonly id: ProviderID
readonly model: (...args: never[]) => ModelRef
readonly apis?: Record<string, (...args: never[]) => ModelRef>
}>(definition: DefinitionType) => definition
export * as Provider from "./provider"
+8 -1
View File
@@ -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<ModelInput>(
},
)
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,
})
+8 -1
View File
@@ -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<AdapterModelInput, "id"> = {}) =>
export const model = (id: string | ModelID, options: Omit<AdapterModelInput, "id"> = {}) =>
AnthropicMessages.model({ ...options, id })
export const provider = Provider.make({
id: ProviderID.make("anthropic"),
model,
})
+13 -4
View File
@@ -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<AzureModelInput>(chatAdapter, { provider: id }, { mapInput })
const responsesModel = Adapter.model<AzureModelInput>(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
+19 -5
View File
@@ -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<ModelInput, "id" | "provider" | "protocol"> & {
}
type CopilotModelInput = ModelOptions & Pick<ModelInput, "id">
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<CopilotModelInput>(OpenAIChat.adapter, { provider: id }, { mapInput })
const responsesModel = Adapter.model<CopilotModelInput>(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
+8 -1
View File
@@ -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<AdapterModelInput, "id"> = {}) =>
export const model = (id: string | ModelID, options: Omit<AdapterModelInput, "id"> = {}) =>
Gemini.model({ ...options, id })
export const provider = Provider.make({
id: ProviderID.make("google"),
model,
})
@@ -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<OpenAICompatibleChatModelInput, "id" | "provider
readonly provider: string
}
type GenericModelOptions = Omit<ModelOptions, "provider"> & {
readonly provider?: string
}
export type FamilyModelOptions = Omit<OpenAICompatibleChatModelInput, "id" | "provider" | "baseURL"> & {
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)
+16 -6
View File
@@ -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<ModelInput> = Omit<ModelInput, "apiKey" | "auth"> & ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
type OpenAIModelInput<ModelInput> = Omit<ModelInput, "apiKey" | "auth"> &
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<Omit<AdapterModelInput, "id">> = {}) => {
export const responses = (id: string | ModelID, options: OpenAIModelInput<Omit<AdapterModelInput, "id">> = {}) => {
return OpenAIResponses.model(withOpenAIOptions(id, { ...options, auth: auth(options) }, { textVerbosity: true }))
}
export const chat = (id: string, options: OpenAIModelInput<Omit<AdapterModelInput, "id">> = {}) => {
export const chat = (id: string | ModelID, options: OpenAIModelInput<Omit<AdapterModelInput, "id">> = {}) => {
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
+8 -2
View File
@@ -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<ModelInput>(
},
)
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,
})
+8 -1
View File
@@ -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,
})
+7 -1
View File
@@ -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()
})
+24 -1
View File
@@ -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<string, unknown>,
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<string, unknown>, fallback?: string) => {
const configured = stringOption(options, "baseURL") ?? input.model.api.url
if (configured) return configured
@@ -191,6 +206,14 @@ const PROVIDERS: Record<string, ProviderModel> = {
...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" })),
@@ -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" }),