diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 08f825d3..cbada38a 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -585,6 +585,7 @@ describe('ProviderService', () => { 'gpt-5.4': 950_000, 'gpt-5.5': 258_400, 'gpt-5.4-mini': 258_400, + 'gpt-6-astra': 997_500, }) expect(env.ANTHROPIC_BASE_URL).toBeUndefined() expect(env.ANTHROPIC_API_KEY).toBeUndefined() diff --git a/src/server/__tests__/proxy-network-settings.test.ts b/src/server/__tests__/proxy-network-settings.test.ts index 9bd928aa..f22efee5 100644 --- a/src/server/__tests__/proxy-network-settings.test.ts +++ b/src/server/__tests__/proxy-network-settings.test.ts @@ -47,6 +47,40 @@ describe('proxy network settings', () => { beforeEach(setup) afterEach(teardown) + for (const apiFormat of ['openai_chat', 'openai_responses'] as const) { + for (const upstreamStatus of [503, 200, 'thrown'] as const) { + test(`${apiFormat} preserves policy refusal despite upstream ${upstreamStatus}`, async () => { + const provider = await new ProviderService().addProvider({ + presetId: 'custom', name: 'Policy fixture', baseUrl: 'https://api.example.com', + apiKey: 'sk-test', apiFormat, + models: { main: 'test-model', haiku: 'test-model', sonnet: 'test-model', opus: 'test-model' }, + }) + const originalFetch = globalThis.fetch + const upstreamFetch = mock(async () => { + if (upstreamStatus === 'thrown') { + throw Object.assign(new Error('Request denied'), { code: 'cyber_policy' }) + } + return Response.json({ + error: { code: 'cyber_policy', message: 'Request denied' }, + }, { status: upstreamStatus }) + }) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + try { + const req = new Request(`http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'test-model', max_tokens: 64, messages: [{ role: 'user', content: 'hello' }] }), + }) + const res = await handleProxyRequest(req, new URL(req.url)) + expect(res.status).toBe(403) + expect(await res.json()).toMatchObject({ error: { type: 'permission_error', code: 'cyber_policy' } }) + expect(upstreamFetch).toHaveBeenCalledTimes(1) + } finally { + globalThis.fetch = originalFetch + } + }) + } + } + test('uses configured AI request timeout for non-stream upstream requests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), diff --git a/src/server/__tests__/proxy-streaming.test.ts b/src/server/__tests__/proxy-streaming.test.ts index 05a919c8..74a72b23 100644 --- a/src/server/__tests__/proxy-streaming.test.ts +++ b/src/server/__tests__/proxy-streaming.test.ts @@ -698,3 +698,35 @@ describe('openaiResponsesStreamToAnthropicResponse', () => { expect(error.code).toBe('ERR_STREAM_PREMATURE_CLOSE') }) }) + + +describe('OpenAI policy failures', () => { + for (const code of ['cyber_policy', 'content_policy', 'content_policy_violation']) { + for (const oauth of [true, false]) { + test(`preserves ${code} as a terminal permission error (OAuth=${oauth})`, async () => { + const chunk = `event: response.failed\ndata: ${JSON.stringify({ response: { error: { code, message: 'Request denied' } } })}\n\n` + const events = await collectSse(openaiResponsesStreamToAnthropic( + makeStream([chunk]), 'test-model', { openAICodexOAuth: oauth }, + )) + expect(events.find((event) => event.event === 'error')?.data).toEqual({ + type: 'error', error: { type: 'permission_error', code, message: 'Request denied' }, + }) + expect(events.some((event) => event.event === 'message_stop')).toBe(false) + await expect(openaiResponsesStreamToAnthropicResponse( + makeStream([chunk]), 'test-model', { openAICodexOAuth: oauth }, + )).rejects.toMatchObject({ code, type: 'permission_error', status: 403 }) + }) + } + } +}) + + +test('Chat SSE policy errors stop without a successful completion', async () => { + const events = await collectSse(openaiChatStreamToAnthropic(makeStream([ + 'data: {"error":{"code":"cyber_policy","message":"Request denied"}}\n\n', + 'data: [DONE]\n\n', + ]), 'test-model')) + expect(events).toEqual([{ event: 'error', data: { + type: 'error', error: { type: 'permission_error', code: 'cyber_policy', message: 'Request denied' }, + } }]) +}) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 55236f09..089b1a13 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -1155,6 +1155,7 @@ describe('Models API', () => { 'gpt-5.4', 'gpt-5.5', 'gpt-5.4-mini', + 'gpt-6-astra', ]) expect(body.models[0]).toMatchObject({ id: 'gpt-5.6-sol', diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 0c6457e1..97562d01 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -9,6 +9,7 @@ * Original work by Jason Young, MIT License */ +import { getOpenAIPolicyError } from '../../services/openaiAuth/policyError.js' import { createGunzip, createInflate } from 'node:zlib' import { ProviderService } from '../services/providerService.js' @@ -267,15 +268,16 @@ export async function handleProxyRequest(req: Request, url: URL): Promise {}) } console.error('[Proxy] Upstream request failed:', err) + const policyError = getOpenAIPolicyError(err) return Response.json( { type: 'error', - error: { + error: policyError ? { type: 'permission_error', ...policyError } : { type: 'api_error', message: err instanceof Error ? err.message : String(err), }, }, - { status: 502 }, + { status: policyError ? 403 : 502 }, ) } } @@ -707,9 +709,15 @@ async function handleOpenaiChat( if (!upstream.ok) { const errText = await upstream.text().catch(() => '') + let policyError = null + try { + policyError = getOpenAIPolicyError(JSON.parse(errText)) + } catch { + // Unstructured upstream failures keep their existing error classification. + } const errorBody = { type: 'error', - error: { + error: policyError ? { type: 'permission_error', ...policyError } : { type: 'api_error', message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, }, @@ -732,7 +740,7 @@ async function handleOpenaiChat( } return Response.json( errorBody, - { status: upstream.status }, + { status: policyError ? 403 : upstream.status }, ) } @@ -788,7 +796,10 @@ async function handleOpenaiChat( // Non-streaming const responseBody = await upstream.json() - const anthropicResponse = openaiChatToAnthropic(responseBody, body.model) + const policyError = getOpenAIPolicyError(responseBody) + const anthropicResponse = policyError + ? { type: 'error', error: { type: 'permission_error', ...policyError } } + : openaiChatToAnthropic(responseBody, body.model) if (traceContext) { recordProxyTraceInBackground({ callId: traceCallId, @@ -805,7 +816,7 @@ async function handleOpenaiChat( responseHeaders: upstream.headers, }) } - return Response.json(anthropicResponse) + return Response.json(anthropicResponse, { status: policyError ? 403 : 200 }) } function shouldUseDeepSeekReasoningCompat(baseUrl: string): boolean { @@ -892,9 +903,15 @@ async function handleOpenaiResponses( if (!upstream.ok) { const errText = await upstream.text().catch(() => '') + let policyError = null + try { + policyError = getOpenAIPolicyError(JSON.parse(errText)) + } catch { + // Unstructured upstream failures keep their existing error classification. + } const errorBody = { type: 'error', - error: { + error: policyError ? { type: 'permission_error', ...policyError } : { type: 'api_error', message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, }, @@ -917,7 +934,7 @@ async function handleOpenaiResponses( } return Response.json( errorBody, - { status: upstream.status }, + { status: policyError ? 403 : upstream.status }, ) } @@ -973,7 +990,10 @@ async function handleOpenaiResponses( // Non-streaming const responseBody = await upstream.json() - const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model) + const policyError = getOpenAIPolicyError(responseBody) + const anthropicResponse = policyError + ? { type: 'error', error: { type: 'permission_error', ...policyError } } + : openaiResponsesToAnthropic(responseBody, body.model) if (traceContext) { recordProxyTraceInBackground({ callId: traceCallId, @@ -990,7 +1010,7 @@ async function handleOpenaiResponses( responseHeaders: upstream.headers, }) } - return Response.json(anthropicResponse) + return Response.json(anthropicResponse, { status: policyError ? 403 : 200 }) } function buildProxyTraceContext( diff --git a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts index c022fa1e..476bb3e8 100644 --- a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts @@ -20,6 +20,7 @@ * - delta.reasoning (GLM-5, Cerebras, Groq — mapped to reasoning_content) */ +import { getOpenAIPolicyError } from '../../../services/openaiAuth/policyError.js' import type { OpenAIChatStreamChunk } from '../transform/types.js' import { stringifyOpenAIToolArguments } from '../transform/toolArguments.js' import { openaiUsageToAnthropic } from '../transform/usage.js' @@ -104,6 +105,7 @@ export function openaiChatStreamToAnthropic( async start(controller) { const reader = upstream.getReader() let errored = false + let policyRejected = false try { while (true) { @@ -134,6 +136,15 @@ export function openaiChatStreamToAnthropic( continue } + const policyError = getOpenAIPolicyError(chunk) + if (policyError) { + policyRejected = true + enqueue(state, 'error', { type: 'error', error: { type: 'permission_error', ...policyError } }) + flushQueue(state, controller, encoder) + await reader.cancel().catch(() => {}) + return + } + processChunk(chunk, state) flushQueue(state, controller, encoder) } @@ -143,7 +154,7 @@ export function openaiChatStreamToAnthropic( controller.error(err) } finally { if (!errored) { - finalizeStream(state) + if (!policyRejected) finalizeStream(state) flushQueue(state, controller, encoder) controller.close() } diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts index 1e06951e..4c23d520 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts @@ -4,6 +4,7 @@ * Original work by Jason Young, MIT License */ +import { getOpenAIPolicyError } from '../../../services/openaiAuth/policyError.js' import { encodeOpenAIReasoningEnvelope } from '../transform/openaiReasoning.js' import { stringifyOpenAIToolArguments } from '../transform/toolArguments.js' import { openaiUsageToAnthropic } from '../transform/usage.js' @@ -412,7 +413,7 @@ function processEvent( } case 'response.incomplete': - if (!options.openAICodexOAuth) break + if (!options.openAICodexOAuth && !getOpenAIPolicyError(data)) break state.terminalSeen = true if (readIncompleteReason(asRecord(data.response)) === 'max_output_tokens') { const response = asRecord(data.response) @@ -434,7 +435,7 @@ function processEvent( case 'response.failed': case 'response.cancelled': case 'error': { - if (!options.openAICodexOAuth) break + if (!options.openAICodexOAuth && !getOpenAIPolicyError(data)) break state.terminalSeen = true const streamError = readStreamError(event, data) controller.enqueue(encoder.encode(formatSse('error', { @@ -539,7 +540,9 @@ function closeAllReasoningBlocks( function readStreamError( event: string, data: Record, -): { type: 'api_error' | 'overloaded_error'; message: string } { +): { type: 'api_error' | 'overloaded_error' | 'permission_error'; message: string; code?: string } { + const policyError = getOpenAIPolicyError(data) + if (policyError) return { type: 'permission_error', ...policyError } const response = asRecord(data.response) const error = asRecord(response?.error) ?? asRecord(data.error) ?? data const code = typeof error?.code === 'string' ? error.code : '' diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts index 6e3f11f3..66f608fa 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts @@ -1,3 +1,4 @@ +import { getOpenAIPolicyError } from '../../../services/openaiAuth/policyError.js' import { openaiResponsesToAnthropic } from '../transform/openaiResponsesToAnthropic.js' import type { AnthropicResponse, @@ -92,7 +93,7 @@ export async function openaiResponsesStreamToAnthropicResponse( completedResponse = response } } else if ( - options.openAICodexOAuth && + (options.openAICodexOAuth || getOpenAIPolicyError(data)) && ( currentEvent === 'response.failed' || currentEvent === 'response.incomplete' || @@ -100,7 +101,10 @@ export async function openaiResponsesStreamToAnthropicResponse( currentEvent === 'error' ) ) { - terminalError = new Error(readTerminalError(currentEvent, data)) + const policyError = getOpenAIPolicyError(data) + terminalError = policyError + ? Object.assign(new Error(policyError.message), { code: policyError.code, type: 'permission_error', status: 403 }) + : new Error(readTerminalError(currentEvent, data)) } else { updateFallbackState(currentEvent, data, fallback) } diff --git a/src/server/services/openaiOfficialProvider.test.ts b/src/server/services/openaiOfficialProvider.test.ts new file mode 100644 index 00000000..4775d69d --- /dev/null +++ b/src/server/services/openaiOfficialProvider.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js' +import { buildOpenAIOfficialRuntimeEnv } from './openaiOfficialProvider.js' + +describe('ChatGPT Official runtime environment', () => { + test('includes the Astra effective context window without changing the default model', () => { + const env = buildOpenAIOfficialRuntimeEnv() + const windows = JSON.parse(env[MODEL_CONTEXT_WINDOWS_ENV_KEY]!) as Record + + expect(windows['gpt-6-astra']).toBe(997_500) + expect(windows['gpt-5.6-sol']).toBe(353_400) + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol') + }) +}) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 1f3bacef..61943720 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -223,6 +223,7 @@ import { startSessionActivity, stopSessionActivity, } from "../../utils/sessionActivity.js"; +import { isOpenAIPolicyError } from "../openaiAuth/policyError.js" import { shouldTriggerNonStreamingFallbackForEmptyStream } from "./streamFallback.js"; import { StreamAssistantCommitBuffer } from "./streamAssistantCommitBuffer.js"; import { @@ -2774,6 +2775,9 @@ async function* queryModel( streamMaxDurationTimer = null; } + // A safety rejection is terminal, including for non-streaming fallback. + if (isOpenAIPolicyError(streamingError)) throw streamingError + // Instrumentation: if the watchdog had already fired and the for-await // threw (rather than exiting cleanly), record that the loop DID exit and // how long after the watchdog. Distinguishes true hangs from error exits. @@ -3068,6 +3072,7 @@ async function* queryModel( // with raw streams, 404s are thrown during creation (caught here). const is404StreamCreationError = !didFallBackToNonStreaming && + !isOpenAIPolicyError(errorFromRetry) && errorFromRetry instanceof CannotRetryError && errorFromRetry.originalError instanceof APIError && errorFromRetry.originalError.status === 404; diff --git a/src/services/api/claudeRequiredThinking.test.ts b/src/services/api/claudeRequiredThinking.test.ts index 6208ec40..da610cc3 100644 --- a/src/services/api/claudeRequiredThinking.test.ts +++ b/src/services/api/claudeRequiredThinking.test.ts @@ -467,6 +467,25 @@ async function captureQueryRequest({ } } +test('does not replay a policy-blocked stream through non-streaming fallback', async () => { + let calls = 0 + const result = await captureQueryRequest({ + model: 'gpt-6-astra', + continuationSystemPrompts: [], + responseFactory: model => { + calls += 1 + return new Response(calls === 1 ? sseEvent('error', { + type: 'error', + error: { type: 'permission_error', code: 'cyber_policy', message: 'Request blocked by safety policy' }, + }) : successfulResponse(model), { + headers: { 'content-type': 'text/event-stream' }, + }) + }, + }) + expect(result.requests).toHaveLength(1) + expect(JSON.stringify(result.content)).toContain('Request blocked by safety policy') +}) + test('keeps required-thinking models enabled when the caller requests disabled thinking', async () => { const { content, requests } = await captureQueryRequest({ model: 'k3', diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts index 73b6d40d..9fe7104b 100644 --- a/src/services/api/withRetry.test.ts +++ b/src/services/api/withRetry.test.ts @@ -399,3 +399,33 @@ describe('RetriableStreamError', () => { expect(wrapped.message).toContain('boom') }) }) + +describe('policy rejection retry boundaries', () => { + test.each([401, 429, 503, 529])('stops HTTP %s policy errors before retry, refresh or model fallback', async status => { + const body = { error: { type: 'overloaded_error', code: 'cyber_policy', message: 'Rejected' } } + const rejection = new APIError(status, body, undefined, new Headers({ 'x-should-retry': 'true', 'retry-after': '0' })) + let clientCalls = 0 + let attempts = 0 + const generator = withRetry( + async () => { clientCalls++; return {} as Anthropic }, + async () => { attempts++; throw rejection }, + { model: 'gpt-6', fallbackModel: 'fallback', initialConsecutive529Errors: 2, thinkingConfig: { type: 'disabled' }, maxRetries: 3 }, + ) + // The first next must fail; even a retry status yield would mean replay was scheduled. + let caught: unknown + try { await generator.next() } catch (error) { caught = error } + expect(caught).toBeInstanceOf(CannotRetryError) + expect((caught as CannotRetryError).originalError).toBe(rejection) + expect(attempts).toBe(1) + expect(clientCalls).toBe(1) + }) + + test('does not retry policy errors disguised as transient SSE or transport errors', () => { + for (const type of ['api_error', 'overloaded_error']) { + const body = { error: { type, code: 'cyber_policy' } } + expect(isRetryableStreamError(new APIError(undefined, body, undefined, undefined))).toBe(false) + } + const error = Object.assign(new Error('Disconnected'), { code: 'ECONNRESET', cause: { error: { code: 'cyber_policy' } } }) + expect(isRetryableStreamTransportError(error)).toBe(false) + }) +}) diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 8c5b5ddc..d6eaff5a 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -51,6 +51,7 @@ import { REPEATED_529_ERROR_MESSAGE, } from './errors.js' import { extractConnectionErrorDetails } from './errorUtils.js' +import { isOpenAIPolicyError } from '../openaiAuth/policyError.js' const abortError = () => new APIUserAbortError() @@ -212,6 +213,7 @@ export class RetriableStreamError extends Error { * technique the overloaded-error check has always used (see shouldRetry). */ export function isRetryableStreamError(error: unknown): boolean { + if (isOpenAIPolicyError(error)) return false if (!(error instanceof APIError)) { return false } @@ -254,6 +256,7 @@ const STREAM_TRANSPORT_DISCONNECT_CODES = new Set([ * shouldRetryStreamAfterTransportDisconnect. */ export function isRetryableStreamTransportError(error: unknown): boolean { + if (isOpenAIPolicyError(error)) return false const code = extractConnectionErrorDetails(error)?.code return code !== undefined && STREAM_TRANSPORT_DISCONNECT_CODES.has(code) } @@ -377,6 +380,11 @@ export async function* withRetry( return await operation(client, attempt, retryContext) } catch (error) { + // Policy rejection is final even when a gateway labels it 401/429/5xx. + // Stop before auth refresh, fast-mode changes or persistent/model fallback. + if (isOpenAIPolicyError(error)) { + throw new CannotRetryError(error, retryContext) + } lastError = error logForDebugging( `API error (attempt ${attempt}/${maxRetries + 1}): ${error instanceof APIError ? `${error.status} ${error.message}` : errorMessage(error)}`, @@ -816,6 +824,7 @@ function handleGcpCredentialError(error: unknown): boolean { } function shouldRetry(error: APIError): boolean { + if (isOpenAIPolicyError(error)) return false // Never retry mock errors - they're from /mock-limits command for testing if (isMockRateLimitError(error)) { return false diff --git a/src/services/openaiAuth/fetch.test.ts b/src/services/openaiAuth/fetch.test.ts index f20cd95d..e97d69fc 100644 --- a/src/services/openaiAuth/fetch.test.ts +++ b/src/services/openaiAuth/fetch.test.ts @@ -48,6 +48,23 @@ describe('buildOpenAICodexFetch', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + test('preserves a structured policy rejection even when upstream wraps it in HTTP 503', async () => { + const codexFetch = buildOpenAICodexFetch(async () => Response.json({ + error: { code: 'cyber_policy', message: 'Request blocked by safety policy' }, + }, { status: 503, headers: { 'x-request-id': 'policy-request-id' } }), 'test')! + const response = await codexFetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'gpt-6-astra', max_tokens: 64, messages: [{ role: 'user', content: 'Hello' }] }), + }) + expect(response.status).toBe(403) + expect(response.headers.get('x-should-retry')).toBe('false') + expect(response.headers.get('x-request-id')).toBe('policy-request-id') + expect(await response.json()).toEqual({ + type: 'error', + error: { type: 'permission_error', code: 'cyber_policy', message: 'Request blocked by safety policy' }, + }) + }) + test('maps Anthropic messages to ChatGPT Codex responses endpoint with account header', async () => { const upstreamCalls: Array<{ url: string diff --git a/src/services/openaiAuth/fetch.ts b/src/services/openaiAuth/fetch.ts index 5f7614e8..430e0c7a 100644 --- a/src/services/openaiAuth/fetch.ts +++ b/src/services/openaiAuth/fetch.ts @@ -1,5 +1,6 @@ import type { ClientOptions } from '@anthropic-ai/sdk' import { randomUUID } from 'crypto' +import { getOpenAIPolicyError } from './policyError.js' import { OPENAI_CODEX_API_ENDPOINT, OPENAI_CODEX_ORIGINATOR, @@ -119,6 +120,21 @@ export function buildOpenAICodexFetch( const errorText = await upstream.text().catch(() => '').finally(() => { upstreamAbort?.dispose() }) + const policyError = getOpenAIPolicyError({ message: errorText }) + if (policyError) { + return Response.json({ + type: 'error', + error: { type: 'permission_error', ...policyError }, + }, { + status: 403, + headers: { + 'x-should-retry': 'false', + ...(upstream.headers.get('x-request-id') + ? { 'x-request-id': upstream.headers.get('x-request-id')! } + : {}), + }, + }) + } return Response.json( { type: 'error', diff --git a/src/services/openaiAuth/models.test.ts b/src/services/openaiAuth/models.test.ts index c6d38ce6..16c4eada 100644 --- a/src/services/openaiAuth/models.test.ts +++ b/src/services/openaiAuth/models.test.ts @@ -15,6 +15,23 @@ import { } from './models.js' describe('openai auth model resolution', () => { + test('provides Astra fallback metadata and effective runtime context for its public alias', () => { + const astra = OPENAI_CODEX_MODEL_CATALOG.find((model) => model.value === 'gpt-6-astra') + expect(astra).toMatchObject({ + label: 'GPT-6 Astra', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + contextWindow: 997_500, + }) + for (const model of ['gpt-6-astra', 'gpt-6']) { + expect(getOpenAICodexContextWindowForModel(model)).toBe(997_500) + expect(getOpenAIModelDisplayName(model)).toBe('GPT-6 Astra') + expect(resolveOpenAIReasoningEffort(model, 'max')).toBe('max') + expect(resolveOpenAIReasoningEffort(model, 'ultra')).toBe('medium') + } + expect(OPENAI_DEFAULT_MAIN_MODEL).toBe('gpt-5.6-sol') + }) + test('does not treat opus as an OpenAI Responses model', () => { expect(isOpenAIResponsesModel('opus')).toBe(false) }) diff --git a/src/services/openaiAuth/models.ts b/src/services/openaiAuth/models.ts index b2e506df..56349500 100644 --- a/src/services/openaiAuth/models.ts +++ b/src/services/openaiAuth/models.ts @@ -7,6 +7,7 @@ export const OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT = 95 export const OPENAI_CODEX_STANDARD_CONTEXT_WINDOW = 272_000 export const OPENAI_CODEX_FRONTIER_CONTEXT_WINDOW = 372_000 export const OPENAI_CODEX_LARGE_CONTEXT_WINDOW = 1_000_000 +export const OPENAI_CODEX_ASTRA_CONTEXT_WINDOW = 1_050_000 export const OPENAI_CODEX_SPARK_CONTEXT_WINDOW = 128_000 export const OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW = Math.floor( (OPENAI_CODEX_STANDARD_CONTEXT_WINDOW * OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / @@ -24,6 +25,10 @@ export const OPENAI_CODEX_SPARK_EFFECTIVE_CONTEXT_WINDOW = Math.floor( (OPENAI_CODEX_SPARK_CONTEXT_WINDOW * OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / 100, ) +export const OPENAI_CODEX_ASTRA_EFFECTIVE_CONTEXT_WINDOW = Math.floor( + (OPENAI_CODEX_ASTRA_CONTEXT_WINDOW * OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / + 100, +) export type OpenAIModelCatalogEntry = { value: string @@ -127,6 +132,15 @@ export const OPENAI_CODEX_MODEL_CATALOG: OpenAIModelCatalogEntry[] = [ supportedReasoningEfforts: GPT_5_5_REASONING_EFFORTS, contextWindow: OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, }, + { + value: 'gpt-6-astra', + label: 'GPT-6 Astra', + description: 'Frontier model for complex reasoning and agentic work', + descriptionForModel: 'GPT-6 Astra - complex reasoning and agentic work', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + contextWindow: OPENAI_CODEX_ASTRA_EFFECTIVE_CONTEXT_WINDOW, + }, ] export function isOpenAIReasoningEffort( @@ -215,6 +229,9 @@ export function resolveOpenAICodexModel(model: string): string { export function getOpenAIModelDisplayName(model: string): string | null { switch (model.trim().toLowerCase()) { + case 'gpt-6': + case 'gpt-6-astra': + return 'GPT-6 Astra' case 'gpt-5.3-codex': return 'GPT-5.3 Codex' case 'gpt-5.6-sol': @@ -254,6 +271,10 @@ export function getOpenAICodexContextWindowForModel( // Codex OAuth follows the Codex app model catalog, not the public API model // context limits. The catalog applies effective_context_window_percent=95, // and the runtime /context display reports this effective window. + if (normalized === 'gpt-6-astra' || normalized === 'gpt-6') { + return OPENAI_CODEX_ASTRA_EFFECTIVE_CONTEXT_WINDOW + } + if ( normalized === 'gpt-5.6-sol' || normalized === 'gpt-5.6-terra' || diff --git a/src/services/openaiAuth/policyError.test.ts b/src/services/openaiAuth/policyError.test.ts new file mode 100644 index 00000000..be23e7d1 --- /dev/null +++ b/src/services/openaiAuth/policyError.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' +import { APIError } from '@anthropic-ai/sdk' +import { getOpenAIPolicyError, isOpenAIPolicyError } from './policyError.js' + +describe('isOpenAIPolicyError', () => { + test.each(['cyber_policy', 'content_policy', 'content_policy_violation'])('recognizes structured %s across response and SDK wrappers', code => { + const body = { response: { error: { code, message: 'Rejected' } } } + expect(isOpenAIPolicyError(body)).toBe(true) + expect(isOpenAIPolicyError(new APIError(503, body, undefined, undefined))).toBe(true) + expect(isOpenAIPolicyError({ originalError: new Error(`503 ${JSON.stringify(body)}`) })).toBe(true) + expect(isOpenAIPolicyError(new Error('failed', { cause: body }))).toBe(true) + }) + + test('preserves upstream code and message for the caller', () => { + expect(getOpenAIPolicyError({ error: { code: 'cyber_policy', message: 'Request blocked' } })).toEqual({ + code: 'cyber_policy', + message: 'Request blocked', + }) + expect(getOpenAIPolicyError({ code: 'content_policy' })).toEqual({ + code: 'content_policy', + message: 'Request rejected by the upstream safety policy.', + }) + }) + + test('does not infer policy from prose, status, or unrelated payload fields', () => { + for (const input of [ + new Error('cyber_policy: overloaded'), + { error: { message: 'content_policy_violation', type: 'api_error' } }, + { status: 403 }, + { data: { code: 'cyber_policy' } }, + { code: 'invalid_prompt' }, + { message: 'prefix {"code":"cyber_policy"}' }, + null, + ]) expect(isOpenAIPolicyError(input)).toBe(false) + const cyclic: { cause?: unknown } = {} + cyclic.cause = cyclic + expect(isOpenAIPolicyError(cyclic)).toBe(false) + }) +}) diff --git a/src/services/openaiAuth/policyError.ts b/src/services/openaiAuth/policyError.ts new file mode 100644 index 00000000..5ac14027 --- /dev/null +++ b/src/services/openaiAuth/policyError.ts @@ -0,0 +1,45 @@ +const POLICY_ERROR_CODES = new Set([ + 'cyber_policy', + 'content_policy', + 'content_policy_violation', +]) + +/** Match explicit upstream policy codes, never infer a rejection from prose. */ +export function getOpenAIPolicyError(input: unknown): { code: string; message: string } | null { + const visited = new Set() + function visit(value: unknown, depth: number): { code: string; message: string } | null { + if (depth > 12 || value === null || typeof value !== 'object' || visited.has(value)) return null + visited.add(value) + const record = value as Record + if (typeof record.code === 'string' && POLICY_ERROR_CODES.has(record.code)) { + return { + code: record.code, + message: typeof record.message === 'string' && record.message.trim() + ? record.message + : 'Request rejected by the upstream safety policy.', + } + } + // SDK errors, Responses events and our retry wrappers use these boundaries. + for (const key of ['error', 'response', 'originalError', 'cause']) { + const match = visit(record[key], depth + 1) + if (match) return match + } + if (typeof record.message === 'string') { + // Anthropic SDK prefixes a serialized error body with its HTTP status. + const serialized = record.message.replace(/^\d{3}\s+/, '').trim() + if (serialized.startsWith('{')) { + try { + return visit(JSON.parse(serialized), depth + 1) + } catch { + // Unstructured messages must not become policy classifications. + } + } + } + return null + } + return visit(input, 0) +} + +export function isOpenAIPolicyError(input: unknown): boolean { + return getOpenAIPolicyError(input) !== null +}