import { Money } from "@opencode/schema/money" import { Agent } from "@opencode/schema/agent" import { Session } from "@opencode/core/session" import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" import { ai, AIError, HttpContext, Message, RateLimitError } from "@opencode/ai/effect" import { classifyProviderFailure } from "@opencode/ai/route" import { compileRequest } from "@opencode/ai/testing" import { describe, expect } from "bun:test" // import { DateTime, Deferred, Effect, Schedule } from "effect" import { DateTime, Effect, Schedule } from "effect" import { HttpClient, HttpClientResponse } from "effect/http" import { exportJWK, generateKeyPair, SignJWT } from "jose" import { App } from "@opencode/core/app" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { KV } from "@opencode/core/kv" import { Location } from "@opencode/core/location" import { Model } from "@opencode/core/model" import { ModelResolver } from "@opencode/core/model-resolver" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { PluginHooks } from "@opencode/core/plugin/hooks" import { GithubCopilotPlugin } from "@opencode/core/plugin/provider/github-copilot" import { ChatGPTPlugin, fetchModels, verifyIDToken } from "@opencode/core/plugin/provider/chatgpt" import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai" import { Project } from "@opencode/core/project" import { Provider } from "@opencode/core/provider" import { AbsolutePath } from "@opencode/core/schema" import { SessionModelRequest } from "@opencode/core/session/model-request" import { SessionModelTransport } from "@opencode/core/session/model-transport" import { SessionRunnerModel } from "@opencode/core/session/runner/model" import { SessionRunnerRetry } from "@opencode/core/session/runner/retry" import { toSessionError } from "@opencode/core/session/to-session-error" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) const unavailableModels = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status: 503 }))), ) const addPlugin = Effect.fn(function* (http = unavailableModels) { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin, ChatGPTPlugin.id) yield* ChatGPTPlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http)) }) const addLegacyPlugin = Effect.fn(function* () { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin, OpenAIPlugin.id) yield* OpenAIPlugin.effect(host) }) const addGithubCopilotPlugin = Effect.fn(function* () { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin, "test") yield* GithubCopilotPlugin.effect(host) }) function required(value: T | undefined): T { if (value === undefined) throw new Error("Expected value") return value } const authorize = Effect.fn(function* () { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const attempt = yield* integrations.oauth.connect({ integrationID, methodID: Integration.MethodID.make("chatgpt-token-sharing"), }) yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID }) return new URL(attempt.url) }) const request = Effect.fn(function* ( providerID: Provider.ID, baseURL: string, sessionID = Session.ID.make("ses_test"), ) { const hooks = yield* PluginHooks.Service const event = yield* hooks.trigger("session", "model.request", { sessionID, agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), kind: "primary", baseURL, headers: {}, }) return { baseURL: event.baseURL, headers: event.headers, hasHttpHooks: (yield* hooks.has("session", "http.request", providerID)) || (yield* hooks.has("session", "http.response", providerID)), } }) describe("ChatGPTPlugin", () => { it.live("loads only visible API-supported ChatGPT models", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease( Effect.sync(() => Bun.serve({ port: 0, fetch: (request) => { if (new URL(request.url).pathname === "/error/models") return new Response(null, { status: 503 }) if (new URL(request.url).pathname === "/empty/models") return Response.json({ models: [] }) expect(request.headers.get("authorization")).toBe("Bearer test-token") expect(request.headers.has("x-openai-chatpass-test")).toBe(false) return Response.json({ models: [ { slug: "gpt-5.6-sol", display_name: "GPT-5.6-Sol", visibility: "list", supported_in_api: true, context_window: 272_000, input_modalities: ["text", "image"], supported_reasoning_levels: [{ effort: "low" }], model_messages: { base_instructions: "not retained" }, }, { slug: "gpt-reserve", display_name: "GPT-Reserve", visibility: "hide", supported_in_api: true, context_window: 272_000, input_modalities: ["text"], }, { slug: "not-in-api", display_name: "Not in API", visibility: "list", supported_in_api: false, context_window: 272_000, input_modalities: ["text"], }, ], }) }, }), ), (server) => Effect.sync(() => server.stop()), ) const baseURL = `http://localhost:${server.port}` expect(yield* fetchModels("test-token", App.make(), baseURL)).toEqual([ { slug: "gpt-5.6-sol", display_name: "GPT-5.6-Sol", visibility: "list", supported_in_api: true, context_window: 272_000, input_modalities: ["text", "image"], supported_reasoning_levels: [{ effort: "low" }], }, ]) expect(yield* Effect.flip(fetchModels("test-token", App.make(), `${baseURL}/empty`))).toBeInstanceOf(Error) expect(yield* Effect.flip(fetchModels("test-token", App.make(), `${baseURL}/error`))).toBeInstanceOf(Error) }), ) it.live("verifies a signed ID token against OpenAI's issuer, client ID, expiry, and sign-in nonce", () => Effect.gen(function* () { const keys = yield* Effect.promise(() => generateKeyPair("RS256")) const publicKey = yield* Effect.promise(() => exportJWK(keys.publicKey)) const server = yield* Effect.acquireRelease( Effect.sync(() => Bun.serve({ port: 0, fetch: () => Response.json({ keys: [{ ...publicKey, kid: "sign-in", alg: "RS256", use: "sig" }] }), }), ), (server) => Effect.sync(() => server.stop()), ) const jwksURL = new URL(`http://localhost:${server.port}/jwks`) const sign = (nonce: string, audience: string, expires: number, key = keys.privateKey) => new SignJWT({ nonce }) .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) .setIssuer("https://auth.openai.com") .setAudience(audience) .setSubject("user_123") .setExpirationTime(expires) .sign(key) const expires = Math.floor(Date.now() / 1000) + 60 const valid = yield* Effect.promise(() => sign("expected", "oaiapp_issued", expires)) yield* verifyIDToken(valid, "oaiapp_issued", "expected", jwksURL) const missingSubject = yield* Effect.promise(() => new SignJWT({ nonce: "expected" }) .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) .setIssuer("https://auth.openai.com") .setAudience("oaiapp_issued") .setExpirationTime(expires) .sign(keys.privateKey), ) expect(yield* Effect.flip(verifyIDToken(missingSubject, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf( Error, ) const blankSubject = yield* Effect.promise(() => new SignJWT({ nonce: "expected" }) .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) .setIssuer("https://auth.openai.com") .setAudience("oaiapp_issued") .setSubject(" ") .setExpirationTime(expires) .sign(keys.privateKey), ) expect(yield* Effect.flip(verifyIDToken(blankSubject, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf( Error, ) expect(yield* Effect.flip(verifyIDToken(valid, "oaiapp_issued", "wrong", jwksURL))).toBeInstanceOf(Error) expect(yield* Effect.flip(verifyIDToken(valid, "wrong-client", "expected", jwksURL))).toBeInstanceOf(Error) const wrongIssuer = yield* Effect.promise(() => new SignJWT({ nonce: "expected" }) .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) .setIssuer("https://someone-else.example") .setAudience("oaiapp_issued") .setSubject("user_123") .setExpirationTime(expires) .sign(keys.privateKey), ) expect(yield* Effect.flip(verifyIDToken(wrongIssuer, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) const noExpiry = yield* Effect.promise(() => new SignJWT({ nonce: "expected" }) .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) .setIssuer("https://auth.openai.com") .setAudience("oaiapp_issued") .setSubject("user_123") .sign(keys.privateKey), ) expect(yield* Effect.flip(verifyIDToken(noExpiry, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) const expired = yield* Effect.promise(() => sign("expected", "oaiapp_issued", expires - 120)) expect(yield* Effect.flip(verifyIDToken(expired, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) const other = yield* Effect.promise(() => generateKeyPair("RS256")) const forged = yield* Effect.promise(() => sign("expected", "oaiapp_issued", expires, other.privateKey)) expect(yield* Effect.flip(verifyIDToken(forged, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) }), ) it.effect("registers the ChatGPT OAuth method", () => Effect.gen(function* () { yield* addPlugin() const integrations = yield* Integration.Service expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([ { id: Integration.MethodID.make("chatgpt-token-sharing"), type: "oauth", label: "Sign in with ChatGPT", }, ]) }), ) it.effect("keeps the Codex browser and headless methods alongside token sharing", () => Effect.gen(function* () { yield* addPlugin() yield* addLegacyPlugin() const integrations = yield* Integration.Service expect((yield* integrations.get(Integration.ID.make("openai")))?.methods.map((method) => method.type === "oauth" && method.id)).toEqual([ Integration.MethodID.make("chatgpt-token-sharing"), Integration.MethodID.make("chatgpt-browser"), Integration.MethodID.make("chatgpt-headless"), ]) }), ) for (const source of ["http", "stream"] as const) { it.effect(`stops retries for ChatGPT usage limits from ${source} failures`, () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60 * 60_000, metadata: { clientID: "oaiapp_issued" }, }), }) yield* addPlugin() const hooks = yield* PluginHooks.Service const body = source === "http" ? JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded" } }) : JSON.stringify({ type: "response.failed", response: { error: { code: "subscription_sharing_usage_limit_exceeded" } }, }) const cause = new AIError({ reason: new RateLimitError({ message: "Rate limit exceeded", body, ...(source === "http" ? { http: new HttpContext({ url: "https://api.openai.com/v1/responses", status: 429, headers: {} }) } : {}), }), }) const decide = yield* SessionRunnerRetry.policy(Session.ID.make("ses_usage_limit")) const input = { cause, error: toSessionError(cause), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), hook: (event: PluginHooks.Domains["session"]["retry"]) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid), retry: SessionRunnerRetry.isRetryable(cause), } expect(input.error.response?.body).toBe(body) expect(input.retry).toBe(true) expect(yield* decide(input)).toEqual({ retry: false }) const other = new AIError({ reason: new RateLimitError({ message: "Rate limit exceeded", body: "{}" }) }) expect(yield* decide({ ...input, cause: other, error: toSessionError(other) })).toMatchObject({ retry: true }) }), ) } it.effect("leaves API-key OpenAI retries alone", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.Key.make({ type: "key", key: "sk-test" }), }) yield* addPlugin() const hooks = yield* PluginHooks.Service const event = yield* hooks.trigger("session", "retry", { sessionID: Session.ID.make("ses_api_key_retry"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), error: { type: "provider.rate-limit", message: "Rate limit exceeded", status: 429, response: { body: '{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}' }, }, attempt: 2, decision: { retry: true, delay: 1000 }, }) expect(event.decision).toEqual({ retry: true, delay: 1000 }) }), ) it.effect("stops deterministic ChatGPT stream failures while retrying temporary unavailability", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60 * 60_000, metadata: { clientID: "oaiapp_issued" }, }), }) yield* addPlugin() const hooks = yield* PluginHooks.Service const decide = yield* SessionRunnerRetry.policy(Session.ID.make("ses_sharing_errors")) const cases = [ ["subscription_sharing_user_not_eligible", false], ["subscription_sharing_unsupported_capability", false], ["subscription_sharing_route_not_supported", false], ["subscription_sharing_invalid_user", false], ["chatpass_v2_scope_not_authorized", false], ["chatpass_v2_invalid_authorization_context", false], ["subscription_sharing_usage_unavailable", true], ["subscription_sharing_user_unavailable", true], ] as const for (const [code, retry] of cases) { const cause = new AIError({ reason: classifyProviderFailure({ message: "Request failed", rawBody: JSON.stringify({ type: "response.failed", response: { error: { code } } }), }), }) expect(SessionRunnerRetry.isRetryable(cause)).toBe(true) const decision = yield* decide({ cause, error: toSessionError(cause), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), hook: (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid), retry: true, }) expect(decision.retry).toBe(retry) } }), ) it.effect("registers a new ChatGPT agent on first sign-in with a loopback callback", () => Effect.gen(function* () { yield* addPlugin() const url = yield* authorize() const redirect = new URL(url.searchParams.get("redirect_uri") ?? "") expect(`${url.origin}${url.pathname}`).toBe("https://auth.openai.com/api/accounts/authorize") expect(Object.fromEntries(url.searchParams)).toMatchObject({ client_id: "dynamic_agent_client", agent_name_hint: "OpenCode", response_type: "code", scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", resource: "https://api.openai.com/v1", code_challenge_method: "S256", }) expect(url.searchParams.get("state")).toBeTruthy() expect(url.searchParams.get("nonce")).toBeTruthy() expect(url.searchParams.get("code_challenge")).toBeTruthy() const hostID = url.searchParams.get("ext_agent_host_id") expect(hostID).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) const kv = yield* KV.Service const storage = PluginHost.storage(kv, ChatGPTPlugin.id) expect(yield* storage.get("chatgpt-agent-host-id")).toBe(hostID) expect((yield* authorize()).searchParams.get("ext_agent_host_id")).toBe(hostID) expect(redirect.hostname).toBe("127.0.0.1") expect(redirect.pathname).toBe("/auth/callback") expect(Number(redirect.port)).toBeGreaterThan(0) }), ) it.live("waits to show browser success until the sign-in callback succeeds", () => Effect.gen(function* () { yield* addPlugin() const integrations = yield* Integration.Service const attempt = yield* integrations.oauth.connect({ integrationID: Integration.ID.make("openai"), methodID: Integration.MethodID.make("chatgpt-token-sharing"), }) const authorization = new URL(attempt.url) const callback = new URL(authorization.searchParams.get("redirect_uri") ?? "") callback.searchParams.set("code", "test-code") callback.searchParams.set("state", authorization.searchParams.get("state") ?? "") const response = yield* Effect.promise(() => fetch(callback)) expect(response.status).toBe(400) expect(yield* Effect.promise(() => response.text())).toContain("did not return a client ID") }), ) it.effect("reauthorizes with the client ID issued to an existing ChatGPT connection", () => Effect.gen(function* () { const credentials = yield* Credential.Service const kv = yield* KV.Service const hostID = "urn:uuid:00000000-0000-4000-8000-000000000000" yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("chatgpt-agent-host-id", hostID) yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60 * 60_000, metadata: { clientID: "oaiapp_issued" }, }), }) yield* addPlugin() const url = yield* authorize() expect(url.searchParams.get("client_id")).toBe("oaiapp_issued") expect(url.searchParams.has("agent_name_hint")).toBe(false) expect(url.searchParams.get("ext_agent_host_id")).toBe(hostID) expect(new URL(url.searchParams.get("redirect_uri") ?? "").hostname).toBe("127.0.0.1") }), ) it.live("rejects a reauthorization callback that returns a different client ID", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60 * 60_000, metadata: { clientID: "oaiapp_issued" }, }), }) yield* addPlugin() const integrations = yield* Integration.Service const attempt = yield* integrations.oauth.connect({ integrationID: Integration.ID.make("openai"), methodID: Integration.MethodID.make("chatgpt-token-sharing"), }) const authorization = new URL(attempt.url) expect(authorization.searchParams.get("client_id")).toBe("oaiapp_issued") const callback = new URL(authorization.searchParams.get("redirect_uri") ?? "") callback.searchParams.set("code", "test-code") callback.searchParams.set("state", authorization.searchParams.get("state") ?? "") callback.searchParams.set("client_id", "oaiapp_other") const response = yield* Effect.promise(() => fetch(callback)) expect(response.status).toBe(400) expect(yield* Effect.promise(() => response.text())).toContain("different client") expect(yield* credentials.list(Integration.ID.make("openai"))).toHaveLength(1) }), ) it.effect("moves only token-sharing credentials to the new method ID and caches their saved models", () => Effect.gen(function* () { const credentials = yield* Credential.Service const kv = yield* KV.Service const legacy = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-browser"), access: "codex-token", refresh: "codex-refresh", expires: Date.now() + 60 * 60_000, metadata: { accountID: "acct_123" }, }), }) const snapshot = [ { slug: "gpt-5.5", display_name: "GPT-5.5", visibility: "list", supported_in_api: true, context_window: 272_000, input_modalities: ["text"], }, ] const sharing = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-browser"), access: "sharing-token", refresh: "sharing-refresh", expires: Date.now() + 60 * 60_000, metadata: { clientID: "oaiapp_issued", models: snapshot }, }), }) yield* addPlugin() expect((yield* credentials.get(legacy.id))?.value).toMatchObject({ methodID: "chatgpt-browser" }) expect((yield* credentials.get(sharing.id))?.value).toMatchObject({ methodID: "chatgpt-token-sharing", access: "sharing-token", metadata: { clientID: "oaiapp_issued" }, }) expect(yield* PluginHost.storage(kv, ChatGPTPlugin.id).get("models:oaiapp_issued")).toEqual(snapshot) }), ) // it.effect("merges account models with catalog metadata and leaves input budgeting to compaction", () => // Effect.gen(function* () { // const catalog = yield* Provider.Service // const models = yield* Model.Service // const credentials = yield* Credential.Service // const kv = yield* KV.Service // yield* catalog.transform((editor) => { // editor.update(Provider.ID.openai, (provider) => { // provider.package = "@opencode/ai/providers/openai" // }) // editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"), (model) => { // model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } // model.capabilities = { tools: true, input: ["text", "image", "pdf"], output: ["text"] } // model.variants = [ // { id: Model.VariantID.make("low"), settings: { reasoningEffort: "low" } }, // { id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }, // ] // }) // editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) // }) // yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ // { // slug: "gpt-5.6-sol", // display_name: "GPT-5.6-Sol", // visibility: "list", // supported_in_api: true, // context_window: 272_000, // input_modalities: ["text", "image"], // supported_reasoning_levels: [{ effort: "low" }, { effort: "ultra" }], // }, // { // slug: "gpt-6-future", // display_name: "GPT-6-Future", // visibility: "list", // supported_in_api: true, // context_window: 272_000, // input_modalities: ["text"], // supported_reasoning_levels: [], // }, // ]) // yield* credentials.create({ // integrationID: Integration.ID.make("openai"), // value: Credential.OAuth.make({ // type: "oauth", // methodID: Integration.MethodID.make("chatgpt-token-sharing"), // access: "chatgpt-token", // refresh: "refresh", // expires: Date.now() + 60 * 60_000, // metadata: { clientID: "oaiapp_issued" }, // }), // }) // yield* addPlugin() // // const available = (yield* models.available()).filter((model) => model.providerID === Provider.ID.openai) // expect(available.map((model) => model.id).sort()).toEqual([ // Model.ID.make("gpt-5.6-sol"), // Model.ID.make("gpt-6-future"), // ]) // const sol = required(available.find((model) => model.id === "gpt-5.6-sol")) // expect(sol.name).toBe("GPT-5.6-Sol") // expect(sol.capabilities.input).toEqual(["text", "image"]) // expect(sol.variants.map((variant) => variant.id)).toEqual([ // Model.VariantID.make("low"), // Model.VariantID.make("ultra"), // ]) // expect(sol.limit).toEqual({ context: 272_000, output: 128_000 }) // expect(sol.cost).toEqual([]) // expect(sol.settings?.compaction).toEqual({ type: "summary" }) // const future = required(available.find((model) => model.id === "gpt-6-future")) // expect(future.name).toBe("GPT-6-Future") // expect(future.package).toBe("@opencode/ai/providers/openai") // expect(future.limit).toEqual({ context: 272_000, output: 32_000 }) // }), // ) // // it.live("shows catalog models while account models refresh in the background and saves them to KV", () => // Effect.gen(function* () { // const catalog = yield* Provider.Service // const models = yield* Model.Service // const credentials = yield* Credential.Service // const kv = yield* KV.Service // yield* catalog.transform((editor) => { // editor.update(Provider.ID.openai, (provider) => { // provider.package = "@opencode/ai/providers/openai" // }) // for (const id of ["gpt-5.5", "gpt-6-astra", "gpt-6-sol"]) // editor.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) // }) // const requested = yield* Deferred.make() // const release = yield* Deferred.make() // const remote = [ // { // slug: "gpt-6-future", // display_name: "GPT-6-Future", // visibility: "list", // supported_in_api: true, // context_window: 272_000, // input_modalities: ["text"], // }, // ] // const http = HttpClient.make((request) => // Effect.gen(function* () { // expect(request.url).toBe("https://api.openai.com/v1/models") // expect(request.headers.authorization).toBe("Bearer chatgpt-token") // yield* Deferred.succeed(requested, undefined) // yield* Deferred.await(release) // return HttpClientResponse.fromWeb(request, Response.json({ models: remote })) // }), // ) // yield* addPlugin(http) // yield* credentials.create({ // integrationID: Integration.ID.make("openai"), // value: Credential.OAuth.make({ // type: "oauth", // methodID: Integration.MethodID.make("chatgpt-token-sharing"), // access: "chatgpt-token", // refresh: "refresh", // expires: Date.now() + 60 * 60_000, // metadata: { clientID: "oaiapp_issued" }, // }), // }) // yield* Deferred.await(requested).pipe(Effect.timeout("2 seconds")) // expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ // Model.ID.make("gpt-5.5"), // Model.ID.make("gpt-6-astra"), // ]) // const storage = PluginHost.storage(kv, ChatGPTPlugin.id) // expect(yield* storage.get("models:oaiapp_issued")).toBeUndefined() // // yield* Deferred.succeed(release, undefined) // const available = yield* models.available().pipe( // Effect.repeat({ // until: (current) => current.some((model) => model.providerID === Provider.ID.openai && model.id === "gpt-6-future"), // schedule: Schedule.spaced("10 millis"), // }), // Effect.timeout("2 seconds"), // ) // expect(available.filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ // Model.ID.make("gpt-6-future"), // ]) // expect(yield* storage.get("models:oaiapp_issued")).toEqual(remote) // }), // ) it.effect("routes ChatGPT over HTTP with only the listed catalog fallback models", () => Effect.gen(function* () { const catalog = yield* Provider.Service const models = yield* Model.Service const credentials = yield* Credential.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, (draft) => { draft.package = "@opencode/ai/providers/openai" draft.settings = { transport: "websocket" } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } model.cost = [ { input: Money.USDPerMillionTokens.make(1), output: Money.USDPerMillionTokens.make(2), cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.zero, }, }, ] }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"), () => {}) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.4"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 64_000 } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"), (model) => { model.modelID = Model.ID.make("gpt-5.4") model.body = { reasoning: { mode: "pro" } } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6"), () => {}) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {}) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {}) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {}) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {}) for (const id of [ "gpt-5.5-fast", "gpt-5.6-luna", "gpt-5.6-luna-fast", "gpt-5.6-sol-fast", "gpt-5.6-terra", "gpt-5.6-terra-fast", "gpt-6-astra-fast", "gpt-6-luna", "gpt-6-luna-fast", "gpt-6-sol", "gpt-6-sol-fast", "gpt-6.1-sol", "gpt-6.1-sol-fast", "gpt-6.1-terra", "gpt-6.1-terra-fast", ]) catalog.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) }) yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60_000, }), }) yield* addPlugin() const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") const provider = required(yield* catalog.get(Provider.ID.openai)) expect(provider.package).toBe("@opencode/ai/providers/openai") expect(provider.settings?.transport).toBe("http") expect(provider.settings?.baseURL).toBe("https://api.openai.com/v1") expect(provider.headers).not.toHaveProperty("x-openai-chatpass-test") expect(direct.baseURL).toBe("https://api.openai.com/v1") expect(direct.headers).toEqual({ "session-id": "ses_test", "thread-id": "ses_test", "x-client-request-id": "ses_test", }) expect(direct.hasHttpHooks).toBe(false) const sessions = yield* Session.Service const location = yield* Location.Service const parent = yield* sessions.create({ location: { directory: location.directory } }) const child = yield* sessions.create({ parentID: parent.id }) expect((yield* request(Provider.ID.openai, "https://api.openai.com/v1", child.id)).headers).toEqual({ "session-id": parent.id, "thread-id": child.id, "x-client-request-id": child.id, }) const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(eligible.package).toBe("@opencode/ai/providers/openai") expect(eligible.headers).not.toHaveProperty("x-openai-chatpass-test") expect(eligible.cost).toEqual([]) expect(eligible.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(eligible.enabled).toBe(true) const gpt56 = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))) expect(gpt56.enabled).toBe(true) expect(gpt56.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true) for (const id of [ "gpt-5.5-pro", "gpt-5.4-pro", "gpt-5.4", "gpt-5.6", "gpt-4.1", "gpt-5.10", "gpt-5", "gpt-5.04-astra", "gpt-4.99", ]) expect(yield* models.get(Provider.ID.openai, Model.ID.make(id))).toBeUndefined() expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id).sort()).toEqual( [ "gpt-5.5", "gpt-5.5-fast", "gpt-5.6-luna", "gpt-5.6-luna-fast", "gpt-5.6-sol", "gpt-5.6-sol-fast", "gpt-5.6-terra", "gpt-5.6-terra-fast", "gpt-6-astra", "gpt-6-astra-fast", "gpt-6-luna", "gpt-6-luna-fast", "gpt-6-sol", "gpt-6-sol-fast", "gpt-6.1-sol", "gpt-6.1-sol-fast", "gpt-6.1-terra", "gpt-6.1-terra-fast", ].map((id) => Model.ID.make(id)), ) }), ) it.effect("keeps the full OpenAI catalog under an API key connection", () => Effect.gen(function* () { const catalog = yield* Provider.Service const models = yield* Model.Service const credentials = yield* Credential.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, (draft) => { draft.package = "@opencode/ai/providers/openai" }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {}) }) yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.Key.make({ type: "key", key: "sk-test" }), }) yield* addPlugin() yield* addLegacyPlugin() const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") const provider = required(yield* catalog.get(Provider.ID.openai)) const model = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(model.package).toBe("@opencode/ai/providers/openai") expect(model.enabled).toBe(true) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(provider.settings?.transport).toBe("websocket") expect(model.settings?.transport).toBeUndefined() expect(direct.baseURL).toBe("https://api.openai.com/v1") expect(direct.headers).toEqual({}) expect(direct.hasHttpHooks).toBe(false) expect(provider.headers).not.toHaveProperty("x-openai-chatpass-test") expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) }), ) it.live("switches between token-sharing and Codex credentials without mixing their routes", () => Effect.gen(function* () { const catalog = yield* Provider.Service const credentials = yield* Credential.Service const kv = yield* KV.Service yield* catalog.transform((editor) => { editor.update(Provider.ID.openai, (provider) => { provider.package = "@opencode/ai/providers/openai" }) editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) }) const legacy = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-browser"), access: "codex-token", refresh: "codex-refresh", expires: Date.now() + 60 * 60_000, metadata: { accountID: "acct_123" }, }), }) yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ { slug: "gpt-5.5", display_name: "GPT-5.5", visibility: "list", supported_in_api: true, context_window: 272_000, input_modalities: ["text"], }, ]) const sharing = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "sharing-token", refresh: "sharing-refresh", expires: Date.now() + 60 * 60_000, metadata: { clientID: "oaiapp_issued" }, }), }) yield* addPlugin() yield* addLegacyPlugin() expect((yield* catalog.get(Provider.ID.openai))?.settings?.baseURL).toBe("https://api.openai.com/v1") yield* credentials.activate(legacy.id) const codex = yield* catalog.get(Provider.ID.openai).pipe( Effect.repeat({ until: (item) => item?.settings?.baseURL === "https://chatgpt.com/backend-api/codex", schedule: Schedule.spaced("10 millis"), }), Effect.timeout("2 seconds"), ) expect(codex?.headers?.["chatgpt-account-id"]).toBe("acct_123") yield* credentials.activate(sharing.id) const direct = yield* catalog.get(Provider.ID.openai).pipe( Effect.repeat({ until: (item) => item?.settings?.baseURL === "https://api.openai.com/v1", schedule: Schedule.spaced("10 millis"), }), Effect.timeout("2 seconds"), ) expect(direct?.settings?.transport).toBe("http") expect(direct?.headers?.["chatgpt-account-id"]).toBeUndefined() }), ) it.effect("omits the default output limit from OpenAI steps and compaction", () => Effect.gen(function* () { yield* addPlugin() yield* addLegacyPlugin() const hooks = yield* PluginHooks.Service const maxTokens = (providerID: Provider.ID) => Effect.gen(function* () { const draft = { sessionID: Session.ID.make("ses_test"), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), system: [], messages: [], options: { maxTokens: 128_000 }, } const events = [ yield* hooks.trigger("session", "context", { ...draft, agent: Agent.ID.make("build"), tools: {} }), yield* hooks.trigger("session", "compaction", { ...draft, agent: Agent.ID.make("build"), tools: {} }), ] return events.map((event) => event.options.maxTokens) }) expect(yield* maxTokens(Provider.ID.openai)).toEqual([undefined, undefined]) expect(yield* maxTokens(Provider.ID.azure)).toEqual([128_000, 128_000]) }), ) for (const connection of ["chatgpt", "key"] as const) { it.effect(`${connection} compaction uses plugin defaults but allows later model and variant overrides`, () => Effect.gen(function* () { const catalog = yield* Provider.Service const models = yield* Model.Service const credentials = yield* Credential.Service const providerID = Provider.ID.openai const baseID = Model.ID.make("gpt-5.5") const modelID = Model.ID.make("gpt-5.6-sol") const variantID = Model.ID.make("gpt-6-astra") // const kv = yield* KV.Service yield* catalog.transform((editor) => { editor.update(providerID, (provider) => { provider.package = "@opencode/ai/providers/openai/responses" }) for (const id of [baseID, modelID, variantID]) editor.models.update(providerID, id, (model) => { model.modelID = baseID }) editor.models.update(providerID, modelID, (model) => { model.settings = { compaction: { type: "native" } } }) }) yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: connection === "chatgpt" ? Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60_000, metadata: { clientID: "oaiapp_issued" }, }) : Credential.Key.make({ type: "key", key: "sk-test" }), }) // if (connection === "chatgpt") // yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ // { // slug: "gpt-5.5", // display_name: "GPT-5.5", // visibility: "list", // supported_in_api: true, // context_window: 272_000, // input_modalities: ["text"], // }, // ]) yield* addPlugin() yield* addLegacyPlugin() const resolver = yield* ModelResolver.Service const resolve = (id: Model.ID, variant?: Model.VariantID) => models.get(providerID, id).pipe( Effect.flatMap((model) => resolver.resolveModel(required(model), variant)), Effect.map((result) => result.compaction), ) expect(yield* resolve(baseID)).toEqual(connection === "chatgpt" ? { type: "summary" } : undefined) expect(yield* resolve(modelID)).toEqual({ type: connection === "chatgpt" ? "summary" : "native" }) // A later provider default does not override the ChatGPT model policy. yield* catalog.transform((editor) => { editor.update(providerID, (provider) => { provider.settings = { compaction: { type: "native" } } }) }) expect(yield* resolve(baseID)).toEqual({ type: connection === "chatgpt" ? "summary" : "native" }) yield* catalog.transform((editor) => { editor.update(providerID, (provider) => { provider.settings = { compaction: { type: "summary" } } }) }) yield* models.transform((editor) => { editor.update(providerID, modelID, (model) => { model.settings = { compaction: { type: "native" } } }) editor.update(providerID, variantID, (model) => { model.settings = { compaction: { type: "summary" } } model.variants = [{ id: Model.VariantID.make("high"), settings: { compaction: { type: "native" } } }] }) }) expect(yield* resolve(baseID)).toEqual({ type: "summary" }) expect(yield* resolve(modelID)).toEqual({ type: "native" }) expect(yield* resolve(variantID, Model.VariantID.make("high"))).toEqual({ type: "native" }) }).pipe(Effect.provide(ModelResolver.layer)), ) } for (const connection of ["chatgpt", "key"] as const) { it.effect(`${connection} sends mid-session reasoning effort switches in a form its backend accepts`, () => Effect.gen(function* () { const catalog = yield* Provider.Service const models = yield* Model.Service const credentials = yield* Credential.Service const modelID = Model.ID.make("gpt-6.1-sol") yield* catalog.transform((editor) => { editor.update(Provider.ID.openai, (provider) => { provider.package = "@opencode/ai/providers/openai" }) editor.models.update(Provider.ID.openai, modelID, (model) => { model.variants = ["high", "max"].map((effort) => ({ id: Model.VariantID.make(effort), settings: { reasoningEffort: effort }, })) }) }) yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: connection === "chatgpt" ? Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("chatgpt-token-sharing"), access: "chatgpt-token", refresh: "refresh", expires: Date.now() + 60_000, metadata: { clientID: "oaiapp_issued" }, }) : Credential.Key.make({ type: "key", key: "sk-test" }), }) yield* addPlugin() yield* addLegacyPlugin() const resolver = yield* ModelResolver.Service const model = required(yield* models.get(Provider.ID.openai, modelID)) const body = (variant: string, messages: ReadonlyArray) => resolver.resolveModel(model, Model.VariantID.make(variant)).pipe( Effect.flatMap((resolved) => compileRequest(ai.request({ model: resolved.model, messages }))), Effect.map((compiled) => compiled.body), ) const switched = [ Message.user("a"), Message.assistant("b"), Message.effort({ effort: "max", previous: "high" }), Message.user("c"), ] const switchedBack = [ ...switched, Message.assistant("d"), Message.effort({ effort: "high", previous: "max" }), Message.user("e"), ] const user = (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }) const assistant = (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }) const update = (effort: string) => ({ type: "configuration_update", reasoning: { effort } }) expect([yield* body("max", switched), yield* body("high", switchedBack)]).toMatchObject( { chatgpt: [ { reasoning: { effort: "max" }, input: [user("a"), assistant("b"), user("c")] }, { reasoning: { effort: "high" }, input: [user("a"), assistant("b"), user("c"), assistant("d"), user("e")], }, ], key: [ { reasoning: { effort: "high" }, input: [user("a"), assistant("b"), update("max"), user("c")] }, { reasoning: { effort: "high" }, input: [user("a"), assistant("b"), update("max"), user("c"), assistant("d"), update("high"), user("e")], }, ], }[connection], ) }).pipe(Effect.provide(ModelResolver.layer)), ) } it.effect("selects WebSocket only from explicit policy", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.Key.make({ type: "key", key: "sk-test" }), }) yield* addPlugin() yield* addGithubCopilotPlugin() const executor = { execute: () => Effect.die("unused WebSocket execution") } const transport = SessionModelTransport.Service.of({ bind: () => executor, close: () => Effect.void, closeAll: Effect.void, }) const sessionID = Session.ID.make("ses_websocket_hooks") const agentID = Agent.ID.make("build") const route = OpenAIResponses.route.with({ id: "deployment-responses", provider: Provider.ID.azure, }) const prepare = (preference?: Provider.Transport) => Effect.gen(function* () { const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { capabilities: { tools: true, input: ["text"], output: ["text"] }, cost: [], limit: { context: 200_000, output: 32_000 }, transport: preference, }) const requests = yield* SessionModelRequest.Service return yield* requests.primary({ session: Session.Info.make({ id: sessionID, projectID: Project.ID.global, cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), }), agent: agentID, model, tools: { definitions: [], execute: () => Effect.die("unused tool execution") }, system: [], messages: [], webSocket: "session", }) }).pipe( Effect.provide(SessionModelRequest.layer), Effect.provideService(SessionModelTransport.Service, transport), ) const prepared = yield* prepare("websocket") const defaulted = yield* prepare() const disabled = yield* prepare("http") expect(prepared.options.webSocket).toBe(executor) expect(prepared.options.http).toBeUndefined() expect(defaulted.options.webSocket).toBeUndefined() expect(disabled.options.webSocket).toBeUndefined() }), ) })