@bitkyc08/opencodex 2.82.0 → 2.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
|
@@ -197,6 +197,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
197
197
|
{ method: "POST", path: "/api/codex-prompt/repair", module: "server/management/codex-prompt-routes", mutates: true, exempt: { reason: "session-only", why: "Prompt repair requires the gui-session principal (codex-prompt-routes.ts:298)." } },
|
|
198
198
|
{ method: "PUT", path: "/api/codex-prompt/base", module: "server/management/codex-prompt-routes", mutates: true, exempt: { reason: "session-only", why: "Base prompt write requires the gui-session principal (codex-prompt-routes.ts:298)." } },
|
|
199
199
|
{ method: "PUT", path: "/api/codex-prompt/base/select", module: "server/management/codex-prompt-routes", mutates: true, exempt: { reason: "session-only", why: "Base prompt selection requires the gui-session principal (codex-prompt-routes.ts:298)." } },
|
|
200
|
+
{ method: "POST", path: "/api/codex-prompt/base/import", module: "server/management/codex-prompt-routes", mutates: true, exempt: { reason: "session-only", why: "Base prompt import requires the gui-session principal (codex-prompt-routes.ts:298)." } },
|
|
200
201
|
{ method: "PUT", path: "/api/codex-prompt/custom", module: "server/management/codex-prompt-routes", mutates: true, exempt: { reason: "session-only", why: "Custom prompt write requires the gui-session principal (codex-prompt-routes.ts:298)." } },
|
|
201
202
|
{ method: "PUT", path: "/api/codex-prompt/toggle", module: "server/management/codex-prompt-routes", mutates: true, exempt: { reason: "session-only", why: "Prompt toggle requires the gui-session principal (codex-prompt-routes.ts:298)." } },
|
|
202
203
|
// server/management/combo-routes
|
|
@@ -361,8 +362,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
361
362
|
{ method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false },
|
|
362
363
|
{ method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false },
|
|
363
364
|
{ method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true },
|
|
364
|
-
{ method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/
|
|
365
|
-
{ method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/
|
|
365
|
+
{ method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
|
|
366
|
+
{ method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
|
|
366
367
|
// server/management/request-history-routes
|
|
367
368
|
{ method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false },
|
|
368
369
|
// server/management/routing-analytics-routes
|
|
@@ -390,11 +391,12 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
390
391
|
// server/management/link-routes
|
|
391
392
|
{ method: "GET", path: "/api/link/status", module: "server/management/link-routes", mutates: false },
|
|
392
393
|
{ method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard surface: a paired session, or on a standalone runtime the current loopback dashboard session on trusted loopback ingress; admin-token and Tailscale identity sessions are refused." } },
|
|
393
|
-
{ method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true
|
|
394
|
-
{ method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true
|
|
394
|
+
{ method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true },
|
|
395
|
+
{ method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true },
|
|
395
396
|
{ method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It requires an operator-paired dashboard session and a pre-join notice; unpaired standalone loopback, admin-token and Tailscale identity sessions are refused." } },
|
|
396
|
-
{ method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true
|
|
397
|
+
{ method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true },
|
|
397
398
|
{ method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
|
|
399
|
+
{ method: "POST", path: "/api/link/{id}/reconnect", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
|
|
398
400
|
{ method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
|
|
399
401
|
// server/management/remote-workspace-routes
|
|
400
402
|
{ method: "GET", path: "/api/remote-workspace", module: "server/management/remote-workspace-routes", mutates: false },
|
|
@@ -3,6 +3,7 @@ import { captureInitialSelectionBaseline, finalizeInitialModelSelection } from "
|
|
|
3
3
|
import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
|
|
4
4
|
import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather";
|
|
5
5
|
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
|
|
6
|
+
import { isPassiveRead } from "../../lib/passive-read";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
7
8
|
import type { CatalogModel } from "../../codex/catalog";
|
|
8
9
|
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
@@ -291,6 +292,8 @@ export async function fetchAllModels(
|
|
|
291
292
|
/** Management renders disabled rows using this detached state after inventory drift. */
|
|
292
293
|
publishProjection?: (projection: OcxConfig) => void,
|
|
293
294
|
): Promise<CatalogModel[]> {
|
|
295
|
+
// A Remote Link read: no upstream, no initial selection, no write (#6959).
|
|
296
|
+
if (isPassiveRead()) return [];
|
|
294
297
|
const { gatherRoutedModels } = await import("../../codex/catalog");
|
|
295
298
|
const baseline = captureInitialSelectionBaseline(config);
|
|
296
299
|
const discoveryBaseline = captureModelDiscoveryBaseline(config);
|
|
@@ -54,7 +54,7 @@ import { noteExplicitShutdownRequested } from "./management/system-restart";
|
|
|
54
54
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
|
|
55
55
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
|
|
56
56
|
import type { PersistedUsageAttempt } from "../usage/log";
|
|
57
|
-
import { isAllowedManagementOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
|
|
57
|
+
import { inheritTrustedHubManagementRequest, isAllowedManagementOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
|
|
58
58
|
import { applySystemEnvToggle } from "./system-env";
|
|
59
59
|
|
|
60
60
|
import type { ManagementApiDeps } from "./management/context";
|
|
@@ -242,7 +242,9 @@ export async function handleManagementAPI(
|
|
|
242
242
|
if (verified.status !== 200) {
|
|
243
243
|
return jsonResponse({ error: "account switch body rejected" }, verified.status, req, config);
|
|
244
244
|
}
|
|
245
|
+
const sourceReq = req;
|
|
245
246
|
req = new Request(req.url, { method: req.method, headers: req.headers, body: Buffer.from(verified.body) });
|
|
247
|
+
inheritTrustedHubManagementRequest(sourceReq, req);
|
|
246
248
|
}
|
|
247
249
|
// Management bodies are small JSON (provider names, key ids, settings). Reject oversized
|
|
248
250
|
// payloads before any handler buffers them — the data plane has its own decompression cap.
|
|
@@ -115,6 +115,8 @@ import {
|
|
|
115
115
|
type RequestLogContext,
|
|
116
116
|
} from "./request-log";
|
|
117
117
|
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
|
|
118
|
+
import { anthropicBillingPhysicalFetch } from "../providers/anthropic-billing-ownership";
|
|
119
|
+
import { rotateAnthropicBillingKeyOnResponse } from "../providers/anthropic-billing-recovery";
|
|
118
120
|
import { linkAbortSignal } from "./responses/core-lifetime";
|
|
119
121
|
import { attachRequestSpendTracker } from "./responses/request-spend";
|
|
120
122
|
import { workflowRefusalResponse } from "./workflow-refusal";
|
|
@@ -441,6 +443,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
441
443
|
if (oauthBinding) logCtx.provider = formatAnthropicProviderForLog(route.providerName, oauthBinding.snapshot.accountId, config);
|
|
442
444
|
const spendTracker = attachRequestSpendTracker(req, logCtx);
|
|
443
445
|
const physicalBudget = createInferenceSendBudget(req, logCtx);
|
|
446
|
+
// Observe-only spend is already booked by the tracker; count billing sends without booking again.
|
|
447
|
+
let physicalSendsUsed = 0;
|
|
444
448
|
let activeRequest: AnthropicMessagesPassthroughRequest;
|
|
445
449
|
let retainedRequestBytes = 0;
|
|
446
450
|
const releaseRetainedRequest = () => {
|
|
@@ -589,8 +593,9 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
589
593
|
// exact incarnation across its await; a returned response can never reserve one.
|
|
590
594
|
sendOwner = snapshot ? captureAnthropicPhysicalSendOwnership(snapshot) : null;
|
|
591
595
|
if (snapshot && !sendOwner) throw new NativeOAuthSelectionChangedError();
|
|
596
|
+
physicalSendsUsed++;
|
|
592
597
|
dispatched = await sendWithConnectionPolicy(
|
|
593
|
-
(activeProvider as OcxProviderTransport).fetch ?? execute,
|
|
598
|
+
anthropicBillingPhysicalFetch((activeProvider as OcxProviderTransport).fetch ?? execute, route.providerName, activeProvider),
|
|
594
599
|
wire.url,
|
|
595
600
|
applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery),
|
|
596
601
|
{ providerName: route.providerName, provider: activeProvider },
|
|
@@ -638,49 +643,70 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
638
643
|
let response: Response;
|
|
639
644
|
try {
|
|
640
645
|
response = await send();
|
|
641
|
-
// A credential-scoped 401 on a static key pool says nothing about the sibling keys.
|
|
642
|
-
while (response.status === 401 && hasKeyPoolFailover(activeProvider) && transientSendAvailable()) {
|
|
643
|
-
const rotated = rotateProviderTransportOn401(config, route.providerName, activeProvider, {
|
|
644
|
-
now: Date.now(),
|
|
645
|
-
attemptedKey: activeProvider.apiKey,
|
|
646
|
-
});
|
|
647
|
-
if (!rotated) break;
|
|
648
|
-
discard(response);
|
|
649
|
-
rebuildFor(rotated);
|
|
650
|
-
response = await send("key-401");
|
|
651
|
-
}
|
|
652
646
|
const retryPolicy = oauthBinding ? undefined : rateLimitRetryPolicyFor(activeProvider);
|
|
653
647
|
let retries = 0;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
&&
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
648
|
+
let billingRecoveryActive = false;
|
|
649
|
+
const keySendAvailable = () => transientSendAvailable()
|
|
650
|
+
&& (!billingRecoveryActive || Math.max(physicalSendsUsed, physicalBudget.used) < physicalBudget.policy.maxTotalModelSends);
|
|
651
|
+
for (;;) {
|
|
652
|
+
const priorResponse = response;
|
|
653
|
+
// A credential-scoped 401 on a static key pool says nothing about the sibling keys.
|
|
654
|
+
while (response.status === 401 && hasKeyPoolFailover(activeProvider) && keySendAvailable()) {
|
|
655
|
+
const rotated = rotateProviderTransportOn401(config, route.providerName, activeProvider, {
|
|
656
|
+
now: Date.now(),
|
|
657
|
+
attemptedKey: activeProvider.apiKey,
|
|
658
|
+
});
|
|
659
|
+
if (!rotated) break;
|
|
660
|
+
discard(response);
|
|
661
|
+
rebuildFor(rotated);
|
|
662
|
+
response = await send("key-401");
|
|
663
|
+
}
|
|
664
|
+
while ((response.status === 400 || response.status === 402) && hasKeyPoolFailover(activeProvider)) {
|
|
665
|
+
const rotated = await rotateAnthropicBillingKeyOnResponse(response, config, route.providerName, activeProvider, { signal: upstream.signal });
|
|
666
|
+
if (!rotated) break;
|
|
667
|
+
billingRecoveryActive = true;
|
|
668
|
+
if (!keySendAvailable()) break;
|
|
669
|
+
discard(response);
|
|
670
|
+
rebuildFor(rotated);
|
|
671
|
+
response = await send("key-401");
|
|
672
|
+
}
|
|
673
|
+
// A refusal this proxy synthesized for a reset replay is not a provider rate limit.
|
|
674
|
+
while (
|
|
675
|
+
response.status === 429
|
|
676
|
+
&& !isNonReplayableResponse(response)
|
|
677
|
+
&& retryPolicy
|
|
678
|
+
&& retries < retryPolicy.attempts
|
|
679
|
+
&& keySendAvailable()
|
|
680
|
+
) {
|
|
681
|
+
retries += 1;
|
|
682
|
+
for await (const _ of prepareSameTarget429Wait({
|
|
683
|
+
body: response.body,
|
|
684
|
+
signal: upstream.signal,
|
|
685
|
+
delayMs: rateLimitRetryDelayMs(retryPolicy, response.headers.get("retry-after"), Date.now()),
|
|
686
|
+
})) { /* pre-stream wait */ }
|
|
687
|
+
if (upstream.signal.aborted) throw upstream.signal.reason;
|
|
688
|
+
response = await send("rate-limit-429");
|
|
689
|
+
}
|
|
690
|
+
while (response.status === 429 && !isNonReplayableResponse(response) && hasKeyPoolFailover(activeProvider)) {
|
|
691
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, activeProvider, {
|
|
692
|
+
retryAfter: response.headers.get("retry-after"),
|
|
693
|
+
now: Date.now(),
|
|
694
|
+
attemptedKey: activeProvider.apiKey,
|
|
695
|
+
attemptedSelection: activeProvider._apiKeyAttempt,
|
|
696
|
+
});
|
|
697
|
+
if (!rotated) break;
|
|
698
|
+
// Rotation already recorded the cooldown; keep the terminal 429 when no send remains.
|
|
699
|
+
if (!keySendAvailable()) break;
|
|
700
|
+
discard(response);
|
|
701
|
+
rebuildFor(rotated);
|
|
702
|
+
response = await send("key-429");
|
|
703
|
+
}
|
|
704
|
+
// A sibling's next refusal may change class; the same counters survive that transition.
|
|
705
|
+
if (response !== priorResponse && (
|
|
706
|
+
(billingRecoveryActive && [401, 429].includes(response.status))
|
|
707
|
+
|| (activeProvider.adapter === "anthropic" && hasKeyPoolFailover(activeProvider) && [400, 402].includes(response.status))
|
|
708
|
+
)) continue;
|
|
709
|
+
break;
|
|
684
710
|
}
|
|
685
711
|
const oauthRetryKey = {};
|
|
686
712
|
const triedAccountIds = new Set<string>();
|
|
@@ -338,6 +338,8 @@ export function relaySseEagerBounded(
|
|
|
338
338
|
upstreamError,
|
|
339
339
|
terminalBoundary.upstreamRefusalCode(),
|
|
340
340
|
opts?.maskCredential,
|
|
341
|
+
terminalBoundary.upstreamErrorType(),
|
|
342
|
+
terminalBoundary.upstreamErrorCode(),
|
|
341
343
|
);
|
|
342
344
|
queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength;
|
|
343
345
|
try {
|
|
@@ -478,6 +480,8 @@ export function relaySseEagerBounded(
|
|
|
478
480
|
}
|
|
479
481
|
}
|
|
480
482
|
} finally {
|
|
483
|
+
upstream.signal.removeEventListener("abort", wakeUp);
|
|
484
|
+
upstream.signal.removeEventListener("abort", wakeParkedRead);
|
|
481
485
|
// Release any retained rewrite-buffer bytes on every teardown path
|
|
482
486
|
// (error, cancel, upstream abort) — consumption/EOF release alone
|
|
483
487
|
// leaves them charged.
|
package/src/server/relay.ts
CHANGED
|
@@ -6,13 +6,14 @@ import {
|
|
|
6
6
|
isCyberPolicyCode,
|
|
7
7
|
isCyberPolicyMessage,
|
|
8
8
|
isTerminalRefusalCode,
|
|
9
|
+
recognizedUpstreamError,
|
|
9
10
|
safetyRefusalCodeFromMessage,
|
|
10
11
|
terminalRefusalFallbackMessage,
|
|
11
12
|
upstreamErrorMessageFromPayload,
|
|
12
13
|
} from "../lib/errors";
|
|
13
14
|
import { redactSecretString } from "../lib/redact";
|
|
14
15
|
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
|
|
15
|
-
import { carryReplayRefusal } from "../lib/upstream-retry";
|
|
16
|
+
import { carryReplayRefusal, isReplayRefusalResponse } from "../lib/upstream-retry";
|
|
16
17
|
import { isUsageDebugEnabled } from "../usage/debug";
|
|
17
18
|
import {
|
|
18
19
|
addRequestLog,
|
|
@@ -168,25 +169,28 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown, maskCredenti
|
|
|
168
169
|
* classifies this terminal by `error.code` alone and retries everything outside
|
|
169
170
|
* its fatal set (codex-rs/codex-api/src/sse/responses.rs:423-450), so stamping
|
|
170
171
|
* `upstream_server_error` on a refusal delivered it as a retryable disconnect
|
|
171
|
-
* and drove the reconnect loop in #5176. Without a refusal code the terminal
|
|
172
|
-
*
|
|
172
|
+
* and drove the reconnect loop in #5176. Without a refusal code the terminal
|
|
173
|
+
* carries only recognized rate-limit/overload classes; unknown codes stay generic.
|
|
173
174
|
*/
|
|
174
175
|
export function upstreamErrorTailFrame(
|
|
175
176
|
encoder: TextEncoder,
|
|
176
177
|
message: string,
|
|
177
178
|
refusalCode?: string,
|
|
178
179
|
maskCredential?: (text: string) => string,
|
|
180
|
+
errorType?: string,
|
|
181
|
+
errorCode?: string,
|
|
179
182
|
): Uint8Array {
|
|
180
183
|
return encoder.encode(
|
|
181
|
-
`event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode, maskCredential)}\n\n`,
|
|
184
|
+
`event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode, maskCredential, errorType, errorCode)}\n\n`,
|
|
182
185
|
);
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
function upstreamErrorFailedPayload(message: string, refusalCode?: string, maskCredential?: (text: string) => string): string {
|
|
188
|
+
function upstreamErrorFailedPayload(message: string, refusalCode?: string, maskCredential?: (text: string) => string, errorType?: string, errorCode?: string): string {
|
|
186
189
|
const diagnostic = redactSecretString(message);
|
|
190
|
+
const recognized = recognizedUpstreamError({ type: errorType, code: errorCode });
|
|
187
191
|
const error = {
|
|
188
|
-
type: refusalCode === undefined ? "upstream_error" : "invalid_request_error",
|
|
189
|
-
code: refusalCode === undefined ? "upstream_server_error" : (maskCredential?.(refusalCode) ?? refusalCode),
|
|
192
|
+
type: refusalCode === undefined ? (recognized?.type ?? "upstream_error") : "invalid_request_error",
|
|
193
|
+
code: refusalCode === undefined ? (recognized?.code ?? "upstream_server_error") : (maskCredential?.(refusalCode) ?? refusalCode),
|
|
190
194
|
message: (maskCredential?.(diagnostic) ?? diagnostic).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS),
|
|
191
195
|
};
|
|
192
196
|
return JSON.stringify({
|
|
@@ -549,6 +553,8 @@ export function relaySseWithFailedTail(
|
|
|
549
553
|
upstreamError,
|
|
550
554
|
terminalBoundary.upstreamRefusalCode(),
|
|
551
555
|
opts?.maskCredential,
|
|
556
|
+
terminalBoundary.upstreamErrorType(),
|
|
557
|
+
terminalBoundary.upstreamErrorCode(),
|
|
552
558
|
));
|
|
553
559
|
controller.enqueue(doneFrame(encoder));
|
|
554
560
|
}
|
|
@@ -898,6 +904,11 @@ export function responseWithDeferredRequestLog(
|
|
|
898
904
|
logCtx: RequestLogContext,
|
|
899
905
|
addLog: (entry: RequestLogEntry) => void = addRequestLog,
|
|
900
906
|
): Response {
|
|
907
|
+
// A synthetic 429 is a transport verdict, not evidence that the provider declined the turn.
|
|
908
|
+
if (isReplayRefusalResponse(response)) {
|
|
909
|
+
logCtx.causeHint = "transport-ambiguous";
|
|
910
|
+
logCtx.terminalSource = "synthetic";
|
|
911
|
+
}
|
|
901
912
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
902
913
|
if (isUsageDebugEnabled() && !logCtx.usageDebugContentType && contentType) {
|
|
903
914
|
logCtx.usageDebugContentType = contentType;
|
|
@@ -21,6 +21,8 @@ export interface FinalRequestAttributionFacts {
|
|
|
21
21
|
readonly closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow" | undefined;
|
|
22
22
|
readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined;
|
|
23
23
|
readonly terminalSource?: "upstream" | "synthetic" | undefined;
|
|
24
|
+
/** A failure cause proved by the transport's in-process verdict. */
|
|
25
|
+
readonly causeHint?: RequestFailureCause | undefined;
|
|
24
26
|
/** The REQUEST's first-output observation, not the final attempt's. See below. */
|
|
25
27
|
readonly outputObserved: boolean;
|
|
26
28
|
readonly locallyAnswered: boolean;
|
|
@@ -53,6 +55,7 @@ export function attributeFinalRequest(facts: FinalRequestAttributionFacts): Stam
|
|
|
53
55
|
...(facts.closeReason ? { closeReason: facts.closeReason } : {}),
|
|
54
56
|
...(facts.transportPhase ? { transportPhase: facts.transportPhase } : {}),
|
|
55
57
|
...(facts.terminalSource ? { terminalSource: facts.terminalSource } : {}),
|
|
58
|
+
...(facts.causeHint ? { causeHint: facts.causeHint } : {}),
|
|
56
59
|
...(facts.attempt?.streamAborted === true ? { streamAborted: true } : {}),
|
|
57
60
|
outputObserved: facts.outputObserved,
|
|
58
61
|
// The one fact that can raise a stage above `semantic-output`, and the reason it is counted
|
|
@@ -52,7 +52,7 @@ export function queryRequestLogs(
|
|
|
52
52
|
for (const entry of logs) {
|
|
53
53
|
if (provider && entry.provider !== provider && !entry.attempts?.some(attempt => attempt.provider === provider)) continue;
|
|
54
54
|
if (conversationId && !matchesLogConversationId(entry.conversationId, conversationId)) continue;
|
|
55
|
-
if (model && entry
|
|
55
|
+
if (model && !matchesModel(entry, model) && !entry.attempts?.some(attempt => matchesModel(attempt, model))) continue;
|
|
56
56
|
if (account && entry.accountLogLabel !== account && !entry.attempts?.some(attempt => attempt.accountLogLabel === account)) continue;
|
|
57
57
|
if (protocolMode && !matchesProtocolMode(entry, protocolMode)) continue;
|
|
58
58
|
if (status && (statusClass === undefined ? String(entry.status) !== status : Math.floor(entry.status / 100) !== statusClass)) continue;
|
|
@@ -91,3 +91,8 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara
|
|
|
91
91
|
export function filteredRequestLogCount(logs: RequestLogEntry[], params: URLSearchParams): number {
|
|
92
92
|
return queryRequestLogs(logs, params).total;
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/** A bare ID may itself contain slashes; compare against each observed destination as a whole. */
|
|
96
|
+
function matchesModel(row: { provider?: string; model?: string }, model: string): boolean {
|
|
97
|
+
return row.model === model || (row.provider !== undefined && row.model !== undefined && `${row.provider}/${row.model}` === model);
|
|
98
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CYBER_POLICY_ERROR_CODE,
|
|
3
|
+
httpStatusFromTerminalError,
|
|
4
|
+
isCyberPolicyCode,
|
|
5
|
+
isCyberPolicyMessage,
|
|
6
|
+
isRateLimitOrQuotaFailureMessage,
|
|
7
|
+
recognizedUpstreamError,
|
|
8
|
+
} from "../lib/errors";
|
|
9
|
+
import { redactSecretString } from "../lib/redact";
|
|
10
|
+
import { outboundCredentials, type OutboundCredentialRegistry } from "../lib/outbound-credential-registry";
|
|
11
|
+
import { configuredCredentials, type ConfiguredCredentials } from "./configured-credentials";
|
|
12
|
+
import { codexWsWrapperStatus } from "./responses/codex-ws-wire";
|
|
13
|
+
|
|
14
|
+
/** First bounded upstream diagnostics shared by request contexts and final rows. */
|
|
15
|
+
export interface UpstreamErrorDiagnostics {
|
|
16
|
+
upstreamErrorCode?: string;
|
|
17
|
+
upstreamErrorType?: string;
|
|
18
|
+
upstreamRequestId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TerminalStatusContext extends UpstreamErrorDiagnostics {
|
|
22
|
+
terminalHttpStatus?: number;
|
|
23
|
+
terminalErrorCode?: typeof CYBER_POLICY_ERROR_CODE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UpstreamErrorPayload {
|
|
27
|
+
type?: unknown;
|
|
28
|
+
code?: unknown;
|
|
29
|
+
message?: unknown;
|
|
30
|
+
/** Codex WebSocket wrapper status (`status_code` is its alias); read only on bare `error` events. */
|
|
31
|
+
status?: unknown;
|
|
32
|
+
status_code?: unknown;
|
|
33
|
+
error?: { type?: unknown; code?: unknown; message?: unknown };
|
|
34
|
+
last_error?: { type?: unknown; code?: unknown; message?: unknown };
|
|
35
|
+
response?: {
|
|
36
|
+
error?: { type?: unknown; code?: unknown; message?: unknown };
|
|
37
|
+
incomplete_details?: { code?: unknown; message?: unknown; reason?: unknown };
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Bare error evidence is provisional: only a genuine terminal owns account-health status.
|
|
42
|
+
const provisionalStatuses = new WeakMap<TerminalStatusContext, number>();
|
|
43
|
+
const genuineTerminals = new WeakSet<TerminalStatusContext>();
|
|
44
|
+
|
|
45
|
+
const UPSTREAM_DIAGNOSTIC_TOKEN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Upstream diagnostics are provider-controlled. The token alphabet alone also admits
|
|
49
|
+
* credential formats (an echoed `sk-...` key fits it), so a value the shared redactor
|
|
50
|
+
* would change is dropped rather than recorded, warned, or projected into log rows.
|
|
51
|
+
*/
|
|
52
|
+
function isSafeDiagnosticToken(value: unknown): value is string {
|
|
53
|
+
return typeof value === "string" && UPSTREAM_DIAGNOSTIC_TOKEN.test(value) && redactSecretString(value) === value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Error classes published by the supported provider APIs (OpenAI/Responses, Anthropic, Gemini).
|
|
58
|
+
* The type is diagnostic only, so the vocabulary is closed: a gateway can echo anything in
|
|
59
|
+
* `error.type`, including a configured credential whose format no redactor recognizes, and a
|
|
60
|
+
* value outside this set is simply not recorded, which is what 2.82.0 did for every type.
|
|
61
|
+
*/
|
|
62
|
+
const KNOWN_UPSTREAM_ERROR_TYPES: ReadonlySet<string> = new Set([
|
|
63
|
+
"api_error", "authentication_error", "billing_error", "insufficient_quota", "invalid_request_error",
|
|
64
|
+
"not_found_error", "overloaded_error", "permission_error", "rate_limit_error", "request_too_large",
|
|
65
|
+
"requests", "server_error", "timeout_error", "tokens",
|
|
66
|
+
"INVALID_ARGUMENT", "FAILED_PRECONDITION", "PERMISSION_DENIED", "UNAUTHENTICATED", "NOT_FOUND",
|
|
67
|
+
"RESOURCE_EXHAUSTED", "INTERNAL", "UNAVAILABLE", "DEADLINE_EXCEEDED",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Bare top-level error codes worth keeping: the classes recognizedUpstreamError maps, plus server_error. */
|
|
71
|
+
const KNOWN_BARE_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
72
|
+
"rate_limit_exceeded", "rate_limit_error", "server_is_overloaded", "overloaded_error", "server_error",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
function noteBoundedDiagnostic(
|
|
76
|
+
logCtx: TerminalStatusContext,
|
|
77
|
+
field: "upstreamErrorCode" | "upstreamErrorType" | "upstreamRequestId",
|
|
78
|
+
value: unknown,
|
|
79
|
+
): void {
|
|
80
|
+
if (logCtx[field] !== undefined) return;
|
|
81
|
+
if (!isSafeDiagnosticToken(value)) return;
|
|
82
|
+
if (field === "upstreamErrorType" && !KNOWN_UPSTREAM_ERROR_TYPES.has(value)) return;
|
|
83
|
+
if (!diagnosticValueAllowed(value)) return;
|
|
84
|
+
logCtx[field] = value;
|
|
85
|
+
const status = logCtx.terminalHttpStatus;
|
|
86
|
+
if (status === undefined || status >= 500) {
|
|
87
|
+
const { upstreamErrorCode: code, upstreamRequestId: requestId } = upstreamDiagnosticLogFields(logCtx);
|
|
88
|
+
console.warn(`[opencodex] upstream failure${status ? ` status=${status}` : ""}${code ? ` code=${code}` : ""}${requestId ? ` request_id=${requestId}` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A diagnostic must not repeat a configured credential or a credential this process sent upstream
|
|
94
|
+
* (an echo). All three fields fail closed while either source cannot vouch for complete coverage:
|
|
95
|
+
* a closed type vocabulary does not prove a member is not also a credential. Applied at capture,
|
|
96
|
+
* again for every retained value interpolated into a warning, and at projection, because a
|
|
97
|
+
* credential can be configured or sent after the value was captured.
|
|
98
|
+
*/
|
|
99
|
+
export function diagnosticValueAllowed(
|
|
100
|
+
value: string,
|
|
101
|
+
registry: OutboundCredentialRegistry = outboundCredentials(),
|
|
102
|
+
configured: ConfiguredCredentials = configuredCredentials(),
|
|
103
|
+
): boolean {
|
|
104
|
+
if (!registry.complete() || !configured.complete) return false;
|
|
105
|
+
return !registry.matches(value) && !configured.matches(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function noteUpstreamRequestId(logCtx: TerminalStatusContext, headers: Headers): void {
|
|
109
|
+
noteBoundedDiagnostic(logCtx, "upstreamRequestId", headers.get("openai-request-id") ?? headers.get("x-request-id"));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function captureUpstreamTerminalDiagnostics(logCtx: TerminalStatusContext, json: UpstreamErrorPayload): void {
|
|
113
|
+
captureTerminalHttpStatus(logCtx, json);
|
|
114
|
+
noteBoundedDiagnostic(logCtx, "upstreamErrorCode",
|
|
115
|
+
json.error?.code ?? json.last_error?.code ?? json.response?.error?.code
|
|
116
|
+
// A bare top-level code (new in 2.83.0) has no envelope contract, so only a known class is kept.
|
|
117
|
+
?? (typeof json.code === "string" && KNOWN_BARE_ERROR_CODES.has(json.code) ? json.code : undefined));
|
|
118
|
+
noteBoundedDiagnostic(logCtx, "upstreamErrorType",
|
|
119
|
+
json.error?.type ?? json.last_error?.type ?? json.response?.error?.type);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function upstreamDiagnosticLogFields(logCtx: UpstreamErrorDiagnostics): UpstreamErrorDiagnostics {
|
|
123
|
+
// One verification per projection: the three fields are checked at the same instant, so they
|
|
124
|
+
// share one snapshot of each source, taken only once a field is actually populated (a row with
|
|
125
|
+
// no upstream diagnostic reads no credential store).
|
|
126
|
+
let sources: { registry: OutboundCredentialRegistry; configured: ConfiguredCredentials } | undefined;
|
|
127
|
+
const allowed = (field: keyof UpstreamErrorDiagnostics): string | undefined => {
|
|
128
|
+
const value = logCtx[field];
|
|
129
|
+
if (!value) return undefined;
|
|
130
|
+
sources ??= { registry: outboundCredentials(), configured: configuredCredentials() };
|
|
131
|
+
return diagnosticValueAllowed(value, sources.registry, sources.configured) ? value : undefined;
|
|
132
|
+
};
|
|
133
|
+
const code = allowed("upstreamErrorCode");
|
|
134
|
+
const type = allowed("upstreamErrorType");
|
|
135
|
+
const requestId = allowed("upstreamRequestId");
|
|
136
|
+
return {
|
|
137
|
+
...(code ? { upstreamErrorCode: code } : {}),
|
|
138
|
+
...(type ? { upstreamErrorType: type } : {}),
|
|
139
|
+
...(requestId ? { upstreamRequestId: requestId } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The server status a Codex WebSocket `error` frame states on its wrapper ({@link codexWsWrapperStatus}),
|
|
145
|
+
* or undefined. Only 5xx is returned. Every 5xx is the same transient class for account health, so
|
|
146
|
+
* this changes what a row says, not where the next turn goes; a wrapper 4xx would move quota and
|
|
147
|
+
* credential routing and is left to its own review (#6740).
|
|
148
|
+
*/
|
|
149
|
+
export function wrappedServerErrorStatus(json: UpstreamErrorPayload): number | undefined {
|
|
150
|
+
const status = codexWsWrapperStatus(json as Record<string, unknown>);
|
|
151
|
+
return status !== undefined && status >= 500 ? status : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function captureTerminalHttpStatus(
|
|
155
|
+
logCtx: TerminalStatusContext,
|
|
156
|
+
json: UpstreamErrorPayload,
|
|
157
|
+
): void {
|
|
158
|
+
const type = json.type;
|
|
159
|
+
const terminal = type === "response.failed" || type === "response.completed" || type === "response.incomplete";
|
|
160
|
+
if (!terminal && type !== "error") return;
|
|
161
|
+
if (terminal && provisionalStatuses.has(logCtx)) {
|
|
162
|
+
const provisional = provisionalStatuses.get(logCtx);
|
|
163
|
+
provisionalStatuses.delete(logCtx);
|
|
164
|
+
if (logCtx.terminalHttpStatus === provisional) {
|
|
165
|
+
delete logCtx.terminalHttpStatus;
|
|
166
|
+
delete logCtx.terminalErrorCode;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (logCtx.terminalHttpStatus !== undefined || (type === "error" && genuineTerminals.has(logCtx))) return;
|
|
170
|
+
if (terminal) genuineTerminals.add(logCtx);
|
|
171
|
+
const noteStatus = (status: number): void => {
|
|
172
|
+
logCtx.terminalHttpStatus = status;
|
|
173
|
+
if (type === "error") provisionalStatuses.set(logCtx, status);
|
|
174
|
+
};
|
|
175
|
+
// A completed terminal only retires provisional bare-error evidence; it records no status.
|
|
176
|
+
if (type === "response.completed") return;
|
|
177
|
+
const responseError = json.response?.error;
|
|
178
|
+
const responseDetails = json.response?.incomplete_details;
|
|
179
|
+
const candidates: Array<{ type?: unknown; code?: unknown; message?: unknown } | undefined> = [
|
|
180
|
+
json.error, json.last_error, responseError, responseDetails, json,
|
|
181
|
+
];
|
|
182
|
+
const policy = candidates.some(candidate => (
|
|
183
|
+
candidate?.code === null || typeof candidate?.code === "string"
|
|
184
|
+
) && isCyberPolicyCode(candidate.code as string | null | undefined))
|
|
185
|
+
|| candidates.some(candidate => (
|
|
186
|
+
typeof candidate?.message === "string"
|
|
187
|
+
&& candidate.message.trim().length > 0
|
|
188
|
+
&& isCyberPolicyMessage(candidate.message)
|
|
189
|
+
));
|
|
190
|
+
if (policy) {
|
|
191
|
+
logCtx.terminalErrorCode = CYBER_POLICY_ERROR_CODE;
|
|
192
|
+
noteStatus(400);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// A quota terminal can carry only a structured reason, without an error message.
|
|
196
|
+
// Keep this separate from normal output limits and from the policy precedence above.
|
|
197
|
+
const quotaTag = (value: unknown): boolean => value === "usage_limit_reached"
|
|
198
|
+
|| value === "rate_limit_exceeded" || value === "insufficient_quota";
|
|
199
|
+
const structuredRefusal = candidates.some(candidate => [400, 401, 403, 499].includes(
|
|
200
|
+
httpStatusFromTerminalError({
|
|
201
|
+
type: typeof candidate?.type === "string" ? candidate.type : undefined,
|
|
202
|
+
code: typeof candidate?.code === "string" ? candidate.code : undefined,
|
|
203
|
+
}),
|
|
204
|
+
));
|
|
205
|
+
const ordinaryIncompleteReason = typeof responseDetails?.reason === "string"
|
|
206
|
+
&& ["max_output_tokens", "content_filter", "steered", "upstream_stall_timeout", "adapter_eof"].includes(responseDetails.reason);
|
|
207
|
+
if (type === "response.incomplete" && !structuredRefusal && (quotaTag(responseDetails?.reason) || candidates.some(candidate =>
|
|
208
|
+
quotaTag(candidate?.code)
|
|
209
|
+
|| quotaTag(candidate?.type) || candidate?.type === "rate_limit_error"
|
|
210
|
+
|| (!ordinaryIncompleteReason && typeof candidate?.message === "string" && isRateLimitOrQuotaFailureMessage(candidate.message))
|
|
211
|
+
))) {
|
|
212
|
+
// The shared quota classifier also accepts a numeric HTTP status as its message.
|
|
213
|
+
// Preserve explicit payment-required evidence rather than relabeling it as 429.
|
|
214
|
+
noteStatus(candidates.some(candidate => typeof candidate?.message === "string"
|
|
215
|
+
&& Number(candidate.message.trim()) === 402) ? 402 : 429);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (type === "error") {
|
|
219
|
+
const structured = recognizedUpstreamError({
|
|
220
|
+
code: candidates.find(candidate => typeof candidate?.code === "string")?.code,
|
|
221
|
+
type: candidates.slice(0, -1).find(candidate => typeof candidate?.type === "string")?.type,
|
|
222
|
+
});
|
|
223
|
+
if (structured) {
|
|
224
|
+
noteStatus(structured.httpStatus);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// An unrecognized class keeps only the status the upstream itself stated on the WebSocket
|
|
228
|
+
// wrapper. Nothing is invented here: with no wrapper status the row stays the synthetic 502, and
|
|
229
|
+
// combo preflight still reads its wider set of explicit event fields after this one.
|
|
230
|
+
const wrapped = wrappedServerErrorStatus(json);
|
|
231
|
+
if (wrapped !== undefined) noteStatus(wrapped);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (type !== "response.failed" || !responseError || typeof responseError !== "object") return;
|
|
235
|
+
const responseCode = responseError.code === null || typeof responseError.code === "string"
|
|
236
|
+
? responseError.code
|
|
237
|
+
: undefined;
|
|
238
|
+
noteStatus(httpStatusFromTerminalError({
|
|
239
|
+
type: typeof responseError.type === "string" ? responseError.type : undefined,
|
|
240
|
+
code: responseCode,
|
|
241
|
+
message: typeof responseError.message === "string" ? responseError.message : undefined,
|
|
242
|
+
}));
|
|
243
|
+
}
|