From 86415eb442b5812b6fe99891239bafabce2da1cd Mon Sep 17 00:00:00 2001 From: Sachin Sharma Date: Sun, 30 Aug 2026 10:36:34 +0530 Subject: [PATCH] fix(proxy): add Codex fallback and preserve provider identity --- docs/api/README.md | 7 + .../AnthropicInvalidRequestFailure.md | 37 ++ docs/api/type-aliases/CodexAuthFile.md | 10 +- docs/api/type-aliases/CodexAuthFileTokens.md | 10 +- docs/api/type-aliases/CodexContentPart.md | 13 + docs/api/type-aliases/CodexFallbackResult.md | 45 ++ .../type-aliases/CodexImportedCredential.md | 16 +- .../CodexProxyStatusAccountIdentity.md | 13 + docs/api/type-aliases/CodexRateLimitWindow.md | 12 +- docs/api/type-aliases/CodexRateLimits.md | 6 +- .../type-aliases/CodexResponsesInputItem.md | 13 + .../api/type-aliases/CodexResponsesRequest.md | 85 +++ docs/api/type-aliases/CodexRuntimeAccount.md | 22 +- docs/api/type-aliases/CodexTokenResponse.md | 12 +- .../api/type-aliases/CodexUsageFetchResult.md | 2 +- docs/api/type-aliases/CodexUsageResponse.md | 6 +- .../DeferredClaudeAccountFailure.md | 45 ++ src/cli/commands/proxy.ts | 83 ++- src/lib/proxy/codexAccountUsage.ts | 24 + src/lib/proxy/codexFallback.ts | 443 +++++++++++++ src/lib/server/routes/claudeProxyRoutes.ts | 607 +++++++++++++---- src/lib/server/routes/codexProxyRoutes.ts | 2 +- src/lib/types/claudeProxy.ts | 18 + src/lib/types/codex.ts | 64 +- src/lib/types/index.ts | 1 + test/continuous-test-suite-codex.ts | 623 +++++++++++++++++- 26 files changed, 2005 insertions(+), 214 deletions(-) create mode 100644 docs/api/type-aliases/AnthropicInvalidRequestFailure.md create mode 100644 docs/api/type-aliases/CodexContentPart.md create mode 100644 docs/api/type-aliases/CodexFallbackResult.md create mode 100644 docs/api/type-aliases/CodexProxyStatusAccountIdentity.md create mode 100644 docs/api/type-aliases/CodexResponsesInputItem.md create mode 100644 docs/api/type-aliases/CodexResponsesRequest.md create mode 100644 docs/api/type-aliases/DeferredClaudeAccountFailure.md create mode 100644 src/lib/proxy/codexFallback.ts create mode 100644 src/lib/types/claudeProxy.ts diff --git a/docs/api/README.md b/docs/api/README.md index 594d6a3e5..5c4e147dd 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -644,6 +644,8 @@ console.log(result.content); - [ClassifierGenerateFn](type-aliases/ClassifierGenerateFn.md) - [ClassifierLogger](type-aliases/ClassifierLogger.md) - [ClassifierRouterDeps](type-aliases/ClassifierRouterDeps.md) +- [AnthropicInvalidRequestFailure](type-aliases/AnthropicInvalidRequestFailure.md) +- [DeferredClaudeAccountFailure](type-aliases/DeferredClaudeAccountFailure.md) - [AllowedCommand](type-aliases/AllowedCommand.md) - [OptionSchema](type-aliases/OptionSchema.md) - [BaseCommandArgs](type-aliases/BaseCommandArgs.md) @@ -864,6 +866,11 @@ console.log(result.content); - [CodexUsageResponse](type-aliases/CodexUsageResponse.md) - [CodexUsageFetchResult](type-aliases/CodexUsageFetchResult.md) - [CodexRuntimeAccount](type-aliases/CodexRuntimeAccount.md) +- [CodexProxyStatusAccountIdentity](type-aliases/CodexProxyStatusAccountIdentity.md) +- [CodexContentPart](type-aliases/CodexContentPart.md) +- [CodexResponsesInputItem](type-aliases/CodexResponsesInputItem.md) +- [CodexResponsesRequest](type-aliases/CodexResponsesRequest.md) +- [CodexFallbackResult](type-aliases/CodexFallbackResult.md) - [Unknown](type-aliases/Unknown.md) - [UnknownRecord](type-aliases/UnknownRecord.md) - [UnknownArray](type-aliases/UnknownArray.md) diff --git a/docs/api/type-aliases/AnthropicInvalidRequestFailure.md b/docs/api/type-aliases/AnthropicInvalidRequestFailure.md new file mode 100644 index 000000000..db47e3107 --- /dev/null +++ b/docs/api/type-aliases/AnthropicInvalidRequestFailure.md @@ -0,0 +1,37 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / AnthropicInvalidRequestFailure + +# Type Alias: AnthropicInvalidRequestFailure + +> **AnthropicInvalidRequestFailure** = `object` + +Defined in: [types/claudeProxy.ts:6](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L6) + +A deterministic upstream validation failure that must reach the caller. + +## Properties + +### status + +> **status**: `number` + +Defined in: [types/claudeProxy.ts:7](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L7) + +--- + +### body + +> **body**: `string` + +Defined in: [types/claudeProxy.ts:8](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L8) + +--- + +### contentType? + +> `optional` **contentType?**: `string` + +Defined in: [types/claudeProxy.ts:9](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L9) diff --git a/docs/api/type-aliases/CodexAuthFile.md b/docs/api/type-aliases/CodexAuthFile.md index 68b8c7139..90d3fb946 100644 --- a/docs/api/type-aliases/CodexAuthFile.md +++ b/docs/api/type-aliases/CodexAuthFile.md @@ -8,7 +8,7 @@ > **CodexAuthFile** = `object` -Defined in: [types/codex.ts:26](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L26) +Defined in: [types/codex.ts:30](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L30) Shape of `~/.codex/auth.json` written by the Codex CLI. @@ -18,7 +18,7 @@ Shape of `~/.codex/auth.json` written by the Codex CLI. > `optional` **auth_mode?**: `string` -Defined in: [types/codex.ts:27](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L27) +Defined in: [types/codex.ts:31](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L31) --- @@ -26,7 +26,7 @@ Defined in: [types/codex.ts:27](https://github.com/juspay/neurolink/blob/release > `optional` **OPENAI_API_KEY?**: `string` \| `null` -Defined in: [types/codex.ts:28](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L28) +Defined in: [types/codex.ts:32](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L32) --- @@ -34,7 +34,7 @@ Defined in: [types/codex.ts:28](https://github.com/juspay/neurolink/blob/release > `optional` **tokens?**: [`CodexAuthFileTokens`](CodexAuthFileTokens.md) -Defined in: [types/codex.ts:29](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L29) +Defined in: [types/codex.ts:33](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L33) --- @@ -42,4 +42,4 @@ Defined in: [types/codex.ts:29](https://github.com/juspay/neurolink/blob/release > `optional` **last_refresh?**: `string` -Defined in: [types/codex.ts:30](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L30) +Defined in: [types/codex.ts:34](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L34) diff --git a/docs/api/type-aliases/CodexAuthFileTokens.md b/docs/api/type-aliases/CodexAuthFileTokens.md index 7623ee5bf..6862c6046 100644 --- a/docs/api/type-aliases/CodexAuthFileTokens.md +++ b/docs/api/type-aliases/CodexAuthFileTokens.md @@ -8,7 +8,7 @@ > **CodexAuthFileTokens** = `object` -Defined in: [types/codex.ts:18](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L18) +Defined in: [types/codex.ts:22](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L22) Token block inside `~/.codex/auth.json`. @@ -18,7 +18,7 @@ Token block inside `~/.codex/auth.json`. > `optional` **id_token?**: `string` -Defined in: [types/codex.ts:19](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L19) +Defined in: [types/codex.ts:23](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L23) --- @@ -26,7 +26,7 @@ Defined in: [types/codex.ts:19](https://github.com/juspay/neurolink/blob/release > **access_token**: `string` -Defined in: [types/codex.ts:20](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L20) +Defined in: [types/codex.ts:24](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L24) --- @@ -34,7 +34,7 @@ Defined in: [types/codex.ts:20](https://github.com/juspay/neurolink/blob/release > `optional` **refresh_token?**: `string` -Defined in: [types/codex.ts:21](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L21) +Defined in: [types/codex.ts:25](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L25) --- @@ -42,4 +42,4 @@ Defined in: [types/codex.ts:21](https://github.com/juspay/neurolink/blob/release > `optional` **account_id?**: `string` -Defined in: [types/codex.ts:22](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L22) +Defined in: [types/codex.ts:26](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L26) diff --git a/docs/api/type-aliases/CodexContentPart.md b/docs/api/type-aliases/CodexContentPart.md new file mode 100644 index 000000000..44b2dd295 --- /dev/null +++ b/docs/api/type-aliases/CodexContentPart.md @@ -0,0 +1,13 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / CodexContentPart + +# Type Alias: CodexContentPart + +> **CodexContentPart** = \{ `type`: `"input_text"`; `text`: `string`; \} \| \{ `type`: `"output_text"`; `text`: `string`; \} \| \{ `type`: `"input_image"`; `image_url`: `string`; \} + +Defined in: [types/codex.ts:114](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L114) + +A text or image content part accepted by the Codex Responses backend. diff --git a/docs/api/type-aliases/CodexFallbackResult.md b/docs/api/type-aliases/CodexFallbackResult.md new file mode 100644 index 000000000..d6fa7ebed --- /dev/null +++ b/docs/api/type-aliases/CodexFallbackResult.md @@ -0,0 +1,45 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / CodexFallbackResult + +# Type Alias: CodexFallbackResult + +> **CodexFallbackResult** = `object` + +Defined in: [types/codex.ts:158](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L158) + +Fully buffered Codex result rendered back as an Anthropic response. + +## Properties + +### text + +> **text**: `string` + +Defined in: [types/codex.ts:159](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L159) + +--- + +### toolCalls + +> **toolCalls**: `NonNullable`\<[`InternalResult`](InternalResult.md)\[`"toolCalls"`\]\> + +Defined in: [types/codex.ts:160](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L160) + +--- + +### usage? + +> `optional` **usage?**: `NonNullable`\<[`InternalResult`](InternalResult.md)\[`"usage"`\]\> + +Defined in: [types/codex.ts:161](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L161) + +--- + +### finishReason + +> **finishReason**: `"end_turn"` \| `"tool_use"` + +Defined in: [types/codex.ts:162](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L162) diff --git a/docs/api/type-aliases/CodexImportedCredential.md b/docs/api/type-aliases/CodexImportedCredential.md index 0375699a8..61c46470c 100644 --- a/docs/api/type-aliases/CodexImportedCredential.md +++ b/docs/api/type-aliases/CodexImportedCredential.md @@ -8,7 +8,7 @@ > **CodexImportedCredential** = `object` -Defined in: [types/codex.ts:34](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L34) +Defined in: [types/codex.ts:38](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L38) Result of importing a Codex credential (from auth.json or the OAuth flow). @@ -18,7 +18,7 @@ Result of importing a Codex credential (from auth.json or the OAuth flow). > **accessToken**: `string` -Defined in: [types/codex.ts:35](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L35) +Defined in: [types/codex.ts:39](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L39) --- @@ -26,7 +26,7 @@ Defined in: [types/codex.ts:35](https://github.com/juspay/neurolink/blob/release > `optional` **refreshToken?**: `string` -Defined in: [types/codex.ts:36](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L36) +Defined in: [types/codex.ts:40](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L40) --- @@ -34,7 +34,7 @@ Defined in: [types/codex.ts:36](https://github.com/juspay/neurolink/blob/release > `optional` **idToken?**: `string` -Defined in: [types/codex.ts:37](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L37) +Defined in: [types/codex.ts:41](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L41) --- @@ -42,7 +42,7 @@ Defined in: [types/codex.ts:37](https://github.com/juspay/neurolink/blob/release > `optional` **accountId?**: `string` -Defined in: [types/codex.ts:39](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L39) +Defined in: [types/codex.ts:43](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L43) ChatGPT account id (from auth.json or decoded from the access token). @@ -52,7 +52,7 @@ ChatGPT account id (from auth.json or decoded from the access token). > `optional` **expiresAt?**: `number` -Defined in: [types/codex.ts:41](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L41) +Defined in: [types/codex.ts:45](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L45) Epoch ms when the access token expires (decoded from the JWT `exp`). @@ -62,7 +62,7 @@ Epoch ms when the access token expires (decoded from the JWT `exp`). > `optional` **planType?**: `string` -Defined in: [types/codex.ts:43](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L43) +Defined in: [types/codex.ts:47](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L47) ChatGPT plan type decoded from the token, for display only. @@ -72,6 +72,6 @@ ChatGPT plan type decoded from the token, for display only. > `optional` **email?**: `string` -Defined in: [types/codex.ts:45](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L45) +Defined in: [types/codex.ts:49](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L49) Account email decoded from the id token, for the account label. diff --git a/docs/api/type-aliases/CodexProxyStatusAccountIdentity.md b/docs/api/type-aliases/CodexProxyStatusAccountIdentity.md new file mode 100644 index 000000000..8a692c13f --- /dev/null +++ b/docs/api/type-aliases/CodexProxyStatusAccountIdentity.md @@ -0,0 +1,13 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / CodexProxyStatusAccountIdentity + +# Type Alias: CodexProxyStatusAccountIdentity + +> **CodexProxyStatusAccountIdentity** = \{ `provider`: `"anthropic"`; `key`: `string`; \} \| \{ `provider`: `"codex"`; `key`: `string`; \} \| \{ `provider`: `"other"`; `key`: `null`; \} + +Defined in: [types/codex.ts:108](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L108) + +Provider-qualified account identity used by proxy status rendering. diff --git a/docs/api/type-aliases/CodexRateLimitWindow.md b/docs/api/type-aliases/CodexRateLimitWindow.md index 8031ba05d..6ff531255 100644 --- a/docs/api/type-aliases/CodexRateLimitWindow.md +++ b/docs/api/type-aliases/CodexRateLimitWindow.md @@ -8,7 +8,7 @@ > **CodexRateLimitWindow** = `object` -Defined in: [types/codex.ts:58](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L58) +Defined in: [types/codex.ts:62](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L62) One rate-limit window as reported by the Codex backend (primary/secondary). @@ -18,7 +18,7 @@ One rate-limit window as reported by the Codex backend (primary/secondary). > `optional` **used_percent?**: `number` \| `null` -Defined in: [types/codex.ts:59](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L59) +Defined in: [types/codex.ts:63](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L63) --- @@ -26,7 +26,7 @@ Defined in: [types/codex.ts:59](https://github.com/juspay/neurolink/blob/release > `optional` **window_minutes?**: `number` \| `null` -Defined in: [types/codex.ts:60](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L60) +Defined in: [types/codex.ts:64](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L64) --- @@ -34,7 +34,7 @@ Defined in: [types/codex.ts:60](https://github.com/juspay/neurolink/blob/release > `optional` **resets_in_seconds?**: `number` \| `null` -Defined in: [types/codex.ts:61](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L61) +Defined in: [types/codex.ts:65](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L65) --- @@ -42,7 +42,7 @@ Defined in: [types/codex.ts:61](https://github.com/juspay/neurolink/blob/release > `optional` **reset_after?**: `number` \| `null` -Defined in: [types/codex.ts:65](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L65) +Defined in: [types/codex.ts:69](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L69) Seconds until reset. Observed alias of `resets_in_seconds` on some responses; accepted defensively so a cooldown lands on the real reset @@ -54,4 +54,4 @@ instead of degrading to the transient ceiling. > `optional` **resets_at?**: `number` \| `null` -Defined in: [types/codex.ts:66](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L66) +Defined in: [types/codex.ts:70](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L70) diff --git a/docs/api/type-aliases/CodexRateLimits.md b/docs/api/type-aliases/CodexRateLimits.md index c8dff8ce3..c5e8a5f67 100644 --- a/docs/api/type-aliases/CodexRateLimits.md +++ b/docs/api/type-aliases/CodexRateLimits.md @@ -8,7 +8,7 @@ > **CodexRateLimits** = `object` -Defined in: [types/codex.ts:70](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L70) +Defined in: [types/codex.ts:74](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L74) Codex rate-limit block: a primary (short) and secondary (long) window. @@ -18,7 +18,7 @@ Codex rate-limit block: a primary (short) and secondary (long) window. > `optional` **primary?**: [`CodexRateLimitWindow`](CodexRateLimitWindow.md) \| `null` -Defined in: [types/codex.ts:71](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L71) +Defined in: [types/codex.ts:75](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L75) --- @@ -26,4 +26,4 @@ Defined in: [types/codex.ts:71](https://github.com/juspay/neurolink/blob/release > `optional` **secondary?**: [`CodexRateLimitWindow`](CodexRateLimitWindow.md) \| `null` -Defined in: [types/codex.ts:72](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L72) +Defined in: [types/codex.ts:76](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L76) diff --git a/docs/api/type-aliases/CodexResponsesInputItem.md b/docs/api/type-aliases/CodexResponsesInputItem.md new file mode 100644 index 000000000..6a939b47e --- /dev/null +++ b/docs/api/type-aliases/CodexResponsesInputItem.md @@ -0,0 +1,13 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / CodexResponsesInputItem + +# Type Alias: CodexResponsesInputItem + +> **CodexResponsesInputItem** = \{ `role`: `"user"` \| `"assistant"`; `content`: [`CodexContentPart`](CodexContentPart.md)[]; \} \| \{ `type`: `"function_call"`; `call_id`: `string`; `name`: `string`; `arguments`: `string`; \} \| \{ `type`: `"function_call_output"`; `call_id`: `string`; `output`: `string`; \} + +Defined in: [types/codex.ts:120](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L120) + +A single item in a Codex Responses request. diff --git a/docs/api/type-aliases/CodexResponsesRequest.md b/docs/api/type-aliases/CodexResponsesRequest.md new file mode 100644 index 000000000..9cf30c739 --- /dev/null +++ b/docs/api/type-aliases/CodexResponsesRequest.md @@ -0,0 +1,85 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / CodexResponsesRequest + +# Type Alias: CodexResponsesRequest + +> **CodexResponsesRequest** = `object` + +Defined in: [types/codex.ts:138](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L138) + +Request shape used to bridge Anthropic Messages traffic to Codex Responses. + +## Properties + +### model + +> **model**: `string` + +Defined in: [types/codex.ts:139](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L139) + +--- + +### input + +> **input**: [`CodexResponsesInputItem`](CodexResponsesInputItem.md)[] + +Defined in: [types/codex.ts:140](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L140) + +--- + +### stream + +> **stream**: `true` + +Defined in: [types/codex.ts:141](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L141) + +--- + +### store + +> **store**: `false` + +Defined in: [types/codex.ts:142](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L142) + +--- + +### instructions? + +> `optional` **instructions?**: `string` + +Defined in: [types/codex.ts:143](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L143) + +--- + +### tools? + +> `optional` **tools?**: `object`[] + +Defined in: [types/codex.ts:144](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L144) + +#### type + +> **type**: `"function"` + +#### name + +> **name**: `string` + +#### description? + +> `optional` **description?**: `string` + +#### parameters + +> **parameters**: `Record`\<`string`, `unknown`\> + +--- + +### tool_choice? + +> `optional` **tool_choice?**: `"auto"` \| `"required"` \| `"none"` \| \{ `type`: `"function"`; `name`: `string`; \} + +Defined in: [types/codex.ts:150](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L150) diff --git a/docs/api/type-aliases/CodexRuntimeAccount.md b/docs/api/type-aliases/CodexRuntimeAccount.md index 5f8b02aa0..7a40a897e 100644 --- a/docs/api/type-aliases/CodexRuntimeAccount.md +++ b/docs/api/type-aliases/CodexRuntimeAccount.md @@ -8,7 +8,7 @@ > **CodexRuntimeAccount** = `object` -Defined in: [types/codex.ts:87](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L87) +Defined in: [types/codex.ts:91](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L91) A Codex account with its runtime cooldown/quota state hydrated from disk. @@ -18,7 +18,7 @@ A Codex account with its runtime cooldown/quota state hydrated from disk. > **key**: `string` -Defined in: [types/codex.ts:88](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L88) +Defined in: [types/codex.ts:92](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L92) --- @@ -26,7 +26,7 @@ Defined in: [types/codex.ts:88](https://github.com/juspay/neurolink/blob/release > **label**: `string` -Defined in: [types/codex.ts:89](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L89) +Defined in: [types/codex.ts:93](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L93) --- @@ -34,7 +34,7 @@ Defined in: [types/codex.ts:89](https://github.com/juspay/neurolink/blob/release > **token**: `string` -Defined in: [types/codex.ts:90](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L90) +Defined in: [types/codex.ts:94](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L94) --- @@ -42,7 +42,7 @@ Defined in: [types/codex.ts:90](https://github.com/juspay/neurolink/blob/release > `optional` **refreshToken?**: `string` -Defined in: [types/codex.ts:91](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L91) +Defined in: [types/codex.ts:95](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L95) --- @@ -50,7 +50,7 @@ Defined in: [types/codex.ts:91](https://github.com/juspay/neurolink/blob/release > `optional` **expiresAt?**: `number` -Defined in: [types/codex.ts:92](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L92) +Defined in: [types/codex.ts:96](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L96) --- @@ -58,7 +58,7 @@ Defined in: [types/codex.ts:92](https://github.com/juspay/neurolink/blob/release > `optional` **accountId?**: `string` -Defined in: [types/codex.ts:93](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L93) +Defined in: [types/codex.ts:97](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L97) --- @@ -66,7 +66,7 @@ Defined in: [types/codex.ts:93](https://github.com/juspay/neurolink/blob/release > `optional` **quota?**: [`AccountQuota`](AccountQuota.md) -Defined in: [types/codex.ts:94](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L94) +Defined in: [types/codex.ts:98](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L98) --- @@ -74,7 +74,7 @@ Defined in: [types/codex.ts:94](https://github.com/juspay/neurolink/blob/release > `optional` **coolingUntil?**: `number` -Defined in: [types/codex.ts:95](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L95) +Defined in: [types/codex.ts:99](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L99) --- @@ -82,7 +82,7 @@ Defined in: [types/codex.ts:95](https://github.com/juspay/neurolink/blob/release > `optional` **coolingReason?**: [`AccountCoolingReason`](AccountCoolingReason.md) -Defined in: [types/codex.ts:96](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L96) +Defined in: [types/codex.ts:100](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L100) --- @@ -90,7 +90,7 @@ Defined in: [types/codex.ts:96](https://github.com/juspay/neurolink/blob/release > `optional` **expiredCooldownUntil?**: `number` -Defined in: [types/codex.ts:100](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L100) +Defined in: [types/codex.ts:104](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L104) A persisted cooldown whose window has already passed. Present only when the account is therefore eligible again, so the success path can delete the diff --git a/docs/api/type-aliases/CodexTokenResponse.md b/docs/api/type-aliases/CodexTokenResponse.md index 67f36a173..cd4589197 100644 --- a/docs/api/type-aliases/CodexTokenResponse.md +++ b/docs/api/type-aliases/CodexTokenResponse.md @@ -8,7 +8,7 @@ > **CodexTokenResponse** = `object` -Defined in: [types/codex.ts:49](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L49) +Defined in: [types/codex.ts:53](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L53) Raw OpenAI OAuth token endpoint response. @@ -18,7 +18,7 @@ Raw OpenAI OAuth token endpoint response. > `optional` **access_token?**: `string` -Defined in: [types/codex.ts:50](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L50) +Defined in: [types/codex.ts:54](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L54) --- @@ -26,7 +26,7 @@ Defined in: [types/codex.ts:50](https://github.com/juspay/neurolink/blob/release > `optional` **refresh_token?**: `string` -Defined in: [types/codex.ts:51](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L51) +Defined in: [types/codex.ts:55](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L55) --- @@ -34,7 +34,7 @@ Defined in: [types/codex.ts:51](https://github.com/juspay/neurolink/blob/release > `optional` **id_token?**: `string` -Defined in: [types/codex.ts:52](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L52) +Defined in: [types/codex.ts:56](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L56) --- @@ -42,7 +42,7 @@ Defined in: [types/codex.ts:52](https://github.com/juspay/neurolink/blob/release > `optional` **token_type?**: `string` -Defined in: [types/codex.ts:53](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L53) +Defined in: [types/codex.ts:57](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L57) --- @@ -50,4 +50,4 @@ Defined in: [types/codex.ts:53](https://github.com/juspay/neurolink/blob/release > `optional` **expires_in?**: `number` -Defined in: [types/codex.ts:54](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L54) +Defined in: [types/codex.ts:58](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L58) diff --git a/docs/api/type-aliases/CodexUsageFetchResult.md b/docs/api/type-aliases/CodexUsageFetchResult.md index 417e0a337..8b01b96f6 100644 --- a/docs/api/type-aliases/CodexUsageFetchResult.md +++ b/docs/api/type-aliases/CodexUsageFetchResult.md @@ -8,6 +8,6 @@ > **CodexUsageFetchResult** = \{ `ok`: `true`; `quota`: [`AccountQuota`](AccountQuota.md); \} \| \{ `ok`: `false`; `reason`: `"not_oauth"` \| `"auth"` \| `"http"` \| `"network"` \| `"parse"`; \} -Defined in: [types/codex.ts:82](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L82) +Defined in: [types/codex.ts:86](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L86) Result of a single Codex usage fetch. diff --git a/docs/api/type-aliases/CodexUsageResponse.md b/docs/api/type-aliases/CodexUsageResponse.md index 3b4820b93..f399270b7 100644 --- a/docs/api/type-aliases/CodexUsageResponse.md +++ b/docs/api/type-aliases/CodexUsageResponse.md @@ -8,7 +8,7 @@ > **CodexUsageResponse** = `object` -Defined in: [types/codex.ts:76](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L76) +Defined in: [types/codex.ts:80](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L80) Loose shape of the Codex usage endpoint response. @@ -18,7 +18,7 @@ Loose shape of the Codex usage endpoint response. > `optional` **rate_limits?**: [`CodexRateLimits`](CodexRateLimits.md) \| `null` -Defined in: [types/codex.ts:77](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L77) +Defined in: [types/codex.ts:81](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L81) --- @@ -26,4 +26,4 @@ Defined in: [types/codex.ts:77](https://github.com/juspay/neurolink/blob/release > `optional` **plan_type?**: `string` \| `null` -Defined in: [types/codex.ts:78](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L78) +Defined in: [types/codex.ts:82](https://github.com/juspay/neurolink/blob/release/src/lib/types/codex.ts#L82) diff --git a/docs/api/type-aliases/DeferredClaudeAccountFailure.md b/docs/api/type-aliases/DeferredClaudeAccountFailure.md new file mode 100644 index 000000000..d665350a1 --- /dev/null +++ b/docs/api/type-aliases/DeferredClaudeAccountFailure.md @@ -0,0 +1,45 @@ +[**NeuroLink API Reference**](../README.md) + +--- + +[NeuroLink API Reference](../README.md) / DeferredClaudeAccountFailure + +# Type Alias: DeferredClaudeAccountFailure + +> **DeferredClaudeAccountFailure** = `object` + +Defined in: [types/claudeProxy.ts:13](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L13) + +A credential failure deferred until peer and provider fallbacks are tried. + +## Properties + +### status + +> **status**: `number` + +Defined in: [types/claudeProxy.ts:14](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L14) + +--- + +### message + +> **message**: `string` + +Defined in: [types/claudeProxy.ts:15](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L15) + +--- + +### errorType + +> **errorType**: `string` + +Defined in: [types/claudeProxy.ts:16](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L16) + +--- + +### responseHeaders? + +> `optional` **responseHeaders?**: `Record`\<`string`, `string`\> + +Defined in: [types/claudeProxy.ts:17](https://github.com/juspay/neurolink/blob/release/src/lib/types/claudeProxy.ts#L17) diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 2564c5caf..78e9d3469 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -81,6 +81,7 @@ import { normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js"; +import { resolveProxyStatusAccountIdentity } from "../../lib/proxy/codexAccountUsage.js"; import { beginProxyRequest, getProxyActivitySnapshot, @@ -2173,11 +2174,12 @@ export async function createProxyStartApp(params: { error instanceof Error ? error.message : String(error) }`, ); - return {}; + return {} as Record; }); const storedAccountKeys = new Set(); const storedAccountExpirations = new Map(); const disabledAccountKeys = new Set(); + const disabledProviderAccountKeys = new Set(); let accountInventoryLoaded = false; try { const { tokenStore } = await import("../../lib/auth/tokenStore.js"); @@ -2230,6 +2232,7 @@ export async function createProxyStartApp(params: { } for (const key of inventory.disabledKeys) { disabledAccountKeys.add(normalizeAnthropicAccountKey(key)); + disabledProviderAccountKeys.add(key); } } catch (err) { logger.debug( @@ -2266,36 +2269,62 @@ export async function createProxyStartApp(params: { const accountRows: NonNullable = Object.values( stats.accounts, ).map((account) => { - const normalizedKey = normalizeAnthropicAccountKey(account.label); - const isLegacyAccount = - account.type === "oauth" && account.label === legacyAccountLabel; - const accountKey = storedAccountKeys.has(normalizedKey) - ? normalizedKey - : isLegacyAccount - ? LEGACY_ANTHROPIC_ACCOUNT_KEY - : account.label === "env" - ? ENV_ANTHROPIC_ACCOUNT_KEY - : normalizedKey; - const isStored = storedAccountKeys.has(accountKey) || isLegacyAccount; - const { allowed, expired, cooling } = deriveAccountAllowance( - accountKey, - now, - activeAccountAllowlist, - storedAccountExpirations, - cooldowns, + const identity = resolveProxyStatusAccountIdentity( + account.label, + account.type, ); + const isAnthropicAccount = identity.provider === "anthropic"; + const isCodexAccount = identity.provider === "codex"; + const normalizedKey = + identity.provider === "anthropic" ? identity.key : null; + const isLegacyAccount = + isAnthropicAccount && account.label === legacyAccountLabel; + const accountKey = isAnthropicAccount + ? storedAccountKeys.has(normalizedKey ?? "") + ? normalizedKey + : isLegacyAccount + ? LEGACY_ANTHROPIC_ACCOUNT_KEY + : account.label === "env" + ? ENV_ANTHROPIC_ACCOUNT_KEY + : normalizedKey + : identity.key; + const isStored = + isAnthropicAccount && + accountKey !== null && + (storedAccountKeys.has(accountKey) || isLegacyAccount); + const { allowed, expired, cooling } = + isAnthropicAccount && accountKey !== null + ? deriveAccountAllowance( + accountKey, + now, + activeAccountAllowlist, + storedAccountExpirations, + cooldowns, + ) + : { + allowed: true, + expired: false, + cooling: + accountKey !== null && + (cooldowns[accountKey]?.coolingUntil ?? 0) > now, + }; + const isDisabled = + (isAnthropicAccount && + accountKey !== null && + disabledAccountKeys.has(accountKey)) || + (isCodexAccount && + accountKey !== null && + disabledProviderAccountKeys.has(accountKey)); const accountStatus = account.type === "internal" ? "internal" - : disabledAccountKeys.has(accountKey) + : isDisabled ? "disabled" - : expired + : isAnthropicAccount && expired ? "expired" - : !allowed + : isAnthropicAccount && !allowed ? "excluded" - : accountInventoryLoaded && - account.type === "oauth" && - !isStored + : accountInventoryLoaded && isAnthropicAccount && !isStored ? "removed" : cooling ? "cooling" @@ -2314,12 +2343,14 @@ export async function createProxyStartApp(params: { cooling, status: accountStatus, allowed: account.type === "internal" ? undefined : allowed, - expired: account.type === "oauth" ? expired : undefined, + expired: isAnthropicAccount ? expired : undefined, }; }); const representedAccountKeys = new Set( accountRows - .filter((account) => account.type === "oauth") + .filter( + (account) => account.type === "oauth" || account.type === "api_key", + ) .map((account) => normalizeAnthropicAccountKey(account.label)), ); for (const accountKey of storedAccountKeys) { diff --git a/src/lib/proxy/codexAccountUsage.ts b/src/lib/proxy/codexAccountUsage.ts index 6a732199d..6aa0e9a94 100644 --- a/src/lib/proxy/codexAccountUsage.ts +++ b/src/lib/proxy/codexAccountUsage.ts @@ -8,6 +8,8 @@ */ import { tokenStore } from "../auth/tokenStore.js"; +import type { CodexProxyStatusAccountIdentity } from "../types/index.js"; +import { normalizeAnthropicAccountKey } from "./accountSelection.js"; import { CODEX_ORIGINATOR, CODEX_USAGE_URL, @@ -28,6 +30,28 @@ import type { export const CODEX_ACCOUNT_PREFIX = "codex:"; +/** Keep an account label in the Codex namespace used by its token store. */ +export function normalizeCodexAccountKey(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith(CODEX_ACCOUNT_PREFIX) + ? trimmed + : `${CODEX_ACCOUNT_PREFIX}${trimmed}`; +} + +/** Resolve the provider-qualified key used to render a proxy status row. */ +export function resolveProxyStatusAccountIdentity( + label: string, + type: string, +): CodexProxyStatusAccountIdentity { + if (type === "oauth" || type === "api_key") { + return { provider: "anthropic", key: normalizeAnthropicAccountKey(label) }; + } + if (type === "codex-oauth") { + return { provider: "codex", key: normalizeCodexAccountKey(label) }; + } + return { provider: "other", key: null }; +} + /** Enumerate Codex OAuth accounts from the token store for usage refresh. */ export async function listCodexAccountsForUsage(): Promise< ProxyPassthroughAccount[] diff --git a/src/lib/proxy/codexFallback.ts b/src/lib/proxy/codexFallback.ts new file mode 100644 index 000000000..23d4d64a8 --- /dev/null +++ b/src/lib/proxy/codexFallback.ts @@ -0,0 +1,443 @@ +/** + * Anthropic Messages API fallback over the pooled Codex Responses transport. + * + * This module deliberately contains only wire-format conversion and buffered + * SSE parsing. Account selection, OAuth, cooldowns, and quota persistence stay + * in the native Codex proxy handler so fallback traffic follows the same pool + * rules as a native Codex request. + */ + +import { extractCodexUsage } from "./codexUsage.js"; +import type { + ClaudeContentBlock, + ClaudeRequest, + CodexContentPart, + CodexFallbackResult, + CodexResponsesInputItem, + CodexResponsesRequest, + InternalResult, +} from "../types/index.js"; + +export class CodexFallbackResponseError extends Error { + readonly status: number; + readonly responseBody: string; + + constructor(status: number, responseBody: string) { + super(`Codex fallback request returned HTTP ${status}`); + this.name = "CodexFallbackResponseError"; + this.status = status; + this.responseBody = responseBody; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function buildSystemInstructions(body: ClaudeRequest): string | undefined { + if (typeof body.system === "string") { + return body.system || undefined; + } + if (Array.isArray(body.system)) { + const text = body.system + .map((block) => (typeof block.text === "string" ? block.text : "")) + .filter(Boolean) + .join("\n\n"); + return text || undefined; + } + return undefined; +} + +function imageUrlForBlock( + block: Extract, +): string | undefined { + if (block.source.type === "url" && block.source.url) { + return block.source.url; + } + if (block.source.type === "base64" && block.source.data) { + return `data:${block.source.media_type ?? "image/png"};base64,${block.source.data}`; + } + return undefined; +} + +function flattenClaudeContent(content: string | ClaudeContentBlock[]): string { + if (typeof content === "string") { + return content; + } + + return content + .map((block) => { + switch (block.type) { + case "text": + return block.text; + case "thinking": + return block.thinking; + case "image": + return "[image attachment]"; + case "tool_use": + return `[tool call ${block.name}] ${JSON.stringify(block.input ?? {})}`; + case "tool_result": + return flattenClaudeContent(block.content); + } + }) + .join("\n"); +} + +function toCodexContentPart( + block: Exclude, + role: "user" | "assistant", +): CodexContentPart { + const textType = role === "assistant" ? "output_text" : "input_text"; + switch (block.type) { + case "text": + return { type: textType, text: block.text }; + case "thinking": + return { type: textType, text: block.thinking }; + case "image": { + const imageUrl = imageUrlForBlock(block); + if (role === "user" && imageUrl) { + return { type: "input_image", image_url: imageUrl }; + } + return { type: textType, text: "[image attachment]" }; + } + } +} + +function convertClaudeMessage( + role: "user" | "assistant", + content: string | ClaudeContentBlock[], +): CodexResponsesInputItem[] { + if (typeof content === "string") { + return [ + { + role, + content: [ + { + type: role === "assistant" ? "output_text" : "input_text", + text: content, + }, + ], + }, + ]; + } + + const input: CodexResponsesInputItem[] = []; + let messageContent: CodexContentPart[] = []; + const flushMessage = (): void => { + if (messageContent.length === 0) { + return; + } + input.push({ role, content: messageContent }); + messageContent = []; + }; + + for (const block of content) { + if (block.type === "tool_use") { + flushMessage(); + input.push({ + type: "function_call", + call_id: block.id, + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }); + continue; + } + if (block.type === "tool_result") { + flushMessage(); + input.push({ + type: "function_call_output", + call_id: block.tool_use_id, + output: flattenClaudeContent(block.content), + }); + continue; + } + messageContent.push(toCodexContentPart(block, role)); + } + flushMessage(); + return input; +} + +/** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */ +export function convertClaudeRequestToCodex( + body: ClaudeRequest, + model: string, +): CodexResponsesRequest { + const input = body.messages.flatMap((message) => + convertClaudeMessage(message.role, message.content), + ); + const request: CodexResponsesRequest = { + model, + input, + stream: true, + // ChatGPT's backend rejects requests unless this is explicitly false. + store: false, + }; + + const instructions = buildSystemInstructions(body); + if (instructions) { + request.instructions = instructions; + } + if (body.tools && body.tools.length > 0) { + request.tools = body.tools.map((tool) => ({ + type: "function", + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + parameters: tool.input_schema, + })); + } + if (body.tool_choice) { + switch (body.tool_choice.type) { + case "any": + request.tool_choice = "required"; + break; + case "tool": + request.tool_choice = { type: "function", name: body.tool_choice.name }; + break; + default: + request.tool_choice = body.tool_choice.type; + break; + } + } + // The ChatGPT Codex backend is not the public Responses API. In particular, + // it rejects `max_output_tokens`, and its model-owned sampling controls do + // not map safely from Anthropic's `temperature` or `top_p`. Omit all three + // so the backend uses its supported defaults instead of rejecting fallback + // traffic before it can be served. + return request; +} + +function parseFunctionArguments(value: unknown): Record { + if (isRecord(value)) { + return value; + } + if (typeof value !== "string") { + throw new Error("Codex fallback function call is missing JSON arguments"); + } + try { + const parsed: unknown = JSON.parse(value || "{}"); + if (!isRecord(parsed)) { + throw new Error("not an object"); + } + return parsed; + } catch { + throw new Error( + "Codex fallback function call returned invalid JSON arguments", + ); + } +} + +function outputTextFromItem(value: unknown): string { + if ( + !isRecord(value) || + value.type !== "message" || + !Array.isArray(value.content) + ) { + return ""; + } + return value.content + .filter(isRecord) + .filter((part) => part.type === "output_text") + .map((part) => (typeof part.text === "string" ? part.text : "")) + .join(""); +} + +function addFunctionCall( + item: Record, + toolCalls: Map[number]>, +): void { + if (item.type !== "function_call") { + return; + } + const callId = asNonEmptyString(item.call_id); + const name = asNonEmptyString(item.name); + if (!callId || !name) { + throw new Error("Codex fallback function call is missing an id or name"); + } + toolCalls.set(callId, { + toolCallId: callId, + toolName: name, + args: parseFunctionArguments(item.arguments), + }); +} + +function parseSSEPayloads( + sse: string, +): Array<{ event?: string; payload: Record }> { + const frames = sse.replace(/\r\n?/g, "\n").split("\n\n"); + const parsed: Array<{ event?: string; payload: Record }> = + []; + + for (const frame of frames) { + if (!frame.trim()) { + continue; + } + let event: string | undefined; + const data: string[] = []; + for (const line of frame.split("\n")) { + if (!line || line.startsWith(":")) { + continue; + } + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim(); + continue; + } + if (line.startsWith("data:")) { + data.push(line.slice("data:".length).trimStart()); + continue; + } + if (line.startsWith("id:") || line.startsWith("retry:")) { + continue; + } + // SSE permits extension fields that this parser does not consume. + continue; + } + if (data.length === 0) { + continue; + } + const raw = data.join("\n").trim(); + if (raw === "[DONE]") { + continue; + } + try { + const payload: unknown = JSON.parse(raw); + if (!isRecord(payload)) { + throw new Error("not an object"); + } + parsed.push({ event, payload }); + } catch { + throw new Error("Codex fallback stream contains malformed JSON"); + } + } + + return parsed; +} + +function responseStatus(payload: Record): string | undefined { + const response = payload.response; + return isRecord(response) ? asNonEmptyString(response.status) : undefined; +} + +function outputTextFromResponse(payload: Record): string { + const response = payload.response; + if (!isRecord(response) || !Array.isArray(response.output)) { + return ""; + } + return response.output.map(outputTextFromItem).join(""); +} + +/** + * Parse a complete Codex Responses SSE stream before emitting Claude output. + * + * A missing terminal event, malformed JSON, terminal error, or empty response + * is rejected. That makes it safe for the caller to try the next configured + * fallback without ever replaying output already sent to a client. + */ +export function parseCodexFallbackSSE(sse: string): CodexFallbackResult { + const payloads = parseSSEPayloads(sse); + const toolCalls = new Map< + string, + NonNullable[number] + >(); + let textFromDeltas = ""; + let textFromCompletedItems = ""; + let textFromResponse = ""; + let usage: CodexFallbackResult["usage"]; + let sawCompleted = false; + + for (const { event, payload } of payloads) { + const type = asNonEmptyString(payload.type) ?? event; + if (!type) { + throw new Error("Codex fallback stream event is missing a type"); + } + if ( + type === "error" || + type === "response.failed" || + type === "response.incomplete" + ) { + throw new Error(`Codex fallback stream terminated with ${type}`); + } + if (type === "response.output_text.delta") { + if (typeof payload.delta !== "string") { + throw new Error("Codex fallback text delta is malformed"); + } + textFromDeltas += payload.delta; + continue; + } + if (type === "response.output_item.done") { + if (!isRecord(payload.item)) { + throw new Error("Codex fallback output item is malformed"); + } + addFunctionCall(payload.item, toolCalls); + textFromCompletedItems += outputTextFromItem(payload.item); + continue; + } + if (type !== "response.completed") { + continue; + } + + if (sawCompleted) { + throw new Error( + "Codex fallback stream emitted more than one completion event", + ); + } + sawCompleted = true; + const status = responseStatus(payload); + if (status !== "completed") { + throw new Error( + `Codex fallback stream completed with unexpected status ${status ?? "unknown"}`, + ); + } + const parsedUsage = extractCodexUsage(payload); + if (parsedUsage) { + usage = { + input: parsedUsage.inputTokens, + output: parsedUsage.outputTokens, + total: parsedUsage.inputTokens + parsedUsage.outputTokens, + cacheReadTokens: parsedUsage.cacheReadTokens, + cacheCreationTokens: parsedUsage.cacheCreationTokens, + }; + } + textFromResponse = outputTextFromResponse(payload); + } + + if (!sawCompleted) { + throw new Error("Codex fallback stream ended before response.completed"); + } + const text = textFromDeltas || textFromCompletedItems || textFromResponse; + const resolvedToolCalls = [...toolCalls.values()]; + if (!text && resolvedToolCalls.length === 0) { + throw new Error("Codex fallback returned no content or tool calls"); + } + return { + text, + toolCalls: resolvedToolCalls, + ...(usage ? { usage } : {}), + finishReason: resolvedToolCalls.length > 0 ? "tool_use" : "end_turn", + }; +} + +/** Consume and validate a native Codex response before producing Claude output. */ +export async function consumeCodexFallbackResponse( + response: Response, +): Promise { + if (!response.ok) { + throw new CodexFallbackResponseError( + response.status, + await response.text().catch(() => ""), + ); + } + if (!response.body) { + throw new Error("Codex fallback returned an empty stream"); + } + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("text/event-stream")) { + // Consume it before failing so the underlying connection can be reused. + await response.text().catch(() => ""); + throw new Error("Codex fallback returned a non-SSE response"); + } + return parseCodexFallbackSSE(await response.text()); +} diff --git a/src/lib/server/routes/claudeProxyRoutes.ts b/src/lib/server/routes/claudeProxyRoutes.ts index cefc2da84..07b94e25e 100644 --- a/src/lib/server/routes/claudeProxyRoutes.ts +++ b/src/lib/server/routes/claudeProxyRoutes.ts @@ -61,6 +61,11 @@ import { parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js"; +import { + CodexFallbackResponseError, + consumeCodexFallbackResponse, + convertClaudeRequestToCodex, +} from "../../proxy/codexFallback.js"; import { buildAnthropicModelsListResponse, buildTranslationOptions, @@ -181,6 +186,7 @@ import type { AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicEntitlementFailure, + AnthropicInvalidRequestFailure, AnthropicLoopState, AnthropicScopedExhaustion, AnthropicNonOkResult, @@ -191,8 +197,10 @@ import type { ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, + DeferredClaudeAccountFailure, ClaudeSnapshot, ClaudeSnapshotBody, + CodexFallbackResult, InternalResult, LoadedClaudeAccountContext, ModelRouterInterface, @@ -231,6 +239,8 @@ import { sanitizeForLog } from "../../utils/logSanitize.js"; import { logger } from "../../utils/logger.js"; import { raceWithAbort, withTimeout } from "../../utils/async/withTimeout.js"; import { ProviderHealthChecker } from "../../utils/providerHealth.js"; +import { handleCodexResponsesRequest } from "./codexProxyRoutes.js"; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -3534,11 +3544,9 @@ async function handleClaudePassthroughJsonResponse(args: { */ async function applyShareAccountGates(args: { accounts: ProxyPassthroughAccount[]; - ctx: ServerContext; - buildLoggedClaudeError: ClaudeLoggedErrorBuilder; }): Promise<{ accounts: ProxyPassthroughAccount[]; - refusal?: { response: unknown; status: number; message: string }; + refusal?: DeferredClaudeAccountFailure; }> { const share = getShareContext(); if (!share) { @@ -3603,26 +3611,16 @@ async function applyShareAccountGates(args: { grant, retryAfterSeconds: earliestShareRecoverySeconds(views, now), }); - // Assigned, not merged into an existing object: a refusal is often the first - // thing to touch this context, and only copying when headers already existed - // meant the borrower learned nothing about why it was refused. - args.ctx.responseHeaders = { - ...(args.ctx.responseHeaders ?? {}), - ...refusal.headers, - }; logger.always( `[proxy] share ${share.peerLabel} withheld: ${reason} (${decision.excluded.length} accounts)`, ); return { accounts: [], refusal: { - response: args.buildLoggedClaudeError( - refusal.status, - refusal.body.error.message, - refusal.body.error.type, - ), status: refusal.status, message: refusal.body.error.message, + errorType: refusal.body.error.type, + responseHeaders: refusal.headers, }, }; } @@ -4204,29 +4202,25 @@ function earliestShareRecoverySeconds( async function loadClaudeProxyAccounts(args: { ctx: ServerContext; body: ClaudeRequest; - tracer?: ProxyTracer; - requestStartTime: number; accountStrategy: "round-robin" | "fill-first"; primaryAccountKey?: string; accountAllowlist?: AccountAllowlist; quotaRoutingEnabled?: boolean; sessionSoftLimit?: number; sessionResetToleranceMs?: number; - buildLoggedClaudeError: ClaudeLoggedErrorBuilder; setRoutingDecision: (decision: ProxyAccountRoutingDecision) => void; -}): Promise { +}): Promise< + LoadedClaudeAccountContext | { failure: DeferredClaudeAccountFailure } +> { const { ctx, body, - tracer, - requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), - buildLoggedClaudeError, setRoutingDecision, } = args; const fs = await import("fs"); @@ -4459,14 +4453,12 @@ async function loadClaudeProxyAccounts(args: { message: "OAuth authentication is not allowed for this organization.", errorCode: "oauth_not_allowed_for_organization", }); - tracer?.setError("permission_error", entitlementMessage); - tracer?.end(403, Date.now() - requestStartTime); return { - response: buildLoggedClaudeError( - 403, - entitlementMessage, - "permission_error", - ), + failure: { + status: 403, + message: entitlementMessage, + errorType: "permission_error", + }, }; } const noCredentialsMessage = accountAllowlist @@ -4474,10 +4466,12 @@ async function loadClaudeProxyAccounts(args: { : compoundKeys.length > 0 ? "Configured Anthropic accounts are disabled or unavailable" : "No Anthropic credentials found"; - tracer?.setError("authentication_error", noCredentialsMessage); - tracer?.end(401, Date.now() - requestStartTime); return { - response: buildLoggedClaudeError(401, noCredentialsMessage), + failure: { + status: 401, + message: noCredentialsMessage, + errorType: "authentication_error", + }, }; } @@ -4525,22 +4519,20 @@ async function loadClaudeProxyAccounts(args: { `Ask the lender to widen the share, or use a model it allows.` : `Borrowed account(s) are no longer covered by a lease: ${detail}. ` + `Run 'neurolink proxy peer sync' to check in with the lender, or ask them to resume the share.`; - tracer?.setError("permission_error", leaseMessage); - tracer?.end(403, Date.now() - requestStartTime); return { - response: buildLoggedClaudeError(403, leaseMessage, "permission_error"), + failure: { + status: 403, + message: leaseMessage, + errorType: "permission_error", + }, }; } const shareFiltered = await applyShareAccountGates({ accounts: leasedAccounts, - ctx, - buildLoggedClaudeError, }); if (shareFiltered.refusal) { - tracer?.setError("rate_limit_error", shareFiltered.refusal.message); - tracer?.end(shareFiltered.refusal.status, Date.now() - requestStartTime); - return { response: shareFiltered.refusal.response }; + return { failure: shareFiltered.refusal }; } const enabledAccounts = shareFiltered.accounts; @@ -4548,9 +4540,13 @@ async function loadClaudeProxyAccounts(args: { const reauthMsg = formatReauthMessage( accounts.map((account) => account.label), ); - tracer?.setError("authentication_error", reauthMsg); - tracer?.end(401, Date.now() - requestStartTime); - return { response: buildLoggedClaudeError(401, reauthMsg) }; + return { + failure: { + status: 401, + message: reauthMsg, + errorType: "authentication_error", + }, + }; } const { orderedAccounts, metricsByKey } = selectClaudeProxyAccountOrder({ @@ -4825,6 +4821,165 @@ async function executeClaudeFallbackWithRetry( throw lastError; } +/** + * Run the configured `codex` fallback through the native pooled Codex route. + * + * The inner response is fully buffered and validated before this function + * creates a single Claude frame. That preserves the proxy's no-replay-after- + * output guarantee when Codex returns an incomplete stream. + */ +async function executeClaudeCodexFallback(args: { + ctx: ServerContext; + body: ClaudeRequest; + model: string; + tracer?: ProxyTracer; + requestStartTime: number; + logProxyBody: ProxyBodyCaptureLogger; + logFinalRequest: ( + status: number, + accountLabel: string, + accountType: string, + errorType?: string, + errorMessage?: string, + extra?: { + inputTokens?: number; + outputTokens?: number; + cacheCreationTokens?: number; + cacheReadTokens?: number; + }, + ) => void; +}): Promise { + const { + ctx, + body, + model, + tracer, + requestStartTime, + logProxyBody, + logFinalRequest, + } = args; + const codexCtx: ServerContext = { + ...ctx, + requestId: `${ctx.requestId}:codex-fallback`, + method: "POST", + path: "/backend-api/codex/responses", + headers: { + "content-type": "application/json", + accept: "text/event-stream", + }, + query: {}, + params: {}, + body: convertClaudeRequestToCodex(body, model), + // Keep the child attribution isolated until its stream has passed + // validation. A failed Codex attempt must not look like a served request. + responseHeaders: {}, + }; + const codexResponse = await handleCodexResponsesRequest(codexCtx); + const codexHeaders = { ...(codexCtx.responseHeaders ?? {}) }; + let parsed: CodexFallbackResult; + try { + parsed = await consumeCodexFallbackResponse(codexResponse); + } catch (error) { + if (error instanceof CodexFallbackResponseError) { + logger.always( + `[proxy] Codex fallback returned ${error.status}: ${sanitizeForLog(error.responseBody, 500)}`, + ); + } + throw error; + } + if (Object.keys(codexHeaders).length > 0) { + ctx.responseHeaders ??= {}; + Object.assign(ctx.responseHeaders, redactHeadersForBorrower(codexHeaders)); + } + + const accountLabel = codexHeaders["x-neurolink-account"] ?? ""; + const accountType = codexHeaders["x-neurolink-account-type"] ?? "codex-oauth"; + const internal: InternalResult = { + content: parsed.text, + // Keep the original Anthropic model in the client wire response. The + // attribution headers above expose that Codex served the fallback. + model: body.model, + finishReason: parsed.finishReason, + ...(parsed.usage ? { usage: parsed.usage } : {}), + toolCalls: parsed.toolCalls, + }; + + if (body.stream) { + const serializer = new ClaudeStreamSerializer( + body.model, + parsed.usage?.input ?? 0, + ); + const frames: string[] = []; + for (const frame of serializer.start()) { + frames.push(frame); + } + if (parsed.text) { + for (const frame of serializer.pushDelta(parsed.text)) { + frames.push(frame); + } + } + for (const toolCall of parsed.toolCalls) { + for (const frame of serializer.pushToolUse( + generateToolUseId(), + toolCall.toolName, + toolCall.args, + )) { + frames.push(frame); + } + } + for (const frame of serializer.finish( + parsed.usage?.output, + parsed.finishReason, + )) { + frames.push(frame); + } + + tracer?.end(200, Date.now() - requestStartTime); + logFinalRequest(200, accountLabel, accountType, undefined, undefined, { + inputTokens: parsed.usage?.input, + outputTokens: parsed.usage?.output, + cacheCreationTokens: parsed.usage?.cacheCreationTokens, + cacheReadTokens: parsed.usage?.cacheReadTokens, + }); + const bufferedBody = frames.join(""); + logProxyBody({ + phase: "client_response", + headers: { "content-type": "text/event-stream" }, + body: bufferedBody, + bodySize: Buffer.byteLength(bufferedBody, "utf8"), + contentType: "text/event-stream", + responseStatus: 200, + durationMs: Date.now() - requestStartTime, + }); + async function* sseGenerator(): AsyncIterable { + for (const frame of frames) { + yield frame; + } + } + return sseGenerator(); + } + + tracer?.end(200, Date.now() - requestStartTime); + const clientResponse = serializeClaudeResponse(internal, body.model); + logFinalRequest(200, accountLabel, accountType, undefined, undefined, { + inputTokens: parsed.usage?.input, + outputTokens: parsed.usage?.output, + cacheCreationTokens: parsed.usage?.cacheCreationTokens, + cacheReadTokens: parsed.usage?.cacheReadTokens, + }); + const clientResponseText = JSON.stringify(clientResponse); + logProxyBody({ + phase: "client_response", + headers: { "content-type": "application/json" }, + body: clientResponseText, + bodySize: Buffer.byteLength(clientResponseText, "utf8"), + contentType: "application/json", + responseStatus: 200, + durationMs: Date.now() - requestStartTime, + }); + return clientResponse; +} + /** * Try each borrowable peer in priority order once the local pool is spent. * @@ -4898,10 +5053,24 @@ async function tryBorrowFromPeers(args: { return null; } +function getCodexFallbackInvalidRequestFailure( + error: unknown, +): AnthropicInvalidRequestFailure | null { + if (!(error instanceof CodexFallbackResponseError) || error.status !== 400) { + return null; + } + return { + status: error.status, + body: error.responseBody, + contentType: "application/json", + }; +} + async function tryConfiguredClaudeFallbackChain(args: { ctx: ServerContext; body: ClaudeRequest; parsedFallbackRequest: ParsedClaudeRequest; + fallbackPlan?: ProxyTranslationPlan; modelRouter?: ModelRouterInterface; tracer?: ProxyTracer; requestStartTime: number; @@ -4919,24 +5088,30 @@ async function tryConfiguredClaudeFallbackChain(args: { cacheReadTokens?: number; }, ) => void; -}): Promise<{ response: unknown | null; lastErrorMessage?: string }> { +}): Promise<{ + response: unknown | null; + lastErrorMessage?: string; + invalidRequestFailure?: AnthropicInvalidRequestFailure; +}> { const { ctx, body, parsedFallbackRequest, + fallbackPlan: providedFallbackPlan, modelRouter, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args; - const chain = modelRouter?.getFallbackChain() ?? []; - const fallbackPlan = buildProxyTranslationPlan( - { provider: "anthropic", model: body.model }, - chain, - body.model, - parsedFallbackRequest, - ); + const fallbackPlan = + providedFallbackPlan ?? + buildProxyTranslationPlan( + { provider: "anthropic", model: body.model }, + modelRouter?.getFallbackChain() ?? [], + body.model, + parsedFallbackRequest, + ); logProxyBody({ phase: "routing_decision", contentType: "application/json", @@ -4952,52 +5127,68 @@ async function tryConfiguredClaudeFallbackChain(args: { reason: "all_anthropic_accounts_exhausted", }); let lastFallbackError: string | undefined; + let invalidRequestFailure: AnthropicInvalidRequestFailure | undefined; for (const fallback of fallbackPlan.attempts.slice(1)) { if (!fallback.provider || !fallback.model) { continue; } - - const availability = - await ProviderHealthChecker.checkFallbackProviderAvailability( - fallback.provider, - fallback.model, - ); - if (!availability.available) { - const reason = availability.reason ?? "provider unavailable"; - logger.always( - `[proxy] fallback ${fallback.provider}/${fallback.model} health-check failed (${reason}), skipping`, - ); - recordFallbackAttempt({ - provider: fallback.provider, - model: fallback.model, - status: "failure", - errorMessage: `[unavailable] ${reason}`, - durationMs: 0, - }); - lastFallbackError = `[${fallback.provider}/${fallback.model}] unavailable: ${reason}`; - continue; - } - const fallbackStart = Date.now(); try { logger.always( `[proxy] fallback → ${fallback.provider}/${fallback.model}`, ); - const options = buildProxyFallbackOptions(parsedFallbackRequest, { - provider: fallback.provider, - model: fallback.model, - }); - const response = await executeClaudeFallbackWithRetry({ - ctx, - body, - tracer, - requestStartTime, - logProxyBody, - logFinalRequest, - options: options as Parameters[0], - providerLabel: fallback.provider, - }); + let response: unknown; + if (fallback.provider === "codex") { + // Codex is a local OAuth account pool, not a generic SDK provider. + // Calling its native route preserves account rotation and cooldowns. + response = await executeClaudeCodexFallback({ + ctx, + body, + model: fallback.model, + tracer, + requestStartTime, + logProxyBody, + logFinalRequest, + }); + } else { + const availability = + await ProviderHealthChecker.checkFallbackProviderAvailability( + fallback.provider, + fallback.model, + ); + if (!availability.available) { + const reason = availability.reason ?? "provider unavailable"; + logger.always( + `[proxy] fallback ${fallback.provider}/${fallback.model} health-check failed (${reason}), skipping`, + ); + recordFallbackAttempt({ + provider: fallback.provider, + model: fallback.model, + status: "failure", + errorMessage: `[unavailable] ${reason}`, + durationMs: 0, + }); + lastFallbackError = `[${fallback.provider}/${fallback.model}] unavailable: ${reason}`; + continue; + } + const options = buildProxyFallbackOptions(parsedFallbackRequest, { + provider: fallback.provider, + model: fallback.model, + }); + response = await executeClaudeFallbackWithRetry({ + ctx, + body, + tracer, + requestStartTime, + logProxyBody, + logFinalRequest, + options: options as Parameters< + ServerContext["neurolink"]["stream"] + >[0], + providerLabel: fallback.provider, + }); + } recordFallbackAttempt({ provider: fallback.provider, model: fallback.model, @@ -5011,16 +5202,20 @@ async function tryConfiguredClaudeFallbackChain(args: { attemptCount: fallbackPlan.attempts.slice(1).length, reason: "fallback_success", }); - // A different provider produced this response — say so, and report no - // quota. Emitting the last Anthropic snapshot here would attribute one - // provider's capacity to another's output. - publishLimitHeaders(ctx, { - quota: null, - source: "none", - servedBy: fallback.provider, - }); + if (fallback.provider !== "codex") { + // A different provider produced this response — say so, and report no + // quota. Emitting the last Anthropic snapshot here would attribute one + // provider's capacity to another's output. + publishLimitHeaders(ctx, { + quota: null, + source: "none", + servedBy: fallback.provider, + }); + } return { response }; } catch (fallbackErr) { + invalidRequestFailure ??= + getCodexFallbackInvalidRequestFailure(fallbackErr) ?? undefined; const errMsg = redactProviderErrorMessage( fallbackErr instanceof Error ? fallbackErr.message @@ -5067,7 +5262,11 @@ async function tryConfiguredClaudeFallbackChain(args: { } } - return { response: null, lastErrorMessage: lastFallbackError }; + return { + response: null, + lastErrorMessage: lastFallbackError, + ...(invalidRequestFailure ? { invalidRequestFailure } : {}), + }; } async function tryAutoClaudeFallback(args: { @@ -5354,41 +5553,46 @@ function buildClaudeAnthropicFailureResponse(args: { ); } - if (invalidRequestFailure) { - tracer?.setError( - "invalid_request_error", - summarizeErrorMessage(invalidRequestFailure.body), + if (invalidRequestFailure && !sawRateLimit) { + const parsedUpstream = parseClaudeErrorBody(invalidRequestFailure.body); + const preserveUpstreamBody = parsedUpstream.message !== undefined; + const message = summarizeErrorMessage( + parsedUpstream.message ?? invalidRequestFailure.body, ); + const errorBodyText = preserveUpstreamBody + ? invalidRequestFailure.body + : JSON.stringify( + buildClaudeError( + invalidRequestFailure.status, + message, + "invalid_request_error", + ), + ); + const contentType = preserveUpstreamBody + ? (invalidRequestFailure.contentType ?? "application/json") + : "application/json"; + tracer?.setError("invalid_request_error", message); tracer?.end(invalidRequestFailure.status, Date.now() - requestStartTime); - try { - const parsedError = JSON.parse(invalidRequestFailure.body); - logFinalRequest( - invalidRequestFailure.status, - "", - "final", - "invalid_request_error", - summarizeErrorMessage(invalidRequestFailure.body), - ); - logProxyBody({ - phase: "client_response", - headers: { - "content-type": - invalidRequestFailure.contentType ?? "application/json", - }, - body: invalidRequestFailure.body, - bodySize: Buffer.byteLength(invalidRequestFailure.body, "utf8"), - contentType: invalidRequestFailure.contentType ?? "application/json", - responseStatus: invalidRequestFailure.status, - durationMs: Date.now() - requestStartTime, - }); - return parsedError; - } catch { - return buildLoggedClaudeError( - invalidRequestFailure.status, - summarizeErrorMessage(invalidRequestFailure.body), - "invalid_request_error", - ); - } + logFinalRequest( + invalidRequestFailure.status, + "", + "final", + "invalid_request_error", + message, + ); + logProxyBody({ + phase: "client_response", + headers: { "content-type": contentType }, + body: errorBodyText, + bodySize: Buffer.byteLength(errorBodyText, "utf8"), + contentType, + responseStatus: invalidRequestFailure.status, + durationMs: Date.now() - requestStartTime, + }); + return new Response(errorBodyText, { + status: invalidRequestFailure.status, + headers: { "content-type": contentType }, + }); } if ((sawNetworkError || sawTransientFailure) && !sawRateLimit) { @@ -7411,6 +7615,7 @@ async function handleAnthropicNonOkResponse(args: { contentType?: string; } | null; entitlementFailure: AnthropicEntitlementFailure | null; + allowConfiguredModelFallback?: boolean; }): Promise { const { response, @@ -7430,6 +7635,7 @@ async function handleAnthropicNonOkResponse(args: { sawTransientFailure, invalidRequestFailure, entitlementFailure, + allowConfiguredModelFallback = false, } = args; let currentLastError = lastError; let currentAuthFailureMessage = authFailureMessage; @@ -7673,6 +7879,30 @@ async function handleAnthropicNonOkResponse(args: { } if (response.status === 404) { + if ( + allowConfiguredModelFallback && + isAnthropicModelNotFound(response.status, errBody) + ) { + // An upstream model retirement is provider-wide, not an account failure. + // Do not cool or disable an account; leave the configured translation + // fallback eligible so legacy Anthropic aliases can use the Codex target. + logger.always( + `[proxy] ← 404 account=${account.label} model unavailable; trying configured fallback`, + ); + logAttempt(404, "not_found_error", summarizeErrorMessage(errBody)); + tracer?.setError("not_found_error", summarizeErrorMessage(errBody)); + tracer?.recordRetry(account.label, "model_not_found"); + currentLastError = summarizeErrorMessage(errBody); + return { + continueLoop: false, + lastError: currentLastError, + authFailureMessage: currentAuthFailureMessage, + sawTransientFailure: currentSawTransientFailure, + invalidRequestFailure: currentInvalidRequestFailure, + entitlementFailure: currentEntitlementFailure, + upstreamSpan: undefined, + }; + } logger.always(`[proxy] ← 404 account=${account.label}`); logAttempt(404, "not_found_error", summarizeErrorMessage(errBody)); tracer?.setError("not_found_error", summarizeErrorMessage(errBody)); @@ -8483,6 +8713,30 @@ function shouldAttemptClaudeFallback(loopState: AnthropicLoopState): boolean { return loopState.invalidRequestFailure === null; } +function buildDeferredClaudeAccountFailureResponse(args: { + ctx: ServerContext; + tracer?: ProxyTracer; + requestStartTime: number; + failure: DeferredClaudeAccountFailure; + buildLoggedClaudeError: ClaudeLoggedErrorBuilder; +}): unknown { + const { ctx, tracer, requestStartTime, failure, buildLoggedClaudeError } = + args; + if (failure.responseHeaders) { + ctx.responseHeaders = { + ...(ctx.responseHeaders ?? {}), + ...failure.responseHeaders, + }; + } + tracer?.setError(failure.errorType, failure.message); + tracer?.end(failure.status, Date.now() - requestStartTime); + return buildLoggedClaudeError( + failure.status, + failure.message, + failure.errorType, + ); +} + async function handleAnthropicRoutedClaudeRequest(args: { ctx: ServerContext; body: ClaudeRequest; @@ -8518,30 +8772,75 @@ async function handleAnthropicRoutedClaudeRequest(args: { setRoutingDecision, } = args; const parsedRequest = parseClaudeRequest(body); + const configuredFallbackPlan = buildProxyTranslationPlan( + { provider: "anthropic", model: body.model }, + modelRouter?.getFallbackChain() ?? [], + body.model, + parsedRequest, + ); + const hasConfiguredFallback = configuredFallbackPlan.attempts + .slice(1) + .some((attempt) => Boolean(attempt.provider && attempt.model)); const loadedAccounts = await loadClaudeProxyAccounts({ ctx, body, - tracer, - requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, - buildLoggedClaudeError, setRoutingDecision, }); - if ("response" in loadedAccounts) { + if ("failure" in loadedAccounts) { // No usable local account. A node that has none of its own — or whose only // accounts are disabled — is still entitled to borrow: that is the whole - // point of being lent capacity. Peers are tried before the credentials - // error is returned, and the error stands if none of them serves. + // point of being lent capacity. Peers and explicitly configured fallbacks + // are tried before returning the credential error. const peerOnlyResult = await tryBorrowFromPeers({ body, logFinalRequest }); if (peerOnlyResult) { return peerOnlyResult; } - return loadedAccounts.response; + const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({ + ctx, + body, + parsedFallbackRequest: parsedRequest, + fallbackPlan: configuredFallbackPlan, + modelRouter, + tracer, + requestStartTime, + logProxyBody, + logFinalRequest, + }); + if (configuredFallbackResult.response) { + return configuredFallbackResult.response; + } + if (configuredFallbackResult.invalidRequestFailure) { + return buildClaudeAnthropicFailureResponse({ + tracer, + requestStartTime, + authFailureMessage: null, + authCooldownMessage: null, + invalidRequestFailure: configuredFallbackResult.invalidRequestFailure, + entitlementFailure: null, + scopedExhaustion: null, + sawNetworkError: false, + sawTransientFailure: false, + sawRateLimit: false, + lastError: undefined, + orderedAccounts: [], + buildLoggedClaudeError, + logProxyBody, + logFinalRequest, + }); + } + return buildDeferredClaudeAccountFailureResponse({ + ctx, + tracer, + requestStartTime, + failure: loadedAccounts.failure, + buildLoggedClaudeError, + }); } const { @@ -8999,6 +9298,7 @@ async function handleAnthropicRoutedClaudeRequest(args: { sawTransientFailure: loopState.sawTransientFailure, invalidRequestFailure: loopState.invalidRequestFailure, entitlementFailure: loopState.entitlementFailure, + allowConfiguredModelFallback: hasConfiguredFallback, }); loopState.lastError = nonOkResult.lastError; loopState.authFailureMessage = nonOkResult.authFailureMessage; @@ -9123,6 +9423,7 @@ async function handleAnthropicRoutedClaudeRequest(args: { ctx, body, parsedFallbackRequest: parsedRequest, + fallbackPlan: configuredFallbackPlan, modelRouter, tracer, requestStartTime, @@ -9132,11 +9433,25 @@ async function handleAnthropicRoutedClaudeRequest(args: { if (configuredFallbackResult.response) { return configuredFallbackResult.response; } + if ( + configuredFallbackResult.invalidRequestFailure && + !loopState.sawRateLimit + ) { + // A converted Codex request can be rejected independently of the original + // Anthropic request. Preserve a real pool 429 as the actionable terminal + // response when both occurred. + loopState.invalidRequestFailure = + configuredFallbackResult.invalidRequestFailure; + } fallbackFailureMessage = configuredFallbackResult.lastErrorMessage; // A translation-layer-selected provider is only permitted by an explicit // routing setting. Empty fallback chains otherwise stay within OAuth. - if (!loopState.sawRateLimit && modelRouter?.isAutoFallbackEnabled?.()) { + if ( + loopState.invalidRequestFailure === null && + !loopState.sawRateLimit && + modelRouter?.isAutoFallbackEnabled?.() + ) { const autoFallbackResult = await tryAutoClaudeFallback({ ctx, body, @@ -10569,6 +10884,21 @@ export function isInvalidRequestError( ); } +/** + * A 404 for a retired model can be served by an explicitly configured fallback; + * other 404s remain terminal so a bad endpoint or resource is never disguised. + */ +function isAnthropicModelNotFound(status: number, errBody: string): boolean { + if (status !== 404) { + return false; + } + const parsed = parseClaudeErrorBody(errBody); + return ( + parsed.errorType === "not_found_error" && + (parsed.message ?? "").toLowerCase().includes("model") + ); +} + /** * A subscription-specific beta rejection. Anthropic returns * `400 invalid_request_error` with a message like @@ -10864,7 +11194,10 @@ export const __testHooks = { redactProviderErrorMessage, isUpstreamOverload, getOverloadRotationDelayMs, + redactHeadersForBorrower, shouldAttemptClaudeFallback, + isAnthropicModelNotFound, + getCodexFallbackInvalidRequestFailure, executeClaudeFallbackWithRetry, buildClaudeAnthropicFailureResponse, isAccountEntitlementError, diff --git a/src/lib/server/routes/codexProxyRoutes.ts b/src/lib/server/routes/codexProxyRoutes.ts index cf050f663..6c8d141e5 100644 --- a/src/lib/server/routes/codexProxyRoutes.ts +++ b/src/lib/server/routes/codexProxyRoutes.ts @@ -312,7 +312,7 @@ function publishCodexHeaders( } /** Core pooled handler for POST /backend-api/codex/responses. */ -async function handleCodexResponsesRequest( +export async function handleCodexResponsesRequest( ctx: ServerContext, ): Promise { const requestStartTime = Date.now(); diff --git a/src/lib/types/claudeProxy.ts b/src/lib/types/claudeProxy.ts new file mode 100644 index 000000000..a097037f5 --- /dev/null +++ b/src/lib/types/claudeProxy.ts @@ -0,0 +1,18 @@ +/** + * Shared internal types for the Anthropic-compatible proxy route. + */ + +/** A deterministic upstream validation failure that must reach the caller. */ +export type AnthropicInvalidRequestFailure = { + status: number; + body: string; + contentType?: string; +}; + +/** A credential failure deferred until peer and provider fallbacks are tried. */ +export type DeferredClaudeAccountFailure = { + status: number; + message: string; + errorType: string; + responseHeaders?: Record; +}; diff --git a/src/lib/types/codex.ts b/src/lib/types/codex.ts index c70353e0c..b32f4e3d1 100644 --- a/src/lib/types/codex.ts +++ b/src/lib/types/codex.ts @@ -12,7 +12,11 @@ * maps onto the session fields and its secondary window onto the weekly fields. */ -import type { AccountCoolingReason, AccountQuota } from "./proxy.js"; +import type { + AccountCoolingReason, + AccountQuota, + InternalResult, +} from "./proxy.js"; /** Token block inside `~/.codex/auth.json`. */ export type CodexAuthFileTokens = { @@ -99,3 +103,61 @@ export type CodexRuntimeAccount = { * spent record — nothing else ever reaps it. */ expiredCooldownUntil?: number; }; + +/** Provider-qualified account identity used by proxy status rendering. */ +export type CodexProxyStatusAccountIdentity = + | { provider: "anthropic"; key: string } + | { provider: "codex"; key: string } + | { provider: "other"; key: null }; + +/** A text or image content part accepted by the Codex Responses backend. */ +export type CodexContentPart = + | { type: "input_text"; text: string } + | { type: "output_text"; text: string } + | { type: "input_image"; image_url: string }; + +/** A single item in a Codex Responses request. */ +export type CodexResponsesInputItem = + | { + role: "user" | "assistant"; + content: CodexContentPart[]; + } + | { + type: "function_call"; + call_id: string; + name: string; + arguments: string; + } + | { + type: "function_call_output"; + call_id: string; + output: string; + }; + +/** Request shape used to bridge Anthropic Messages traffic to Codex Responses. */ +export type CodexResponsesRequest = { + model: string; + input: CodexResponsesInputItem[]; + stream: true; + store: false; + instructions?: string; + tools?: Array<{ + type: "function"; + name: string; + description?: string; + parameters: Record; + }>; + tool_choice?: + | "auto" + | "required" + | "none" + | { type: "function"; name: string }; +}; + +/** Fully buffered Codex result rendered back as an Anthropic response. */ +export type CodexFallbackResult = { + text: string; + toolCalls: NonNullable; + usage?: NonNullable; + finishReason: "end_turn" | "tool_use"; +}; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index c7a47e57f..b2a796ece 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -12,6 +12,7 @@ export * from "./artifact.js"; export * from "./auth.js"; export * from "./autoresearch.js"; export * from "./circuitBreakerErrors.js"; +export * from "./claudeProxy.js"; export * from "./cli.js"; export * from "./client.js"; export * from "./codex.js"; diff --git a/test/continuous-test-suite-codex.ts b/test/continuous-test-suite-codex.ts index b7937d618..2f6d08277 100644 --- a/test/continuous-test-suite-codex.ts +++ b/test/continuous-test-suite-codex.ts @@ -51,9 +51,21 @@ import { import { codexRateLimitsToQuota, parseCodexRateLimitHeaders, + resolveProxyStatusAccountIdentity, } from "../src/lib/proxy/codexAccountUsage.js"; +import { + CodexFallbackResponseError, + consumeCodexFallbackResponse, + convertClaudeRequestToCodex, + parseCodexFallbackSSE, +} from "../src/lib/proxy/codexFallback.js"; +import { runWithShareContext } from "../src/lib/proxy/shareContext.js"; +import { __testHooks as claudeProxyTestHooks } from "../src/lib/server/routes/claudeProxyRoutes.js"; import { __testHooks } from "../src/lib/server/routes/codexProxyRoutes.js"; -import type { CodexRuntimeAccount } from "../src/lib/types/index.js"; +import type { + CodexRuntimeAccount, + ProxyShareRequestContext, +} from "../src/lib/types/index.js"; import { assert, assertEqual, defineSuite, runCLI } from "./helpers/harness.js"; const { test, runSuite } = defineSuite("Codex Pool Engine", { @@ -63,6 +75,13 @@ const { test, runSuite } = defineSuite("Codex Pool Engine", { /** Fixed clock so reset arithmetic is deterministic. */ const NOW = 1_800_000_000_000; const NOW_SECONDS = Math.floor(NOW / 1000); +const BORROWED_REQUEST_CONTEXT: ProxyShareRequestContext = { + grantId: "test-grant", + peerLabel: "test-peer", + level: "live", + gates: {}, + ledger: "unlimited", +}; function account( label: string, @@ -76,6 +95,77 @@ function account( } as CodexRuntimeAccount; } +// --------------------------------------------------------------------------- +// Status identity +// --------------------------------------------------------------------------- + +await test("status keeps same-label Anthropic and Codex cooldowns separate", () => { + const label = "shared@example.com"; + const anthropic = resolveProxyStatusAccountIdentity(label, "oauth"); + const codex = resolveProxyStatusAccountIdentity(label, "codex-oauth"); + + assertEqual( + anthropic.key, + "anthropic:shared@example.com", + "Anthropic status must use the Anthropic account key", + ); + assertEqual( + codex.key, + "codex:shared@example.com", + "Codex status must use the Codex account key", + ); + assert( + anthropic.provider !== codex.provider && anthropic.key !== codex.key, + "a shared email must not share provider cooldown state", + ); +}); + +await test("status treats Anthropic API-key accounts as Anthropic", () => { + const apiKey = resolveProxyStatusAccountIdentity("env", "api_key"); + assertEqual( + apiKey.provider, + "anthropic", + "Anthropic API-key rows must retain their provider identity", + ); + assertEqual( + apiKey.key, + "anthropic:env", + "Anthropic API-key rows must use the Anthropic cooldown key", + ); +}); + +await test("borrowed Codex fallback hides lender attribution headers", () => { + const headers = runWithShareContext(BORROWED_REQUEST_CONTEXT, () => + claudeProxyTestHooks.redactHeadersForBorrower({ + "x-neurolink-account": "lender@example.com", + "x-neurolink-account-type": "codex-oauth", + "x-neurolink-pool-accounts": "3", + "x-neurolink-served-by": "codex", + }), + ); + + assertEqual( + headers["x-neurolink-account"], + undefined, + "borrowed fallback exposed the lender account label", + ); + assertEqual( + headers["x-neurolink-account-type"], + undefined, + "borrowed fallback exposed the lender account type", + ); + assertEqual( + headers["x-neurolink-pool-accounts"], + undefined, + "borrowed fallback exposed lender pool metadata", + ); + assertEqual( + headers["x-neurolink-served-by"], + "codex", + "borrowed fallback hid provider attribution", + ); +}); + // --------------------------------------------------------------------------- // Ordering // --------------------------------------------------------------------------- @@ -990,4 +1080,535 @@ await test("codex capture holds its byte cap against a single oversized chunk", } }); +// --------------------------------------------------------------------------- +// Claude -> Codex fallback wire bridge +// --------------------------------------------------------------------------- + +await test("Claude fallback converts system, history, tools, and tool results", () => { + const request = convertClaudeRequestToCodex( + { + model: "claude-sonnet-4-20250514", + max_tokens: 777, + system: [ + { type: "text", text: "Follow the system policy." }, + { type: "text", text: "Return concise answers." }, + ], + temperature: 0.3, + top_p: 0.8, + messages: [ + { role: "user", content: "Find the release status." }, + { + role: "assistant", + content: [ + { type: "text", text: "I will look it up." }, + { + type: "tool_use", + id: "toolu_lookup", + name: "lookup_release", + input: { branch: "release" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_lookup", + content: "release is healthy", + }, + { type: "text", text: "Summarize that result." }, + ], + }, + ], + tools: [ + { + name: "lookup_release", + description: "Read release status.", + input_schema: { + type: "object", + properties: { branch: { type: "string" } }, + required: ["branch"], + }, + }, + ], + tool_choice: { type: "tool", name: "lookup_release" }, + }, + "gpt-5.6-terra", + ); + + assertEqual(request.model, "gpt-5.6-terra", "fallback model was not used"); + assertEqual(request.store, false, "Codex backend requires store=false"); + assertEqual(request.stream, true, "fallback must buffer an SSE response"); + assertEqual( + request.instructions, + "Follow the system policy.\n\nReturn concise answers.", + "system blocks were not preserved as Codex instructions", + ); + assert( + !("max_output_tokens" in request), + "Anthropic max_tokens must not reach the Codex backend", + ); + assert( + !("temperature" in request), + "Anthropic temperature must not reach the Codex backend", + ); + assert( + !("top_p" in request), + "Anthropic top_p must not reach the Codex backend", + ); + assertEqual( + JSON.stringify(request.tool_choice), + JSON.stringify({ type: "function", name: "lookup_release" }), + "named tool choice was not translated", + ); + assertEqual( + request.tools?.[0]?.type, + "function", + "tool type was not translated", + ); + assertEqual( + (request.tools?.[0]?.parameters.required as string[] | undefined)?.[0], + "branch", + "tool schema was not preserved", + ); + assertEqual(request.input.length, 5, "conversation items were not preserved"); + assertEqual( + (request.input[1] as { role?: string }).role, + "assistant", + "assistant history lost its role", + ); + assertEqual( + (request.input[2] as { type?: string }).type, + "function_call", + "assistant tool use was not translated", + ); + assertEqual( + (request.input[3] as { type?: string }).type, + "function_call_output", + "tool result was not translated", + ); + assertEqual( + (request.input[3] as { call_id?: string }).call_id, + "toolu_lookup", + "tool result no longer matches the prior tool call", + ); +}); + +await test("Claude fallback maps automatic and required tool choices", () => { + const base = { + model: "claude-sonnet-4-20250514", + max_tokens: 64, + messages: [{ role: "user" as const, content: "Use a tool." }], + }; + const auto = convertClaudeRequestToCodex( + { ...base, tool_choice: { type: "auto" } }, + "gpt-5.6-terra", + ); + const required = convertClaudeRequestToCodex( + { ...base, tool_choice: { type: "any" } }, + "gpt-5.6-terra", + ); + const none = convertClaudeRequestToCodex( + { ...base, tool_choice: { type: "none" } }, + "gpt-5.6-terra", + ); + assertEqual(auto.tool_choice, "auto", "automatic tool choice changed"); + assertEqual( + required.tool_choice, + "required", + "required tool choice was not translated", + ); + assertEqual(none.tool_choice, "none", "disabled tool choice changed"); +}); + +await test("Codex fallback parses text SSE and usage only after completion", () => { + const parsed = parseCodexFallbackSSE( + [ + "event: response.output_text.delta", + 'data: {"type":"response.output_text.delta","delta":"Hello "}', + "", + "event: response.output_text.delta", + 'data: {"type":"response.output_text.delta","delta":"world"}', + "", + "event: response.completed", + 'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":12,"output_tokens":5,"input_tokens_details":{"cached_tokens":3,"cache_write_tokens":2}}}}', + "", + ].join("\n"), + ); + assertEqual(parsed.text, "Hello world", "text deltas were not joined"); + assertEqual(parsed.finishReason, "end_turn", "text finish reason changed"); + assertEqual(parsed.usage?.input, 12, "input usage was not read"); + assertEqual(parsed.usage?.output, 5, "output usage was not read"); + assertEqual( + parsed.usage?.cacheReadTokens, + 3, + "cache-read usage was not read", + ); + assertEqual( + parsed.usage?.cacheCreationTokens, + 2, + "cache-creation usage was not read", + ); +}); + +await test("Codex fallback ignores unrecognized SSE extension fields", () => { + const parsed = parseCodexFallbackSSE( + [ + "event: response.output_text.delta", + "trace: upstream-extension", + "field-without-a-value", + 'data: {"type":"response.output_text.delta","delta":"Hello"}', + "", + "event: response.completed", + 'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}', + "", + ].join("\n"), + ); + assertEqual( + parsed.text, + "Hello", + "extension fields discarded a valid response", + ); +}); + +await test("Codex fallback parses completed function calls", () => { + const parsed = parseCodexFallbackSSE( + [ + "event: response.output_item.done", + 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_123","name":"lookup_release","arguments":"{\\"branch\\":\\"release\\"}"}}', + "", + "event: response.completed", + 'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":9,"output_tokens":4}}}', + "", + ].join("\n"), + ); + assertEqual(parsed.text, "", "tool-only response invented text"); + assertEqual(parsed.finishReason, "tool_use", "tool finish reason changed"); + assertEqual(parsed.toolCalls.length, 1, "function call was not captured"); + assertEqual( + parsed.toolCalls[0]?.toolCallId, + "call_123", + "function call id was not preserved", + ); + assertEqual( + parsed.toolCalls[0]?.args.branch as string, + "release", + "function call arguments were not parsed", + ); +}); + +await test("Codex fallback recovers final text when delta events are absent", () => { + const parsed = parseCodexFallbackSSE( + [ + "event: response.output_item.done", + 'data: {"type":"response.output_item.done","item":{"type":"message","content":[{"type":"output_text","text":"final answer"}]}}', + "", + "event: response.completed", + 'data: {"type":"response.completed","response":{"status":"completed"}}', + "", + ].join("\n"), + ); + assertEqual( + parsed.text, + "final answer", + "completed message text was not used as a fallback", + ); +}); + +await test("Codex fallback rejects empty, malformed, and incomplete streams", () => { + const cases = [ + [ + "empty", + [ + "event: response.completed", + 'data: {"type":"response.completed","response":{"status":"completed"}}', + "", + ].join("\n"), + ], + [ + "malformed", + ["event: response.output_text.delta", "data: {not-json}", ""].join("\n"), + ], + [ + "incomplete", + [ + "event: response.output_text.delta", + 'data: {"type":"response.output_text.delta","delta":"partial"}', + "", + ].join("\n"), + ], + ] as const; + for (const [name, stream] of cases) { + let rejected = false; + try { + parseCodexFallbackSSE(stream); + } catch { + rejected = true; + } + assert(rejected, `${name} Codex stream was treated as a success`); + } +}); + +await test("Codex fallback consumes non-success responses before rejecting", async () => { + const response = new Response('{"error":{"message":"quota exhausted"}}', { + status: 429, + headers: { "content-type": "application/json" }, + }); + let rejected = false; + try { + await consumeCodexFallbackResponse(response); + } catch { + rejected = true; + } + assert(rejected, "non-success Codex response was treated as a success"); + assert(response.bodyUsed, "non-success Codex response body was not consumed"); +}); + +await test("an unavailable Anthropic model remains eligible for configured fallback", async () => { + const account = { + key: "anthropic:legacy@example.com", + label: "legacy@example.com", + token: "test-token", + type: "oauth" as const, + }; + const run = ( + allowConfiguredModelFallback: boolean, + message = "model: claude-3-5-sonnet", + ) => + claudeProxyTestHooks.handleAnthropicNonOkResponse({ + response: new Response( + JSON.stringify({ + type: "error", + error: { + type: "not_found_error", + message, + }, + }), + { status: 404, headers: { "content-type": "application/json" } }, + ), + account: account as never, + accountState: { + consecutiveRefreshFailures: 0, + permanentlyDisabled: false, + } as never, + enabledAccounts: [account] as never, + orderedAccounts: [account] as never, + requestStartTime: Date.now(), + fetchStartMs: Date.now(), + attemptNumber: 1, + logAttempt: () => undefined, + logProxyBody: () => undefined, + logFinalRequest: () => undefined, + lastError: undefined, + authFailureMessage: null, + sawTransientFailure: false, + invalidRequestFailure: null, + entitlementFailure: null, + allowConfiguredModelFallback, + }); + + const fallbackEligible = await run(true); + assertEqual( + fallbackEligible.continueLoop, + false, + "model-not-found should end the Anthropic account loop", + ); + assertEqual( + fallbackEligible.response, + undefined, + "model-not-found should reach the configured fallback", + ); + assertEqual( + fallbackEligible.invalidRequestFailure, + null, + "model-not-found should not suppress configured fallback", + ); + + const terminal = await run(false); + const terminalError = terminal.response as { + type?: string; + error?: { type?: string }; + }; + if (!terminalError) { + throw new Error("unconfigured model-not-found did not remain terminal"); + } + assertEqual( + terminalError.type, + "error", + "unconfigured model-not-found did not retain Claude error shape", + ); + assertEqual( + terminalError.error?.type, + "not_found_error", + "unconfigured model-not-found did not retain error type", + ); + + const unrelated = await run(true, "resource: missing-file"); + const unrelatedError = unrelated.response as { + type?: string; + error?: { type?: string }; + }; + if (!unrelatedError) { + throw new Error("unrelated 404 did not remain terminal"); + } + assertEqual( + unrelatedError.error?.type, + "not_found_error", + "configured fallback captured an unrelated 404", + ); +}); + +await test("Codex fallback preserves a request error without an Anthropic cooldown", async () => { + const responseBody = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "The image data you provided does not represent a valid image.", + }, + }); + const invalidRequestFailure = + claudeProxyTestHooks.getCodexFallbackInvalidRequestFailure( + new CodexFallbackResponseError(400, responseBody), + ); + assert(invalidRequestFailure !== null, "Codex 400 was not retained"); + assertEqual( + claudeProxyTestHooks.getCodexFallbackInvalidRequestFailure( + new CodexFallbackResponseError(429, responseBody), + ), + null, + "Codex quota errors must not be treated as invalid requests", + ); + + const result = claudeProxyTestHooks.buildClaudeAnthropicFailureResponse({ + tracer: undefined, + requestStartTime: Date.now(), + authFailureMessage: null, + authCooldownMessage: null, + invalidRequestFailure, + entitlementFailure: null, + scopedExhaustion: null, + sawNetworkError: false, + sawTransientFailure: false, + sawRateLimit: false, + lastError: undefined, + orderedAccounts: [], + buildLoggedClaudeError: (() => { + throw new Error("invalid request should build a direct HTTP response"); + }) as never, + logProxyBody: () => undefined, + logFinalRequest: () => undefined, + }); + if (!(result instanceof Response)) { + throw new Error("invalid request did not retain HTTP status"); + } + assertEqual(result.status, 400, "Codex 400 was rewritten unexpectedly"); + const body = (await result.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + assertEqual(body.type, "error", "response was not Claude-compatible"); + assertEqual( + body.error?.type, + "invalid_request_error", + "response did not retain invalid_request_error", + ); + assert( + body.error?.message?.includes("does not represent a valid image") === true, + "response did not retain the Codex validation message", + ); +}); + +await test("Claude invalid requests retain structured upstream error details", async () => { + const responseBody = JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: `The invalid field is messages[0].content: ${"x".repeat(220)}`, + details: { error_code: "invalid_field" }, + }, + }); + const result = claudeProxyTestHooks.buildClaudeAnthropicFailureResponse({ + tracer: undefined, + requestStartTime: Date.now(), + authFailureMessage: null, + authCooldownMessage: null, + invalidRequestFailure: { + status: 400, + body: responseBody, + contentType: "application/vnd.anthropic+json; charset=utf-8", + }, + entitlementFailure: null, + scopedExhaustion: null, + sawNetworkError: false, + sawTransientFailure: false, + sawRateLimit: false, + lastError: undefined, + orderedAccounts: [], + buildLoggedClaudeError: (() => { + throw new Error("invalid request should build a direct HTTP response"); + }) as never, + logProxyBody: () => undefined, + logFinalRequest: () => undefined, + }); + if (!(result instanceof Response)) { + throw new Error("invalid request did not retain HTTP status"); + } + assertEqual(result.status, 400, "invalid request did not retain HTTP status"); + assertEqual( + result.headers.get("content-type"), + "application/vnd.anthropic+json; charset=utf-8", + "invalid request did not retain the upstream content type", + ); + assertEqual( + await result.text(), + responseBody, + "invalid request discarded structured upstream error detail", + ); +}); + +await test("Anthropic cooldown wins over a Codex fallback request error", async () => { + const invalidRequestFailure = + claudeProxyTestHooks.getCodexFallbackInvalidRequestFailure( + new CodexFallbackResponseError( + 400, + JSON.stringify({ error: { message: "invalid converted request" } }), + ), + ); + assert(invalidRequestFailure !== null, "Codex 400 was not retained"); + + const result = claudeProxyTestHooks.buildClaudeAnthropicFailureResponse({ + tracer: undefined, + requestStartTime: Date.now(), + authFailureMessage: null, + authCooldownMessage: null, + invalidRequestFailure, + entitlementFailure: null, + scopedExhaustion: null, + sawNetworkError: false, + sawTransientFailure: false, + sawRateLimit: true, + lastError: undefined, + orderedAccounts: [], + buildLoggedClaudeError: (() => { + throw new Error("rate limit should build a direct HTTP response"); + }) as never, + logProxyBody: () => undefined, + logFinalRequest: () => undefined, + }); + if (!(result instanceof Response)) { + throw new Error("rate limit did not retain HTTP status"); + } + assertEqual(result.status, 429, "Codex 400 masked the Anthropic cooldown"); + const body = (await result.json()) as { + type?: string; + error?: { type?: string }; + }; + assertEqual(body.type, "error", "response was not Claude-compatible"); + assertEqual( + body.error?.type, + "overloaded_error", + "response did not retain rate-limit semantics", + ); +}); + await runSuite();