mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(openai): stop policy rejection retries and support Astra context
Adapt structured policy-error handling and Astra model metadata from sub2api's recent compatibility updates. Preserve rejection codes through HTTP and SSE, stop retry and non-streaming replay, and add Astra's effective context to the official runtime. Reference: https://github.com/Wei-Shaw/sub2api/pull/6636 and https://github.com/Wei-Shaw/sub2api/pull/6572. Implementation uses the existing TypeScript runtime and local regression harnesses.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' },
|
||||
} }])
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
+30
-10
@@ -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<Respon
|
||||
}).catch(() => {})
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): { 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 : ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, number>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<T>(
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<object>()
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user