opencode/packages/core/test/plugin/provider-chatgpt.test.ts
opencode-agent[bot] 4e982c0814
fix(core): add GPT-6.1 Terra to Sign in with ChatGPT models (#59359)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-10-26 15:47:38 -05:00

1202 lines
51 KiB
TypeScript

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 { AIError, HttpContext, LLM, 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)
yield* GithubCopilotPlugin.effect(host)
})
function required<T>(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<void>()
// const release = yield* Deferred.make<void>()
// 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<Message>) =>
resolver.resolveModel(model, Model.VariantID.make(variant)).pipe(
Effect.flatMap((resolved) => compileRequest(LLM.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()
}),
)
})