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