feat(llm): provider packages own model request construction
Provider packages in @opencode-ai/llm/providers/* now implement a uniform ProviderPackage contract: model(modelID, settings) => Model. SessionRunnerModel becomes provider-agnostic: it resolves a package specifier, folds catalog settings, credentials, and transport overlays into one Settings object, and delegates request construction to the package. - llm: add ProviderPackage (Settings, Definition, define), flat model(id, config) constructors on the openai-responses, anthropic-messages, and openai-compatible-chat protocols, contract-shaped model exports on the openai, anthropic, and openai-compatible providers, and a new providers/openai/codex entry point that targets the ChatGPT codex backend and sets the chatgpt-account-id header from settings.accountID. - schema: Provider.Native gains optional package. - core: SessionRunnerModel loads packages through a static built-in map (dynamic import for foreign specifiers) and applies one settings fold; the ChatGPT conditional is deleted from the runner. The OpenAI plugin's catalog transform now assigns the codex package to eligible models when a ChatGPT connection is active, alongside the existing eligibility and cost rewrites. - llm schema: hoist ToolResultValue union out of its Object.assign self reference; the previous shape only typechecked under lucky check ordering and broke under core's typecheck with the new import graph. Closes #34765
This commit is contained in:
parent
911fb7063a
commit
38398fd450
22 changed files with 634 additions and 114 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||
|
||||
export class PackageLoadError extends Schema.TaggedErrorClass<PackageLoadError>()("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<string, PackageModule> = {
|
||||
"@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<ProviderPackage.Definition["model"], PackageLoadError> => {
|
||||
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Service, Interface>()("@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<Model, UnsupportedApiError> => {
|
||||
): Effect.Effect<Model, UnsupportedApiError | ProviderV2.PackageLoadError> => {
|
||||
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<string, string | undefined> = {
|
||||
"@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<string, UnsupportedApiError> => {
|
||||
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<Record<string, unknown>> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ type Api =
|
|||
readonly url?: string
|
||||
readonly settings?: Record<string, unknown>
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
|
||||
| {
|
||||
readonly type: "native"
|
||||
readonly package?: string
|
||||
readonly url?: string
|
||||
readonly settings: Record<string, unknown>
|
||||
}
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue