@bitkyc08/opencodex 2.60.0 → 2.61.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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
import type { ResponsesTerminalStatus } from "../bridge";
|
|
2
2
|
import type { AttemptRecoveryKind } from "../usage/log";
|
|
3
|
+
import {
|
|
4
|
+
REQUEST_FAILURE_CAUSES,
|
|
5
|
+
type RequestFailureCause,
|
|
6
|
+
causeForRecoveryKind,
|
|
7
|
+
} from "../lib/request-failure-model";
|
|
8
|
+
import {
|
|
9
|
+
REQUEST_OUTCOME_CLASSES,
|
|
10
|
+
classifyRequestOutcome,
|
|
11
|
+
type RequestOutcomeClass,
|
|
12
|
+
} from "../usage/request-outcome";
|
|
3
13
|
|
|
4
14
|
export const REQUEST_METRICS_PROTOCOLS = Object.freeze(["responses", "chat", "messages", "unknown"] as const);
|
|
5
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The exporter's result label set IS the shared outcome vocabulary, not a copy of it. Restating
|
|
17
|
+
* these four strings here is what let the exporter and the dashboard drift into disagreeing about
|
|
18
|
+
* the same request.
|
|
19
|
+
*/
|
|
20
|
+
export const REQUEST_METRICS_RESULTS = REQUEST_OUTCOME_CLASSES;
|
|
21
|
+
/**
|
|
22
|
+
* Closed recovery classes exported as Prometheus label values.
|
|
23
|
+
*
|
|
24
|
+
* Bounded by construction: the label can only ever take one of these strings, so no user, model,
|
|
25
|
+
* account or request identifier can reach a series name. `quota`, `policy` and `ciphertext` are
|
|
26
|
+
* separate members because an operator seeing a spike needs to know which one it is -- waiting
|
|
27
|
+
* out a rate limit, changing accounts, changing the prompt and dropping stale ciphertext are
|
|
28
|
+
* four different responses, and collapsing them is what made the existing counter unactionable.
|
|
29
|
+
*/
|
|
6
30
|
export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([
|
|
7
31
|
"transient",
|
|
8
32
|
"connection",
|
|
9
33
|
"credential",
|
|
10
34
|
"rate_limit",
|
|
35
|
+
"quota",
|
|
36
|
+
"policy",
|
|
37
|
+
"ciphertext",
|
|
11
38
|
"payload",
|
|
12
39
|
"empty_completion",
|
|
13
40
|
"effort_downgrade",
|
|
@@ -17,8 +44,20 @@ export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([
|
|
|
17
44
|
export const REQUEST_DURATION_BUCKETS_SECONDS = Object.freeze([0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60] as const);
|
|
18
45
|
export const REQUEST_TTFT_BUCKETS_SECONDS = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30] as const);
|
|
19
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The failure-cause label set IS the shared dictionary, for the same reason the result label set
|
|
49
|
+
* is the shared outcome vocabulary: a restated copy is what let two surfaces drift into
|
|
50
|
+
* disagreeing about the same request.
|
|
51
|
+
*
|
|
52
|
+
* It labels a COUNTER and never a histogram. Fifteen causes across four protocols is sixty
|
|
53
|
+
* series, fixed for the lifetime of the roster, and every value comes from a frozen list, so no
|
|
54
|
+
* user, model, account or request identifier can reach a series name. A histogram labelled by
|
|
55
|
+
* cause would multiply that by its bucket count for no question anyone asks.
|
|
56
|
+
*/
|
|
57
|
+
export const REQUEST_METRICS_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES;
|
|
58
|
+
|
|
20
59
|
export type RequestMetricsProtocol = typeof REQUEST_METRICS_PROTOCOLS[number];
|
|
21
|
-
export type RequestMetricsResult =
|
|
60
|
+
export type RequestMetricsResult = RequestOutcomeClass;
|
|
22
61
|
export type RequestMetricsRecoveryClass = typeof REQUEST_METRICS_RECOVERY_CLASSES[number];
|
|
23
62
|
|
|
24
63
|
export interface RequestMetricFinalFact {
|
|
@@ -33,6 +72,11 @@ export interface RequestMetricFinalFact {
|
|
|
33
72
|
recoveryKinds: readonly AttemptRecoveryKind[];
|
|
34
73
|
}>;
|
|
35
74
|
spendSends?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Why this request failed, as the recorder derived it. Absent when it did not fail, which is
|
|
77
|
+
* why the counter below cannot be reconstructed by subtracting completions from totals.
|
|
78
|
+
*/
|
|
79
|
+
failureCause?: RequestFailureCause;
|
|
36
80
|
}
|
|
37
81
|
|
|
38
82
|
export interface RequestMetricsRecorder {
|
|
@@ -56,6 +100,7 @@ interface HistogramCell {
|
|
|
56
100
|
const protocolCell = (value: RequestMetricsProtocol): number => REQUEST_METRICS_PROTOCOLS.indexOf(value);
|
|
57
101
|
const resultCell = (value: RequestMetricsResult): number => REQUEST_METRICS_RESULTS.indexOf(value);
|
|
58
102
|
const recoveryCell = (value: RequestMetricsRecoveryClass): number => REQUEST_METRICS_RECOVERY_CLASSES.indexOf(value);
|
|
103
|
+
const failureCauseCell = (value: RequestFailureCause): number => REQUEST_METRICS_FAILURE_CAUSES.indexOf(value);
|
|
59
104
|
|
|
60
105
|
function matrix(rows: number, columns: number): number[][] {
|
|
61
106
|
return Array.from({ length: rows }, () => Array.from({ length: columns }, () => 0));
|
|
@@ -71,35 +116,34 @@ function histograms(bounds: readonly number[]): HistogramCell[][] {
|
|
|
71
116
|
));
|
|
72
117
|
}
|
|
73
118
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Metrics class for each shared failure cause.
|
|
121
|
+
*
|
|
122
|
+
* Keyed on the cause rather than on the recovery kind so this projection and the durable log
|
|
123
|
+
* speak one vocabulary. Total by construction: the previous switch ended in `default: "other"`,
|
|
124
|
+
* which meant a recovery kind added later compiled cleanly and then disappeared into an
|
|
125
|
+
* unactionable bucket. A missing member is now a typecheck failure.
|
|
126
|
+
*/
|
|
127
|
+
const CAUSE_METRICS_CLASS = {
|
|
128
|
+
"transport-unsent": "connection",
|
|
129
|
+
"transport-ambiguous": "connection",
|
|
130
|
+
"upstream-declined": "transient",
|
|
131
|
+
"rate-limit": "rate_limit",
|
|
132
|
+
"quota-exhausted": "quota",
|
|
133
|
+
"credential-rejected": "credential",
|
|
134
|
+
"policy-refusal": "policy",
|
|
135
|
+
"parameter-rejected": "effort_downgrade",
|
|
136
|
+
"ciphertext-refusal": "ciphertext",
|
|
137
|
+
"payload-too-large": "payload",
|
|
138
|
+
"payload-rejected": "payload",
|
|
139
|
+
"upstream-fault": "transient",
|
|
140
|
+
"empty-output": "empty_completion",
|
|
141
|
+
"client-cancelled": "other",
|
|
142
|
+
"local-refusal": "other",
|
|
143
|
+
} as const satisfies Record<RequestFailureCause, RequestMetricsRecoveryClass>;
|
|
85
144
|
|
|
86
145
|
function recoveryClass(kind: AttemptRecoveryKind): RequestMetricsRecoveryClass {
|
|
87
|
-
|
|
88
|
-
case "transient-5xx": return "transient";
|
|
89
|
-
case "connection-reset": return "connection";
|
|
90
|
-
case "oauth-401":
|
|
91
|
-
case "key-401": return "credential";
|
|
92
|
-
case "key-429":
|
|
93
|
-
case "rate-limit-429":
|
|
94
|
-
case "anthropic-oauth-429":
|
|
95
|
-
case "oauth-account-429": return "rate_limit";
|
|
96
|
-
case "image-413":
|
|
97
|
-
case "console-go-upload-retry":
|
|
98
|
-
case "opaque-blob-rejection": return "payload";
|
|
99
|
-
case "empty-completion": return "empty_completion";
|
|
100
|
-
case "reasoning-effort-downgrade": return "effort_downgrade";
|
|
101
|
-
default: return "other";
|
|
102
|
-
}
|
|
146
|
+
return CAUSE_METRICS_CLASS[causeForRecoveryKind(kind)];
|
|
103
147
|
}
|
|
104
148
|
|
|
105
149
|
function observeHistogram(cell: HistogramCell, bounds: readonly number[], value: number): void {
|
|
@@ -145,6 +189,7 @@ export function createRequestMetricsOwner(
|
|
|
145
189
|
let logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
|
|
146
190
|
let physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0);
|
|
147
191
|
let recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length);
|
|
192
|
+
let failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length);
|
|
148
193
|
let durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS);
|
|
149
194
|
let ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS);
|
|
150
195
|
let missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
|
|
@@ -152,7 +197,7 @@ export function createRequestMetricsOwner(
|
|
|
152
197
|
return {
|
|
153
198
|
recordFinalRequest(fact): void {
|
|
154
199
|
const protocol: RequestMetricsProtocol = fact.protocol ?? "unknown";
|
|
155
|
-
const result =
|
|
200
|
+
const result = classifyRequestOutcome(fact);
|
|
156
201
|
const protocolIndex = protocolCell(protocol);
|
|
157
202
|
const resultIndex = resultCell(result);
|
|
158
203
|
logicalRequests[protocolIndex]![resultIndex]! += 1;
|
|
@@ -165,6 +210,13 @@ export function createRequestMetricsOwner(
|
|
|
165
210
|
), 0);
|
|
166
211
|
physicalSends[protocolIndex]! += sends;
|
|
167
212
|
|
|
213
|
+
// Counted from the cause the recorder derived, not re-derived here. Two derivations of one
|
|
214
|
+
// answer is the disagreement this batch exists to remove, and the recorder is the only
|
|
215
|
+
// place that sees the transport facts a cause needs.
|
|
216
|
+
if (fact.failureCause !== undefined) {
|
|
217
|
+
failureCauses[protocolIndex]![failureCauseCell(fact.failureCause)]! += 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
168
220
|
for (const attempt of attempts ?? []) {
|
|
169
221
|
for (const kind of new Set(attempt.recoveryKinds)) {
|
|
170
222
|
recoveries[protocolIndex]![recoveryCell(recoveryClass(kind))]! += 1;
|
|
@@ -205,6 +257,15 @@ export function createRequestMetricsOwner(
|
|
|
205
257
|
lines.push(`opencodex_recoveries_total{protocol="${protocol}",recovery="${recovery}"} ${recoveries[protocolCell(protocol)]![recoveryCell(recovery)]}`);
|
|
206
258
|
}
|
|
207
259
|
}
|
|
260
|
+
lines.push(
|
|
261
|
+
"# HELP opencodex_request_failures_total Finalized logical requests that did not deliver an answer, by derived cause.",
|
|
262
|
+
"# TYPE opencodex_request_failures_total counter",
|
|
263
|
+
);
|
|
264
|
+
for (const protocol of REQUEST_METRICS_PROTOCOLS) {
|
|
265
|
+
for (const cause of REQUEST_METRICS_FAILURE_CAUSES) {
|
|
266
|
+
lines.push(`opencodex_request_failures_total{protocol="${protocol}",cause="${cause}"} ${failureCauses[protocolCell(protocol)]![failureCauseCell(cause)]}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
208
269
|
appendHistogram(lines, "opencodex_request_duration_seconds", "Finalized logical request duration in seconds.", durations, REQUEST_DURATION_BUCKETS_SECONDS);
|
|
209
270
|
appendHistogram(lines, "opencodex_ttft_seconds", "Observed time to first output in seconds.", ttft, REQUEST_TTFT_BUCKETS_SECONDS);
|
|
210
271
|
lines.push(
|
|
@@ -228,6 +289,7 @@ export function createRequestMetricsOwner(
|
|
|
228
289
|
logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
|
|
229
290
|
physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0);
|
|
230
291
|
recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length);
|
|
292
|
+
failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length);
|
|
231
293
|
durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS);
|
|
232
294
|
ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS);
|
|
233
295
|
missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
|
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE,
|
|
5
5
|
UPSTREAM_NO_RESPONSE_CODE,
|
|
6
6
|
} from "../../lib/upstream-retry";
|
|
7
|
-
import {
|
|
7
|
+
import type { RequestFailureCause, RequestFailureStage } from "../../lib/request-failure-model";
|
|
8
|
+
import { packageVersion } from "../../lib/package-version";
|
|
8
9
|
// If the 101 never arrives (network black hole), give SSE a chance well before
|
|
9
10
|
// the caller's connect timeout (default 200s) would fire.
|
|
10
11
|
export const UPGRADE_DEADLINE_MS = 10_000;
|
|
@@ -72,13 +73,7 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
|
|
|
72
73
|
* importing management-api from the transport layer would invert the
|
|
73
74
|
* layering and pull the management surface into every WS exchange.
|
|
74
75
|
*/
|
|
75
|
-
const OCX_VERSION = (
|
|
76
|
-
try {
|
|
77
|
-
return JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version as string;
|
|
78
|
-
} catch {
|
|
79
|
-
return "0.0.0";
|
|
80
|
-
}
|
|
81
|
-
})();
|
|
76
|
+
const OCX_VERSION = packageVersion("0.0.0");
|
|
82
77
|
|
|
83
78
|
/**
|
|
84
79
|
* The durable form of the stage counters, carried out of the exchange on the
|
|
@@ -210,6 +205,37 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu
|
|
|
210
205
|
return "no-response-event";
|
|
211
206
|
}
|
|
212
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The same four outcomes said in the shared stage-and-cause vocabulary (#4191).
|
|
210
|
+
*
|
|
211
|
+
* A projection, not a second classifier: {@link classifyCodexWsFailure} stays the one place that
|
|
212
|
+
* reads the counters, and this only restates its answer in the words the durable log, the metrics
|
|
213
|
+
* projection and the HTTP path already use. Without it the WebSocket transport is the one surface
|
|
214
|
+
* whose failures cannot be compared with anything else, which is the reported symptom -- every
|
|
215
|
+
* such failure reached the user as one of two bare sentences.
|
|
216
|
+
*
|
|
217
|
+
* It does not relax the transport's own rule. The no-replay-after-send contract in
|
|
218
|
+
* `codex-ws-exchange.ts` holds regardless of what this returns, and the stage below is
|
|
219
|
+
* deliberately not consulted as a fallback-eligibility signal; it reports where the exchange got
|
|
220
|
+
* to, and `resendPermission` happens to agree that everything past `before-send` is refused.
|
|
221
|
+
*/
|
|
222
|
+
export const CODEX_WS_FAILURE_PROJECTION = {
|
|
223
|
+
/** The create frame never left, so the origin provably never saw this turn. */
|
|
224
|
+
"before-send": { stage: "pre-header", cause: "transport-unsent" },
|
|
225
|
+
/** The frame left and the socket said nothing at all. The turn may be running upstream. */
|
|
226
|
+
"no-upstream-frame": { stage: "pre-header", cause: "transport-ambiguous" },
|
|
227
|
+
/** Control frames only: the peer is alive and answered, but no Responses event arrived. */
|
|
228
|
+
"no-response-event": { stage: "protocol-prelude", cause: "transport-ambiguous" },
|
|
229
|
+
/** Events already reached the caller, so a resend would duplicate output they have seen. */
|
|
230
|
+
"after-response-started": { stage: "semantic-output", cause: "transport-ambiguous" },
|
|
231
|
+
} as const satisfies Record<CodexWsFailureCause, { stage: RequestFailureStage; cause: RequestFailureCause }>;
|
|
232
|
+
|
|
233
|
+
export function projectCodexWsFailure(
|
|
234
|
+
stage: CodexWsFailureStage,
|
|
235
|
+
): { stage: RequestFailureStage; cause: RequestFailureCause } {
|
|
236
|
+
return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)];
|
|
237
|
+
}
|
|
238
|
+
|
|
213
239
|
/**
|
|
214
240
|
* Render the stage as a suffix appended to an existing failure message.
|
|
215
241
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResponsesTerminalStatus } from "../../bridge";
|
|
2
2
|
import { comboFailureDecision } from "../../combos";
|
|
3
3
|
import { httpStatusFromTerminalError } from "../../lib/errors";
|
|
4
|
+
import type { RequestFailureStage } from "../../lib/request-failure-model";
|
|
4
5
|
import type { RequestLogContext } from "../request-log";
|
|
5
6
|
import { createSseInspector } from "../relay";
|
|
6
7
|
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
@@ -108,9 +109,48 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean {
|
|
|
108
109
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true;
|
|
109
110
|
const type = (payload as { type?: unknown }).type;
|
|
110
111
|
if (typeof type !== "string") return true;
|
|
112
|
+
if (type === "response.created") {
|
|
113
|
+
// A created event is a control frame only while its snapshot is empty. An origin that
|
|
114
|
+
// resumes a turn can put completed items in it, and treating that as a prelude would let
|
|
115
|
+
// a replacement re-emit output the caller already received.
|
|
116
|
+
const response = (payload as { response?: unknown }).response;
|
|
117
|
+
if (response && typeof response === "object" && !Array.isArray(response)) {
|
|
118
|
+
const output = (response as { output?: unknown }).output;
|
|
119
|
+
if (Array.isArray(output) && output.length > 0) return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
111
122
|
return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type);
|
|
112
123
|
}
|
|
113
124
|
|
|
125
|
+
/**
|
|
126
|
+
* How far this SSE body got, in the vocabulary of src/lib/request-failure-model.ts.
|
|
127
|
+
*
|
|
128
|
+
* The preflight cannot separate `semantic-output` from `side-effect`: it classifies any event
|
|
129
|
+
* that is not a lifecycle control frame as committing, without reading item types. Both stages
|
|
130
|
+
* refuse a resend, so the distinction would change no decision -- it is named here so a later
|
|
131
|
+
* reader does not mistake the collapse for an omission.
|
|
132
|
+
*
|
|
133
|
+
* A terminal that settled carrying no output is `protocol-prelude`, not `terminal`. That is
|
|
134
|
+
* the failure model's own rule: `terminal` means the answer was delivered, and an empty
|
|
135
|
+
* completion delivered none.
|
|
136
|
+
*
|
|
137
|
+
* Only the two nothing-observed stages actually reach a read error today: the loop below hands
|
|
138
|
+
* the body back as `accepted` the moment output commits or a terminal arrives, so a stream
|
|
139
|
+
* that committed anything never reports a stage at all. The committed branches stay because
|
|
140
|
+
* this has to be total for any other caller, and because a later change to that loop must not
|
|
141
|
+
* be able to promote a committed stream into a replaceable one by omission.
|
|
142
|
+
*/
|
|
143
|
+
function observedResponsesStage(state: {
|
|
144
|
+
readonly outputCommitted: boolean;
|
|
145
|
+
readonly terminalStatus: ResponsesTerminalStatus | undefined;
|
|
146
|
+
readonly responseCreated: boolean;
|
|
147
|
+
}): RequestFailureStage {
|
|
148
|
+
if (state.outputCommitted) return "semantic-output";
|
|
149
|
+
if (state.terminalStatus === "completed") return "terminal";
|
|
150
|
+
if (state.responseCreated || state.terminalStatus !== undefined) return "protocol-prelude";
|
|
151
|
+
return "headers-only";
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
function replayBufferedResponse(
|
|
115
155
|
response: Response,
|
|
116
156
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
@@ -186,13 +226,22 @@ function failedTerminalResponse(
|
|
|
186
226
|
|
|
187
227
|
export type ComboStreamPreflightResult =
|
|
188
228
|
| { kind: "accepted"; response: Response }
|
|
189
|
-
| { kind: "failed"; response: Response }
|
|
229
|
+
| { kind: "failed"; response: Response }
|
|
230
|
+
/**
|
|
231
|
+
* The body errored mid-stream and `replayReadErrors` asked for the prefix back rather than
|
|
232
|
+
* a rethrow. `stage` is how far the inspection actually got; whether that permits a
|
|
233
|
+
* replacement is the resend gate's decision, not this function's. Callers that only act on
|
|
234
|
+
* a projected terminal can treat this exactly as `accepted`, which is what it was before
|
|
235
|
+
* the stage became observable.
|
|
236
|
+
*/
|
|
237
|
+
| { kind: "read-error"; response: Response; error: unknown; stage: RequestFailureStage };
|
|
190
238
|
|
|
191
239
|
/**
|
|
192
|
-
* Buffer a
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
240
|
+
* Buffer a Responses SSE only until the request becomes unsafe to replay or reaches a
|
|
241
|
+
* terminal. Combo failover and native post-header reset recovery share this protocol
|
|
242
|
+
* boundary, because they are asking the same question about the same bytes. This owns exactly
|
|
243
|
+
* one body reader. The aggregate buffer is capped by bytes and retained chunks; hitting either
|
|
244
|
+
* cap commits the current target instead of growing memory or guessing that replay is safe.
|
|
196
245
|
*/
|
|
197
246
|
export async function preflightComboStreamResponse(
|
|
198
247
|
response: Response,
|
|
@@ -211,12 +260,18 @@ export async function preflightComboStreamResponse(
|
|
|
211
260
|
const buffered: Uint8Array[] = [];
|
|
212
261
|
let bufferedBytes = 0;
|
|
213
262
|
let outputCommitted = false;
|
|
263
|
+
let responseCreated = false;
|
|
214
264
|
let terminalStatus: ResponsesTerminalStatus | undefined;
|
|
215
265
|
let retryableTerminalPayload: Record<string, unknown> | undefined;
|
|
216
266
|
const inspector = createSseInspector({
|
|
217
267
|
logCtx,
|
|
268
|
+
// A payload the inspector could not parse still reached this proxy, and it may be output.
|
|
269
|
+
// Committing on it is what keeps an unreadable frame from reading as an empty prelude.
|
|
270
|
+
onOpaquePayload: () => { outputCommitted = true; },
|
|
218
271
|
onParsedPayload: payload => {
|
|
219
272
|
if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return;
|
|
273
|
+
if (payload !== null && typeof payload === "object" && !Array.isArray(payload)
|
|
274
|
+
&& (payload as { type?: unknown }).type === "response.created") responseCreated = true;
|
|
220
275
|
const retryable = retryableTerminal(payload);
|
|
221
276
|
const matchedBareError = retryable && payload !== null && typeof payload === "object"
|
|
222
277
|
&& !Array.isArray(payload) && (payload as { type?: unknown }).type === "error";
|
|
@@ -239,7 +294,9 @@ export async function preflightComboStreamResponse(
|
|
|
239
294
|
// The native relay still owns post-header transport failures. Preserve
|
|
240
295
|
// the bounded prefix and the errored reader; cancelling it here would
|
|
241
296
|
// erase the failure before either client relay or inspection sees it.
|
|
242
|
-
|
|
297
|
+
const replay = replayBufferedResponse(response, reader, buffered);
|
|
298
|
+
const stage = observedResponsesStage({ outputCommitted, terminalStatus, responseCreated });
|
|
299
|
+
return { kind: "read-error", response: replay, error, stage };
|
|
243
300
|
}
|
|
244
301
|
if (next.done) {
|
|
245
302
|
inspector.finish();
|
|
@@ -277,3 +334,108 @@ export async function preflightComboStreamResponse(
|
|
|
277
334
|
inspector.dispose();
|
|
278
335
|
}
|
|
279
336
|
}
|
|
337
|
+
|
|
338
|
+
/** Produce a replacement body for a mid-stream failure at `stage`, or null to keep the error. */
|
|
339
|
+
export type ProtocolSafeResetRecovery = (
|
|
340
|
+
error: unknown,
|
|
341
|
+
stage: RequestFailureStage,
|
|
342
|
+
) => Promise<Response | null>;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Defer protocol inspection until the downstream actually pulls the body.
|
|
346
|
+
*
|
|
347
|
+
* Direct passthrough must return response headers before the first SSE event arrives, so the
|
|
348
|
+
* inspection cannot be awaited at the dispatch site the way combo routing awaits it. Wrapping
|
|
349
|
+
* the body moves it to the first pull, which is the earliest moment the client is willing to
|
|
350
|
+
* wait anyway.
|
|
351
|
+
*/
|
|
352
|
+
export function deferProtocolSafeResetRecovery(
|
|
353
|
+
response: Response,
|
|
354
|
+
logCtx: RequestLogContext,
|
|
355
|
+
recover: ProtocolSafeResetRecovery,
|
|
356
|
+
options?: { allowMissingContentType?: boolean },
|
|
357
|
+
): Response {
|
|
358
|
+
if (!response.body) return response;
|
|
359
|
+
|
|
360
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
361
|
+
let initialization: Promise<void> | undefined;
|
|
362
|
+
let closed = false;
|
|
363
|
+
|
|
364
|
+
const cancelBody = (body: ReadableStream<Uint8Array> | null, reason?: unknown): void => {
|
|
365
|
+
try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ }
|
|
366
|
+
};
|
|
367
|
+
const initialize = async (): Promise<void> => {
|
|
368
|
+
const preflight = await preflightComboStreamResponse(
|
|
369
|
+
response,
|
|
370
|
+
logCtx,
|
|
371
|
+
() => false,
|
|
372
|
+
{ allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true },
|
|
373
|
+
);
|
|
374
|
+
let selected = preflight.response;
|
|
375
|
+
if (preflight.kind === "read-error") {
|
|
376
|
+
const replacement = await recover(preflight.error, preflight.stage);
|
|
377
|
+
if (replacement) {
|
|
378
|
+
cancelBody(selected.body, "using protocol-safe replacement stream");
|
|
379
|
+
selected = replacement;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (closed) {
|
|
383
|
+
cancelBody(selected.body, "downstream cancelled before protocol preflight completed");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
reader = selected.body?.getReader();
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const body = new ReadableStream<Uint8Array>({
|
|
390
|
+
async pull(controller) {
|
|
391
|
+
try {
|
|
392
|
+
initialization ??= initialize();
|
|
393
|
+
await initialization;
|
|
394
|
+
if (closed) return;
|
|
395
|
+
if (!reader) {
|
|
396
|
+
closed = true;
|
|
397
|
+
controller.close();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const next = await reader.read();
|
|
401
|
+
if (closed) return;
|
|
402
|
+
if (next.done) {
|
|
403
|
+
closed = true;
|
|
404
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
405
|
+
reader = undefined;
|
|
406
|
+
controller.close();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
controller.enqueue(next.value);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (closed) return;
|
|
412
|
+
closed = true;
|
|
413
|
+
try { reader?.releaseLock(); } catch { /* errored reader */ }
|
|
414
|
+
reader = undefined;
|
|
415
|
+
controller.error(error);
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
cancel(reason) {
|
|
419
|
+
if (closed) return;
|
|
420
|
+
closed = true;
|
|
421
|
+
if (reader) {
|
|
422
|
+
try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ }
|
|
423
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
424
|
+
reader = undefined;
|
|
425
|
+
} else if (initialization === undefined) {
|
|
426
|
+
// Nothing has read the upstream yet, so this body is still ours to cancel.
|
|
427
|
+
cancelBody(response.body, reason);
|
|
428
|
+
}
|
|
429
|
+
// A cancel while the preflight is mid-flight falls through deliberately. That body is
|
|
430
|
+
// locked by the preflight's own reader, so cancelling it here would reject and be
|
|
431
|
+
// swallowed; `initialize` sees `closed` when it settles and releases whichever body it
|
|
432
|
+
// ended up selecting, which is the one that actually has to be let go.
|
|
433
|
+
},
|
|
434
|
+
}, { highWaterMark: 0 });
|
|
435
|
+
|
|
436
|
+
return new Response(body, {
|
|
437
|
+
status: response.status,
|
|
438
|
+
statusText: response.statusText,
|
|
439
|
+
headers: response.headers,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { capturePoolQuotaWriter } from "../../codex/account-store";
|
|
2
|
+
import {
|
|
3
|
+
admissionModelDeniedResponse,
|
|
4
|
+
AdmissionModelDeniedError,
|
|
5
|
+
assertRouteAllowedByScope,
|
|
6
|
+
resolveAdmissionModelScope,
|
|
7
|
+
} from "../admission-model-scope";
|
|
2
8
|
import type { Server } from "bun";
|
|
3
9
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
4
10
|
import {
|
|
@@ -655,7 +661,12 @@ export async function handleResponsesCompact(
|
|
|
655
661
|
// routes ordinary turns elsewhere (#2901); the compaction-scoped router
|
|
656
662
|
// may land that on the configured default provider instead of 404.
|
|
657
663
|
route = routeCompactionModel(config, compactModel, evidenceFromBody(raw));
|
|
664
|
+
// A compaction override picks the model, not the caller, so the key's scope
|
|
665
|
+
// is applied to what the override resolved to rather than to the selector
|
|
666
|
+
// the client sent.
|
|
667
|
+
assertRouteAllowedByScope(resolveAdmissionModelScope(config, admission), compactRequestedModel, route);
|
|
658
668
|
} catch (err) {
|
|
669
|
+
if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
|
|
659
670
|
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
660
671
|
// Persist the evaluation trace (per-candidate exclusions + the
|
|
661
672
|
// no-eligible reason) so a failed compact policy request stays
|
|
@@ -116,6 +116,88 @@ export function isReasoningBlobCallerMismatchMessage(message: string): boolean {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Longest embedded payload this will parse. The wrapper is a short error envelope; anything
|
|
121
|
+
* larger is not the shape being matched, and refusing to walk it keeps an upstream-controlled
|
|
122
|
+
* string from deciding how much work the classifier does.
|
|
123
|
+
*/
|
|
124
|
+
const LITELLM_EMBEDDED_PAYLOAD_LIMIT = 16_384;
|
|
125
|
+
const LITELLM_WRAPPER_PREFIX = "litellm.BadRequestError:";
|
|
126
|
+
const LITELLM_WRAPPER_MARKER = "OpenAIException - ";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The JSON an OpenAI-compatible gateway embeds in its own error message, or undefined.
|
|
130
|
+
*
|
|
131
|
+
* Brace-aware rather than a regex because the embedded object legitimately contains braces and
|
|
132
|
+
* escaped quotes inside its message, and the gateway appends its own prose after the closing
|
|
133
|
+
* brace. Counting depth outside string literals is the only way to find the real end.
|
|
134
|
+
*/
|
|
135
|
+
function liteLlmEmbeddedErrorPayload(message: string): unknown {
|
|
136
|
+
if (!message.startsWith(LITELLM_WRAPPER_PREFIX)) return undefined;
|
|
137
|
+
const markerIndex = message.indexOf(LITELLM_WRAPPER_MARKER);
|
|
138
|
+
if (markerIndex < 0) return undefined;
|
|
139
|
+
const start = message.indexOf("{", markerIndex + LITELLM_WRAPPER_MARKER.length);
|
|
140
|
+
if (start < 0) return undefined;
|
|
141
|
+
const end = Math.min(message.length, start + LITELLM_EMBEDDED_PAYLOAD_LIMIT);
|
|
142
|
+
|
|
143
|
+
let depth = 0;
|
|
144
|
+
let inString = false;
|
|
145
|
+
let escaped = false;
|
|
146
|
+
for (let index = start; index < end; index += 1) {
|
|
147
|
+
const character = message[index]!;
|
|
148
|
+
if (inString) {
|
|
149
|
+
if (escaped) escaped = false;
|
|
150
|
+
else if (character === "\\") escaped = true;
|
|
151
|
+
else if (character === '"') inString = false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (character === '"') { inString = true; continue; }
|
|
155
|
+
if (character === "{") depth += 1;
|
|
156
|
+
else if (character === "}") {
|
|
157
|
+
depth -= 1;
|
|
158
|
+
if (depth === 0) {
|
|
159
|
+
try { return JSON.parse(message.slice(start, index + 1)) as unknown; } catch { return undefined; }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
/** True for an error message that is a gateway envelope rather than an upstream's own wording. */
|
|
168
|
+
function isLiteLlmEnvelopeMessage(message: string): boolean {
|
|
169
|
+
return message.startsWith(LITELLM_WRAPPER_PREFIX) && message.includes(LITELLM_WRAPPER_MARKER);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* An OpenAI-compatible gateway relaying the one authoritative ciphertext rejection inside its
|
|
175
|
+
* own error string.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately narrower than {@link isSelfIdentifiedOpaqueBlobRejection}. The embedded payload is
|
|
178
|
+
* matched against exactly one identity -- `invalid_request_error` carrying
|
|
179
|
+
* `invalid_encrypted_content` -- and the generic classifier is NOT re-run against it. Re-running
|
|
180
|
+
* it would let every other opaque identity arrive through the wrapper as well: the code-less
|
|
181
|
+
* unverifiable-ciphertext wording, the #4469 caller mismatch, and the two xAI decoder strings.
|
|
182
|
+
* Each of those was admitted on evidence from a specific upstream about how that upstream words
|
|
183
|
+
* its own rejection, and a gateway in between is not that evidence. Only the coded identity is
|
|
184
|
+
* unambiguous enough to survive relaying.
|
|
185
|
+
*/
|
|
186
|
+
export function isLiteLlmWrappedCiphertextRejection(payload: unknown): boolean {
|
|
187
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
188
|
+
const outer = (payload as { error?: unknown }).error;
|
|
189
|
+
if (!outer || typeof outer !== "object" || Array.isArray(outer)) return false;
|
|
190
|
+
const message = (outer as { message?: unknown }).message;
|
|
191
|
+
if (typeof message !== "string") return false;
|
|
192
|
+
const embedded = liteLlmEmbeddedErrorPayload(message);
|
|
193
|
+
if (!embedded || typeof embedded !== "object" || Array.isArray(embedded)) return false;
|
|
194
|
+
const inner = (embedded as { error?: unknown }).error;
|
|
195
|
+
if (!inner || typeof inner !== "object" || Array.isArray(inner)) return false;
|
|
196
|
+
const { type, code } = inner as { type?: unknown; code?: unknown };
|
|
197
|
+
return type === "invalid_request_error" && code === "invalid_encrypted_content";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
119
201
|
export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
120
202
|
if (isEncryptedFunctionOutputRejection(bodyText)) return true;
|
|
121
203
|
try {
|
|
@@ -132,6 +214,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
|
132
214
|
|
|
133
215
|
if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
|
|
134
216
|
const error = record.error as { type?: unknown; code?: unknown; message?: unknown };
|
|
217
|
+
// A gateway envelope is decided ONLY by its embedded payload, before any wording check
|
|
218
|
+
// below runs. Those checks match anchored phrases anywhere in the message, and a gateway
|
|
219
|
+
// quotes the upstream's message inside its own -- so without this the relayed text would
|
|
220
|
+
// satisfy the caller-mismatch identity and gain a resend the strict wrapper check exists to
|
|
221
|
+
// withhold. Returning here rather than falling through is the point.
|
|
222
|
+
if (typeof error.message === "string" && isLiteLlmEnvelopeMessage(error.message)) {
|
|
223
|
+
return isLiteLlmWrappedCiphertextRejection(payload);
|
|
224
|
+
}
|
|
135
225
|
if (error.type === "invalid_request_error") {
|
|
136
226
|
if (error.code === "invalid_encrypted_content") return true;
|
|
137
227
|
if (
|