Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 31 additions & 27 deletions packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const CHATGPT_OAUTH_MODELS = new Set(["gpt-5.2", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5"])

interface PkceCodes {
verifier: string
Expand Down Expand Up @@ -358,38 +359,41 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp

export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "openai",
async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") return provider.models
return Object.fromEntries(
Object.entries(provider.models)
.filter(([, model]) => model.api.id.includes("codex") || CHATGPT_OAUTH_MODELS.has(model.api.id))
.map(([modelID, model]) => [
modelID,
{
...model,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit:
model.api.id === "gpt-5.5"
? {
context: 400_000,
input: 272_000,
output: 128_000,
}
: model.limit,
},
]),
)
},
},
auth: {
provider: "openai",
async loader(getAuth, provider) {
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}

// Filter models to only allowed Codex models for OAuth
const allowedModels = new Set([
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
])
for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
if (allowedModels.has(model.api.id)) continue
delete provider.models[modelId]
}

// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
}
}

return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
Expand Down
36 changes: 30 additions & 6 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,18 @@ function limitImages(msgs: ModelMessage[]): ModelMessage[] {
if (part.type !== "image") return part
if (toDrop > 0) {
toDrop--
return { type: "text" as const, text: `[Image omitted: exceeds the configured limit of ${maxImages} prompt image(s).]` }
return {
type: "text" as const,
text: `[Image omitted: exceeds the configured limit of ${maxImages} prompt image(s).]`,
}
}
if (maxSize !== undefined) {
const size = imageByteSize(String(part.image))
if (size !== undefined && size > maxSize) {
return { type: "text" as const, text: `[Image omitted: exceeds the configured ${maxSize}-byte prompt image size limit.]` }
return {
type: "text" as const,
text: `[Image omitted: exceeds the configured ${maxSize}-byte prompt image size limit.]`,
}
}
}
return part
Expand Down Expand Up @@ -991,6 +997,7 @@ export function options(input: {
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["reasoningSummary"] = "auto"
result["include"] = ["reasoning.encrypted_content"]
}
}

Expand All @@ -999,6 +1006,7 @@ export function options(input: {
if (
input.model.api.id.includes("gpt-5.") &&
!input.model.api.id.includes("codex") &&
!input.model.api.id.includes("gpt-5.5") &&
!input.model.api.id.includes("-chat") &&
input.model.providerID !== "azure"
) {
Expand Down Expand Up @@ -1159,9 +1167,7 @@ function flattenDiscriminatedUnion(schema: JSONSchema.BaseSchema | JSONSchema7):
const propertyOwners: Record<string, unknown[]> = {}
for (const v of variants) {
if (!v.properties) continue
const variantValue = discriminator
? (v.properties as Record<string, any>)[discriminator]?.const
: undefined
const variantValue = discriminator ? (v.properties as Record<string, any>)[discriminator]?.const : undefined
for (const [key, prop] of Object.entries(v.properties as Record<string, any>)) {
if (key === discriminator) continue
if (!(key in properties)) properties[key] = prop
Expand Down Expand Up @@ -1200,7 +1206,9 @@ function flattenDiscriminatedUnion(schema: JSONSchema.BaseSchema | JSONSchema7):
properties[discriminator] = {
type: "string",
enum: enumValues,
description: baseDescription ? `${baseDescription}\n\nPer-${discriminator}: ${hints}.` : `Per-${discriminator}: ${hints}.`,
description: baseDescription
? `${baseDescription}\n\nPer-${discriminator}: ${hints}.`
: `Per-${discriminator}: ${hints}.`,
}
}

Expand All @@ -1212,6 +1220,18 @@ function flattenDiscriminatedUnion(schema: JSONSchema.BaseSchema | JSONSchema7):
} as JSONSchema7
}

function flattenNestedDiscriminatedUnions(schema: JSONSchema7): JSONSchema7 {
const visit = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(visit)
if (node === null || typeof node !== "object") return node

const flattened = flattenDiscriminatedUnion(node as JSONSchema7) as Record<string, unknown>
return Object.fromEntries(Object.entries(flattened).map(([key, value]) => [key, visit(value)]))
}

return visit(schema) as JSONSchema7
}

export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
/*
if (["openai", "azure"].includes(providerID)) {
Expand Down Expand Up @@ -1239,6 +1259,10 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
// and zod's runtime parse still enforces per-variant required fields strictly.
schema = flattenDiscriminatedUnion(schema)

if (model.providerID === "openai" && model.api.id.includes("gpt-5.5")) {
schema = flattenNestedDiscriminatedUnions(schema)
}

// Convert integer enums to string enums for Google/Gemini
if (model.providerID === "google" || model.api.id.includes("gemini")) {
const isPlainObject = (node: unknown): node is Record<string, any> =>
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/test/plugin/codex.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { Auth } from "@mimo-ai/sdk"
import type { Provider } from "@mimo-ai/sdk/v2"
import {
CodexAuthPlugin,
parseJwtClaims,
extractAccountIdFromClaims,
extractAccountId,
Expand All @@ -13,6 +16,54 @@ function createTestJwt(payload: object): string {
}

describe("plugin.codex", () => {
test("keeps gpt-5.5 available for ChatGPT Plus/Pro OAuth", async () => {
const hooks = await CodexAuthPlugin({} as never)
const provider = {
models: {
"gpt-5.4": {
id: "gpt-5.4",
api: { id: "gpt-5.4" },
cost: { input: 1, output: 2, cache: { read: 3, write: 4 } },
limit: { context: 1, input: 1, output: 1 },
},
"gpt-5.5": {
id: "gpt-5.5",
api: { id: "gpt-5.5" },
cost: { input: 1, output: 2, cache: { read: 3, write: 4 } },
limit: { context: 1, input: 1, output: 1 },
},
"gpt-5.1": {
id: "gpt-5.1",
api: { id: "gpt-5.1" },
cost: { input: 1, output: 2, cache: { read: 3, write: 4 } },
limit: { context: 1, input: 1, output: 1 },
},
},
} as unknown as Provider

const models = await hooks.provider!.models!(provider, { auth: { type: "oauth" } as Auth })

expect(models["gpt-5.4"]).toBeDefined()
expect(models["gpt-5.5"]).toBeDefined()
expect(models["gpt-5.1"]).toBeUndefined()
expect(models["gpt-5.5"].cost).toEqual({ input: 0, output: 0, cache: { read: 0, write: 0 } })
expect(models["gpt-5.5"].limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
})

test("does not filter models for OpenAI API key auth", async () => {
const hooks = await CodexAuthPlugin({} as never)
const provider = {
models: {
"gpt-5.1": { id: "gpt-5.1", api: { id: "gpt-5.1" } },
"gpt-5.5": { id: "gpt-5.5", api: { id: "gpt-5.5" } },
},
} as unknown as Provider

const models = await hooks.provider!.models!(provider, { auth: { type: "api", key: "sk-test" } as Auth })

expect(models).toBe(provider.models)
})

describe("parseJwtClaims", () => {
test("parses valid JWT with claims", () => {
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
expect(result.textVerbosity).toBe("low")
})

test("gpt-5.5 should include encrypted reasoning content without textVerbosity", () => {
const model = createGpt5Model("gpt-5.5")
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
expect(result.reasoningEffort).toBe("medium")
expect(result.reasoningSummary).toBe("auto")
expect(result.include).toEqual(["reasoning.encrypted_content"])
expect(result.textVerbosity).toBeUndefined()
})

test("gpt-5.2-chat-latest should NOT have textVerbosity set (only supports medium)", () => {
const model = createGpt5Model("gpt-5.2-chat-latest")
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
Expand Down Expand Up @@ -3376,4 +3385,43 @@ describe("ProviderTransform.schema - openai discriminated-union flatten", () =>
expect(result.properties.a).toBeDefined()
expect(result.anyOf).toBeUndefined()
})

test("gpt-5.5 — flattens nested discriminated unions in tool parameters", () => {
const nested = {
type: "object",
properties: {
operation: {
type: "object",
anyOf: anyOfSchema.anyOf,
},
},
required: ["operation"],
additionalProperties: false,
} as any

const result = ProviderTransform.schema({ providerID: "openai", api: { id: "gpt-5.5" } } as any, nested) as any

expect(result.properties.operation.anyOf).toBeUndefined()
expect(result.properties.operation.type).toBe("object")
expect(result.properties.operation.properties.action.enum).toEqual(["create", "list", "rename"])
expect(result.properties.operation.required).toEqual(["action"])
})

test("non-gpt-5.5 — leaves nested discriminated unions untouched", () => {
const nested = {
type: "object",
properties: {
operation: {
type: "object",
anyOf: anyOfSchema.anyOf,
},
},
required: ["operation"],
additionalProperties: false,
} as any

const result = ProviderTransform.schema({ providerID: "openai", api: { id: "gpt-5.4" } } as any, nested) as any

expect(result.properties.operation.anyOf).toHaveLength(3)
})
})