From 38398fd45084302302f79140e30276c0436f4517 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 14:01:17 -0400 Subject: [PATCH 1/5] feat(llm): provider packages own model request construction Provider packages in @opencode-ai/llm/providers/* now implement a uniform ProviderPackage contract: model(modelID, settings) => Model. SessionRunnerModel becomes provider-agnostic: it resolves a package specifier, folds catalog settings, credentials, and transport overlays into one Settings object, and delegates request construction to the package. - llm: add ProviderPackage (Settings, Definition, define), flat model(id, config) constructors on the openai-responses, anthropic-messages, and openai-compatible-chat protocols, contract-shaped model exports on the openai, anthropic, and openai-compatible providers, and a new providers/openai/codex entry point that targets the ChatGPT codex backend and sets the chatgpt-account-id header from settings.accountID. - schema: Provider.Native gains optional package. - core: SessionRunnerModel loads packages through a static built-in map (dynamic import for foreign specifiers) and applies one settings fold; the ChatGPT conditional is deleted from the runner. The OpenAI plugin's catalog transform now assigns the codex package to eligible models when a ChatGPT connection is active, alongside the existing eligibility and cost rewrites. - llm schema: hoist ToolResultValue union out of its Object.assign self reference; the previous shape only typechecked under lucky check ordering and broke under core's typecheck with the new import graph. Closes #34765 --- packages/client/src/generated/types.ts | 15 +- packages/core/src/plugin/provider/openai.ts | 64 ++++++-- packages/core/src/provider.ts | 45 +++++- packages/core/src/session/runner/model.ts | 141 ++++++++++-------- .../core/test/plugin/provider-openai.test.ts | 54 ++++++- .../core/test/session-runner-model.test.ts | 77 +++++++++- packages/llm/package.json | 2 + packages/llm/src/index.ts | 1 + .../llm/src/protocols/anthropic-messages.ts | 27 +++- .../src/protocols/openai-compatible-chat.ts | 25 +++- .../llm/src/protocols/openai-responses.ts | 27 +++- packages/llm/src/provider-package.ts | 18 +++ packages/llm/src/providers/anthropic.ts | 14 +- packages/llm/src/providers/index.ts | 1 + .../llm/src/providers/openai-compatible.ts | 15 ++ packages/llm/src/providers/openai.ts | 15 +- packages/llm/src/providers/openai/codex.ts | 20 +++ packages/llm/src/schema/messages.ts | 57 +++---- packages/llm/test/exports.test.ts | 9 +- .../test/provider/provider-package.test.ts | 120 +++++++++++++++ packages/schema/src/provider.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + 22 files changed, 635 insertions(+), 115 deletions(-) create mode 100644 packages/llm/src/provider-package.ts create mode 100644 packages/llm/src/providers/openai/codex.ts create mode 100644 packages/llm/test/provider/provider-package.test.ts diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c874..efa3d02ba0 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1955,6 +1955,7 @@ export type ModelsListOutput = { | { readonly id: string readonly type: "native" + readonly package?: string readonly url?: string readonly settings: { readonly [x: string]: JsonValue } } @@ -2010,7 +2011,12 @@ export type ProvidersListOutput = { readonly url?: string readonly settings?: { readonly [x: string]: JsonValue } } - | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } + | { + readonly type: "native" + readonly package?: string + readonly url?: string + readonly settings: { readonly [x: string]: JsonValue } + } readonly request: { readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } @@ -2043,7 +2049,12 @@ export type ProvidersGetOutput = { readonly url?: string readonly settings?: { readonly [x: string]: JsonValue } } - | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } + | { + readonly type: "native" + readonly package?: string + readonly url?: string + readonly settings: { readonly [x: string]: JsonValue } + } readonly request: { readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 46553eaff4..12412841db 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,9 +1,10 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Deferred, Effect } from "effect" +import { Deferred, Effect, Stream } from "effect" import type { Scope } from "effect" import { Credential } from "../../credential" +import { EventV2 } from "../../event" import { InstallationVersion } from "../../installation/version" import { Integration } from "../../integration" import { ModelV2 } from "../../model" @@ -17,6 +18,7 @@ const callbackPort = 1455 const pollingSafetyMargin = 3000 const browserMethodID = Integration.MethodID.make("chatgpt-browser") const headlessMethodID = Integration.MethodID.make("chatgpt-headless") +const codexPackage = "@opencode-ai/llm/providers/openai/codex" type Pkce = { verifier: string @@ -154,24 +156,55 @@ const headless = { export const OpenAIPlugin = define({ id: "openai", effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service yield* ctx.integration.transform((draft) => { draft.method.update(browser) draft.method.update(headless) }) yield* ctx.catalog.transform( Effect.fn(function* (evt) { + const connection = yield* ctx.integration.connection.active("openai") + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + const chatgpt = isChatGPT(credential) for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue - if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // OpenAIPlugin sends OpenAI models through Responses; this alias is a - // chat-completions-only model, so hide it only from OpenAI's catalog. - model.enabled = false - }) + if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) { + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // OpenAIPlugin sends OpenAI models through Responses; this alias is a + // chat-completions-only model, so hide it only from OpenAI's catalog. + model.enabled = false + }) + } + if (!chatgpt) continue + for (const model of item.models.values()) { + evt.model.update(item.provider.id, model.id, (draft) => { + if (!eligible(draft)) { + draft.enabled = false + return + } + draft.cost = draft.cost.map((cost) => ({ ...cost, input: 0, output: 0, cache: { read: 0, write: 0 } })) + draft.api = { + type: "native", + id: draft.api.id, + package: codexPackage, + settings: + draft.api.type === "aisdk" + ? { ...item.provider.api.settings, ...draft.api.settings } + : draft.api.settings, + } + }) + } } }), ) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")), + Stream.runForEach(() => ctx.catalog.reload()), + Effect.forkScoped({ startImmediately: true }), + ) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return @@ -269,16 +302,27 @@ function authorizeURL(redirect: string, pkce: Pkce, state: string) { codex_cli_simplified_flow: "true", state, originator: "opencode", - })}` + }).toString()}` } function extractAccountID(tokens: TokenResponse) { return claim(tokens.id_token) ?? claim(tokens.access_token) } +function isChatGPT(credential: { readonly type: string; readonly methodID?: string } | undefined) { + return ( + credential?.type === "oauth" && + (credential.methodID === browserMethodID || credential.methodID === headlessMethodID) + ) +} + +function eligible(model: { readonly id: string }) { + return model.id.includes("codex") +} + function claim(token: string) { const part = token.split(".")[1] - if (!part) return + if (!part) return undefined try { const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims return ( @@ -287,6 +331,6 @@ function claim(token: string) { claims.organizations?.[0]?.id ) } catch { - return + return undefined } } diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 03f7d7eef3..8cdefdda7d 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,7 +1,9 @@ export * as ProviderV2 from "./provider" -import { Types } from "effect" +import { Effect, Schema, Types } from "effect" import { Provider } from "@opencode-ai/schema/provider" +import { ProviderPackage } from "@opencode-ai/llm/provider-package" +import { Anthropic, OpenAI, OpenAICodex, OpenAICompatible } from "@opencode-ai/llm/providers" export const ID = Provider.ID export type ID = typeof ID.Type @@ -23,3 +25,44 @@ export const Info = Provider.Info export type Info = Provider.Info export type MutableInfo = Omit, "api"> & { api: MutableApi } + +export class PackageLoadError extends Schema.TaggedErrorClass()("ProviderV2.PackageLoadError", { + specifier: Schema.String, + reason: Schema.String, +}) { + override get message() { + return `Failed to load provider package ${this.specifier}: ${this.reason}` + } +} + +type PackageModule = { readonly model: ProviderPackage.Definition["model"] } + +const builtins: Record = { + "@opencode-ai/llm/providers/openai": OpenAI, + "@opencode-ai/llm/providers/anthropic": Anthropic, + "@opencode-ai/llm/providers/openai-compatible": OpenAICompatible, + "@opencode-ai/llm/providers/openai/codex": OpenAICodex, +} + +export const loadPackage = ( + specifier: string, +): Effect.Effect => { + const builtin = builtins[specifier] + if (builtin) return Effect.succeed(builtin.model) + return Effect.tryPromise({ + try: () => import(specifier), + catch: (cause) => + new PackageLoadError({ specifier, reason: cause instanceof Error ? cause.message : String(cause) }), + }).pipe( + Effect.flatMap((module) => { + if (hasModel(module)) return Effect.succeed(module.model) + return Effect.fail(new PackageLoadError({ specifier, reason: "missing model export" })) + }), + ) +} + +export const load = loadPackage + +function hasModel(module: unknown): module is PackageModule { + return typeof module === "object" && module !== null && "model" in module && typeof module.model === "function" +} diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c2..57806781ca 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,11 +1,7 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" -import { type Model } from "@opencode-ai/llm" -import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" -import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" -import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" -import { Auth, type AnyRoute } from "@opencode-ai/llm/route" +import { Model } from "@opencode-ai/llm" import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" @@ -69,6 +65,7 @@ export type Error = | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError + | ProviderV2.PackageLoadError | Integration.AuthorizationError export interface Interface { @@ -80,27 +77,6 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { - if (credential?.type === "key") return Auth.value(credential.key) - if (credential?.type === "oauth") return Auth.value(credential.access) - const value = model.request.body.apiKey ?? model.api.settings?.apiKey - if (typeof value === "string") return Auth.value(value) -} - -const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { - const body = model.request.body - const httpBody = Object.hasOwn(body, "apiKey") - ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) - : body - return route.with({ - provider: model.providerID, - endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, - headers: model.request.headers, - http: { body: httpBody }, - limits: { context: model.limit.context, output: model.limit.output }, - }) -} - const withVariant = ( model: ModelV2.Info, variantID: ModelV2.VariantID | undefined, @@ -131,52 +107,99 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, credential?: Credential.Value, -): Effect.Effect => { +): Effect.Effect => { const resolved = credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) }) - const key = apiKey(resolved, credential) - if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { - return Effect.succeed( - withDefaults(resolved, OpenAIResponses.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.api.id }), - ) - } - if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") { - return Effect.succeed( - withDefaults(resolved, AnthropicMessages.route) - .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: resolved.api.id }), - ) - } - if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) { - return Effect.succeed( - withDefaults(resolved, OpenAICompatibleChat.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.api.id }), - ) - } - return Effect.fail( - new UnsupportedApiError({ - providerID: resolved.providerID, - modelID: resolved.id, - api: apiName(resolved), - }), + return packageSpecifier(resolved).pipe( + Effect.flatMap((specifier) => + ProviderV2.loadPackage(specifier).pipe( + Effect.map((load) => { + const selected = load(resolved.api.id ?? resolved.id, { + ...resolved.api.settings, + ...(credential?.type === "oauth" ? credential.metadata : undefined), + baseURL: resolved.api.url ?? settingsString(resolved.api.settings, "baseURL"), + apiKey: apiKey(resolved, credential), + providerOptions: requestSettings(resolved.request), + headers: resolved.request.headers, + body: stripApiKey(resolved.request.body), + limits: { context: resolved.limit.context, output: resolved.limit.output }, + }) + return Model.update(selected, { + provider: resolved.providerID, + route: selected.route.with({ provider: resolved.providerID }), + }) + }), + ), + ), ) } export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) +/** Legacy aisdk catalog entries dispatch to the equivalent native provider packages. */ +const aisdkPackages: Record = { + "@ai-sdk/openai": "@opencode-ai/llm/providers/openai", + "@ai-sdk/anthropic": "@opencode-ai/llm/providers/anthropic", + "@ai-sdk/openai-compatible": "@opencode-ai/llm/providers/openai-compatible", +} + export const supported = (model: ModelV2.Info) => - model.api.type === "aisdk" && - (model.api.package === "@ai-sdk/openai" || - model.api.package === "@ai-sdk/anthropic" || - (model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined)) + (model.api.type === "native" && model.api.package !== undefined) || + (model.api.type === "aisdk" && + aisdkPackages[model.api.package] !== undefined && + // The openai-compatible package has no default endpoint; a URL is required. + (model.api.package !== "@ai-sdk/openai-compatible" || model.api.url !== undefined)) + +const packageSpecifier = (model: ModelV2.Info): Effect.Effect => { + if (supported(model)) { + if (model.api.type === "native" && model.api.package !== undefined) return Effect.succeed(model.api.package) + const specifier = model.api.type === "aisdk" ? aisdkPackages[model.api.package] : undefined + if (specifier !== undefined) return Effect.succeed(specifier) + } + return Effect.fail( + new UnsupportedApiError({ + providerID: model.providerID, + modelID: model.id, + api: apiName(model), + }), + ) +} + +const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { + if (credential?.type === "key") return credential.key + if (credential?.type === "oauth") return credential.access + const value = model.request.body.apiKey ?? model.api.settings?.apiKey + if (typeof value === "string") return value + return undefined +} + +const stripApiKey = (body: ModelV2.Info["request"]["body"]) => { + if (!Object.hasOwn(body, "apiKey")) return body + return Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) +} + +const requestSettings = (request: ModelV2.Info["request"]) => { + if (!("settings" in request)) return undefined + const settings = request.settings + if (!isRecord(settings)) return undefined + if (Object.keys(settings).length === 0) return undefined + return settings +} + +const settingsString = (settings: ModelV2.Info["api"]["settings"], key: string) => { + const value = settings?.[key] + if (typeof value === "string") return value + return undefined +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value) +} /** Resolves models from the catalog belonging to the current Location runtime. */ export const locationLayer = Layer.effect( diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 31a80f9319..e49862e55c 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -16,7 +17,6 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service const host = yield* PluginHost.make(plugin) const integrations = yield* Integration.Service yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) @@ -61,7 +61,6 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ @@ -78,7 +77,6 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ @@ -95,7 +93,6 @@ describe("OpenAIPlugin", () => { it.effect("uses the Responses API for language models", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() @@ -114,7 +111,6 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() @@ -173,4 +169,52 @@ describe("OpenAIPlugin", () => { ).toBe(true) }), ) + + it.effect("routes eligible models to the codex package for ChatGPT connections", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai", settings: { store: false } }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5-codex"), (draft) => { + draft.api = { id: ModelV2.ID.make("gpt-5-codex"), type: "aisdk", package: "@ai-sdk/openai" } + draft.cost = [{ input: 1, output: 2, cache: { read: 3, write: 4 } }] + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), (draft) => { + draft.api = { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "@ai-sdk/openai" } + }) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "account-123" }, + }), + }) + + yield* addPlugin() + + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-codex")))).toMatchObject({ + enabled: true, + api: { + type: "native", + id: "gpt-5-codex", + package: "@opencode-ai/llm/providers/openai/codex", + settings: { store: false }, + }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + }) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(false) + }), + ) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a3..4253e8a95c 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -20,7 +20,12 @@ type Api = readonly url?: string readonly settings?: Record } - | { readonly type: "native"; readonly url?: string; readonly settings: Record } + | { + readonly type: "native" + readonly package?: string + readonly url?: string + readonly settings: Record + } const model = (api: Api, variants: ModelV2.Info["variants"] = []) => ModelV2.Info.make({ @@ -269,6 +274,71 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("maps native provider package models into bearer-authenticated routes", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ + type: "native", + package: "@opencode-ai/llm/providers/openai", + url: "https://openai.example/v1", + settings: {}, + }), + request: { headers: {}, body: {} }, + }), + Credential.Key.make({ type: "key", key: "secret" }), + ) + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route).toMatchObject({ + id: "openai-responses", + endpoint: { baseURL: "https://openai.example/v1" }, + }) + expect(headers.authorization).toBe("Bearer secret") + }), + ) + + it.effect("routes native ChatGPT OAuth credentials to the codex backend", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ + type: "native", + package: "@opencode-ai/llm/providers/openai/codex", + settings: {}, + }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "oauth-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "account-123" }, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(headers.authorization).toBe("Bearer oauth-token") + expect(headers["chatgpt-account-id"]).toBe("account-123") + }), + ) + it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) @@ -342,6 +412,11 @@ describe("SessionRunnerModel", () => { ), ).toBe(false) expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false) + expect( + SessionRunnerModel.supported( + model({ type: "native", package: "@opencode-ai/llm/providers/openai", settings: {} }), + ), + ).toBe(true) }), ) }) diff --git a/packages/llm/package.json b/packages/llm/package.json index 0b4cd12e25..7b21a60e50 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -14,6 +14,7 @@ ".": "./src/index.ts", "./route": "./src/route/index.ts", "./provider": "./src/provider.ts", + "./provider-package": "./src/provider-package.ts", "./providers": "./src/providers/index.ts", "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", @@ -22,6 +23,7 @@ "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", "./providers/openai": "./src/providers/openai.ts", + "./providers/openai/codex": "./src/providers/openai/codex.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", diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77..de062cc3f5 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, diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 1c0dcd32a4..22d8f3375b 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -1,11 +1,13 @@ import { Effect, Schema } from "effect" -import { Route } from "../route/client" -import { Auth } from "../route/auth" +import { Route, type RoutePatch } from "../route/client" +import { Auth, type Auth as AuthDef } from "../route/auth" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { LLMEvent, + type Model, + type ProviderOptions, Usage, type CacheHint, type FinishReason, @@ -852,4 +854,25 @@ export const route = Route.make({ headers: () => ({ "anthropic-version": "2023-06-01" }), }) +export interface ModelConfig { + readonly auth: AuthDef + readonly baseURL?: string + readonly headers?: Readonly> + readonly providerOptions?: ProviderOptions + readonly body?: Readonly> + readonly limits?: { readonly context: number; readonly output: number } +} + +export const model = (id: string, config: ModelConfig): Model => + route + .with({ + auth: config.auth, + endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL }, + headers: config.headers, + providerOptions: config.providerOptions, + http: config.body === undefined ? undefined : { body: config.body }, + limits: config.limits, + } satisfies RoutePatch) + .model({ id }) + export * as AnthropicMessages from "./anthropic-messages" diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts index ce3f0a83d7..07bdf2f531 100644 --- a/packages/llm/src/protocols/openai-compatible-chat.ts +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -1,6 +1,8 @@ -import { Route, type RouteRoutedModelInput } from "../route/client" +import { Route, type RoutePatch, type RouteRoutedModelInput } from "../route/client" +import type { Auth as AuthDef } from "../route/auth" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" +import type { Model, ProviderOptions } from "../schema" import * as OpenAIChat from "./openai-chat" const ADAPTER = "openai-compatible-chat" @@ -21,4 +23,25 @@ export const route = Route.make({ framing: Framing.sse, }) +export interface ModelConfig { + readonly auth: AuthDef + readonly baseURL?: string + readonly headers?: Readonly> + readonly providerOptions?: ProviderOptions + readonly body?: Readonly> + readonly limits?: { readonly context: number; readonly output: number } +} + +export const model = (id: string, config: ModelConfig): Model => + route + .with({ + auth: config.auth, + endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL }, + headers: config.headers, + providerOptions: config.providerOptions, + http: config.body === undefined ? undefined : { body: config.body }, + limits: config.limits, + } satisfies RoutePatch) + .model({ id, provider: "openai-compatible" }) + export * as OpenAICompatibleChat from "./openai-compatible-chat" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c92..d5d37d96c4 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -1,11 +1,13 @@ import { Effect, Schema } from "effect" -import { Route } from "../route/client" -import { Auth } from "../route/auth" +import { Route, type RoutePatch } from "../route/client" +import { Auth, type Auth as AuthDef } from "../route/auth" import { Endpoint } from "../route/endpoint" import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { LLMEvent, + type Model, + type ProviderOptions, Usage, type FinishReason, type JsonSchema, @@ -991,6 +993,27 @@ export const route = Route.make({ defaults: { providerOptions: { openai: { store: false } } }, }) +export interface ModelConfig { + readonly auth: AuthDef + readonly baseURL?: string + readonly headers?: Readonly> + readonly providerOptions?: ProviderOptions + readonly body?: Readonly> + readonly limits?: { readonly context: number; readonly output: number } +} + +export const model = (id: string, config: ModelConfig): Model => + route + .with({ + auth: config.auth, + endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL }, + headers: config.headers, + providerOptions: config.providerOptions, + http: config.body === undefined ? undefined : { body: config.body }, + limits: config.limits, + } satisfies RoutePatch) + .model({ id }) + const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) const webSocketMessage = (body: OpenAIResponsesBody | Record) => diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts new file mode 100644 index 0000000000..ed292da7b1 --- /dev/null +++ b/packages/llm/src/provider-package.ts @@ -0,0 +1,18 @@ +export * as ProviderPackage from "./provider-package" + +import type { Model } from "./schema" + +export interface Settings extends Readonly> { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: Readonly> + readonly headers?: Readonly> + readonly body?: Readonly> + readonly limits?: { readonly context: number; readonly output: number } +} + +export interface Definition { + readonly model: (modelID: string, settings: S) => Model +} + +export const define = (model: (modelID: string, settings: S) => Model) => model diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 0c9640af5e..0975ad40b0 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -2,6 +2,7 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" import { ProviderID, type ModelID } from "../schema" +import { ProviderPackage } from "../provider-package" import * as AnthropicMessages from "../protocols/anthropic-messages" export const id = ProviderID.make("anthropic") @@ -10,6 +11,8 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface AnthropicSettings extends ProviderPackage.Settings {} + 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 +35,13 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model = ProviderPackage.define((modelID, settings: AnthropicSettings) => + AnthropicMessages.model(modelID, { + auth: settings.apiKey === undefined ? Auth.none : Auth.header("x-api-key", settings.apiKey), + baseURL: settings.baseURL, + headers: settings.headers, + providerOptions: settings.providerOptions === undefined ? undefined : { anthropic: settings.providerOptions }, + body: settings.body, + limits: settings.limits, + }), +) diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts index 774274cf2d..1e2123b46c 100644 --- a/packages/llm/src/providers/index.ts +++ b/packages/llm/src/providers/index.ts @@ -6,6 +6,7 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" export * as GitHubCopilot from "./github-copilot" export * as Google from "./google" export * as OpenAI from "./openai" +export * as OpenAICodex from "./openai/codex" export * as OpenAICompatible from "./openai-compatible" export * as OpenRouter from "./openrouter" export * as XAI from "./xai" diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index a79f65f6df..fb497e0889 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -2,6 +2,8 @@ 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 { Auth } from "../route/auth" +import { ProviderPackage } from "../provider-package" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" export const id = ProviderID.make("openai-compatible") @@ -17,6 +19,8 @@ export type FamilyModelOptions = RouteDefaultsInput & readonly baseURL?: string } +export interface OpenAICompatibleSettings extends ProviderPackage.Settings {} + export const routes = [OpenAICompatibleChat.route] export const configure = (input: GenericModelOptions) => { @@ -56,6 +60,17 @@ export const provider = { configure, } +export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) => + OpenAICompatibleChat.model(modelID, { + auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), + baseURL: settings.baseURL, + headers: settings.headers, + providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions }, + body: settings.body, + limits: settings.limits, + }), +) + 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..a5420d1d0e 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,6 +1,8 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { Auth } from "../route/auth" import type { Route, RouteDefaultsInput } from "../route/client" import { ProviderID, type ModelID } from "../schema" +import { ProviderPackage } from "../provider-package" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" @@ -21,6 +23,8 @@ export type Config = RouteDefaultsInput & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface OpenAISettings extends ProviderPackage.Settings {} + const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const defaults = (input: Config) => { @@ -57,7 +61,16 @@ export const configure = (input: Config = {}) => { export const provider = configure() -export const model = provider.model +export const model = ProviderPackage.define((modelID, settings: OpenAISettings) => + OpenAIResponses.model(modelID, { + auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), + baseURL: settings.baseURL, + headers: settings.headers, + providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions }, + body: settings.body, + limits: settings.limits, + }), +) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat diff --git a/packages/llm/src/providers/openai/codex.ts b/packages/llm/src/providers/openai/codex.ts new file mode 100644 index 0000000000..62c113f277 --- /dev/null +++ b/packages/llm/src/providers/openai/codex.ts @@ -0,0 +1,20 @@ +import { Auth } from "../../route/auth" +import { ProviderPackage } from "../../provider-package" +import { OpenAIResponses } from "../../protocols/openai-responses" + +export interface OpenAICodexSettings extends ProviderPackage.Settings { + readonly accountID?: string +} + +export const model = ProviderPackage.define((modelID, settings: OpenAICodexSettings) => + OpenAIResponses.model(modelID, { + auth: (settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey)).andThen( + settings.accountID === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": settings.accountID }), + ), + baseURL: "https://chatgpt.com/backend-api/codex", + headers: settings.headers, + providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions }, + body: settings.body, + limits: settings.limits, + }), +) diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index 4a9de3a735..795cd5331b 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -42,40 +42,41 @@ export type MediaPart = Schema.Schema.Type export { ToolContent, ToolFileContent, ToolTextContent } +// Standalone schema const so the derived type does not participate in the +// Object.assign self-reference below; keeps checking order-independent. +const toolResultValueSchema = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("json"), + value: Schema.Unknown, + }), + Schema.Struct({ + type: Schema.Literal("text"), + value: Schema.Unknown, + }), + Schema.Struct({ + type: Schema.Literal("error"), + value: Schema.Unknown, + }), + Schema.Struct({ + type: Schema.Literal("content"), + value: Schema.Array(ToolContent), + }), +]).annotate({ identifier: "LLM.ToolResult" }) + const isToolResultValue = (value: unknown): value is ToolResultValue => isRecord(value) && (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && "value" in value -export const ToolResultValue = Object.assign( - Schema.Union([ - Schema.Struct({ - type: Schema.Literal("json"), - value: Schema.Unknown, - }), - Schema.Struct({ - type: Schema.Literal("text"), - value: Schema.Unknown, - }), - Schema.Struct({ - type: Schema.Literal("error"), - value: Schema.Unknown, - }), - Schema.Struct({ - type: Schema.Literal("content"), - value: Schema.Array(ToolContent), - }), - ]).annotate({ identifier: "LLM.ToolResult" }), - { - is: isToolResultValue, - make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => { - if (isToolResultValue(value)) return value - if (type === "content") return { type, value: Array.isArray(value) ? value : [] } - return { type, value } - }, +export const ToolResultValue = Object.assign(toolResultValueSchema, { + is: isToolResultValue, + make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => { + if (isToolResultValue(value)) return value + if (type === "content") return { type, value: Array.isArray(value) ? value : [] } + return { type, value } }, -) -export type ToolResultValue = Schema.Schema.Type +}) +export type ToolResultValue = Schema.Schema.Type export interface ToolOutput { readonly structured: unknown diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 4bed7e2e13..d52108c787 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test" -import { LLM, LLMClient, Provider } from "@opencode-ai/llm" +import { LLM, LLMClient, Provider, ProviderPackage } from "@opencode-ai/llm" import { Route, Protocol } from "@opencode-ai/llm/route" import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" +import { ProviderPackage as ProviderPackageSubpath } from "@opencode-ai/llm/provider-package" import { CloudflareAIGateway, CloudflareWorkersAI, OpenAI, + OpenAICodex, OpenAICompatible, OpenRouter, XAI, @@ -21,6 +23,8 @@ describe("public exports", () => { expect(LLMClient.layer).toBeDefined() expect(Provider.make).toBeFunction() expect(ProviderSubpath.make).toBe(Provider.make) + expect(ProviderPackage.define).toBeFunction() + expect(ProviderPackageSubpath.define).toBe(ProviderPackage.define) }) test("route barrel exposes route-authoring APIs", () => { @@ -30,10 +34,11 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBe(OpenAI.model) + expect(OpenAI.provider.model).toBeFunction() expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() + expect(OpenAICodex.model).toBeFunction() expect(OpenAICompatible.deepseek.model).toBeFunction() expect(CloudflareAIGateway.configure).toBeFunction() expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction() diff --git a/packages/llm/test/provider/provider-package.test.ts b/packages/llm/test/provider/provider-package.test.ts new file mode 100644 index 0000000000..e028dabd82 --- /dev/null +++ b/packages/llm/test/provider/provider-package.test.ts @@ -0,0 +1,120 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMClient } from "../../src" +import { Anthropic, OpenAI, OpenAICodex } from "../../src/providers" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const JsonRecord = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) +const decodeJsonRecord = Schema.decodeUnknownSync(JsonRecord) + +const requestFor = (model: ReturnType) => + LLM.request({ + model, + prompt: "Say hello.", + cache: "none", + }) + +describe("provider package contract", () => { + it.effect("builds OpenAI Responses models from flat settings", () => + Effect.gen(function* () { + yield* LLMClient.generate( + requestFor( + OpenAI.model("gpt-x", { + apiKey: "sk-test", + headers: { "x-package": "openai" }, + body: { metadata: { source: "package" } }, + providerOptions: { store: true }, + limits: { context: 100, output: 20 }, + }), + ), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const body = decodeJsonRecord(input.text) + + expect(web.headers.get("authorization")).toBe("Bearer sk-test") + expect(web.headers.get("x-package")).toBe("openai") + expect(body).toMatchObject({ + model: "gpt-x", + metadata: { source: "package" }, + store: true, + stream: true, + }) + expect(body).not.toHaveProperty("apiKey") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("builds Codex models against the ChatGPT Codex endpoint with optional account header", () => + Effect.gen(function* () { + yield* LLMClient.generate( + requestFor(OpenAICodex.model("gpt-5-codex", { apiKey: "oauth-token", accountID: "account-123" })), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + + expect(web.url.startsWith("https://chatgpt.com/backend-api/codex")).toBe(true) + expect(web.headers.get("authorization")).toBe("Bearer oauth-token") + expect(web.headers.get("chatgpt-account-id")).toBe("account-123") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + + yield* LLMClient.generate(requestFor(OpenAICodex.model("gpt-5-codex", { apiKey: "oauth-token" }))).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + + expect(web.url.startsWith("https://chatgpt.com/backend-api/codex")).toBe(true) + expect(web.headers.get("authorization")).toBe("Bearer oauth-token") + expect(web.headers.get("chatgpt-account-id")).toBeNull() + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("builds Anthropic Messages models with x-api-key auth", () => + Effect.gen(function* () { + yield* LLMClient.generate(requestFor(Anthropic.model("claude-x", { apiKey: "anthropic-key" }))).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const body = decodeJsonRecord(input.text) + + expect(web.headers.get("x-api-key")).toBe("anthropic-key") + expect(body).toMatchObject({ model: "claude-x", stream: true }) + return input.respond( + sseEvents({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) +}) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 51ff4b3791..9bc609d209 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -34,6 +34,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(optional), url: Schema.String.pipe(optional), settings: Schema.Record(Schema.String, Schema.Unknown), }).annotate({ identifier: "Provider.Native" }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5e067f3afb..0ba12d2629 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4779,6 +4779,7 @@ export type ModelApi = | { id: string type: "native" + package?: string url?: string settings: { [key: string]: unknown @@ -4853,6 +4854,7 @@ export type ProviderAisdk = { export type ProviderNative = { type: "native" + package?: string url?: string settings: { [key: string]: unknown From 759db0d172a58342a0abbc54c02fb486841c8cfa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 14:09:58 -0400 Subject: [PATCH 2/5] refactor(core): use Predicate.isReadonlyObject for settings guard --- packages/core/src/session/runner/model.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 57806781ca..ffc86a9312 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -2,7 +2,7 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" import { Model } from "@opencode-ai/llm" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Predicate, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { Credential } from "../../credential" @@ -186,7 +186,7 @@ const stripApiKey = (body: ModelV2.Info["request"]["body"]) => { const requestSettings = (request: ModelV2.Info["request"]) => { if (!("settings" in request)) return undefined const settings = request.settings - if (!isRecord(settings)) return undefined + if (!Predicate.isReadonlyObject(settings)) return undefined if (Object.keys(settings).length === 0) return undefined return settings } @@ -197,10 +197,6 @@ const settingsString = (settings: ModelV2.Info["api"]["settings"], key: string) return undefined } -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** Resolves models from the catalog belonging to the current Location runtime. */ export const locationLayer = Layer.effect( Service, From 5e5294c0368f72e284803d66c7babc659b198978 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 14:25:29 -0400 Subject: [PATCH 3/5] refactor(llm): collapse model construction to provider packages and routes Provider packages are now the only module-level model constructors; the protocol-level model wrappers added earlier on this branch had one call site each and duplicated the Settings vocabulary, so packages call route.with(...).model({id}) directly. Deletes provider facades with no importers outside llm tests; configure stays for V1 native-request. --- .../llm/src/protocols/anthropic-messages.ts | 27 +-------- .../src/protocols/openai-compatible-chat.ts | 25 +-------- .../llm/src/protocols/openai-responses.ts | 27 +-------- packages/llm/src/providers/anthropic.ts | 23 ++++---- .../llm/src/providers/openai-compatible.ts | 55 ++++--------------- packages/llm/src/providers/openai.ts | 29 +++++----- packages/llm/src/providers/openai/codex.ts | 22 ++++---- packages/llm/test/auth-options.types.ts | 13 +++-- packages/llm/test/exports.test.ts | 8 +-- .../llm/test/provider/golden.recorded.test.ts | 26 +++++---- .../provider/openai-compatible-chat.test.ts | 45 --------------- .../test/provider/provider-package.test.ts | 38 ++++++++++++- 12 files changed, 117 insertions(+), 221 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 22d8f3375b..1c0dcd32a4 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -1,13 +1,11 @@ import { Effect, Schema } from "effect" -import { Route, type RoutePatch } from "../route/client" -import { Auth, type Auth as AuthDef } from "../route/auth" +import { Route } from "../route/client" +import { Auth } from "../route/auth" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { LLMEvent, - type Model, - type ProviderOptions, Usage, type CacheHint, type FinishReason, @@ -854,25 +852,4 @@ export const route = Route.make({ headers: () => ({ "anthropic-version": "2023-06-01" }), }) -export interface ModelConfig { - readonly auth: AuthDef - readonly baseURL?: string - readonly headers?: Readonly> - readonly providerOptions?: ProviderOptions - readonly body?: Readonly> - readonly limits?: { readonly context: number; readonly output: number } -} - -export const model = (id: string, config: ModelConfig): Model => - route - .with({ - auth: config.auth, - endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL }, - headers: config.headers, - providerOptions: config.providerOptions, - http: config.body === undefined ? undefined : { body: config.body }, - limits: config.limits, - } satisfies RoutePatch) - .model({ id }) - export * as AnthropicMessages from "./anthropic-messages" diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts index 07bdf2f531..ce3f0a83d7 100644 --- a/packages/llm/src/protocols/openai-compatible-chat.ts +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -1,8 +1,6 @@ -import { Route, type RoutePatch, type RouteRoutedModelInput } from "../route/client" -import type { Auth as AuthDef } from "../route/auth" +import { Route, type RouteRoutedModelInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" -import type { Model, ProviderOptions } from "../schema" import * as OpenAIChat from "./openai-chat" const ADAPTER = "openai-compatible-chat" @@ -23,25 +21,4 @@ export const route = Route.make({ framing: Framing.sse, }) -export interface ModelConfig { - readonly auth: AuthDef - readonly baseURL?: string - readonly headers?: Readonly> - readonly providerOptions?: ProviderOptions - readonly body?: Readonly> - readonly limits?: { readonly context: number; readonly output: number } -} - -export const model = (id: string, config: ModelConfig): Model => - route - .with({ - auth: config.auth, - endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL }, - headers: config.headers, - providerOptions: config.providerOptions, - http: config.body === undefined ? undefined : { body: config.body }, - limits: config.limits, - } satisfies RoutePatch) - .model({ id, provider: "openai-compatible" }) - export * as OpenAICompatibleChat from "./openai-compatible-chat" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index d5d37d96c4..4936d31c92 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -1,13 +1,11 @@ import { Effect, Schema } from "effect" -import { Route, type RoutePatch } from "../route/client" -import { Auth, type Auth as AuthDef } from "../route/auth" +import { Route } from "../route/client" +import { Auth } from "../route/auth" import { Endpoint } from "../route/endpoint" import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { LLMEvent, - type Model, - type ProviderOptions, Usage, type FinishReason, type JsonSchema, @@ -993,27 +991,6 @@ export const route = Route.make({ defaults: { providerOptions: { openai: { store: false } } }, }) -export interface ModelConfig { - readonly auth: AuthDef - readonly baseURL?: string - readonly headers?: Readonly> - readonly providerOptions?: ProviderOptions - readonly body?: Readonly> - readonly limits?: { readonly context: number; readonly output: number } -} - -export const model = (id: string, config: ModelConfig): Model => - route - .with({ - auth: config.auth, - endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL }, - headers: config.headers, - providerOptions: config.providerOptions, - http: config.body === undefined ? undefined : { body: config.body }, - limits: config.limits, - } satisfies RoutePatch) - .model({ id }) - const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) const webSocketMessage = (body: OpenAIResponsesBody | Record) => diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 0975ad40b0..63094704b6 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -3,13 +3,13 @@ import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" import { ProviderID, type ModelID } from "../schema" import { ProviderPackage } from "../provider-package" -import * as AnthropicMessages from "../protocols/anthropic-messages" +import { AnthropicMessages } from "../protocols/anthropic-messages" export const id = ProviderID.make("anthropic") export const routes = [AnthropicMessages.route] -export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } export interface AnthropicSettings extends ProviderPackage.Settings {} @@ -34,14 +34,15 @@ export const configure = (input: Config = {}) => { } } -export const provider = configure() export const model = ProviderPackage.define((modelID, settings: AnthropicSettings) => - AnthropicMessages.model(modelID, { - auth: settings.apiKey === undefined ? Auth.none : Auth.header("x-api-key", settings.apiKey), - baseURL: settings.baseURL, - headers: settings.headers, - providerOptions: settings.providerOptions === undefined ? undefined : { anthropic: settings.providerOptions }, - body: settings.body, - limits: settings.limits, - }), + AnthropicMessages.route + .with({ + auth: settings.apiKey === undefined ? Auth.none : Auth.header("x-api-key", settings.apiKey), + endpoint: { baseURL: settings.baseURL }, + headers: settings.headers, + providerOptions: settings.providerOptions && { anthropic: settings.providerOptions }, + http: { body: settings.body }, + limits: settings.limits, + }) + .model({ id: modelID }), ) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index fb497e0889..fbb53a851d 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -1,10 +1,9 @@ import { ProviderID, type ModelID } from "../schema" -import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { Auth } from "../route/auth" import { ProviderPackage } from "../provider-package" -import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" export const id = ProviderID.make("openai-compatible") @@ -14,11 +13,6 @@ type GenericModelOptions = RouteDefaultsInput & readonly baseURL: string } -export type FamilyModelOptions = RouteDefaultsInput & - ProviderAuthOption<"optional"> & { - readonly baseURL?: string - } - export interface OpenAICompatibleSettings extends ProviderPackage.Settings {} export const routes = [OpenAICompatibleChat.route] @@ -39,42 +33,15 @@ export const configure = (input: GenericModelOptions) => { } } -const define = (profile: OpenAICompatibleProfile) => { - const configureProfile = (input: FamilyModelOptions = {}) => { - const facade = configure({ - ...input, - baseURL: input.baseURL ?? profile.baseURL, - provider: profile.provider, - }) - return { - id: ProviderID.make(profile.provider), - model: facade.model, - configure: configureProfile, - } - } - return configureProfile() -} - -export const provider = { - id, - configure, -} - export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) => - OpenAICompatibleChat.model(modelID, { - auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), - baseURL: settings.baseURL, - headers: settings.headers, - providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions }, - body: settings.body, - limits: settings.limits, - }), + OpenAICompatibleChat.route + .with({ + auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), + endpoint: { baseURL: settings.baseURL }, + headers: settings.headers, + providerOptions: settings.providerOptions && { openai: settings.providerOptions }, + http: { body: settings.body }, + limits: settings.limits, + }) + .model({ id: modelID, provider: "openai-compatible" }), ) - -export const baseten = define(profiles.baseten) -export const cerebras = define(profiles.cerebras) -export const deepinfra = define(profiles.deepinfra) -export const deepseek = define(profiles.deepseek) -export const fireworks = define(profiles.fireworks) -export const groq = define(profiles.groq) -export const togetherai = define(profiles.togetherai) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index a5420d1d0e..51e7b47daf 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -3,8 +3,8 @@ import { Auth } from "../route/auth" import type { Route, RouteDefaultsInput } from "../route/client" import { ProviderID, type ModelID } from "../schema" import { ProviderPackage } from "../provider-package" -import * as OpenAIChat from "../protocols/openai-chat" -import * as OpenAIResponses from "../protocols/openai-responses" +import { OpenAIChat } from "../protocols/openai-chat" +import { OpenAIResponses } from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" @@ -16,7 +16,7 @@ export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, Op // This provider facade wraps the lower-level Responses and Chat model factories // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // and default option normalization. -export type Config = RouteDefaultsInput & +type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string readonly queryParams?: Record @@ -59,18 +59,15 @@ export const configure = (input: Config = {}) => { } } -export const provider = configure() - export const model = ProviderPackage.define((modelID, settings: OpenAISettings) => - OpenAIResponses.model(modelID, { - auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), - baseURL: settings.baseURL, - headers: settings.headers, - providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions }, - body: settings.body, - limits: settings.limits, - }), + OpenAIResponses.route + .with({ + auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), + endpoint: { baseURL: settings.baseURL }, + headers: settings.headers, + providerOptions: settings.providerOptions && { openai: settings.providerOptions }, + http: { body: settings.body }, + limits: settings.limits, + }) + .model({ id: modelID }), ) -export const responses = provider.responses -export const responsesWebSocket = provider.responsesWebSocket -export const chat = provider.chat diff --git a/packages/llm/src/providers/openai/codex.ts b/packages/llm/src/providers/openai/codex.ts index 62c113f277..d99defa6fa 100644 --- a/packages/llm/src/providers/openai/codex.ts +++ b/packages/llm/src/providers/openai/codex.ts @@ -7,14 +7,16 @@ export interface OpenAICodexSettings extends ProviderPackage.Settings { } export const model = ProviderPackage.define((modelID, settings: OpenAICodexSettings) => - OpenAIResponses.model(modelID, { - auth: (settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey)).andThen( - settings.accountID === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": settings.accountID }), - ), - baseURL: "https://chatgpt.com/backend-api/codex", - headers: settings.headers, - providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions }, - body: settings.body, - limits: settings.limits, - }), + OpenAIResponses.route + .with({ + auth: (settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey)).andThen( + settings.accountID === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": settings.accountID }), + ), + endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" }, + headers: settings.headers, + providerOptions: settings.providerOptions && { openai: settings.providerOptions }, + http: { body: settings.body }, + limits: settings.limits, + }) + .model({ id: modelID }), ) diff --git a/packages/llm/test/auth-options.types.ts b/packages/llm/test/auth-options.types.ts index 18f9508c3c..5c41a3df2a 100644 --- a/packages/llm/test/auth-options.types.ts +++ b/packages/llm/test/auth-options.types.ts @@ -67,7 +67,6 @@ requiredAuthModel("custom-model", {}) // @ts-expect-error auth is an override, so apiKey cannot be supplied with it. requiredAuthModel("custom-model", { apiKey: "key", auth }) -OpenAI.responses("gpt-4.1-mini") OpenAI.configure({}).responses("gpt-4.1-mini") OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini") OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini") @@ -99,7 +98,6 @@ OpenAI.configure({ providerOptions: { openai: { store: "false" } } }) // @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) -OpenAI.chat("gpt-4.1-mini") OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini") OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini") OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini") @@ -155,9 +153,14 @@ XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {}) // @ts-expect-error xAI Chat selectors only accept model ids. XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {}) -OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat") -// @ts-expect-error OpenAI-compatible family selectors only accept model ids. -OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {}) +OpenAICompatible.configure({ + apiKey: "deepseek-key", + provider: "deepseek", + baseURL: "https://api.deepseek.com/v1", +}).model("deepseek-chat") +OpenAICompatible.configure({ apiKey: "deepseek-key", provider: "deepseek", baseURL: "https://api.deepseek.com/v1" }) + // @ts-expect-error OpenAI-compatible model selectors only accept model ids. + .model("deepseek-chat", {}) Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama") // @ts-expect-error Cloudflare Workers AI model selectors only accept model ids. diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index d52108c787..89b5db842f 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -34,12 +34,12 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBeFunction() - expect(OpenAI.provider.responses).toBe(OpenAI.responses) - expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() expect(OpenAICodex.model).toBeFunction() - expect(OpenAICompatible.deepseek.model).toBeFunction() + expect(OpenAICompatible.model).toBeFunction() + expect( + OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "fixture" }).model, + ).toBeFunction() expect(CloudflareAIGateway.configure).toBeFunction() expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction() expect(CloudflareWorkersAI.configure).toBeFunction() diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index ef67c866d8..e5b3be214b 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -39,17 +39,21 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b") const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct") const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b") -const deepseek = OpenAICompatible.deepseek - .configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }) - .model("deepseek-chat") -const together = OpenAICompatible.togetherai - .configure({ - apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture", - }) - .model("meta-llama/Llama-3.3-70B-Instruct-Turbo") -const groq = OpenAICompatible.groq - .configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" }) - .model("llama-3.3-70b-versatile") +const deepseek = OpenAICompatible.configure({ + apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture", + provider: "deepseek", + baseURL: "https://api.deepseek.com/v1", +}).model("deepseek-chat") +const together = OpenAICompatible.configure({ + apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture", + provider: "togetherai", + baseURL: "https://api.together.xyz/v1", +}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo") +const groq = OpenAICompatible.configure({ + apiKey: process.env.GROQ_API_KEY ?? "fixture", + provider: "groq", + baseURL: "https://api.groq.com/openai/v1", +}).model("llama-3.3-70b-versatile") const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) const openrouter = openRouter.model("openai/gpt-4o-mini") const openrouterGpt55 = openRouter.model("openai/gpt-5.5") diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 43ae283e9f..457a11de28 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -3,7 +3,6 @@ import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM, Message, ToolCallPart } from "../../src" import { Auth, LLMClient } from "../../src/route" -import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" @@ -40,15 +39,6 @@ const usageChunk = (usage: object) => ({ usage, }) -const providerFamilies = [ - ["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"], - ["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"], - ["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"], - ["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"], - ["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"], - ["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"], -] as const - describe("OpenAI-compatible Chat route", () => { it.effect("prepares generic Chat target", () => Effect.gen(function* () { @@ -90,41 +80,6 @@ describe("OpenAI-compatible Chat route", () => { }), ) - it.effect("provides model helpers for compatible provider families", () => - Effect.gen(function* () { - expect( - providerFamilies.map(([provider, family]) => { - const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`) - return { - id: String(model.id), - provider: String(model.provider), - route: model.route.id, - baseURL: model.route.endpoint.baseURL, - } - }), - ).toEqual( - providerFamilies.map(([provider, _, baseURL]) => ({ - id: `${provider}-model`, - provider, - route: "openai-compatible-chat", - baseURL, - })), - ) - - const custom = OpenAICompatible.deepseek - .configure({ - apiKey: "test-key", - baseURL: "https://custom.deepseek.test/v1", - }) - .model("deepseek-chat") - expect(custom).toMatchObject({ - provider: "deepseek", - route: { id: "openai-compatible-chat" }, - }) - expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1") - }), - ) - it.effect("matches AI SDK compatible basic request body fixture", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(request) diff --git a/packages/llm/test/provider/provider-package.test.ts b/packages/llm/test/provider/provider-package.test.ts index e028dabd82..bf7935fc35 100644 --- a/packages/llm/test/provider/provider-package.test.ts +++ b/packages/llm/test/provider/provider-package.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM, LLMClient } from "../../src" -import { Anthropic, OpenAI, OpenAICodex } from "../../src/providers" +import { Anthropic, OpenAI, OpenAICodex, OpenAICompatible } from "../../src/providers" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" import { sseEvents } from "../lib/sse" @@ -117,4 +117,40 @@ describe("provider package contract", () => { ) }), ) + + it.effect("builds OpenAI-compatible Chat models from flat settings", () => + Effect.gen(function* () { + yield* LLMClient.generate( + requestFor( + OpenAICompatible.model("compatible-x", { + apiKey: "compatible-key", + baseURL: "https://api.compatible.test/v1", + body: { user: "provider-package" }, + providerOptions: { serviceTier: "priority" }, + }), + ), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const body = decodeJsonRecord(input.text) + + expect(web.url).toBe("https://api.compatible.test/v1/chat/completions") + expect(web.headers.get("authorization")).toBe("Bearer compatible-key") + expect(body).toMatchObject({ + model: "compatible-x", + user: "provider-package", + stream: true, + }) + expect(body).not.toHaveProperty("apiKey") + return input.respond(sseEvents({ choices: [{ delta: {}, finish_reason: "stop" }], usage: null }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) }) From 781165c884374182b079fcd4886f8f2d07e32499 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 14:35:48 -0400 Subject: [PATCH 4/5] fix(core): keep explicit native package api through catalog merge The catalog treated any native model api with empty settings and no url as a placeholder inheriting the provider api, which stomped plugin-assigned native packages (ChatGPT codex retargeting) back to aisdk. A native api carrying a package is explicitly targeted and now survives projection. --- packages/core/src/catalog.ts | 7 +++++- packages/core/test/catalog.test.ts | 34 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 1945024b54..a98902f01d 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -77,7 +77,12 @@ 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 + // A native api with a package is explicitly targeted; only package-less, + // settings-less native apis are placeholders that inherit the provider api. + model.api.type === "native" && + model.api.package === undefined && + !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: { ...provider.api.settings, ...model.api.settings } } diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 6c736cde1e..8444bec813 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -213,6 +213,40 @@ describe("CatalogV2", () => { }), ) + it.effect("keeps an explicit native package api over the provider api", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai", + } + }) + catalog.model.update(providerID, modelID, (model) => { + // Plugins retarget models at explicit native packages (e.g. ChatGPT + // subscription models at the codex package); empty settings must not + // demote the api back to the provider placeholder rule. + model.api = { + type: "native", + id: model.api.id, + package: "@opencode-ai/llm/providers/openai/codex", + settings: {}, + } + }) + }) + + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ + id: modelID, + type: "native", + package: "@opencode-ai/llm/providers/openai/codex", + settings: {}, + }) + }), + ) + it.effect("resolves provider and model request merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service From f019bb3abfd44cbedb304c8cce8728b4e05388a8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 14:51:00 -0400 Subject: [PATCH 5/5] fix(core): tighten ChatGPT codex model eligibility Replace the codex-substring heuristic with the empirically verified allow/disallow lists plus the >5.4 version rule; the ChatGPT backend rejects models like gpt-5.3-codex that the substring rule admitted. --- packages/core/src/plugin/provider/openai.ts | 13 ++++++++++--- packages/core/test/plugin/provider-openai.test.ts | 10 ++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 12412841db..4facbd22b2 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -181,7 +181,7 @@ export const OpenAIPlugin = define({ if (!chatgpt) continue for (const model of item.models.values()) { evt.model.update(item.provider.id, model.id, (draft) => { - if (!eligible(draft)) { + if (!eligible(draft.api.id)) { draft.enabled = false return } @@ -316,8 +316,15 @@ function isChatGPT(credential: { readonly type: string; readonly methodID?: stri ) } -function eligible(model: { readonly id: string }) { - return model.id.includes("codex") +const chatgptAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const chatgptDisallowed = new Set(["gpt-5.5-pro"]) + +/** Which API model ids a ChatGPT subscription may call through the codex backend. */ +function eligible(apiID: string) { + if (chatgptAllowed.has(apiID)) return true + if (chatgptDisallowed.has(apiID)) return false + const match = apiID.match(/^gpt-(\d+\.\d+)/) + return match ? Number.parseFloat(match[1]) > 5.4 : false } function claim(token: string) { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index e49862e55c..e82ab78099 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -182,8 +182,8 @@ describe("OpenAIPlugin", () => { catalog.provider.update(item.id, (draft) => { draft.api = item.api }) - catalog.model.update(item.id, ModelV2.ID.make("gpt-5-codex"), (draft) => { - draft.api = { id: ModelV2.ID.make("gpt-5-codex"), type: "aisdk", package: "@ai-sdk/openai" } + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.3-codex-spark"), (draft) => { + draft.api = { id: ModelV2.ID.make("gpt-5.3-codex-spark"), type: "aisdk", package: "@ai-sdk/openai" } draft.cost = [{ input: 1, output: 2, cache: { read: 3, write: 4 } }] }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), (draft) => { @@ -204,11 +204,13 @@ describe("OpenAIPlugin", () => { yield* addPlugin() - expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-codex")))).toMatchObject({ + expect( + required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.3-codex-spark"))), + ).toMatchObject({ enabled: true, api: { type: "native", - id: "gpt-5-codex", + id: "gpt-5.3-codex-spark", package: "@opencode-ai/llm/providers/openai/codex", settings: { store: false }, },