refactor(core): contain Codex in OpenAI plugin (#39734)
This commit is contained in:
parent
5b4ebf3e9d
commit
671e164f8d
5 changed files with 112 additions and 297 deletions
|
|
@ -18,7 +18,6 @@ import { Credential } from "./credential"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
|
||||||
import { Provider } from "./provider"
|
import { Provider } from "./provider"
|
||||||
|
|
||||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||||
|
|
@ -153,12 +152,7 @@ export const fromCatalogModel = (
|
||||||
const packageName = Provider.packageName(resolved.package)
|
const packageName = Provider.packageName(resolved.package)
|
||||||
const key = apiKey(resolved, credential)
|
const key = apiKey(resolved, credential)
|
||||||
|
|
||||||
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
|
||||||
return Effect.succeed(codexModel(resolved, credential, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
|
|
@ -227,10 +221,6 @@ export const fromCatalogModel = (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
|
||||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
|
||||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
|
||||||
|
|
||||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||||
if (!credential) return {}
|
if (!credential) return {}
|
||||||
if (credential.type === "key") return { apiKey: credential.key }
|
if (credential.type === "key") return { apiKey: credential.key }
|
||||||
|
|
@ -252,22 +242,6 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
const codexModel = (
|
|
||||||
model: Info,
|
|
||||||
credential: Credential.Value | undefined,
|
|
||||||
key: ReturnType<typeof Auth.value> | undefined,
|
|
||||||
) => {
|
|
||||||
const account = OpenAICodex.accountID(credential)
|
|
||||||
return withDefaults(model, OpenAIResponses.route)
|
|
||||||
.with({
|
|
||||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
|
||||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
|
||||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsupported = (model: Info) =>
|
const unsupported = (model: Info) =>
|
||||||
new UnsupportedPackageError({
|
new UnsupportedPackageError({
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
export * as OpenAICodex from "./openai-codex"
|
|
||||||
|
|
||||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
|
||||||
// Codex routing lives in ModelResolver and catalog filtering.
|
|
||||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
|
||||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
|
||||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
|
||||||
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
|
||||||
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
|
||||||
// provider entry could replace the hardcoded rules with catalog data.
|
|
||||||
|
|
||||||
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
|
||||||
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
|
||||||
|
|
||||||
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
|
||||||
|
|
||||||
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
|
||||||
type CredentialLike = {
|
|
||||||
readonly type: string
|
|
||||||
readonly methodID?: string
|
|
||||||
readonly metadata?: Record<string, unknown> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
|
||||||
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
|
||||||
|
|
||||||
export const accountID = (credential: CredentialLike | undefined) => {
|
|
||||||
if (!isChatGPT(credential)) return undefined
|
|
||||||
const value = credential?.metadata?.accountID
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
|
||||||
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
|
||||||
|
|
||||||
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
|
||||||
export const eligible = (apiID: string) => {
|
|
||||||
if (allowed.has(apiID)) return true
|
|
||||||
if (disallowed.has(apiID)) return false
|
|
||||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
|
||||||
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
|
||||||
}
|
|
||||||
|
|
@ -10,14 +10,16 @@ import { Model } from "../../model"
|
||||||
import { OauthCallbackPage } from "../../oauth/page"
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { Provider } from "../../provider"
|
import { Provider } from "../../provider"
|
||||||
import type { PluginInternal } from "../internal"
|
import type { PluginInternal } from "../internal"
|
||||||
import { OpenAICodex } from "./openai-codex"
|
|
||||||
|
|
||||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const issuer = "https://auth.openai.com"
|
const issuer = "https://auth.openai.com"
|
||||||
const callbackPort = 1455
|
const callbackPort = 1455
|
||||||
const pollingSafetyMargin = 3000
|
const pollingSafetyMargin = 3000
|
||||||
|
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||||
|
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||||
|
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||||
|
|
||||||
type Pkce = {
|
type Pkce = {
|
||||||
verifier: string
|
verifier: string
|
||||||
|
|
@ -164,14 +166,18 @@ export const OpenAIPlugin = define({
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const loading = Semaphore.makeUnsafe(1)
|
const loading = Semaphore.makeUnsafe(1)
|
||||||
let chatgpt = false
|
let chatgpt: Credential.OAuth | undefined
|
||||||
|
|
||||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||||
const connection = yield* ctx.integration.connection.active("openai")
|
const connection = yield* ctx.integration.connection.active("openai")
|
||||||
const credential = connection
|
const credential = connection
|
||||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
: undefined
|
: undefined
|
||||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
chatgpt =
|
||||||
|
credential?.type === "oauth" &&
|
||||||
|
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
|
||||||
|
? credential
|
||||||
|
: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
|
@ -183,16 +189,19 @@ export const OpenAIPlugin = define({
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
if (!Provider.isAISDK(item.provider.package)) continue
|
if (!Provider.isAISDK(item.provider.package)) continue
|
||||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||||
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
|
provider.package = "@opencode-ai/ai/providers/openai"
|
||||||
// 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) return
|
if (!chatgpt) return
|
||||||
const item = evt.provider.get(Provider.ID.openai)
|
const item = evt.provider.get(Provider.ID.openai)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||||
|
const account = chatgpt.metadata?.accountID
|
||||||
|
item.provider.headers = Provider.mergeHeaders(
|
||||||
|
item.provider.headers,
|
||||||
|
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
||||||
|
)
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||||
// subscription covers usage, so hide the rest and zero the cost.
|
// subscription covers usage, so hide the rest and zero the cost.
|
||||||
|
|
@ -201,14 +210,36 @@ export const OpenAIPlugin = define({
|
||||||
draft.enabled = false
|
draft.enabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
|
const apiID = draft.modelID ?? draft.id
|
||||||
|
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||||
|
if (
|
||||||
|
!codexAllowed.has(apiID) &&
|
||||||
|
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||||
|
) {
|
||||||
draft.enabled = false
|
draft.enabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
draft.cost = []
|
draft.cost = []
|
||||||
|
if (draft.id.includes("gpt-5.5")) {
|
||||||
|
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
|
||||||
|
}
|
||||||
|
if (draft.id.includes("gpt-5.6")) {
|
||||||
|
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
yield* ctx.session.hook("request", (evt) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||||
|
const url = new URL(evt.url)
|
||||||
|
if (url.origin === "https://api.openai.com") {
|
||||||
|
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
|
||||||
|
}
|
||||||
|
evt.headers.originator = "opencode"
|
||||||
|
evt.headers["session-id"] = evt.sessionID
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
|
@ -216,21 +247,6 @@ export const OpenAIPlugin = define({
|
||||||
Stream.runForEach(refresh),
|
Stream.runForEach(refresh),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
yield* ctx.aisdk.hook(
|
|
||||||
"sdk",
|
|
||||||
Effect.fn(function* (evt) {
|
|
||||||
if (evt.package !== "@ai-sdk/openai") return
|
|
||||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
|
||||||
evt.sdk = mod.createOpenAI(evt.options)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* ctx.aisdk.hook(
|
|
||||||
"language",
|
|
||||||
Effect.fn(function* (evt) {
|
|
||||||
if (evt.model.providerID !== Provider.ID.openai) return
|
|
||||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
} satisfies PluginInternal.InternalPlugin)
|
} satisfies PluginInternal.InternalPlugin)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -307,12 +307,12 @@ describe("ModelResolver", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
model("@opencode-ai/ai/providers/openai", {
|
||||||
settings: { baseURL: "https://openai.example/v1" },
|
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||||
headers: {},
|
headers: { "chatgpt-account-id": "acct_123" },
|
||||||
body: {},
|
body: {},
|
||||||
}),
|
}),
|
||||||
Credential.OAuth.make({
|
Credential.OAuth.make({
|
||||||
|
|
@ -337,37 +337,8 @@ describe("ModelResolver", () => {
|
||||||
id: "openai-responses",
|
id: "openai-responses",
|
||||||
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||||
})
|
})
|
||||||
|
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
|
||||||
model("@opencode-ai/ai/providers/openai", {
|
|
||||||
settings: { baseURL: "https://openai.example/v1" },
|
|
||||||
}),
|
|
||||||
Credential.OAuth.make({
|
|
||||||
type: "oauth",
|
|
||||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
|
||||||
access: "chatgpt-token",
|
|
||||||
refresh: "refresh",
|
|
||||||
expires: Date.now() + 60_000,
|
|
||||||
metadata: { accountID: "acct_123" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const headers = yield* resolved.route.auth.apply({
|
|
||||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
|
||||||
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 chatgpt-token")
|
|
||||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -407,37 +378,6 @@ describe("ModelResolver", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
|
||||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
|
||||||
settings: { baseURL: "https://openai.example/v1" },
|
|
||||||
headers: {},
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
Credential.OAuth.make({
|
|
||||||
type: "oauth",
|
|
||||||
methodID: Integration.MethodID.make("chatgpt-headless"),
|
|
||||||
access: "chatgpt-token",
|
|
||||||
refresh: "refresh",
|
|
||||||
expires: Date.now() + 60_000,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
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 chatgpt-token")
|
|
||||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
|
@ -9,6 +9,7 @@ import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
import { Plugin } from "@opencode-ai/core/plugin"
|
import { Plugin } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
@ -18,7 +19,6 @@ const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
const addPlugin = Effect.fn(function* () {
|
const addPlugin = Effect.fn(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||||
|
|
@ -29,19 +29,6 @@ function required<T>(value: T | undefined): T {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeSelectorSdk(calls: string[]) {
|
|
||||||
const make = (method: string) => (id: string) => {
|
|
||||||
calls.push(`${method}:${id}`)
|
|
||||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
responses: make("responses"),
|
|
||||||
messages: make("messages"),
|
|
||||||
chat: make("chat"),
|
|
||||||
languageModel: make("languageModel"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -61,104 +48,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* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
yield* addPlugin()
|
|
||||||
const result = yield* aisdk.runSDK({
|
|
||||||
model: Model.Info.make({
|
|
||||||
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
|
|
||||||
modelID: Model.ID.make("gpt-5"),
|
|
||||||
package: Provider.aisdk("test-provider"),
|
|
||||||
}),
|
|
||||||
package: "@ai-sdk/openai",
|
|
||||||
options: { name: "custom-openai", apiKey: "test" },
|
|
||||||
})
|
|
||||||
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
yield* addPlugin()
|
|
||||||
const result = yield* aisdk.runSDK({
|
|
||||||
model: Model.Info.make({
|
|
||||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
|
|
||||||
modelID: Model.ID.make("gpt-5"),
|
|
||||||
package: Provider.aisdk("test-provider"),
|
|
||||||
}),
|
|
||||||
package: "@ai-sdk/openai-compatible",
|
|
||||||
options: { name: "openai" },
|
|
||||||
})
|
|
||||||
expect(result.sdk).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("uses the Responses API for language models", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
const calls: string[] = []
|
|
||||||
yield* addPlugin()
|
|
||||||
const result = yield* aisdk.runLanguage({
|
|
||||||
model: Model.Info.make({
|
|
||||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
|
|
||||||
modelID: Model.ID.make("gpt-5"),
|
|
||||||
package: Provider.aisdk("test-provider"),
|
|
||||||
}),
|
|
||||||
sdk: fakeSelectorSdk(calls),
|
|
||||||
options: {},
|
|
||||||
})
|
|
||||||
expect(calls).toEqual(["responses:gpt-5"])
|
|
||||||
expect(result.language).toBeDefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("ignores non-OpenAI providers", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
const calls: string[] = []
|
|
||||||
yield* addPlugin()
|
|
||||||
const result = yield* aisdk.runLanguage({
|
|
||||||
model: Model.Info.make({
|
|
||||||
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
|
|
||||||
modelID: Model.ID.make("gpt-5"),
|
|
||||||
package: Provider.aisdk("test-provider"),
|
|
||||||
}),
|
|
||||||
sdk: fakeSelectorSdk(calls),
|
|
||||||
options: {},
|
|
||||||
})
|
|
||||||
expect(calls).toEqual([])
|
|
||||||
expect(result.language).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
const item = Provider.Info.make({
|
|
||||||
...Provider.Info.empty(Provider.ID.openai),
|
|
||||||
package: Provider.aisdk("@ai-sdk/openai"),
|
|
||||||
})
|
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.package = item.package
|
|
||||||
})
|
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
|
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
|
|
||||||
})
|
|
||||||
yield* addPlugin()
|
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
|
|
||||||
expect(
|
|
||||||
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
|
|
||||||
).toBe(false)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|
@ -172,6 +61,7 @@ describe("OpenAIPlugin", () => {
|
||||||
draft.package = item.package
|
draft.package = item.package
|
||||||
})
|
})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
||||||
|
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||||
model.cost = [
|
model.cost = [
|
||||||
{
|
{
|
||||||
input: Money.USDPerMillionTokens.make(1),
|
input: Money.USDPerMillionTokens.make(1),
|
||||||
|
|
@ -189,7 +79,9 @@ describe("OpenAIPlugin", () => {
|
||||||
model.body = { reasoning: { mode: "pro" } }
|
model.body = { reasoning: { mode: "pro" } }
|
||||||
})
|
})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => {
|
||||||
|
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||||
|
})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||||
})
|
})
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
|
|
@ -205,8 +97,47 @@ describe("OpenAIPlugin", () => {
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
|
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||||
|
url: "https://api.openai.com/v1/responses",
|
||||||
|
method: "POST",
|
||||||
|
headers: {},
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
|
||||||
|
url: "https://custom.example/v1/responses",
|
||||||
|
method: "POST",
|
||||||
|
headers: {},
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||||
|
url: "https://proxy.example/v1/responses?region=us",
|
||||||
|
method: "POST",
|
||||||
|
headers: {},
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||||
|
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
|
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||||
|
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||||
|
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||||
|
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||||
|
expect(custom.headers).toEqual({})
|
||||||
|
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||||
|
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
|
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
expect(eligible.cost).toEqual([])
|
expect(eligible.cost).toEqual([])
|
||||||
|
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||||
expect(eligible.enabled).toBe(true)
|
expect(eligible.enabled).toBe(true)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||||
false,
|
false,
|
||||||
|
|
@ -215,9 +146,9 @@ describe("OpenAIPlugin", () => {
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
|
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||||
true,
|
expect(gpt56.enabled).toBe(true)
|
||||||
)
|
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -234,7 +165,9 @@ describe("OpenAIPlugin", () => {
|
||||||
catalog.provider.update(item.id, (draft) => {
|
catalog.provider.update(item.id, (draft) => {
|
||||||
draft.package = item.package
|
draft.package = item.package
|
||||||
})
|
})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
||||||
|
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||||
|
})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||||
})
|
})
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
|
|
@ -243,29 +176,23 @@ describe("OpenAIPlugin", () => {
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
|
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||||
|
url: "https://api.openai.com/v1/responses",
|
||||||
|
method: "POST",
|
||||||
|
headers: {},
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
|
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
|
expect(model.enabled).toBe(true)
|
||||||
|
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||||
|
expect(request.headers).toEqual({})
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
const item = Provider.Info.make({
|
|
||||||
...Provider.Info.empty(Provider.ID.make("custom-openai")),
|
|
||||||
package: Provider.aisdk("test-provider"),
|
|
||||||
})
|
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.package = item.package
|
|
||||||
})
|
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
|
|
||||||
})
|
|
||||||
yield* addPlugin()
|
|
||||||
expect(
|
|
||||||
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
|
|
||||||
.enabled,
|
|
||||||
).toBe(true)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue