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/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/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 46553eaff4..4facbd22b2 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.api.id)) { + 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,34 @@ 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) + ) +} + +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) { 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 +338,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..ffc86a9312 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,12 +1,8 @@ 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 { Context, Effect, Layer, Schema } from "effect" +import { Model } from "@opencode-ai/llm" +import { Context, Effect, Layer, Predicate, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { Credential } from "../../credential" @@ -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,95 @@ 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 (!Predicate.isReadonlyObject(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 +} /** Resolves models from the catalog belonging to the current Location runtime. */ export const locationLayer = Layer.effect( 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 diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 31a80f9319..e82ab78099 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,54 @@ 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.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) => { + 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.3-codex-spark"))), + ).toMatchObject({ + enabled: true, + api: { + type: "native", + id: "gpt-5.3-codex-spark", + 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/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..63094704b6 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -2,13 +2,16 @@ 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 * as AnthropicMessages from "../protocols/anthropic-messages" +import { ProviderPackage } from "../provider-package" +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 {} const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth @@ -31,5 +34,15 @@ export const configure = (input: Config = {}) => { } } -export const provider = configure() -export const model = provider.model +export const model = ProviderPackage.define((modelID, settings: AnthropicSettings) => + 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/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..fbb53a851d 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -1,8 +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 { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" +import { Auth } from "../route/auth" +import { ProviderPackage } from "../provider-package" export const id = ProviderID.make("openai-compatible") @@ -12,10 +13,7 @@ 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] @@ -35,31 +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, +export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) => + 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, }) - return { - id: ProviderID.make(profile.provider), - model: facade.model, - configure: configureProfile, - } - } - return configureProfile() -} - -export const provider = { - id, - configure, -} - -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) + .model({ id: modelID, provider: "openai-compatible" }), +) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad8493..51e7b47daf 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,8 +1,10 @@ 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 * as OpenAIChat from "../protocols/openai-chat" -import * as OpenAIResponses from "../protocols/openai-responses" +import { ProviderPackage } from "../provider-package" +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" @@ -14,13 +16,15 @@ 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 readonly providerOptions?: OpenAIProviderOptionsInput } +export interface OpenAISettings extends ProviderPackage.Settings {} + const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const defaults = (input: Config) => { @@ -55,9 +59,15 @@ export const configure = (input: Config = {}) => { } } -export const provider = configure() - -export const model = provider.model -export const responses = provider.responses -export const responsesWebSocket = provider.responsesWebSocket -export const chat = provider.chat +export const model = ProviderPackage.define((modelID, settings: OpenAISettings) => + 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 }), +) diff --git a/packages/llm/src/providers/openai/codex.ts b/packages/llm/src/providers/openai/codex.ts new file mode 100644 index 0000000000..d99defa6fa --- /dev/null +++ b/packages/llm/src/providers/openai/codex.ts @@ -0,0 +1,22 @@ +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.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/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/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 4bed7e2e13..89b5db842f 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,11 +34,12 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBe(OpenAI.model) - expect(OpenAI.provider.responses).toBe(OpenAI.responses) - expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() - expect(OpenAICompatible.deepseek.model).toBeFunction() + expect(OpenAICodex.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 new file mode 100644 index 0000000000..bf7935fc35 --- /dev/null +++ b/packages/llm/test/provider/provider-package.test.ts @@ -0,0 +1,156 @@ +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, OpenAICompatible } 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" } }, + ) + }), + ), + ), + ) + }), + ) + + 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" }, + }) + }), + ), + ), + ) + }), + ) +}) 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