From 733bb0613448c2bba1f24cc4871676b1402f76ca Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:05:02 +0530 Subject: [PATCH 01/12] feat(llm): add native provider packages --- packages/llm/package.json | 13 ++++-- packages/llm/src/index.ts | 5 +++ packages/llm/src/provider-package.ts | 16 +++++++ .../src/provider-packages/amazon-bedrock.ts | 28 ++++++++++++ .../provider-packages/anthropic-messages.ts | 15 +++++++ .../llm/src/provider-packages/openai-chat.ts | 20 +++++++++ .../openai-compatible-chat.ts | 17 +++++++ .../src/provider-packages/openai-responses.ts | 25 +++++++++++ packages/llm/src/provider-packages/shared.ts | 8 ++++ packages/llm/test/provider-package.test.ts | 45 +++++++++++++++++++ 10 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 packages/llm/src/provider-package.ts create mode 100644 packages/llm/src/provider-packages/amazon-bedrock.ts create mode 100644 packages/llm/src/provider-packages/anthropic-messages.ts create mode 100644 packages/llm/src/provider-packages/openai-chat.ts create mode 100644 packages/llm/src/provider-packages/openai-compatible-chat.ts create mode 100644 packages/llm/src/provider-packages/openai-responses.ts create mode 100644 packages/llm/src/provider-packages/shared.ts create mode 100644 packages/llm/test/provider-package.test.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index faccd3dd47..4c9d62fdcd 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -15,14 +15,19 @@ "./route": "./src/route/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", + "./provider-package": "./src/provider-package.ts", + "./providers/amazon-bedrock": "./src/provider-packages/amazon-bedrock.ts", + "./providers/anthropic": "./src/provider-packages/anthropic-messages.ts", + "./providers/anthropic/messages": "./src/provider-packages/anthropic-messages.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/providers/openai.ts", - "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai": "./src/provider-packages/openai-responses.ts", + "./providers/openai/responses": "./src/provider-packages/openai-responses.ts", + "./providers/openai/chat": "./src/provider-packages/openai-chat.ts", + "./providers/openai-compatible": "./src/provider-packages/openai-compatible-chat.ts", + "./providers/openai-compatible/chat": "./src/provider-packages/openai-compatible-chat.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77..843b4536a6 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,6 +1,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" +export { ProviderPackage } from "./provider-package" export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export type { RouteModelInput, @@ -31,3 +32,7 @@ export type { ModelFactory as ProviderModelFactory, ModelOptions as ProviderModelOptions, } from "./provider" +export type { + Definition as ProviderPackageDefinition, + Settings as ProviderPackageSettings, +} from "./provider-package" diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts new file mode 100644 index 0000000000..977a211864 --- /dev/null +++ b/packages/llm/src/provider-package.ts @@ -0,0 +1,16 @@ +import type { Model } from "./schema" + +export interface Settings extends Readonly> { + readonly headers?: Readonly> + readonly body?: Readonly> + readonly limits?: { + readonly context: number + readonly output: number + } +} + +export interface Definition { + readonly model: (id: string, settings: ProviderSettings) => Model +} + +export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/provider-packages/amazon-bedrock.ts b/packages/llm/src/provider-packages/amazon-bedrock.ts new file mode 100644 index 0000000000..f00b2056ae --- /dev/null +++ b/packages/llm/src/provider-packages/amazon-bedrock.ts @@ -0,0 +1,28 @@ +import type { ProviderPackage } from "../provider-package" +import * as AmazonBedrock from "../providers/amazon-bedrock" +import type { BedrockCredentials } from "../protocols/bedrock-converse" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return AmazonBedrock.configure({ + ...defaults(settings), + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + region: settings.region, + }).model(id) +} diff --git a/packages/llm/src/provider-packages/anthropic-messages.ts b/packages/llm/src/provider-packages/anthropic-messages.ts new file mode 100644 index 0000000000..be6def4081 --- /dev/null +++ b/packages/llm/src/provider-packages/anthropic-messages.ts @@ -0,0 +1,15 @@ +import type { ProviderPackage } from "../provider-package" +import * as Anthropic from "../providers/anthropic" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + Anthropic.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + }).model(id) diff --git a/packages/llm/src/provider-packages/openai-chat.ts b/packages/llm/src/provider-packages/openai-chat.ts new file mode 100644 index 0000000000..6a6d04c704 --- /dev/null +++ b/packages/llm/src/provider-packages/openai-chat.ts @@ -0,0 +1,20 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAI from "../providers/openai" +import type { OpenAIProviderOptionsInput } from "../providers/openai-options" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + OpenAI.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + providerOptions: settings.providerOptions, + }).chat(id) diff --git a/packages/llm/src/provider-packages/openai-compatible-chat.ts b/packages/llm/src/provider-packages/openai-compatible-chat.ts new file mode 100644 index 0000000000..ba7d569b9c --- /dev/null +++ b/packages/llm/src/provider-packages/openai-compatible-chat.ts @@ -0,0 +1,17 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAICompatible from "../providers/openai-compatible" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + OpenAICompatible.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + provider: settings.provider, + }).model(id) diff --git a/packages/llm/src/provider-packages/openai-responses.ts b/packages/llm/src/provider-packages/openai-responses.ts new file mode 100644 index 0000000000..4851a96a34 --- /dev/null +++ b/packages/llm/src/provider-packages/openai-responses.ts @@ -0,0 +1,25 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAI from "../providers/openai" +import type { OpenAIProviderOptionsInput } from "../providers/openai-options" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + const provider = OpenAI.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + providerOptions: settings.providerOptions, + }) + if (settings.transport === undefined || settings.transport === "http") return provider.responses(id) + if (settings.transport === "websocket") return provider.responsesWebSocket(id) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} diff --git a/packages/llm/src/provider-packages/shared.ts b/packages/llm/src/provider-packages/shared.ts new file mode 100644 index 0000000000..e3c7ec2444 --- /dev/null +++ b/packages/llm/src/provider-packages/shared.ts @@ -0,0 +1,8 @@ +import type { RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" + +export const defaults = (settings: ProviderPackage.Settings): RouteDefaultsInput => ({ + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + limits: settings.limits, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, +}) diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts new file mode 100644 index 0000000000..a4f1627c67 --- /dev/null +++ b/packages/llm/test/provider-package.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { model } from "@opencode-ai/llm/providers/openai" + +describe("provider package entrypoints", () => { + test("semantic API aliases expose the same contract", async () => { + const modules = await Promise.all([ + import("@opencode-ai/llm/providers/openai"), + import("@opencode-ai/llm/providers/openai/responses"), + import("@opencode-ai/llm/providers/openai/chat"), + import("@opencode-ai/llm/providers/anthropic"), + import("@opencode-ai/llm/providers/anthropic/messages"), + import("@opencode-ai/llm/providers/openai-compatible"), + import("@opencode-ai/llm/providers/openai-compatible/chat"), + import("@opencode-ai/llm/providers/amazon-bedrock"), + ]) + + for (const module of modules) expect(module.model).toBeFunction() + expect(modules[0].model).toBe(modules[1].model) + expect(modules[3].model).toBe(modules[4].model) + expect(modules[5].model).toBe(modules[6].model) + }) + + test("maps package settings onto the executable model", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + baseURL: "https://api.openai.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + unrelatedInheritedSetting: true, + }) + + expect(selected.route.id).toBe("openai-responses") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + }) + + test("selects transport without changing the semantic API", () => { + expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses") + expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe( + "openai-responses-websocket", + ) + }) +}) From 547496656c3c771289d8fca7d20a08eda98b9631 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:10:45 +0530 Subject: [PATCH 02/12] refactor(llm): colocate provider package logic --- packages/llm/package.json | 14 ++++----- .../src/provider-packages/amazon-bedrock.ts | 28 ------------------ .../provider-packages/anthropic-messages.ts | 15 ---------- .../llm/src/provider-packages/openai-chat.ts | 20 ------------- .../openai-compatible-chat.ts | 17 ----------- .../src/provider-packages/openai-responses.ts | 25 ---------------- packages/llm/src/provider-packages/shared.ts | 8 ----- packages/llm/src/providers/amazon-bedrock.ts | 27 ++++++++++++++++- packages/llm/src/providers/anthropic.ts | 15 +++++++++- .../llm/src/providers/openai-compatible.ts | 17 +++++++++++ packages/llm/src/providers/openai.ts | 29 ++++++++++++++++++- packages/llm/src/providers/openai/chat.ts | 2 ++ .../llm/src/providers/openai/responses.ts | 2 ++ packages/llm/test/exports.test.ts | 1 - packages/llm/test/provider-package.test.ts | 4 --- 15 files changed, 95 insertions(+), 129 deletions(-) delete mode 100644 packages/llm/src/provider-packages/amazon-bedrock.ts delete mode 100644 packages/llm/src/provider-packages/anthropic-messages.ts delete mode 100644 packages/llm/src/provider-packages/openai-chat.ts delete mode 100644 packages/llm/src/provider-packages/openai-compatible-chat.ts delete mode 100644 packages/llm/src/provider-packages/openai-responses.ts delete mode 100644 packages/llm/src/provider-packages/shared.ts create mode 100644 packages/llm/src/providers/openai/chat.ts create mode 100644 packages/llm/src/providers/openai/responses.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index 4c9d62fdcd..2a5d97d5bf 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -16,18 +16,16 @@ "./provider": "./src/provider.ts", "./providers": "./src/providers/index.ts", "./provider-package": "./src/provider-package.ts", - "./providers/amazon-bedrock": "./src/provider-packages/amazon-bedrock.ts", - "./providers/anthropic": "./src/provider-packages/anthropic-messages.ts", - "./providers/anthropic/messages": "./src/provider-packages/anthropic-messages.ts", + "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", + "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/provider-packages/openai-responses.ts", - "./providers/openai/responses": "./src/provider-packages/openai-responses.ts", - "./providers/openai/chat": "./src/provider-packages/openai-chat.ts", - "./providers/openai-compatible": "./src/provider-packages/openai-compatible-chat.ts", - "./providers/openai-compatible/chat": "./src/provider-packages/openai-compatible-chat.ts", + "./providers/openai": "./src/providers/openai.ts", + "./providers/openai/responses": "./src/providers/openai/responses.ts", + "./providers/openai/chat": "./src/providers/openai/chat.ts", + "./providers/openai-compatible": "./src/providers/openai-compatible.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", diff --git a/packages/llm/src/provider-packages/amazon-bedrock.ts b/packages/llm/src/provider-packages/amazon-bedrock.ts deleted file mode 100644 index f00b2056ae..0000000000 --- a/packages/llm/src/provider-packages/amazon-bedrock.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as AmazonBedrock from "../providers/amazon-bedrock" -import type { BedrockCredentials } from "../protocols/bedrock-converse" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly auth?: "bearer" | "sigv4" - readonly baseURL?: string - readonly credentials?: BedrockCredentials - readonly region?: string - readonly topP?: number -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => { - if (settings.auth === "bearer" && settings.apiKey === undefined) - throw new Error("Amazon Bedrock bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) - throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") - return AmazonBedrock.configure({ - ...defaults(settings), - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, - baseURL: settings.baseURL, - credentials: settings.credentials, - generation: settings.topP === undefined ? undefined : { topP: settings.topP }, - region: settings.region, - }).model(id) -} diff --git a/packages/llm/src/provider-packages/anthropic-messages.ts b/packages/llm/src/provider-packages/anthropic-messages.ts deleted file mode 100644 index be6def4081..0000000000 --- a/packages/llm/src/provider-packages/anthropic-messages.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as Anthropic from "../providers/anthropic" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - Anthropic.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - }).model(id) diff --git a/packages/llm/src/provider-packages/openai-chat.ts b/packages/llm/src/provider-packages/openai-chat.ts deleted file mode 100644 index 6a6d04c704..0000000000 --- a/packages/llm/src/provider-packages/openai-chat.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAI from "../providers/openai" -import type { OpenAIProviderOptionsInput } from "../providers/openai-options" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string - readonly queryParams?: Readonly> - readonly providerOptions?: OpenAIProviderOptionsInput -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - OpenAI.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, - providerOptions: settings.providerOptions, - }).chat(id) diff --git a/packages/llm/src/provider-packages/openai-compatible-chat.ts b/packages/llm/src/provider-packages/openai-compatible-chat.ts deleted file mode 100644 index ba7d569b9c..0000000000 --- a/packages/llm/src/provider-packages/openai-compatible-chat.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAICompatible from "../providers/openai-compatible" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL: string - readonly provider?: string -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - OpenAICompatible.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - provider: settings.provider, - }).model(id) diff --git a/packages/llm/src/provider-packages/openai-responses.ts b/packages/llm/src/provider-packages/openai-responses.ts deleted file mode 100644 index 4851a96a34..0000000000 --- a/packages/llm/src/provider-packages/openai-responses.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAI from "../providers/openai" -import type { OpenAIProviderOptionsInput } from "../providers/openai-options" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string - readonly queryParams?: Readonly> - readonly transport?: "http" | "websocket" - readonly providerOptions?: OpenAIProviderOptionsInput -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => { - const provider = OpenAI.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, - providerOptions: settings.providerOptions, - }) - if (settings.transport === undefined || settings.transport === "http") return provider.responses(id) - if (settings.transport === "websocket") return provider.responsesWebSocket(id) - throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) -} diff --git a/packages/llm/src/provider-packages/shared.ts b/packages/llm/src/provider-packages/shared.ts deleted file mode 100644 index e3c7ec2444..0000000000 --- a/packages/llm/src/provider-packages/shared.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteDefaultsInput } from "../route/client" -import type { ProviderPackage } from "../provider-package" - -export const defaults = (settings: ProviderPackage.Settings): RouteDefaultsInput => ({ - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - limits: settings.limits, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, -}) diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index 2f1791e0d6..e578d614c6 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -1,5 +1,6 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as BedrockConverse from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse" @@ -15,6 +16,15 @@ export type Config = RouteDefaultsInput & { /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ readonly baseURL?: string } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} export const routes = [BedrockConverse.route] const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` @@ -40,4 +50,19 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return configure({ + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + region: settings.region, + }).model(id) +} diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 0c9640af5e..8ef0293eeb 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -1,6 +1,7 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as AnthropicMessages from "../protocols/anthropic-messages" @@ -10,6 +11,11 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (id, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + }).model(id) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index a79f65f6df..36d9b3fd5a 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -2,6 +2,7 @@ import { ProviderID, type ModelID } from "../schema" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" export const id = ProviderID.make("openai-compatible") @@ -12,6 +13,12 @@ type GenericModelOptions = RouteDefaultsInput & readonly baseURL: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + export type FamilyModelOptions = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string @@ -56,6 +63,16 @@ export const provider = { configure, } +export const model: ProviderPackage.Definition["model"] = (id, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + }).model(id) + export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) export const deepinfra = define(profiles.deepinfra) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad8493..b40a94514b 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,5 +1,6 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import type { Route, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -21,6 +22,14 @@ export type Config = RouteDefaultsInput & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const defaults = (input: Config) => { @@ -57,7 +66,25 @@ export const configure = (input: Config = {}) => { export const provider = configure() -export const model = provider.model +const config = (settings: Settings): Config => ({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, +}) + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + const configured = configure(config(settings)) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(id) + if (settings.transport === "websocket") return configured.responsesWebSocket(id) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} + +export const chatModel: ProviderPackage.Definition["model"] = (id, settings) => + configure(config(settings)).chat(id) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat diff --git a/packages/llm/src/providers/openai/chat.ts b/packages/llm/src/providers/openai/chat.ts new file mode 100644 index 0000000000..eb92db8ac5 --- /dev/null +++ b/packages/llm/src/providers/openai/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/src/providers/openai/responses.ts b/packages/llm/src/providers/openai/responses.ts new file mode 100644 index 0000000000..4db3a232c7 --- /dev/null +++ b/packages/llm/src/providers/openai/responses.ts @@ -0,0 +1,2 @@ +export { model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 693a21638b..6ed66a0d8e 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -30,7 +30,6 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBe(OpenAI.model) expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index a4f1627c67..c8cba78eb1 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -8,16 +8,12 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/openai/responses"), import("@opencode-ai/llm/providers/openai/chat"), import("@opencode-ai/llm/providers/anthropic"), - import("@opencode-ai/llm/providers/anthropic/messages"), import("@opencode-ai/llm/providers/openai-compatible"), - import("@opencode-ai/llm/providers/openai-compatible/chat"), import("@opencode-ai/llm/providers/amazon-bedrock"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) - expect(modules[3].model).toBe(modules[4].model) - expect(modules[5].model).toBe(modules[6].model) }) test("maps package settings onto the executable model", () => { From 9848587052203485c24f4eaed1927053c40feab9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:29:06 +0530 Subject: [PATCH 03/12] refactor(llm): name provider model identity --- packages/llm/src/provider-package.ts | 2 +- packages/llm/src/providers/amazon-bedrock.ts | 4 ++-- packages/llm/src/providers/anthropic.ts | 4 ++-- packages/llm/src/providers/openai-compatible.ts | 4 ++-- packages/llm/src/providers/openai.ts | 10 +++++----- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts index 977a211864..fd878a9d06 100644 --- a/packages/llm/src/provider-package.ts +++ b/packages/llm/src/provider-package.ts @@ -10,7 +10,7 @@ export interface Settings extends Readonly> { } export interface Definition { - readonly model: (id: string, settings: ProviderSettings) => Model + readonly model: (modelID: string, settings: ProviderSettings) => Model } export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index e578d614c6..4e5040c9ec 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -50,7 +50,7 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (id, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { if (settings.auth === "bearer" && settings.apiKey === undefined) throw new Error("Amazon Bedrock bearer auth requires apiKey") if (settings.auth === "sigv4" && settings.apiKey !== undefined) @@ -64,5 +64,5 @@ export const model: ProviderPackage.Definition["model"] = (id, setting http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, region: settings.region, - }).model(id) + }).model(modelID) } diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 8ef0293eeb..1bf8bf569c 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -38,11 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (id, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, - }).model(id) + }).model(modelID) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index 36d9b3fd5a..8b5d23eed7 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -63,7 +63,7 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (id, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, @@ -71,7 +71,7 @@ export const model: ProviderPackage.Definition["model"] = (id, setting http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, provider: settings.provider, - }).model(id) + }).model(modelID) export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index b40a94514b..6b49b9def2 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -76,15 +76,15 @@ const config = (settings: Settings): Config => ({ queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, }) -export const model: ProviderPackage.Definition["model"] = (id, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { const configured = configure(config(settings)) - if (settings.transport === undefined || settings.transport === "http") return configured.responses(id) - if (settings.transport === "websocket") return configured.responsesWebSocket(id) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) + if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) } -export const chatModel: ProviderPackage.Definition["model"] = (id, settings) => - configure(config(settings)).chat(id) +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat From 62e44a80e75f4183d51ce79c142de67642a883d9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 25 Jun 2026 00:17:32 +0530 Subject: [PATCH 04/12] feat(core): migrate native provider config --- packages/core/src/catalog.ts | 24 +++- packages/core/src/config/plugin/provider.ts | 95 ++++++++++++-- packages/core/src/config/provider.ts | 37 ++---- packages/core/src/model-request.ts | 33 ++++- packages/core/src/model.ts | 3 + packages/core/src/provider.ts | 5 + packages/core/src/session/runner/model.ts | 6 + packages/core/src/v1/config/migrate.ts | 38 ++---- packages/core/test/catalog.test.ts | 1 + packages/core/test/config/config.test.ts | 31 ++--- packages/core/test/config/provider.test.ts | 120 +++++++++++++++--- .../core/test/session-runner-model.test.ts | 34 +++++ packages/core/test/shared-schema.test.ts | 2 + packages/schema/src/model.ts | 9 ++ packages/schema/src/provider.ts | 14 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 5 + 16 files changed, 358 insertions(+), 99 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 0b274299e5..9b993391f5 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -78,12 +78,28 @@ export const layer = Layer.effect( const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = - model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 - ? { ...provider.api, id: model.api.id } + model.api.type === "native" && !model.api.package + ? { + ...provider.api, + id: model.api.id, + url: model.api.url ?? provider.api.url, + settings: ModelRequest.mergeRecords(provider.api.settings, model.api.settings), + } + : model.api.type === "native" && provider.api.type === "native" && !model.api.url + ? { + ...model.api, + package: model.api.package ?? provider.api.package, + url: provider.api.url, + settings: ModelRequest.mergeRecords(provider.api.settings, model.api.settings), + } : model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url - ? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } } + ? { + ...model.api, + url: provider.api.url, + settings: ModelRequest.mergeRecords(provider.api.settings, model.api.settings), + } : model.api.type === "aisdk" && provider.api.type === "aisdk" - ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } + ? { ...model.api, settings: ModelRequest.mergeRecords(provider.api.settings, model.api.settings) } : model.api const request = { ...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6fb13f1c63..92cb0c1d42 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -48,26 +48,95 @@ export const Plugin = define({ const model = ModelV2.parse(configuredDefault) catalog.model.default.set(model.providerID, model.modelID) } + const providerAiSDK = new Map() + const modelAiSDK = new Map() for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const providerID = id + if (item.aiSDK !== undefined) providerAiSDK.set(providerID, item.aiSDK) catalog.provider.update(providerID, (provider) => { if (item.name !== undefined) provider.name = item.name - if (item.api !== undefined) provider.api = { ...item.api } - if (item.request !== undefined) { - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) + if (item.package !== undefined) { + const settings = ModelRequest.mergeRecords(provider.api.settings, item.settings) + const url = + item.settings && Object.hasOwn(item.settings, "baseURL") + ? typeof settings.baseURL === "string" + ? settings.baseURL + : undefined + : provider.api.url + provider.api = (providerAiSDK.get(providerID) ?? provider.api.type === "aisdk") + ? { type: "aisdk", package: item.package, ...(url === undefined ? {} : { url }), settings } + : { type: "native", package: item.package, ...(url === undefined ? {} : { url }), settings } + } else if (item.settings !== undefined) { + provider.api.settings = ModelRequest.mergeRecords(provider.api.settings, item.settings) + if (Object.hasOwn(item.settings, "baseURL")) { + provider.api.url = + typeof provider.api.settings.baseURL === "string" ? provider.api.settings.baseURL : undefined + } } + if (item.package === undefined && item.aiSDK !== undefined) { + if (item.aiSDK && provider.api.type === "native" && provider.api.package !== undefined) { + provider.api = { ...provider.api, type: "aisdk", package: provider.api.package } + } + if (!item.aiSDK && provider.api.type === "aisdk") { + provider.api = { ...provider.api, type: "native", settings: provider.api.settings ?? {} } + } + } + ModelRequest.assign(provider.request, { headers: item.headers, body: item.body }) }) const providerApi = catalog.provider.get(providerID)?.provider.api const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined for (const [id, config] of Object.entries(item.models ?? {})) { + const modelKey = `${providerID}/${id}` + if (config.aiSDK !== undefined) modelAiSDK.set(modelKey, config.aiSDK) catalog.model.update(providerID, id, (model) => { if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name - if (config.api !== undefined) model.api = { ...model.api, ...config.api } + if (config.id !== undefined) model.api.id = config.id + if (config.package !== undefined) { + const aiSDK = + modelAiSDK.get(modelKey) ?? providerAiSDK.get(providerID) ?? providerApi?.type === "aisdk" + const settings = ModelRequest.mergeRecords(model.api.settings, config.settings) + const url = + config.settings && Object.hasOwn(config.settings, "baseURL") + ? typeof settings.baseURL === "string" + ? settings.baseURL + : undefined + : model.api.url + model.api = aiSDK + ? { + id: model.api.id, + type: "aisdk", + package: config.package, + ...(url === undefined ? {} : { url }), + settings, + } + : { + id: model.api.id, + type: "native", + package: config.package, + ...(url === undefined ? {} : { url }), + settings, + } + } else if (config.settings !== undefined) { + model.api.settings = ModelRequest.mergeRecords(model.api.settings, config.settings) + if (Object.hasOwn(config.settings, "baseURL")) { + model.api.url = + typeof model.api.settings.baseURL === "string" ? model.api.settings.baseURL : undefined + } + } + if (config.package === undefined && config.aiSDK !== undefined) { + if (config.aiSDK && model.api.type === "native" && model.api.package !== undefined) { + model.api = { ...model.api, type: "aisdk", package: model.api.package } + } + if (!config.aiSDK && model.api.type === "aisdk") { + model.api = { ...model.api, type: "native", settings: model.api.settings ?? {} } + } + } const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage + const aiSDK = + modelAiSDK.get(modelKey) ?? providerAiSDK.get(providerID) ?? providerApi?.type === "aisdk" if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, @@ -75,19 +144,22 @@ export const Plugin = define({ output: [...config.capabilities.output], } } - if (config.request !== undefined) { + if (config.headers !== undefined || config.body !== undefined) { ModelRequest.assign(model.request, { - headers: config.request.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), + headers: config.headers, + ...(aiSDK + ? ModelRequest.normalizeAiSdkOptions(packageName, config.body ?? {}) + : { body: config.body }), }) - if (config.request.variant !== undefined) model.request.variant = config.request.variant } + if (config.variant !== undefined) model.request.variant = config.variant if (config.variants !== undefined) { for (const variant of config.variants) { let existing = model.variants.find((item) => item.id === variant.id) if (!existing) { existing = { id: variant.id, + settings: {}, headers: {}, body: {}, generation: {}, @@ -95,9 +167,12 @@ export const Plugin = define({ } model.variants.push(existing) } + existing.settings = ModelRequest.mergeRecords(existing.settings, variant.settings) ModelRequest.assign(existing, { headers: variant.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + ...(aiSDK + ? ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}) + : { body: variant.body }), }) } } diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 1b54757078..665799ec3f 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,7 +1,6 @@ export * as ConfigProvider from "./provider" import { Schema } from "effect" -import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" export class Request extends Schema.Class("ConfigV2.Provider.Request")({ @@ -9,6 +8,11 @@ export class Request extends Schema.Class("ConfigV2.Provider.Request")( body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} +const Overlays = { + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + ...Request.fields, +} + class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ read: Schema.Finite.pipe(Schema.optional), write: Schema.Finite.pipe(Schema.optional), @@ -30,32 +34,18 @@ class Limit extends Schema.Class("ConfigV2.Model.Limit")({ output: Schema.Int.pipe(Schema.optional), }) {} -const ModelApi = Schema.Union([ - Schema.Struct({ - id: ModelV2.ID.pipe(Schema.optional), - ...ProviderV2.AISDK.fields, - }), - Schema.Struct({ - id: ModelV2.ID.pipe(Schema.optional), - ...ProviderV2.Native.fields, - }), - Schema.Struct({ - id: ModelV2.ID, - }), -]) - class Model extends Schema.Class("ConfigV2.Model")({ + id: ModelV2.ID.pipe(Schema.optional), + package: Schema.String.pipe(Schema.optional), + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Overlays, family: ModelV2.Family.pipe(Schema.optional), name: Schema.String.pipe(Schema.optional), - api: ModelApi.pipe(Schema.optional), capabilities: ModelV2.Capabilities.pipe(Schema.optional), - request: Schema.Struct({ - ...Request.fields, - variant: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), variants: Schema.Struct({ id: ModelV2.VariantID, - ...Request.fields, + ...Overlays, }).pipe(Schema.Array, Schema.optional), cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), @@ -65,7 +55,8 @@ class Model extends Schema.Class("ConfigV2.Model")({ export class Info extends Schema.Class("ConfigV2.Provider")({ name: Schema.String.pipe(Schema.optional), env: Schema.String.pipe(Schema.Array, Schema.optional), - api: ProviderV2.Api.pipe(Schema.optional), - request: Request.pipe(Schema.optional), + package: Schema.String.pipe(Schema.optional), + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Overlays, models: Schema.Record(Schema.String, Model).pipe(Schema.optional), }) {} diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts index 06059a9d73..6779d808ef 100644 --- a/packages/core/src/model-request.ts +++ b/packages/core/src/model-request.ts @@ -67,16 +67,41 @@ const profiles = new Map([ export const namespace = (packageName: string) => profiles.get(packageName)?.namespace +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const mergeRecords = (...items: ReadonlyArray> | undefined>) => { + const result: Record = {} + for (const item of items) { + for (const [key, value] of Object.entries(item ?? {})) { + result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value + } + } + return result +} + +export const mergeHeaders = (...items: ReadonlyArray> | undefined>) => { + const result = new Map() + for (const item of items) { + for (const entry of Object.entries(item ?? {})) result.set(entry[0].toLowerCase(), entry) + } + return Object.fromEntries(result.values()) +} + export const merge = (base: Request, override: Partial) => ({ - headers: { ...base.headers, ...override.headers }, - body: { ...base.body, ...override.body }, + headers: mergeHeaders(base.headers, override.headers), + body: mergeRecords(base.body, override.body), generation: { ...base.generation, ...override.generation }, options: { ...base.options, ...override.options }, }) export const assign = (target: MutableRequest, override: Partial) => { - Object.assign(target.headers, override.headers) - Object.assign(target.body, override.body) + const headers = mergeHeaders(target.headers, override.headers) + Object.keys(target.headers).forEach((key) => delete target.headers[key]) + Object.assign(target.headers, headers) + const body = mergeRecords(target.body, override.body) + Object.keys(target.body).forEach((key) => delete target.body[key]) + Object.assign(target.body, body) Object.assign((target.generation ??= {}), override.generation) Object.assign((target.options ??= {}), override.options) } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 52fff98733..bb22033943 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -8,6 +8,9 @@ export type ID = typeof ID.Type export const VariantID = Model.VariantID export type VariantID = typeof VariantID.Type +export const Package = Model.Package +export type Package = Model.Package + // Grouping of models, eg claude opus, claude sonnet export const Family = Model.Family export type Family = Model.Family diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 03f7d7eef3..95de2c3ebc 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -6,6 +6,11 @@ import { Provider } from "@opencode-ai/schema/provider" export const ID = Provider.ID export type ID = typeof ID.Type +export const Overlays = Provider.Overlays + +export const Package = Provider.Package +export type Package = Provider.Package + export const AISDK = Provider.AISDK export const Native = Provider.Native diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 97b862e432..253be42216 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -122,6 +122,12 @@ const withVariant = ( return Effect.succeed( variant ? produce(model, (draft) => { + if (variant.settings !== undefined) { + draft.api.settings = ModelRequest.mergeRecords(draft.api.settings, variant.settings) + if (Object.hasOwn(variant.settings, "baseURL")) { + draft.api.url = typeof draft.api.settings.baseURL === "string" ? draft.api.settings.baseURL : undefined + } + } ModelRequest.assign(draft.request, variant) }) : model, diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index c474cac51a..67a78cba86 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -174,15 +174,14 @@ function migrateProvider(info: ConfigProviderV1.Info) { return { name: info.name, env: info.env, - api: info.npm - ? { - type: "aisdk" as const, - package: info.npm, - url: info.api ?? options.url, - settings: options.settings ?? {}, - } - : undefined, - request: info.options && { headers: options.headers, body: options.body }, + package: info.npm, + aiSDK: info.npm ? true : undefined, + settings: { + ...options.settings, + ...(info.api ?? options.url ? { baseURL: info.api ?? options.url } : {}), + }, + headers: options.headers, + body: options.body, models: info.models && Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])), @@ -221,22 +220,13 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st return { family: info.family, name: info.name, - api: info.provider?.npm - ? { - ...(info.id === undefined ? {} : { id: info.id }), - type: "aisdk" as const, - package: info.provider.npm, - url: info.provider.api, - settings: {}, - } - : info.id === undefined - ? undefined - : { id: info.id }, + id: info.id, + package: info.provider?.npm, + aiSDK: info.provider?.npm ? true : undefined, + settings: info.provider?.api ? { baseURL: info.provider.api } : undefined, capabilities, - request: (info.headers || request) && { - headers: info.headers, - body: request, - }, + headers: info.headers, + body: request, variants: info.variants && Object.entries(info.variants).map(([id, options]) => ({ diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 23a3a0f4aa..a165343c83 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -213,6 +213,7 @@ describe("CatalogV2", () => { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com", + settings: {}, }) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 6275d8fed3..552a87da49 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -103,13 +103,10 @@ describe("Config", () => { }, }) - expect(migrated.providers?.bedrock?.api).toEqual({ - type: "aisdk", + expect(migrated.providers?.bedrock).toMatchObject({ package: "@ai-sdk/amazon-bedrock", - url: undefined, + aiSDK: true, settings: { region: "us-east-1", profile: "dev" }, - }) - expect(migrated.providers?.bedrock?.request).toEqual({ headers: { "x-test": "1" }, body: { trace: true }, }) @@ -585,34 +582,34 @@ describe("Config", () => { }) expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } }) expect(documents[0]?.info.providers?.custom).toMatchObject({ - request: { body: { apiKey: "secret" } }, + body: { apiKey: "secret" }, models: { model: { - request: { body: { reasoningEffort: "high" } }, + body: { reasoningEffort: "high" }, variants: [{ id: "fast", body: { temperature: 0.2 } }], }, }, }) expect(documents[0]?.info.providers?.openai).toMatchObject({ - api: { settings: {} }, - request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } }, + package: "@ai-sdk/openai", + aiSDK: true, + settings: {}, + headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" }, models: { model: { - request: { - body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, - }, + body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }], }, }, }) expect(documents[0]?.info.providers?.anthropic).toMatchObject({ + package: "@ai-sdk/anthropic", + aiSDK: true, models: { model: { - request: { - body: { - output_config: { effort: "high", task_budget: 4096 }, - metadata: { user_id: "user-1" }, - }, + body: { + output_config: { effort: "high", task_budget: 4096 }, + metadata: { user_id: "user-1" }, }, }, }, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 12f4a01c78..f8715b6e68 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -45,16 +45,90 @@ function withEnv(vars: Record, effect: () = ) } -function request(headers: Record, variant?: string) { - return { - headers, - variant, - } -} - const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("merges flat provider and model overlays", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ providers: { custom: { aiSDK: true } } }), + }), + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "custom-provider", + settings: { auth: { type: "token", region: "us-east-1" } }, + headers: { "X-Test": "provider" }, + body: { reasoning: { type: "enabled", budget: 8_000 }, tags: ["provider"] }, + models: { + chat: { + package: "custom-model-provider", + settings: { auth: { region: "us-west-2" } }, + headers: { "x-test": "model" }, + body: { reasoning: { budget: 32_000 }, tags: ["model"] }, + }, + inherit: { + settings: { auth: { region: "eu-west-1" }, baseURL: "https://model.example/v1" }, + }, + clear: { settings: { baseURL: "https://old.example/v1" } }, + }, + }, + }, + }), + }), + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { models: { clear: { package: "custom-provider", settings: { baseURL: null } } } }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + const model = required(yield* catalog.model.get(providerID, modelID)) + expect(model.api).toEqual({ + id: modelID, + type: "aisdk", + package: "custom-model-provider", + settings: { auth: { type: "token", region: "us-west-2" } }, + }) + expect(model.request.headers).toEqual({ "x-test": "model" }) + expect(model.request.body).toEqual({ + reasoning: { type: "enabled", budget: 32_000 }, + tags: ["model"], + }) + expect(required(yield* catalog.model.get(providerID, ModelV2.ID.make("inherit"))).api).toEqual({ + id: ModelV2.ID.make("inherit"), + type: "aisdk", + package: "custom-provider", + url: "https://model.example/v1", + settings: { + auth: { type: "token", region: "eu-west-1" }, + baseURL: "https://model.example/v1", + }, + }) + expect(required(yield* catalog.model.get(providerID, ModelV2.ID.make("clear"))).api).toEqual({ + id: ModelV2.ID.make("clear"), + type: "aisdk", + package: "custom-provider", + settings: { auth: { type: "token", region: "us-east-1" }, baseURL: null }, + }) + }), + ) + it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -68,7 +142,9 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + package: "@ai-sdk/openai", + aiSDK: true, + settings: { baseURL: "https://opencode.test/v1" }, models: { "alpha-gpt-next": { variants: [ @@ -120,7 +196,9 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + package: "@ai-sdk/openai", + aiSDK: true, + settings: { baseURL: "https://opencode.test/v1" }, }, }, }), @@ -171,8 +249,8 @@ describe("ConfigProviderPlugin.Plugin", () => { custom: { name: "Configured", env: ["CUSTOM_API_KEY"], - api: { type: "native", settings: {} }, - request: request({ first: "first", shared: "first" }), + package: "custom-native", + headers: { first: "first", shared: "first" }, models: { chat: { name: "First", @@ -180,7 +258,8 @@ describe("ConfigProviderPlugin.Plugin", () => { disabled: true, limit: { context: 100, output: 50 }, cost: { input: 1, output: 2 }, - request: request({ first: "first", shared: "first" }, "retained"), + headers: { first: "first", shared: "first" }, + variant: "retained", variants: [ { id: "fast", @@ -199,17 +278,19 @@ describe("ConfigProviderPlugin.Plugin", () => { model: "custom/default", providers: { custom: { - api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, - request: request({ last: "last", shared: "last" }), + package: "custom-sdk", + aiSDK: true, + settings: { baseURL: "https://example.test" }, + headers: { last: "last", shared: "last" }, models: { default: { name: "Default", }, chat: { - api: { id: "api-chat" }, + id: "api-chat", name: "Last", limit: { output: 75 }, - request: request({ last: "last", shared: "last" }), + headers: { last: "last", shared: "last" }, variants: [ { id: "fast", @@ -249,7 +330,12 @@ describe("ConfigProviderPlugin.Plugin", () => { }) expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") expect(provider.disabled).toBeUndefined() - expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.api).toEqual({ + type: "aisdk", + package: "custom-sdk", + url: "https://example.test", + settings: { baseURL: "https://example.test" }, + }) expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index efebe60d04..f570dabfc7 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -148,6 +148,40 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("applies a selected variant base URL", () => + Effect.gen(function* () { + const base = model( + { type: "aisdk", package: "@ai-sdk/openai", url: "https://default.example/v1" }, + [ + { + id: ModelV2.VariantID.make("regional"), + settings: { baseURL: "https://regional.example/v1" }, + headers: {}, + body: {}, + generation: {}, + options: {}, + }, + ], + ) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_regional_variant"), + projectID: ProjectV2.ID.global, + title: "test", + model: { id: base.id, providerID: base.providerID, variant: ModelV2.VariantID.make("regional") }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + const resolved = yield* SessionRunnerModel.resolve( + session, + base, + ) + + expect(resolved.route.endpoint.baseURL).toBe("https://regional.example/v1") + }), + ) + it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () => Effect.gen(function* () { const catalog = model( diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index a1794b9263..3632c701fe 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -99,6 +99,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreLLM.ToolContent, LLM.ToolContent], [ModelV2.ID, Model.ID], [ModelV2.VariantID, Model.VariantID], + [ModelV2.Package, Model.Package], [ModelV2.Ref, Model.Ref], [ModelV2.Family, Model.Family], [ModelV2.Capabilities, Model.Capabilities], @@ -106,6 +107,7 @@ test("Core reuses the canonical shared schemas", async () => { [ModelV2.Api, Model.Api], [ModelV2.Info, Model.Info], [ProviderV2.ID, Provider.ID], + [ProviderV2.Package, Provider.Package], [ProviderV2.AISDK, Provider.AISDK], [ProviderV2.Native, Provider.Native], [ProviderV2.Api, Provider.Api], diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 62b76df0fc..3f95afd49c 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -11,6 +11,14 @@ export type ID = typeof ID.Type export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) export type VariantID = typeof VariantID.Type +export const Package = Schema.Struct({ + modelID: ID, + package: Schema.String.pipe(Schema.optional), + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Provider.Overlays, +}) +export type Package = typeof Package.Type + export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, @@ -68,6 +76,7 @@ export const Info = Schema.Struct({ }), variants: Schema.Struct({ id: VariantID, + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), ...ModelRequest.Request.fields, }).pipe(Schema.Array, Schema.mutable), time: Schema.Struct({ diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index d3d5109608..21e24285a2 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -22,6 +22,19 @@ export const ID = Schema.String.pipe( ) export type ID = typeof ID.Type +export const Overlays = { + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +} + +export const Package = Schema.Struct({ + package: Schema.String, + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Overlays, +}) +export type Package = typeof Package.Type + export interface AISDK extends Schema.Schema.Type {} export const AISDK = Schema.Struct({ type: Schema.Literal("aisdk"), @@ -33,6 +46,7 @@ export const AISDK = Schema.Struct({ export interface Native extends Schema.Schema.Type {} export const Native = Schema.Struct({ type: Schema.Literal("native"), + package: Schema.String.pipe(Schema.optional), url: Schema.String.pipe(Schema.optional), settings: Schema.Record(Schema.String, Schema.Unknown), }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9f9ddb14c2..107e3a08ce 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4002,6 +4002,7 @@ export type ModelV2Info = { | { id: string type: "native" + package?: string url?: string settings: { [key: string]: unknown @@ -4036,6 +4037,9 @@ export type ModelV2Info = { } variants: Array<{ id: string + settings?: { + [key: string]: unknown + } headers: { [key: string]: string } @@ -4096,6 +4100,7 @@ export type ProviderV2Info = { } | { type: "native" + package?: string url?: string settings: { [key: string]: unknown From 2b0b57347495d450fa14e693b7a0ad7871e1eae5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:05:02 +0530 Subject: [PATCH 05/12] feat(llm): add native provider packages --- packages/llm/package.json | 13 ++++-- packages/llm/src/index.ts | 5 +++ packages/llm/src/provider-package.ts | 16 +++++++ .../src/provider-packages/amazon-bedrock.ts | 28 ++++++++++++ .../provider-packages/anthropic-messages.ts | 15 +++++++ .../llm/src/provider-packages/openai-chat.ts | 20 +++++++++ .../openai-compatible-chat.ts | 17 +++++++ .../src/provider-packages/openai-responses.ts | 25 +++++++++++ packages/llm/src/provider-packages/shared.ts | 8 ++++ packages/llm/test/provider-package.test.ts | 45 +++++++++++++++++++ 10 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 packages/llm/src/provider-package.ts create mode 100644 packages/llm/src/provider-packages/amazon-bedrock.ts create mode 100644 packages/llm/src/provider-packages/anthropic-messages.ts create mode 100644 packages/llm/src/provider-packages/openai-chat.ts create mode 100644 packages/llm/src/provider-packages/openai-compatible-chat.ts create mode 100644 packages/llm/src/provider-packages/openai-responses.ts create mode 100644 packages/llm/src/provider-packages/shared.ts create mode 100644 packages/llm/test/provider-package.test.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index faccd3dd47..4c9d62fdcd 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -15,14 +15,19 @@ "./route": "./src/route/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", + "./provider-package": "./src/provider-package.ts", + "./providers/amazon-bedrock": "./src/provider-packages/amazon-bedrock.ts", + "./providers/anthropic": "./src/provider-packages/anthropic-messages.ts", + "./providers/anthropic/messages": "./src/provider-packages/anthropic-messages.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/providers/openai.ts", - "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai": "./src/provider-packages/openai-responses.ts", + "./providers/openai/responses": "./src/provider-packages/openai-responses.ts", + "./providers/openai/chat": "./src/provider-packages/openai-chat.ts", + "./providers/openai-compatible": "./src/provider-packages/openai-compatible-chat.ts", + "./providers/openai-compatible/chat": "./src/provider-packages/openai-compatible-chat.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77..843b4536a6 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,6 +1,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" +export { ProviderPackage } from "./provider-package" export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export type { RouteModelInput, @@ -31,3 +32,7 @@ export type { ModelFactory as ProviderModelFactory, ModelOptions as ProviderModelOptions, } from "./provider" +export type { + Definition as ProviderPackageDefinition, + Settings as ProviderPackageSettings, +} from "./provider-package" diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts new file mode 100644 index 0000000000..977a211864 --- /dev/null +++ b/packages/llm/src/provider-package.ts @@ -0,0 +1,16 @@ +import type { Model } from "./schema" + +export interface Settings extends Readonly> { + readonly headers?: Readonly> + readonly body?: Readonly> + readonly limits?: { + readonly context: number + readonly output: number + } +} + +export interface Definition { + readonly model: (id: string, settings: ProviderSettings) => Model +} + +export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/provider-packages/amazon-bedrock.ts b/packages/llm/src/provider-packages/amazon-bedrock.ts new file mode 100644 index 0000000000..f00b2056ae --- /dev/null +++ b/packages/llm/src/provider-packages/amazon-bedrock.ts @@ -0,0 +1,28 @@ +import type { ProviderPackage } from "../provider-package" +import * as AmazonBedrock from "../providers/amazon-bedrock" +import type { BedrockCredentials } from "../protocols/bedrock-converse" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return AmazonBedrock.configure({ + ...defaults(settings), + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + region: settings.region, + }).model(id) +} diff --git a/packages/llm/src/provider-packages/anthropic-messages.ts b/packages/llm/src/provider-packages/anthropic-messages.ts new file mode 100644 index 0000000000..be6def4081 --- /dev/null +++ b/packages/llm/src/provider-packages/anthropic-messages.ts @@ -0,0 +1,15 @@ +import type { ProviderPackage } from "../provider-package" +import * as Anthropic from "../providers/anthropic" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + Anthropic.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + }).model(id) diff --git a/packages/llm/src/provider-packages/openai-chat.ts b/packages/llm/src/provider-packages/openai-chat.ts new file mode 100644 index 0000000000..6a6d04c704 --- /dev/null +++ b/packages/llm/src/provider-packages/openai-chat.ts @@ -0,0 +1,20 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAI from "../providers/openai" +import type { OpenAIProviderOptionsInput } from "../providers/openai-options" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + OpenAI.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + providerOptions: settings.providerOptions, + }).chat(id) diff --git a/packages/llm/src/provider-packages/openai-compatible-chat.ts b/packages/llm/src/provider-packages/openai-compatible-chat.ts new file mode 100644 index 0000000000..ba7d569b9c --- /dev/null +++ b/packages/llm/src/provider-packages/openai-compatible-chat.ts @@ -0,0 +1,17 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAICompatible from "../providers/openai-compatible" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + OpenAICompatible.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + provider: settings.provider, + }).model(id) diff --git a/packages/llm/src/provider-packages/openai-responses.ts b/packages/llm/src/provider-packages/openai-responses.ts new file mode 100644 index 0000000000..4851a96a34 --- /dev/null +++ b/packages/llm/src/provider-packages/openai-responses.ts @@ -0,0 +1,25 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAI from "../providers/openai" +import type { OpenAIProviderOptionsInput } from "../providers/openai-options" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + const provider = OpenAI.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + providerOptions: settings.providerOptions, + }) + if (settings.transport === undefined || settings.transport === "http") return provider.responses(id) + if (settings.transport === "websocket") return provider.responsesWebSocket(id) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} diff --git a/packages/llm/src/provider-packages/shared.ts b/packages/llm/src/provider-packages/shared.ts new file mode 100644 index 0000000000..e3c7ec2444 --- /dev/null +++ b/packages/llm/src/provider-packages/shared.ts @@ -0,0 +1,8 @@ +import type { RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" + +export const defaults = (settings: ProviderPackage.Settings): RouteDefaultsInput => ({ + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + limits: settings.limits, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, +}) diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts new file mode 100644 index 0000000000..a4f1627c67 --- /dev/null +++ b/packages/llm/test/provider-package.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { model } from "@opencode-ai/llm/providers/openai" + +describe("provider package entrypoints", () => { + test("semantic API aliases expose the same contract", async () => { + const modules = await Promise.all([ + import("@opencode-ai/llm/providers/openai"), + import("@opencode-ai/llm/providers/openai/responses"), + import("@opencode-ai/llm/providers/openai/chat"), + import("@opencode-ai/llm/providers/anthropic"), + import("@opencode-ai/llm/providers/anthropic/messages"), + import("@opencode-ai/llm/providers/openai-compatible"), + import("@opencode-ai/llm/providers/openai-compatible/chat"), + import("@opencode-ai/llm/providers/amazon-bedrock"), + ]) + + for (const module of modules) expect(module.model).toBeFunction() + expect(modules[0].model).toBe(modules[1].model) + expect(modules[3].model).toBe(modules[4].model) + expect(modules[5].model).toBe(modules[6].model) + }) + + test("maps package settings onto the executable model", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + baseURL: "https://api.openai.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + unrelatedInheritedSetting: true, + }) + + expect(selected.route.id).toBe("openai-responses") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + }) + + test("selects transport without changing the semantic API", () => { + expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses") + expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe( + "openai-responses-websocket", + ) + }) +}) From 928510608d2d297aa4117debb7eb381a7bd7aa17 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:10:45 +0530 Subject: [PATCH 06/12] refactor(llm): colocate provider package logic --- packages/llm/package.json | 14 ++++----- .../src/provider-packages/amazon-bedrock.ts | 28 ------------------ .../provider-packages/anthropic-messages.ts | 15 ---------- .../llm/src/provider-packages/openai-chat.ts | 20 ------------- .../openai-compatible-chat.ts | 17 ----------- .../src/provider-packages/openai-responses.ts | 25 ---------------- packages/llm/src/provider-packages/shared.ts | 8 ----- packages/llm/src/providers/amazon-bedrock.ts | 27 ++++++++++++++++- packages/llm/src/providers/anthropic.ts | 15 +++++++++- .../llm/src/providers/openai-compatible.ts | 17 +++++++++++ packages/llm/src/providers/openai.ts | 29 ++++++++++++++++++- packages/llm/src/providers/openai/chat.ts | 2 ++ .../llm/src/providers/openai/responses.ts | 2 ++ packages/llm/test/exports.test.ts | 1 - packages/llm/test/provider-package.test.ts | 4 --- 15 files changed, 95 insertions(+), 129 deletions(-) delete mode 100644 packages/llm/src/provider-packages/amazon-bedrock.ts delete mode 100644 packages/llm/src/provider-packages/anthropic-messages.ts delete mode 100644 packages/llm/src/provider-packages/openai-chat.ts delete mode 100644 packages/llm/src/provider-packages/openai-compatible-chat.ts delete mode 100644 packages/llm/src/provider-packages/openai-responses.ts delete mode 100644 packages/llm/src/provider-packages/shared.ts create mode 100644 packages/llm/src/providers/openai/chat.ts create mode 100644 packages/llm/src/providers/openai/responses.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index 4c9d62fdcd..2a5d97d5bf 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -16,18 +16,16 @@ "./provider": "./src/provider.ts", "./providers": "./src/providers/index.ts", "./provider-package": "./src/provider-package.ts", - "./providers/amazon-bedrock": "./src/provider-packages/amazon-bedrock.ts", - "./providers/anthropic": "./src/provider-packages/anthropic-messages.ts", - "./providers/anthropic/messages": "./src/provider-packages/anthropic-messages.ts", + "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", + "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/provider-packages/openai-responses.ts", - "./providers/openai/responses": "./src/provider-packages/openai-responses.ts", - "./providers/openai/chat": "./src/provider-packages/openai-chat.ts", - "./providers/openai-compatible": "./src/provider-packages/openai-compatible-chat.ts", - "./providers/openai-compatible/chat": "./src/provider-packages/openai-compatible-chat.ts", + "./providers/openai": "./src/providers/openai.ts", + "./providers/openai/responses": "./src/providers/openai/responses.ts", + "./providers/openai/chat": "./src/providers/openai/chat.ts", + "./providers/openai-compatible": "./src/providers/openai-compatible.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", diff --git a/packages/llm/src/provider-packages/amazon-bedrock.ts b/packages/llm/src/provider-packages/amazon-bedrock.ts deleted file mode 100644 index f00b2056ae..0000000000 --- a/packages/llm/src/provider-packages/amazon-bedrock.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as AmazonBedrock from "../providers/amazon-bedrock" -import type { BedrockCredentials } from "../protocols/bedrock-converse" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly auth?: "bearer" | "sigv4" - readonly baseURL?: string - readonly credentials?: BedrockCredentials - readonly region?: string - readonly topP?: number -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => { - if (settings.auth === "bearer" && settings.apiKey === undefined) - throw new Error("Amazon Bedrock bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) - throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") - return AmazonBedrock.configure({ - ...defaults(settings), - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, - baseURL: settings.baseURL, - credentials: settings.credentials, - generation: settings.topP === undefined ? undefined : { topP: settings.topP }, - region: settings.region, - }).model(id) -} diff --git a/packages/llm/src/provider-packages/anthropic-messages.ts b/packages/llm/src/provider-packages/anthropic-messages.ts deleted file mode 100644 index be6def4081..0000000000 --- a/packages/llm/src/provider-packages/anthropic-messages.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as Anthropic from "../providers/anthropic" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - Anthropic.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - }).model(id) diff --git a/packages/llm/src/provider-packages/openai-chat.ts b/packages/llm/src/provider-packages/openai-chat.ts deleted file mode 100644 index 6a6d04c704..0000000000 --- a/packages/llm/src/provider-packages/openai-chat.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAI from "../providers/openai" -import type { OpenAIProviderOptionsInput } from "../providers/openai-options" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string - readonly queryParams?: Readonly> - readonly providerOptions?: OpenAIProviderOptionsInput -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - OpenAI.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, - providerOptions: settings.providerOptions, - }).chat(id) diff --git a/packages/llm/src/provider-packages/openai-compatible-chat.ts b/packages/llm/src/provider-packages/openai-compatible-chat.ts deleted file mode 100644 index ba7d569b9c..0000000000 --- a/packages/llm/src/provider-packages/openai-compatible-chat.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAICompatible from "../providers/openai-compatible" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL: string - readonly provider?: string -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - OpenAICompatible.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - provider: settings.provider, - }).model(id) diff --git a/packages/llm/src/provider-packages/openai-responses.ts b/packages/llm/src/provider-packages/openai-responses.ts deleted file mode 100644 index 4851a96a34..0000000000 --- a/packages/llm/src/provider-packages/openai-responses.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAI from "../providers/openai" -import type { OpenAIProviderOptionsInput } from "../providers/openai-options" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string - readonly queryParams?: Readonly> - readonly transport?: "http" | "websocket" - readonly providerOptions?: OpenAIProviderOptionsInput -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => { - const provider = OpenAI.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, - providerOptions: settings.providerOptions, - }) - if (settings.transport === undefined || settings.transport === "http") return provider.responses(id) - if (settings.transport === "websocket") return provider.responsesWebSocket(id) - throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) -} diff --git a/packages/llm/src/provider-packages/shared.ts b/packages/llm/src/provider-packages/shared.ts deleted file mode 100644 index e3c7ec2444..0000000000 --- a/packages/llm/src/provider-packages/shared.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteDefaultsInput } from "../route/client" -import type { ProviderPackage } from "../provider-package" - -export const defaults = (settings: ProviderPackage.Settings): RouteDefaultsInput => ({ - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - limits: settings.limits, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, -}) diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index 2f1791e0d6..e578d614c6 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -1,5 +1,6 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as BedrockConverse from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse" @@ -15,6 +16,15 @@ export type Config = RouteDefaultsInput & { /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ readonly baseURL?: string } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} export const routes = [BedrockConverse.route] const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` @@ -40,4 +50,19 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return configure({ + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + region: settings.region, + }).model(id) +} diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 0c9640af5e..8ef0293eeb 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -1,6 +1,7 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as AnthropicMessages from "../protocols/anthropic-messages" @@ -10,6 +11,11 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (id, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + }).model(id) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index a79f65f6df..36d9b3fd5a 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -2,6 +2,7 @@ import { ProviderID, type ModelID } from "../schema" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" export const id = ProviderID.make("openai-compatible") @@ -12,6 +13,12 @@ type GenericModelOptions = RouteDefaultsInput & readonly baseURL: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + export type FamilyModelOptions = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string @@ -56,6 +63,16 @@ export const provider = { configure, } +export const model: ProviderPackage.Definition["model"] = (id, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + }).model(id) + export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) export const deepinfra = define(profiles.deepinfra) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad8493..b40a94514b 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,5 +1,6 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import type { Route, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -21,6 +22,14 @@ export type Config = RouteDefaultsInput & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const defaults = (input: Config) => { @@ -57,7 +66,25 @@ export const configure = (input: Config = {}) => { export const provider = configure() -export const model = provider.model +const config = (settings: Settings): Config => ({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, +}) + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + const configured = configure(config(settings)) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(id) + if (settings.transport === "websocket") return configured.responsesWebSocket(id) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} + +export const chatModel: ProviderPackage.Definition["model"] = (id, settings) => + configure(config(settings)).chat(id) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat diff --git a/packages/llm/src/providers/openai/chat.ts b/packages/llm/src/providers/openai/chat.ts new file mode 100644 index 0000000000..eb92db8ac5 --- /dev/null +++ b/packages/llm/src/providers/openai/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/src/providers/openai/responses.ts b/packages/llm/src/providers/openai/responses.ts new file mode 100644 index 0000000000..4db3a232c7 --- /dev/null +++ b/packages/llm/src/providers/openai/responses.ts @@ -0,0 +1,2 @@ +export { model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 693a21638b..6ed66a0d8e 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -30,7 +30,6 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBe(OpenAI.model) expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index a4f1627c67..c8cba78eb1 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -8,16 +8,12 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/openai/responses"), import("@opencode-ai/llm/providers/openai/chat"), import("@opencode-ai/llm/providers/anthropic"), - import("@opencode-ai/llm/providers/anthropic/messages"), import("@opencode-ai/llm/providers/openai-compatible"), - import("@opencode-ai/llm/providers/openai-compatible/chat"), import("@opencode-ai/llm/providers/amazon-bedrock"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) - expect(modules[3].model).toBe(modules[4].model) - expect(modules[5].model).toBe(modules[6].model) }) test("maps package settings onto the executable model", () => { From d1446a9e72a3aaef900f480f783ed981c9511a50 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:29:06 +0530 Subject: [PATCH 07/12] refactor(llm): name provider model identity --- packages/llm/src/provider-package.ts | 2 +- packages/llm/src/providers/amazon-bedrock.ts | 4 ++-- packages/llm/src/providers/anthropic.ts | 4 ++-- packages/llm/src/providers/openai-compatible.ts | 4 ++-- packages/llm/src/providers/openai.ts | 10 +++++----- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts index 977a211864..fd878a9d06 100644 --- a/packages/llm/src/provider-package.ts +++ b/packages/llm/src/provider-package.ts @@ -10,7 +10,7 @@ export interface Settings extends Readonly> { } export interface Definition { - readonly model: (id: string, settings: ProviderSettings) => Model + readonly model: (modelID: string, settings: ProviderSettings) => Model } export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index e578d614c6..4e5040c9ec 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -50,7 +50,7 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (id, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { if (settings.auth === "bearer" && settings.apiKey === undefined) throw new Error("Amazon Bedrock bearer auth requires apiKey") if (settings.auth === "sigv4" && settings.apiKey !== undefined) @@ -64,5 +64,5 @@ export const model: ProviderPackage.Definition["model"] = (id, setting http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, region: settings.region, - }).model(id) + }).model(modelID) } diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 8ef0293eeb..1bf8bf569c 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -38,11 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (id, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, - }).model(id) + }).model(modelID) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index 36d9b3fd5a..8b5d23eed7 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -63,7 +63,7 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (id, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, @@ -71,7 +71,7 @@ export const model: ProviderPackage.Definition["model"] = (id, setting http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, provider: settings.provider, - }).model(id) + }).model(modelID) export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index b40a94514b..6b49b9def2 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -76,15 +76,15 @@ const config = (settings: Settings): Config => ({ queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, }) -export const model: ProviderPackage.Definition["model"] = (id, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { const configured = configure(config(settings)) - if (settings.transport === undefined || settings.transport === "http") return configured.responses(id) - if (settings.transport === "websocket") return configured.responsesWebSocket(id) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) + if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) } -export const chatModel: ProviderPackage.Definition["model"] = (id, settings) => - configure(config(settings)).chat(id) +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat From 1a9bd8c1fde07e7fe8ec08ac3444fce04bed7a34 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 25 Jun 2026 19:49:14 +0530 Subject: [PATCH 08/12] refactor(schema): flatten provider identity --- packages/core/src/catalog.ts | 29 +---- packages/core/src/config/plugin/provider.ts | 91 ++----------- packages/core/src/config/provider.ts | 37 ++++-- packages/core/src/model.ts | 69 ++++++++-- packages/core/src/provider-overlay.ts | 34 ----- packages/core/src/provider.ts | 63 ++++++--- packages/core/src/session/runner/model.ts | 10 +- packages/core/src/v1/config/migrate.ts | 38 ++++-- packages/core/test/catalog.test.ts | 1 - packages/core/test/config/config.test.ts | 31 +++-- packages/core/test/config/provider.test.ts | 120 +++--------------- .../core/test/session-runner-model.test.ts | 29 ----- packages/core/test/shared-schema.test.ts | 51 ++++++-- packages/schema/src/model.ts | 47 ++----- packages/schema/src/provider.ts | 41 +----- packages/sdk/js/src/v2/gen/types.gen.ts | 5 - 16 files changed, 261 insertions(+), 435 deletions(-) delete mode 100644 packages/core/src/provider-overlay.ts diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 00fbb5f3dc..431aa004b3 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -7,7 +7,6 @@ import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" import { Integration } from "./integration" -import { ProviderOverlay } from "./provider-overlay" export type ProviderRecord = { provider: ProviderV2.MutableInfo @@ -78,32 +77,16 @@ export const layer = Layer.effect( const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = - model.api.type === "native" && !model.api.package - ? { - ...provider.api, - id: model.api.id, - url: model.api.url ?? provider.api.url, - settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings), - } - : model.api.type === "native" && provider.api.type === "native" && !model.api.url - ? { - ...model.api, - package: model.api.package ?? provider.api.package, - url: provider.api.url, - settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings), - } + model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 + ? { ...provider.api, id: model.api.id } : model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url - ? { - ...model.api, - url: provider.api.url, - settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings), - } + ? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } } : model.api.type === "aisdk" && provider.api.type === "aisdk" - ? { ...model.api, settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings) } + ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } : model.api const request = { - headers: ProviderOverlay.mergeHeaders(provider.request.headers, model.request.headers), - body: ProviderOverlay.mergeRecords(provider.request.body, model.request.body), + headers: { ...provider.request.headers, ...model.request.headers }, + body: { ...provider.request.body, ...model.request.body }, variant: model.request.variant, } return ModelV2.Info.make({ diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 85dfced30c..6f6e0528da 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -5,7 +5,6 @@ import { Effect } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" -import { ProviderOverlay } from "../../provider-overlay" export const Plugin = define({ id: "config-provider", @@ -48,90 +47,22 @@ export const Plugin = define({ const model = ModelV2.parse(configuredDefault) catalog.model.default.set(model.providerID, model.modelID) } - const providerAiSDK = new Map() - const modelAiSDK = new Map() for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const providerID = id - if (item.aiSDK !== undefined) providerAiSDK.set(providerID, item.aiSDK) catalog.provider.update(providerID, (provider) => { if (item.name !== undefined) provider.name = item.name - if (item.package !== undefined) { - const settings = ProviderOverlay.mergeRecords(provider.api.settings, item.settings) - const url = - item.settings && Object.hasOwn(item.settings, "baseURL") - ? typeof settings.baseURL === "string" - ? settings.baseURL - : undefined - : provider.api.url - provider.api = (providerAiSDK.get(providerID) ?? provider.api.type === "aisdk") - ? { type: "aisdk", package: item.package, ...(url === undefined ? {} : { url }), settings } - : { type: "native", package: item.package, ...(url === undefined ? {} : { url }), settings } - } else if (item.settings !== undefined) { - provider.api.settings = ProviderOverlay.mergeRecords(provider.api.settings, item.settings) - if (Object.hasOwn(item.settings, "baseURL")) { - provider.api.url = - typeof provider.api.settings.baseURL === "string" ? provider.api.settings.baseURL : undefined - } + if (item.api !== undefined) provider.api = { ...item.api } + if (item.request !== undefined) { + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) } - if (item.package === undefined && item.aiSDK !== undefined) { - if (item.aiSDK && provider.api.type === "native" && provider.api.package !== undefined) { - provider.api = { ...provider.api, type: "aisdk", package: provider.api.package } - } - if (!item.aiSDK && provider.api.type === "aisdk") { - provider.api = { ...provider.api, type: "native", settings: provider.api.settings ?? {} } - } - } - ProviderOverlay.assign(provider.request, { headers: item.headers, body: item.body }) }) - const providerApi = catalog.provider.get(providerID)?.provider.api for (const [id, config] of Object.entries(item.models ?? {})) { - const modelKey = `${providerID}/${id}` - if (config.aiSDK !== undefined) modelAiSDK.set(modelKey, config.aiSDK) catalog.model.update(providerID, id, (model) => { if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name - if (config.id !== undefined) model.api.id = config.id - if (config.package !== undefined) { - const aiSDK = - modelAiSDK.get(modelKey) ?? providerAiSDK.get(providerID) ?? providerApi?.type === "aisdk" - const settings = ProviderOverlay.mergeRecords(model.api.settings, config.settings) - const url = - config.settings && Object.hasOwn(config.settings, "baseURL") - ? typeof settings.baseURL === "string" - ? settings.baseURL - : undefined - : model.api.url - model.api = aiSDK - ? { - id: model.api.id, - type: "aisdk", - package: config.package, - ...(url === undefined ? {} : { url }), - settings, - } - : { - id: model.api.id, - type: "native", - package: config.package, - ...(url === undefined ? {} : { url }), - settings, - } - } else if (config.settings !== undefined) { - model.api.settings = ProviderOverlay.mergeRecords(model.api.settings, config.settings) - if (Object.hasOwn(config.settings, "baseURL")) { - model.api.url = - typeof model.api.settings.baseURL === "string" ? model.api.settings.baseURL : undefined - } - } - if (config.package === undefined && config.aiSDK !== undefined) { - if (config.aiSDK && model.api.type === "native" && model.api.package !== undefined) { - model.api = { ...model.api, type: "aisdk", package: model.api.package } - } - if (!config.aiSDK && model.api.type === "aisdk") { - model.api = { ...model.api, type: "native", settings: model.api.settings ?? {} } - } - } + if (config.api !== undefined) model.api = { ...model.api, ...config.api } if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, @@ -139,24 +70,24 @@ export const Plugin = define({ output: [...config.capabilities.output], } } - if (config.headers !== undefined || config.body !== undefined) { - ProviderOverlay.assign(model.request, { headers: config.headers, body: config.body }) + if (config.request !== undefined) { + Object.assign(model.request.headers, config.request.headers) + Object.assign(model.request.body, config.request.body) + if (config.request.variant !== undefined) model.request.variant = config.request.variant } - if (config.variant !== undefined) model.request.variant = config.variant if (config.variants !== undefined) { for (const variant of config.variants) { let existing = model.variants.find((item) => item.id === variant.id) if (!existing) { existing = { id: variant.id, - settings: {}, headers: {}, body: {}, } model.variants.push(existing) } - existing.settings = ProviderOverlay.mergeRecords(existing.settings, variant.settings) - ProviderOverlay.assign(existing, { headers: variant.headers, body: variant.body }) + Object.assign(existing.headers, variant.headers) + Object.assign(existing.body, variant.body) } } if (config.cost !== undefined) { diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 665799ec3f..1b54757078 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProvider from "./provider" import { Schema } from "effect" +import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" export class Request extends Schema.Class("ConfigV2.Provider.Request")({ @@ -8,11 +9,6 @@ export class Request extends Schema.Class("ConfigV2.Provider.Request")( body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} -const Overlays = { - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - ...Request.fields, -} - class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ read: Schema.Finite.pipe(Schema.optional), write: Schema.Finite.pipe(Schema.optional), @@ -34,18 +30,32 @@ class Limit extends Schema.Class("ConfigV2.Model.Limit")({ output: Schema.Int.pipe(Schema.optional), }) {} +const ModelApi = Schema.Union([ + Schema.Struct({ + id: ModelV2.ID.pipe(Schema.optional), + ...ProviderV2.AISDK.fields, + }), + Schema.Struct({ + id: ModelV2.ID.pipe(Schema.optional), + ...ProviderV2.Native.fields, + }), + Schema.Struct({ + id: ModelV2.ID, + }), +]) + class Model extends Schema.Class("ConfigV2.Model")({ - id: ModelV2.ID.pipe(Schema.optional), - package: Schema.String.pipe(Schema.optional), - aiSDK: Schema.Boolean.pipe(Schema.optional), - ...Overlays, family: ModelV2.Family.pipe(Schema.optional), name: Schema.String.pipe(Schema.optional), + api: ModelApi.pipe(Schema.optional), capabilities: ModelV2.Capabilities.pipe(Schema.optional), - variant: Schema.String.pipe(Schema.optional), + request: Schema.Struct({ + ...Request.fields, + variant: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), variants: Schema.Struct({ id: ModelV2.VariantID, - ...Overlays, + ...Request.fields, }).pipe(Schema.Array, Schema.optional), cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), @@ -55,8 +65,7 @@ class Model extends Schema.Class("ConfigV2.Model")({ export class Info extends Schema.Class("ConfigV2.Provider")({ name: Schema.String.pipe(Schema.optional), env: Schema.String.pipe(Schema.Array, Schema.optional), - package: Schema.String.pipe(Schema.optional), - aiSDK: Schema.Boolean.pipe(Schema.optional), - ...Overlays, + api: ProviderV2.Api.pipe(Schema.optional), + request: Request.pipe(Schema.optional), models: Schema.Record(Schema.String, Model).pipe(Schema.optional), }) {} diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index bb22033943..bae4408560 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,6 +1,8 @@ -import { Types } from "effect" +import { Schema } from "effect" import { Model } from "@opencode-ai/schema/model" import { ProviderV2 } from "./provider" +import { withStatics } from "./schema" +import type { DeepMutable } from "./schema" export const ID = Model.ID export type ID = typeof ID.Type @@ -8,10 +10,6 @@ export type ID = typeof ID.Type export const VariantID = Model.VariantID export type VariantID = typeof VariantID.Type -export const Package = Model.Package -export type Package = Model.Package - -// Grouping of models, eg claude opus, claude sonnet export const Family = Model.Family export type Family = Model.Family @@ -23,15 +21,62 @@ export const Cost = Model.Cost export const Ref = Model.Ref export type Ref = typeof Ref.Type -export const Api = Model.Api -export type Api = Model.Api +// Temporary runtime schema until core catalog consumers migrate to the flat +// package identity in @opencode-ai/schema. +export const Api = Schema.Union([ + Schema.Struct({ id: ID, ...ProviderV2.AISDK.fields }), + Schema.Struct({ id: ID, ...ProviderV2.Native.fields }), +]).pipe(Schema.toTaggedUnion("type")) +export type Api = typeof Api.Type -export const Info = Model.Info -export type Info = Model.Info +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + providerID: ProviderV2.ID, + family: Family.pipe(Schema.optional), + name: Schema.String, + api: Api, + capabilities: Capabilities, + request: Schema.Struct({ + ...ProviderV2.Request.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...ProviderV2.Request.fields, + }).pipe(Schema.Array, Schema.mutable), + time: Schema.Struct({ released: Schema.Finite }), + cost: Cost.pipe(Schema.Array, Schema.mutable), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + enabled: Schema.Boolean, + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) + .annotate({ identifier: "ModelV2.Info" }) + .pipe( + withStatics((schema) => ({ + empty: (providerID: ProviderV2.ID, modelID: ID) => + schema.make({ + id: modelID, + providerID, + name: modelID, + api: { id: modelID, type: "native", settings: {} }, + capabilities: { tools: false, input: [], output: [] }, + request: { headers: {}, body: {} }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 0, output: 0 }, + }), + })), + ) -export type MutableInfo = Omit, "api"> & { - api: ProviderV2.MutableApi -} +export type MutableInfo = Omit, "api"> & { api: ProviderV2.MutableApi } export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") diff --git a/packages/core/src/provider-overlay.ts b/packages/core/src/provider-overlay.ts deleted file mode 100644 index 2a2044c929..0000000000 --- a/packages/core/src/provider-overlay.ts +++ /dev/null @@ -1,34 +0,0 @@ -export * as ProviderOverlay from "./provider-overlay" - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -export const mergeRecords = (...items: ReadonlyArray> | undefined>) => { - const result: Record = {} - for (const item of items) { - for (const [key, value] of Object.entries(item ?? {})) { - result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value - } - } - return result -} - -export const mergeHeaders = (...items: ReadonlyArray> | undefined>) => { - const result = new Map() - for (const item of items) { - for (const entry of Object.entries(item ?? {})) result.set(entry[0].toLowerCase(), entry) - } - return Object.fromEntries(result.values()) -} - -export const assign = ( - target: { headers: Record; body: Record }, - overlay: { readonly headers?: Readonly>; readonly body?: Readonly> }, -) => { - const headers = mergeHeaders(target.headers, overlay.headers) - Object.keys(target.headers).forEach((key) => delete target.headers[key]) - Object.assign(target.headers, headers) - const body = mergeRecords(target.body, overlay.body) - Object.keys(target.body).forEach((key) => delete target.body[key]) - Object.assign(target.body, body) -} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 95de2c3ebc..d1a541dd65 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,30 +1,61 @@ export * as ProviderV2 from "./provider" -import { Types } from "effect" +import { Schema } from "effect" import { Provider } from "@opencode-ai/schema/provider" +import { Integration } from "./integration" +import { withStatics } from "./schema" +import type { DeepMutable } from "./schema" export const ID = Provider.ID export type ID = typeof ID.Type -export const Overlays = Provider.Overlays +// Temporary runtime schema until core catalog consumers migrate to the flat +// package identity in @opencode-ai/schema. +export interface AISDK extends Schema.Schema.Type {} +export const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(Schema.optional), + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) -export const Package = Provider.Package -export type Package = Provider.Package +export interface Native extends Schema.Schema.Type {} +export const Native = Schema.Struct({ + type: Schema.Literal("native"), + url: Schema.String.pipe(Schema.optional), + settings: Schema.Record(Schema.String, Schema.Unknown), +}) -export const AISDK = Provider.AISDK - -export const Native = Provider.Native - -export const Api = Provider.Api -export type Api = Provider.Api -export type MutableApi = T extends Api - ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) - : never +export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) +export type Api = typeof Api.Type export const Request = Provider.Request export type Request = Provider.Request -export const Info = Provider.Info -export type Info = Provider.Info +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + integrationID: Integration.ID.pipe(Schema.optional), + name: Schema.String, + disabled: Schema.Boolean.pipe(Schema.optional), + api: Api, + request: Request, +}) + .annotate({ identifier: "ProviderV2.Info" }) + .pipe( + withStatics((schema) => ({ + empty: (id: ID) => + schema.make({ + id, + name: id, + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }), + })), + ) -export type MutableInfo = Omit, "api"> & { api: MutableApi } +export type MutableApi = T extends Api + ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never + +export type MutableInfo = Omit, "api"> & { api: MutableApi } diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b24746ef10..b864df8ba3 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -12,7 +12,6 @@ import { Credential } from "../../credential" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" -import { ProviderOverlay } from "../../provider-overlay" import { SessionSchema } from "../schema" export class ModelNotSelectedError extends Schema.TaggedErrorClass()( @@ -118,13 +117,8 @@ const withVariant = ( return Effect.succeed( variant ? produce(model, (draft) => { - if (variant.settings !== undefined) { - draft.api.settings = ProviderOverlay.mergeRecords(draft.api.settings, variant.settings) - if (Object.hasOwn(variant.settings, "baseURL")) { - draft.api.url = typeof draft.api.settings.baseURL === "string" ? draft.api.settings.baseURL : undefined - } - } - ProviderOverlay.assign(draft.request, variant) + Object.assign(draft.request.headers, variant.headers) + Object.assign(draft.request.body, variant.body) }) : model, ) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 6e35e10f5e..1583c6c1d5 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -173,14 +173,15 @@ function migrateProvider(info: ConfigProviderV1.Info) { return { name: info.name, env: info.env, - package: info.npm, - aiSDK: info.npm ? true : undefined, - settings: { - ...options.settings, - ...(info.api ?? options.url ? { baseURL: info.api ?? options.url } : {}), - }, - headers: options.headers, - body: options.body, + api: info.npm + ? { + type: "aisdk" as const, + package: info.npm, + url: info.api ?? options.url, + settings: options.settings ?? {}, + } + : undefined, + request: info.options && { headers: options.headers, body: options.body }, models: info.models && Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])), @@ -215,13 +216,22 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st return { family: info.family, name: info.name, - id: info.id, - package: info.provider?.npm, - aiSDK: info.provider?.npm ? true : undefined, - settings: info.provider?.api ? { baseURL: info.provider.api } : undefined, + api: info.provider?.npm + ? { + ...(info.id === undefined ? {} : { id: info.id }), + type: "aisdk" as const, + package: info.provider.npm, + url: info.provider.api, + settings: {}, + } + : info.id === undefined + ? undefined + : { id: info.id }, capabilities, - headers: info.headers, - body: request, + request: (info.headers || request) && { + headers: info.headers, + body: request, + }, variants: info.variants && Object.entries(info.variants).map(([id, options]) => ({ diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index bc5a74efc0..a3a1c4972d 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -213,7 +213,6 @@ describe("CatalogV2", () => { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com", - settings: {}, }) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 756977104b..b092bb5582 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -103,10 +103,13 @@ describe("Config", () => { }, }) - expect(migrated.providers?.bedrock).toMatchObject({ + expect(migrated.providers?.bedrock?.api).toEqual({ + type: "aisdk", package: "@ai-sdk/amazon-bedrock", - aiSDK: true, + url: undefined, settings: { region: "us-east-1", profile: "dev" }, + }) + expect(migrated.providers?.bedrock?.request).toEqual({ headers: { "x-test": "1" }, body: { trace: true }, }) @@ -582,34 +585,34 @@ describe("Config", () => { }) expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } }) expect(documents[0]?.info.providers?.custom).toMatchObject({ - body: { apiKey: "secret" }, + request: { body: { apiKey: "secret" } }, models: { model: { - body: { reasoningEffort: "high" }, + request: { body: { reasoningEffort: "high" } }, variants: [{ id: "fast", body: { temperature: 0.2 } }], }, }, }) expect(documents[0]?.info.providers?.openai).toMatchObject({ - package: "@ai-sdk/openai", - aiSDK: true, - settings: {}, - headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" }, + api: { settings: {} }, + request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } }, models: { model: { - body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" }, + request: { + body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" }, + }, variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }], }, }, }) expect(documents[0]?.info.providers?.anthropic).toMatchObject({ - package: "@ai-sdk/anthropic", - aiSDK: true, models: { model: { - body: { - output_config: { effort: "high", task_budget: 4096 }, - metadata: { user_id: "user-1" }, + request: { + body: { + output_config: { effort: "high", task_budget: 4096 }, + metadata: { user_id: "user-1" }, + }, }, }, }, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 602852d701..605bcd63cf 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -45,90 +45,16 @@ function withEnv(vars: Record, effect: () = ) } +function request(headers: Record, variant?: string) { + return { + headers, + variant, + } +} + const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { - it.effect("merges flat provider and model overlays", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const providerID = ProviderV2.ID.make("custom") - const modelID = ModelV2.ID.make("chat") - const config = Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - info: decode({ providers: { custom: { aiSDK: true } } }), - }), - new Config.Document({ - type: "document", - info: decode({ - providers: { - custom: { - package: "custom-provider", - settings: { auth: { type: "token", region: "us-east-1" } }, - headers: { "X-Test": "provider" }, - body: { reasoning: { type: "enabled", budget: 8_000 }, tags: ["provider"] }, - models: { - chat: { - package: "custom-model-provider", - settings: { auth: { region: "us-west-2" } }, - headers: { "x-test": "model" }, - body: { reasoning: { budget: 32_000 }, tags: ["model"] }, - }, - inherit: { - settings: { auth: { region: "eu-west-1" }, baseURL: "https://model.example/v1" }, - }, - clear: { settings: { baseURL: "https://old.example/v1" } }, - }, - }, - }, - }), - }), - new Config.Document({ - type: "document", - info: decode({ - providers: { - custom: { models: { clear: { package: "custom-provider", settings: { baseURL: null } } } }, - }, - }), - }), - ]), - }) - - yield* addPlugin(config) - - const model = required(yield* catalog.model.get(providerID, modelID)) - expect(model.api).toEqual({ - id: modelID, - type: "aisdk", - package: "custom-model-provider", - settings: { auth: { type: "token", region: "us-west-2" } }, - }) - expect(model.request.headers).toEqual({ "x-test": "model" }) - expect(model.request.body).toEqual({ - reasoning: { type: "enabled", budget: 32_000 }, - tags: ["model"], - }) - expect(required(yield* catalog.model.get(providerID, ModelV2.ID.make("inherit"))).api).toEqual({ - id: ModelV2.ID.make("inherit"), - type: "aisdk", - package: "custom-provider", - url: "https://model.example/v1", - settings: { - auth: { type: "token", region: "eu-west-1" }, - baseURL: "https://model.example/v1", - }, - }) - expect(required(yield* catalog.model.get(providerID, ModelV2.ID.make("clear"))).api).toEqual({ - id: ModelV2.ID.make("clear"), - type: "aisdk", - package: "custom-provider", - settings: { auth: { type: "token", region: "us-east-1" }, baseURL: null }, - }) - }), - ) - it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -142,9 +68,7 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - package: "@ai-sdk/openai", - aiSDK: true, - settings: { baseURL: "https://opencode.test/v1" }, + api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, models: { "alpha-gpt-next": { variants: [ @@ -195,9 +119,7 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - package: "@ai-sdk/openai", - aiSDK: true, - settings: { baseURL: "https://opencode.test/v1" }, + api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, }, }, }), @@ -247,8 +169,8 @@ describe("ConfigProviderPlugin.Plugin", () => { custom: { name: "Configured", env: ["CUSTOM_API_KEY"], - package: "custom-native", - headers: { first: "first", shared: "first" }, + api: { type: "native", settings: {} }, + request: request({ first: "first", shared: "first" }), models: { chat: { name: "First", @@ -256,8 +178,7 @@ describe("ConfigProviderPlugin.Plugin", () => { disabled: true, limit: { context: 100, output: 50 }, cost: { input: 1, output: 2 }, - headers: { first: "first", shared: "first" }, - variant: "retained", + request: request({ first: "first", shared: "first" }, "retained"), variants: [ { id: "fast", @@ -276,19 +197,17 @@ describe("ConfigProviderPlugin.Plugin", () => { model: "custom/default", providers: { custom: { - package: "custom-sdk", - aiSDK: true, - settings: { baseURL: "https://example.test" }, - headers: { last: "last", shared: "last" }, + api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + request: request({ last: "last", shared: "last" }), models: { default: { name: "Default", }, chat: { - id: "api-chat", + api: { id: "api-chat" }, name: "Last", limit: { output: 75 }, - headers: { last: "last", shared: "last" }, + request: request({ last: "last", shared: "last" }), variants: [ { id: "fast", @@ -328,12 +247,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }) expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") expect(provider.disabled).toBeUndefined() - expect(provider.api).toEqual({ - type: "aisdk", - package: "custom-sdk", - url: "https://example.test", - settings: { baseURL: "https://example.test" }, - }) + expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 502b021d80..94669b24be 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -141,35 +141,6 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("applies a selected variant base URL", () => - Effect.gen(function* () { - const base = model( - { type: "aisdk", package: "@ai-sdk/openai", url: "https://default.example/v1" }, - [ - { - id: ModelV2.VariantID.make("regional"), - settings: { baseURL: "https://regional.example/v1" }, - headers: {}, - body: {}, - }, - ], - ) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_regional_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { id: base.id, providerID: base.providerID, variant: ModelV2.VariantID.make("regional") }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location: { directory: AbsolutePath.make("/project") }, - }) - const resolved = yield* SessionRunnerModel.resolve(session, base) - - expect(resolved.route.endpoint.baseURL).toBe("https://regional.example/v1") - }), - ) - it.effect("overlays selected OpenAI-compatible Session variant bodies", () => Effect.gen(function* () { const catalog = model( diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 4f529a1c62..1d4cebb660 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -96,20 +96,12 @@ test("Core reuses the canonical shared schemas", async () => { [coreLLM.ToolContent, LLM.ToolContent], [ModelV2.ID, Model.ID], [ModelV2.VariantID, Model.VariantID], - [ModelV2.Package, Model.Package], [ModelV2.Ref, Model.Ref], [ModelV2.Family, Model.Family], [ModelV2.Capabilities, Model.Capabilities], [ModelV2.Cost, Model.Cost], - [ModelV2.Api, Model.Api], - [ModelV2.Info, Model.Info], [ProviderV2.ID, Provider.ID], - [ProviderV2.Package, Provider.Package], - [ProviderV2.AISDK, Provider.AISDK], - [ProviderV2.Native, Provider.Native], - [ProviderV2.Api, Provider.Api], [ProviderV2.Request, Provider.Request], - [ProviderV2.Info, Provider.Info], [corePermission.Effect, Permission.Effect], [corePermission.Rule, Permission.Rule], [corePermission.Ruleset, Permission.Ruleset], @@ -157,15 +149,50 @@ test("Core reuses the canonical shared schemas", async () => { for (const [core, shared] of schemas) expect(core).toBe(shared) expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test"))) - expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( - ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), - ) - expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test"))) expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe( "directory:/tmp", ) }) +test("shared provider schemas use flat package identity", () => { + expect( + Schema.decodeUnknownSync(Provider.Info)({ + id: "openai", + name: "OpenAI", + package: "@opencode-ai/llm/providers/openai", + settings: { baseURL: "https://api.openai.com/v1" }, + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + }), + ).toMatchObject({ package: "@opencode-ai/llm/providers/openai" }) + expect( + Schema.decodeUnknownSync(Provider.Info)({ + id: "legacy", + name: "Legacy", + package: "aisdk:@ai-sdk/openai", + }), + ).toMatchObject({ package: "aisdk:@ai-sdk/openai" }) + expect( + Schema.decodeUnknownSync(Model.Info)({ + id: "claude-sonnet", + modelID: "us.anthropic.claude-sonnet-4-6", + providerID: "amazon-bedrock", + name: "Claude Sonnet", + package: "@opencode-ai/llm/providers/amazon-bedrock", + settings: { region: "us-west-2" }, + headers: {}, + body: { additionalModelRequestFields: { reasoning_config: { type: "enabled" } } }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [{ id: "max", settings: {}, body: { budget_tokens: 31999 } }], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200000, output: 64000 }, + }), + ).toMatchObject({ id: "claude-sonnet", modelID: "us.anthropic.claude-sonnet-4-6" }) +}) + test("shared record schemas construct and decode plain objects", () => { const made = Prompt.make({ text: "hello" }) const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 3af906d04c..4b04efd9f9 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -10,14 +10,6 @@ export type ID = typeof ID.Type export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) export type VariantID = typeof VariantID.Type -export const Package = Schema.Struct({ - modelID: ID, - package: Schema.String.pipe(Schema.optional), - aiSDK: Schema.Boolean.pipe(Schema.optional), - ...Provider.Overlays, -}) -export type Package = typeof Package.Type - export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, @@ -49,35 +41,23 @@ export const Cost = Schema.Struct({ }), }) -export const Api = Schema.Union([ - Schema.Struct({ - id: ID, - ...Provider.AISDK.fields, - }), - Schema.Struct({ - id: ID, - ...Provider.Native.fields, - }), -]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type +export interface Variant extends Schema.Schema.Type {} +export const Variant = Schema.Struct({ + id: VariantID, + ...Provider.Overlays, +}) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + modelID: ID.pipe(Schema.optional), providerID: Provider.ID, family: Family.pipe(Schema.optional), name: Schema.String, - api: Api, + package: Provider.Package.pipe(Schema.optional), + ...Provider.Overlays, capabilities: Capabilities, - request: Schema.Struct({ - ...Provider.Request.fields, - variant: Schema.String.pipe(Schema.optional), - }), - variants: Schema.Struct({ - id: VariantID, - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - ...Provider.Request.fields, - }).pipe(Schema.Array, Schema.mutable), + variants: Variant.pipe(Schema.Array, Schema.mutable, Schema.optional), time: Schema.Struct({ released: Schema.Finite, }), @@ -93,15 +73,12 @@ export const Info = Schema.Struct({ .annotate({ identifier: "ModelV2.Info" }) .pipe( withStatics((schema) => ({ - empty: (providerID: Provider.ID, modelID: ID) => + empty: (providerID: Provider.ID, id: ID) => schema.make({ - id: modelID, + id, providerID, - name: modelID, - api: { id: modelID, type: "native", settings: {} }, + name: id, capabilities: { tools: false, input: [], output: [] }, - request: { headers: {}, body: {} }, - variants: [], time: { released: 0 }, cost: [], status: "active", diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 21e24285a2..cd2cafaa45 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -22,38 +22,15 @@ export const ID = Schema.String.pipe( ) export type ID = typeof ID.Type +export const Package = Schema.String +export type Package = typeof Package.Type + export const Overlays = { settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), } -export const Package = Schema.Struct({ - package: Schema.String, - aiSDK: Schema.Boolean.pipe(Schema.optional), - ...Overlays, -}) -export type Package = typeof Package.Type - -export interface AISDK extends Schema.Schema.Type {} -export const AISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) - -export interface Native extends Schema.Schema.Type {} -export const Native = Schema.Struct({ - type: Schema.Literal("native"), - package: Schema.String.pipe(Schema.optional), - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown), -}) - -export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type - export interface Request extends Schema.Schema.Type {} export const Request = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), @@ -66,18 +43,12 @@ export const Info = Schema.Struct({ integrationID: Integration.ID.pipe(Schema.optional), name: Schema.String, disabled: Schema.Boolean.pipe(Schema.optional), - api: Api, - request: Request, + package: Package, + ...Overlays, }) .annotate({ identifier: "ProviderV2.Info" }) .pipe( withStatics((schema) => ({ - empty: (id: ID) => - schema.make({ - id, - name: id, - api: { type: "native", settings: {} }, - request: { headers: {}, body: {} }, - }), + empty: (id: ID) => schema.make({ id, name: id, package: "" }), })), ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ece914c9d0..3116c295f1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4002,7 +4002,6 @@ export type ModelV2Info = { | { id: string type: "native" - package?: string url?: string settings: { [key: string]: unknown @@ -4024,9 +4023,6 @@ export type ModelV2Info = { } variants: Array<{ id: string - settings?: { - [key: string]: unknown - } headers: { [key: string]: string } @@ -4074,7 +4070,6 @@ export type ProviderV2Info = { } | { type: "native" - package?: string url?: string settings: { [key: string]: unknown From 2f85f4744d90dbb842e3d3459d7a5089b9a12ab4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:05:02 +0530 Subject: [PATCH 09/12] feat(llm): add native provider packages --- packages/llm/package.json | 13 ++++-- packages/llm/src/index.ts | 5 +++ packages/llm/src/provider-package.ts | 16 +++++++ .../src/provider-packages/amazon-bedrock.ts | 28 ++++++++++++ .../provider-packages/anthropic-messages.ts | 15 +++++++ .../llm/src/provider-packages/openai-chat.ts | 20 +++++++++ .../openai-compatible-chat.ts | 17 +++++++ .../src/provider-packages/openai-responses.ts | 25 +++++++++++ packages/llm/src/provider-packages/shared.ts | 8 ++++ packages/llm/test/provider-package.test.ts | 45 +++++++++++++++++++ 10 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 packages/llm/src/provider-package.ts create mode 100644 packages/llm/src/provider-packages/amazon-bedrock.ts create mode 100644 packages/llm/src/provider-packages/anthropic-messages.ts create mode 100644 packages/llm/src/provider-packages/openai-chat.ts create mode 100644 packages/llm/src/provider-packages/openai-compatible-chat.ts create mode 100644 packages/llm/src/provider-packages/openai-responses.ts create mode 100644 packages/llm/src/provider-packages/shared.ts create mode 100644 packages/llm/test/provider-package.test.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index d5b370c7d4..6b52dccfe9 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -15,14 +15,19 @@ "./route": "./src/route/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", + "./provider-package": "./src/provider-package.ts", + "./providers/amazon-bedrock": "./src/provider-packages/amazon-bedrock.ts", + "./providers/anthropic": "./src/provider-packages/anthropic-messages.ts", + "./providers/anthropic/messages": "./src/provider-packages/anthropic-messages.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/providers/openai.ts", - "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai": "./src/provider-packages/openai-responses.ts", + "./providers/openai/responses": "./src/provider-packages/openai-responses.ts", + "./providers/openai/chat": "./src/provider-packages/openai-chat.ts", + "./providers/openai-compatible": "./src/provider-packages/openai-compatible-chat.ts", + "./providers/openai-compatible/chat": "./src/provider-packages/openai-compatible-chat.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77..843b4536a6 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,6 +1,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" +export { ProviderPackage } from "./provider-package" export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export type { RouteModelInput, @@ -31,3 +32,7 @@ export type { ModelFactory as ProviderModelFactory, ModelOptions as ProviderModelOptions, } from "./provider" +export type { + Definition as ProviderPackageDefinition, + Settings as ProviderPackageSettings, +} from "./provider-package" diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts new file mode 100644 index 0000000000..977a211864 --- /dev/null +++ b/packages/llm/src/provider-package.ts @@ -0,0 +1,16 @@ +import type { Model } from "./schema" + +export interface Settings extends Readonly> { + readonly headers?: Readonly> + readonly body?: Readonly> + readonly limits?: { + readonly context: number + readonly output: number + } +} + +export interface Definition { + readonly model: (id: string, settings: ProviderSettings) => Model +} + +export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/provider-packages/amazon-bedrock.ts b/packages/llm/src/provider-packages/amazon-bedrock.ts new file mode 100644 index 0000000000..f00b2056ae --- /dev/null +++ b/packages/llm/src/provider-packages/amazon-bedrock.ts @@ -0,0 +1,28 @@ +import type { ProviderPackage } from "../provider-package" +import * as AmazonBedrock from "../providers/amazon-bedrock" +import type { BedrockCredentials } from "../protocols/bedrock-converse" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return AmazonBedrock.configure({ + ...defaults(settings), + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + region: settings.region, + }).model(id) +} diff --git a/packages/llm/src/provider-packages/anthropic-messages.ts b/packages/llm/src/provider-packages/anthropic-messages.ts new file mode 100644 index 0000000000..be6def4081 --- /dev/null +++ b/packages/llm/src/provider-packages/anthropic-messages.ts @@ -0,0 +1,15 @@ +import type { ProviderPackage } from "../provider-package" +import * as Anthropic from "../providers/anthropic" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + Anthropic.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + }).model(id) diff --git a/packages/llm/src/provider-packages/openai-chat.ts b/packages/llm/src/provider-packages/openai-chat.ts new file mode 100644 index 0000000000..6a6d04c704 --- /dev/null +++ b/packages/llm/src/provider-packages/openai-chat.ts @@ -0,0 +1,20 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAI from "../providers/openai" +import type { OpenAIProviderOptionsInput } from "../providers/openai-options" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + OpenAI.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + providerOptions: settings.providerOptions, + }).chat(id) diff --git a/packages/llm/src/provider-packages/openai-compatible-chat.ts b/packages/llm/src/provider-packages/openai-compatible-chat.ts new file mode 100644 index 0000000000..ba7d569b9c --- /dev/null +++ b/packages/llm/src/provider-packages/openai-compatible-chat.ts @@ -0,0 +1,17 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAICompatible from "../providers/openai-compatible" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => + OpenAICompatible.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + provider: settings.provider, + }).model(id) diff --git a/packages/llm/src/provider-packages/openai-responses.ts b/packages/llm/src/provider-packages/openai-responses.ts new file mode 100644 index 0000000000..4851a96a34 --- /dev/null +++ b/packages/llm/src/provider-packages/openai-responses.ts @@ -0,0 +1,25 @@ +import type { ProviderPackage } from "../provider-package" +import * as OpenAI from "../providers/openai" +import type { OpenAIProviderOptionsInput } from "../providers/openai-options" +import { defaults } from "./shared" + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + const provider = OpenAI.configure({ + ...defaults(settings), + apiKey: settings.apiKey, + baseURL: settings.baseURL, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + providerOptions: settings.providerOptions, + }) + if (settings.transport === undefined || settings.transport === "http") return provider.responses(id) + if (settings.transport === "websocket") return provider.responsesWebSocket(id) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} diff --git a/packages/llm/src/provider-packages/shared.ts b/packages/llm/src/provider-packages/shared.ts new file mode 100644 index 0000000000..e3c7ec2444 --- /dev/null +++ b/packages/llm/src/provider-packages/shared.ts @@ -0,0 +1,8 @@ +import type { RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" + +export const defaults = (settings: ProviderPackage.Settings): RouteDefaultsInput => ({ + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + limits: settings.limits, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, +}) diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts new file mode 100644 index 0000000000..a4f1627c67 --- /dev/null +++ b/packages/llm/test/provider-package.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { model } from "@opencode-ai/llm/providers/openai" + +describe("provider package entrypoints", () => { + test("semantic API aliases expose the same contract", async () => { + const modules = await Promise.all([ + import("@opencode-ai/llm/providers/openai"), + import("@opencode-ai/llm/providers/openai/responses"), + import("@opencode-ai/llm/providers/openai/chat"), + import("@opencode-ai/llm/providers/anthropic"), + import("@opencode-ai/llm/providers/anthropic/messages"), + import("@opencode-ai/llm/providers/openai-compatible"), + import("@opencode-ai/llm/providers/openai-compatible/chat"), + import("@opencode-ai/llm/providers/amazon-bedrock"), + ]) + + for (const module of modules) expect(module.model).toBeFunction() + expect(modules[0].model).toBe(modules[1].model) + expect(modules[3].model).toBe(modules[4].model) + expect(modules[5].model).toBe(modules[6].model) + }) + + test("maps package settings onto the executable model", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + baseURL: "https://api.openai.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + unrelatedInheritedSetting: true, + }) + + expect(selected.route.id).toBe("openai-responses") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + }) + + test("selects transport without changing the semantic API", () => { + expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses") + expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe( + "openai-responses-websocket", + ) + }) +}) From beefda1f8fe017852626baef83a467840604a6fd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:10:45 +0530 Subject: [PATCH 10/12] refactor(llm): colocate provider package logic --- packages/llm/package.json | 14 ++++----- .../src/provider-packages/amazon-bedrock.ts | 28 ------------------ .../provider-packages/anthropic-messages.ts | 15 ---------- .../llm/src/provider-packages/openai-chat.ts | 20 ------------- .../openai-compatible-chat.ts | 17 ----------- .../src/provider-packages/openai-responses.ts | 25 ---------------- packages/llm/src/provider-packages/shared.ts | 8 ----- packages/llm/src/providers/amazon-bedrock.ts | 27 ++++++++++++++++- packages/llm/src/providers/anthropic.ts | 15 +++++++++- .../llm/src/providers/openai-compatible.ts | 17 +++++++++++ packages/llm/src/providers/openai.ts | 29 ++++++++++++++++++- packages/llm/src/providers/openai/chat.ts | 2 ++ .../llm/src/providers/openai/responses.ts | 2 ++ packages/llm/test/exports.test.ts | 1 - packages/llm/test/provider-package.test.ts | 4 --- 15 files changed, 95 insertions(+), 129 deletions(-) delete mode 100644 packages/llm/src/provider-packages/amazon-bedrock.ts delete mode 100644 packages/llm/src/provider-packages/anthropic-messages.ts delete mode 100644 packages/llm/src/provider-packages/openai-chat.ts delete mode 100644 packages/llm/src/provider-packages/openai-compatible-chat.ts delete mode 100644 packages/llm/src/provider-packages/openai-responses.ts delete mode 100644 packages/llm/src/provider-packages/shared.ts create mode 100644 packages/llm/src/providers/openai/chat.ts create mode 100644 packages/llm/src/providers/openai/responses.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index 6b52dccfe9..f78509d247 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -16,18 +16,16 @@ "./provider": "./src/provider.ts", "./providers": "./src/providers/index.ts", "./provider-package": "./src/provider-package.ts", - "./providers/amazon-bedrock": "./src/provider-packages/amazon-bedrock.ts", - "./providers/anthropic": "./src/provider-packages/anthropic-messages.ts", - "./providers/anthropic/messages": "./src/provider-packages/anthropic-messages.ts", + "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", + "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/provider-packages/openai-responses.ts", - "./providers/openai/responses": "./src/provider-packages/openai-responses.ts", - "./providers/openai/chat": "./src/provider-packages/openai-chat.ts", - "./providers/openai-compatible": "./src/provider-packages/openai-compatible-chat.ts", - "./providers/openai-compatible/chat": "./src/provider-packages/openai-compatible-chat.ts", + "./providers/openai": "./src/providers/openai.ts", + "./providers/openai/responses": "./src/providers/openai/responses.ts", + "./providers/openai/chat": "./src/providers/openai/chat.ts", + "./providers/openai-compatible": "./src/providers/openai-compatible.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", diff --git a/packages/llm/src/provider-packages/amazon-bedrock.ts b/packages/llm/src/provider-packages/amazon-bedrock.ts deleted file mode 100644 index f00b2056ae..0000000000 --- a/packages/llm/src/provider-packages/amazon-bedrock.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as AmazonBedrock from "../providers/amazon-bedrock" -import type { BedrockCredentials } from "../protocols/bedrock-converse" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly auth?: "bearer" | "sigv4" - readonly baseURL?: string - readonly credentials?: BedrockCredentials - readonly region?: string - readonly topP?: number -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => { - if (settings.auth === "bearer" && settings.apiKey === undefined) - throw new Error("Amazon Bedrock bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) - throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") - return AmazonBedrock.configure({ - ...defaults(settings), - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, - baseURL: settings.baseURL, - credentials: settings.credentials, - generation: settings.topP === undefined ? undefined : { topP: settings.topP }, - region: settings.region, - }).model(id) -} diff --git a/packages/llm/src/provider-packages/anthropic-messages.ts b/packages/llm/src/provider-packages/anthropic-messages.ts deleted file mode 100644 index be6def4081..0000000000 --- a/packages/llm/src/provider-packages/anthropic-messages.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as Anthropic from "../providers/anthropic" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - Anthropic.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - }).model(id) diff --git a/packages/llm/src/provider-packages/openai-chat.ts b/packages/llm/src/provider-packages/openai-chat.ts deleted file mode 100644 index 6a6d04c704..0000000000 --- a/packages/llm/src/provider-packages/openai-chat.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAI from "../providers/openai" -import type { OpenAIProviderOptionsInput } from "../providers/openai-options" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string - readonly queryParams?: Readonly> - readonly providerOptions?: OpenAIProviderOptionsInput -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - OpenAI.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, - providerOptions: settings.providerOptions, - }).chat(id) diff --git a/packages/llm/src/provider-packages/openai-compatible-chat.ts b/packages/llm/src/provider-packages/openai-compatible-chat.ts deleted file mode 100644 index ba7d569b9c..0000000000 --- a/packages/llm/src/provider-packages/openai-compatible-chat.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAICompatible from "../providers/openai-compatible" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL: string - readonly provider?: string -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => - OpenAICompatible.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - provider: settings.provider, - }).model(id) diff --git a/packages/llm/src/provider-packages/openai-responses.ts b/packages/llm/src/provider-packages/openai-responses.ts deleted file mode 100644 index 4851a96a34..0000000000 --- a/packages/llm/src/provider-packages/openai-responses.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ProviderPackage } from "../provider-package" -import * as OpenAI from "../providers/openai" -import type { OpenAIProviderOptionsInput } from "../providers/openai-options" -import { defaults } from "./shared" - -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly baseURL?: string - readonly queryParams?: Readonly> - readonly transport?: "http" | "websocket" - readonly providerOptions?: OpenAIProviderOptionsInput -} - -export const model: ProviderPackage.Definition["model"] = (id, settings) => { - const provider = OpenAI.configure({ - ...defaults(settings), - apiKey: settings.apiKey, - baseURL: settings.baseURL, - queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, - providerOptions: settings.providerOptions, - }) - if (settings.transport === undefined || settings.transport === "http") return provider.responses(id) - if (settings.transport === "websocket") return provider.responsesWebSocket(id) - throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) -} diff --git a/packages/llm/src/provider-packages/shared.ts b/packages/llm/src/provider-packages/shared.ts deleted file mode 100644 index e3c7ec2444..0000000000 --- a/packages/llm/src/provider-packages/shared.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteDefaultsInput } from "../route/client" -import type { ProviderPackage } from "../provider-package" - -export const defaults = (settings: ProviderPackage.Settings): RouteDefaultsInput => ({ - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - limits: settings.limits, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, -}) diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index 2f1791e0d6..e578d614c6 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -1,5 +1,6 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as BedrockConverse from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse" @@ -15,6 +16,15 @@ export type Config = RouteDefaultsInput & { /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ readonly baseURL?: string } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} export const routes = [BedrockConverse.route] const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` @@ -40,4 +50,19 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return configure({ + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + region: settings.region, + }).model(id) +} diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 0c9640af5e..8ef0293eeb 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -1,6 +1,7 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as AnthropicMessages from "../protocols/anthropic-messages" @@ -10,6 +11,11 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (id, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + }).model(id) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index a79f65f6df..36d9b3fd5a 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -2,6 +2,7 @@ import { ProviderID, type ModelID } from "../schema" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" export const id = ProviderID.make("openai-compatible") @@ -12,6 +13,12 @@ type GenericModelOptions = RouteDefaultsInput & readonly baseURL: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + export type FamilyModelOptions = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string @@ -56,6 +63,16 @@ export const provider = { configure, } +export const model: ProviderPackage.Definition["model"] = (id, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + }).model(id) + export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) export const deepinfra = define(profiles.deepinfra) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad8493..b40a94514b 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,5 +1,6 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import type { Route, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -21,6 +22,14 @@ export type Config = RouteDefaultsInput & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const defaults = (input: Config) => { @@ -57,7 +66,25 @@ export const configure = (input: Config = {}) => { export const provider = configure() -export const model = provider.model +const config = (settings: Settings): Config => ({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, +}) + +export const model: ProviderPackage.Definition["model"] = (id, settings) => { + const configured = configure(config(settings)) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(id) + if (settings.transport === "websocket") return configured.responsesWebSocket(id) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} + +export const chatModel: ProviderPackage.Definition["model"] = (id, settings) => + configure(config(settings)).chat(id) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat diff --git a/packages/llm/src/providers/openai/chat.ts b/packages/llm/src/providers/openai/chat.ts new file mode 100644 index 0000000000..eb92db8ac5 --- /dev/null +++ b/packages/llm/src/providers/openai/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/src/providers/openai/responses.ts b/packages/llm/src/providers/openai/responses.ts new file mode 100644 index 0000000000..4db3a232c7 --- /dev/null +++ b/packages/llm/src/providers/openai/responses.ts @@ -0,0 +1,2 @@ +export { model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 693a21638b..6ed66a0d8e 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -30,7 +30,6 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBe(OpenAI.model) expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index a4f1627c67..c8cba78eb1 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -8,16 +8,12 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/openai/responses"), import("@opencode-ai/llm/providers/openai/chat"), import("@opencode-ai/llm/providers/anthropic"), - import("@opencode-ai/llm/providers/anthropic/messages"), import("@opencode-ai/llm/providers/openai-compatible"), - import("@opencode-ai/llm/providers/openai-compatible/chat"), import("@opencode-ai/llm/providers/amazon-bedrock"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) - expect(modules[3].model).toBe(modules[4].model) - expect(modules[5].model).toBe(modules[6].model) }) test("maps package settings onto the executable model", () => { From 3a09383d331e3b3c8ead76a81f11b934f7d4b7d5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 23:29:06 +0530 Subject: [PATCH 11/12] refactor(llm): name provider model identity --- packages/llm/src/provider-package.ts | 2 +- packages/llm/src/providers/amazon-bedrock.ts | 4 ++-- packages/llm/src/providers/anthropic.ts | 4 ++-- packages/llm/src/providers/openai-compatible.ts | 4 ++-- packages/llm/src/providers/openai.ts | 10 +++++----- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts index 977a211864..fd878a9d06 100644 --- a/packages/llm/src/provider-package.ts +++ b/packages/llm/src/provider-package.ts @@ -10,7 +10,7 @@ export interface Settings extends Readonly> { } export interface Definition { - readonly model: (id: string, settings: ProviderSettings) => Model + readonly model: (modelID: string, settings: ProviderSettings) => Model } export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index e578d614c6..4e5040c9ec 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -50,7 +50,7 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (id, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { if (settings.auth === "bearer" && settings.apiKey === undefined) throw new Error("Amazon Bedrock bearer auth requires apiKey") if (settings.auth === "sigv4" && settings.apiKey !== undefined) @@ -64,5 +64,5 @@ export const model: ProviderPackage.Definition["model"] = (id, setting http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, region: settings.region, - }).model(id) + }).model(modelID) } diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 8ef0293eeb..1bf8bf569c 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -38,11 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (id, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, - }).model(id) + }).model(modelID) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index 36d9b3fd5a..8b5d23eed7 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -63,7 +63,7 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (id, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, @@ -71,7 +71,7 @@ export const model: ProviderPackage.Definition["model"] = (id, setting http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, provider: settings.provider, - }).model(id) + }).model(modelID) export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index b40a94514b..6b49b9def2 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -76,15 +76,15 @@ const config = (settings: Settings): Config => ({ queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, }) -export const model: ProviderPackage.Definition["model"] = (id, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { const configured = configure(config(settings)) - if (settings.transport === undefined || settings.transport === "http") return configured.responses(id) - if (settings.transport === "websocket") return configured.responsesWebSocket(id) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) + if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) } -export const chatModel: ProviderPackage.Definition["model"] = (id, settings) => - configure(config(settings)).chat(id) +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat From 8efd8783d05dee0825699d2fae57facc3c2beee5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 25 Jun 2026 20:36:02 +0530 Subject: [PATCH 12/12] fix(schema): adapt legacy catalog responses --- packages/core/src/model.ts | 28 ++++++++++++++++++++++++ packages/core/src/provider.ts | 16 ++++++++++++++ packages/server/src/handlers/model.ts | 3 ++- packages/server/src/handlers/provider.ts | 7 ++++-- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index bae4408560..293954ecb2 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -78,6 +78,34 @@ export const Info = Schema.Struct({ export type MutableInfo = Omit, "api"> & { api: ProviderV2.MutableApi } +export function flatten(info: Info): Model.Info { + return Model.Info.make({ + id: info.id, + modelID: info.api.id === info.id ? undefined : info.api.id, + providerID: info.providerID, + family: info.family, + name: info.name, + package: info.api.type === "aisdk" ? `aisdk:${info.api.package}` : undefined, + settings: { + ...info.api.settings, + ...(info.api.url === undefined ? {} : { baseURL: info.api.url }), + }, + headers: info.request.headers, + body: info.request.body, + capabilities: info.capabilities, + variants: info.variants.map((variant) => ({ + id: variant.id, + headers: variant.headers, + body: variant.body, + })), + time: info.time, + cost: info.cost, + status: info.status, + enabled: info.enabled, + limit: info.limit, + }) +} + export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") return { diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index d1a541dd65..40f1f357d6 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -59,3 +59,19 @@ export type MutableApi = T extends Api : never export type MutableInfo = Omit, "api"> & { api: MutableApi } + +export function flatten(info: Info): Provider.Info { + return Provider.Info.make({ + id: info.id, + integrationID: info.integrationID, + name: info.name, + disabled: info.disabled, + package: info.api.type === "aisdk" ? `aisdk:${info.api.package}` : "", + settings: { + ...info.api.settings, + ...(info.api.url === undefined ? {} : { baseURL: info.api.url }), + }, + headers: info.request.headers, + body: info.request.body, + }) +} diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 36639ae7b1..a2c2fd4960 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" import { response } from "../location" +import { ModelV2 } from "@opencode-ai/core/model" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { @@ -10,7 +11,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - return yield* response(catalog.model.available()) + return yield* response(catalog.model.available().pipe(Effect.map((models) => models.map(ModelV2.flatten)))) }), ) }), diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index c3f25ab0df..9144cde29f 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" +import { ProviderV2 } from "@opencode-ai/core/provider" import { response } from "../location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => @@ -12,7 +13,9 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - return yield* response(catalog.provider.available()) + return yield* response( + catalog.provider.available().pipe(Effect.map((providers) => providers.map(ProviderV2.flatten))), + ) }), ) .handle( @@ -25,7 +28,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han providerID: ctx.params.providerID, message: `Provider not found: ${ctx.params.providerID}`, }) - return yield* response(Effect.succeed(provider)) + return yield* response(Effect.succeed(ProviderV2.flatten(provider))) }), ) }),