Skip to content
Merged
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
13 changes: 13 additions & 0 deletions packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export function shouldKeepCodexOAuthModel(modelId: string, apiId: string): boole
return major > 5 || (major === 5 && minor > 4)
}

export function hasCodexOAuthGpt55Limit(apiId: string): boolean {
return /^gpt-5\.5(?:$|-)/.test(apiId)
}

function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
const params = new URLSearchParams({
response_type: "code",
Expand Down Expand Up @@ -449,6 +453,15 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
output: 0,
cache: { read: 0, write: 0 },
}

if (hasCodexOAuthGpt55Limit(model.api.id)) {
const limit: typeof model.limit & { input: number } = {
context: 400_000,
input: 272_000,
output: 128_000,
}
model.limit = limit
}
}

return {
Expand Down
27 changes: 22 additions & 5 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ function json(input: unknown) {
return undefined
}

function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}

export type ParsedStreamError =
| {
type: "context_overflow"
Expand All @@ -111,18 +115,24 @@ export type ParsedStreamError =
| {
type: "api_error"
message: string
isRetryable: false
isRetryable: boolean
responseBody: string
}

export function parseStreamError(input: unknown): ParsedStreamError | undefined {
const body = json(input)
if (!body) return
const raw = json(input)
if (!isRecord(raw)) return

const inner = typeof raw.message === "string" ? json(raw.message) : undefined
// OpenAI stream errors can arrive wrapped in an Error-like object. Use the
// inner provider payload so responseBody matches the payload users need.
const body = isRecord(inner) && inner.type === "error" ? inner : raw

const responseBody = JSON.stringify(body)
if (body.type !== "error") return

switch (body?.error?.code) {
const error = isRecord(body.error) ? body.error : undefined
switch (error?.code) {
case "context_length_exceeded":
return {
type: "context_overflow",
Expand All @@ -146,10 +156,17 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
case "invalid_prompt":
return {
type: "api_error",
message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.",
message: typeof error.message === "string" ? error.message : "Invalid prompt.",
isRetryable: false,
responseBody,
}
case "server_error":
return {
type: "api_error",
message: typeof error.message === "string" ? error.message : "Server error.",
isRetryable: true,
responseBody,
}
}
}

Expand Down
77 changes: 77 additions & 0 deletions packages/opencode/test/plugin/codex.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, test } from "bun:test"
import {
CodexAuthPlugin,
parseJwtClaims,
extractAccountIdFromClaims,
extractAccountId,
formatOAuthFailure,
hasCodexOAuthGpt55Limit,
shouldKeepCodexOAuthModel,
type IdTokenClaims,
} from "../../src/plugin/codex"
Expand Down Expand Up @@ -143,6 +145,81 @@ describe("plugin.codex", () => {
})
})

describe("hasCodexOAuthGpt55Limit", () => {
test("matches GPT-5.5 API ids and explicit variants", () => {
expect(hasCodexOAuthGpt55Limit("gpt-5.5")).toBe(true)
expect(hasCodexOAuthGpt55Limit("gpt-5.5-codex")).toBe(true)
expect(hasCodexOAuthGpt55Limit("gpt-5.5-mini")).toBe(true)
})

test("does not match unrelated future models", () => {
expect(hasCodexOAuthGpt55Limit("gpt-5.50")).toBe(false)
expect(hasCodexOAuthGpt55Limit("chatgpt-5.5")).toBe(false)
expect(hasCodexOAuthGpt55Limit("gpt-5.6")).toBe(false)
})
})

describe("CodexAuthPlugin", () => {
test("overrides GPT-5.5 limits for OAuth Codex plans", async () => {
const provider = {
models: {
"gpt-5.5": {
id: "gpt-5.5",
api: { id: "gpt-5.5" },
cost: {
input: 2,
output: 8,
cache: { read: 1, write: 2 },
},
limit: {
context: 1_050_000,
input: 922_000,
output: 128_000,
},
},
},
}

expect(provider.models["gpt-5.5"].limit).toEqual({
context: 1_050_000,
input: 922_000,
output: 128_000,
})

const hooks = await CodexAuthPlugin({
client: {} as never,
project: {} as never,
directory: "",
worktree: "",
experimental_workspace: {
register() {},
},
} as never)

await hooks.auth!.loader!(
async () =>
({
type: "oauth",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
}) as never,
provider as never,
)

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

describe("formatOAuthFailure", () => {
test("includes safe JSON error fields and request metadata", async () => {
const response = new Response(
Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,4 +294,60 @@ describe("session.message-v2.fromError", () => {
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
expect(result.data.isRetryable).toBe(true)
})

test("converts OpenAI server_error stream chunks to retryable APIError", () => {
const result = MessageV2.fromError(
{
message: JSON.stringify({
type: "error",
error: {
code: "server_error",
message: "An error occurred while processing your request.",
},
}),
},
{ providerID: ProviderID.make("openai") },
)

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.")
})
Comment thread
Astro-Han marked this conversation as resolved.

test("uses fallback message for OpenAI server_error stream chunks without message", () => {
const result = MessageV2.fromError(
{
message: JSON.stringify({
type: "error",
error: {
code: "server_error",
},
}),
},
{ providerID: ProviderID.make("openai") },
)

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect((result as MessageV2.APIError).data.message).toBe("Server error.")
expect(SessionRetry.retryable(result)).toBe("Server error.")
})

test("does not convert unknown OpenAI stream error chunks to retryable APIError", () => {
const result = MessageV2.fromError(
{
message: JSON.stringify({
type: "error",
error: {
code: "bad_request",
message: "Bad request",
},
}),
},
{ providerID: ProviderID.make("openai") },
)

expect(MessageV2.APIError.isInstance(result)).toBe(false)
expect(SessionRetry.retryable(result)).toBeUndefined()
})
})
Loading