@bitkyc08/opencodex 2.81.0 → 2.83.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/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ba8uG696.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- 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 +18 -14
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +11 -4
- 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/tool-output-recovery.ts +20 -0
- package/src/bridge/errors.ts +7 -1
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +22 -20
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/account-auth.ts +20 -12
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +26 -3
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- 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 +1 -1
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +28 -4
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/native-models.ts +1 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +68 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- 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/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/main-account.ts +6 -7
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +375 -227
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +276 -206
- package/src/grok/reset-coupon-ledger.ts +223 -57
- package/src/grok/reset-coupons.ts +13 -6
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +7 -3
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/store.ts +1 -1
- package/src/oauth/types.ts +1 -1
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- 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/model-presets.ts +1 -1
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +6 -6
- package/src/providers/registry/entries-extended.ts +4 -0
- package/src/providers/registry/model-seeds.ts +3 -3
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +25 -7
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/serve-options.ts +18 -4
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +2 -1
- package/src/server/lifecycle.ts +31 -4
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/grok-coupon-routes.ts +277 -52
- package/src/server/management/link-routes.ts +42 -20
- package/src/server/management/route-registry.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-terminal-status.ts +186 -0
- package/src/server/request-log.ts +15 -89
- package/src/server/responses/adapter-continuation.ts +11 -3
- package/src/server/responses/adapter-dispatch.ts +99 -19
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/compact.ts +3 -2
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +9 -9
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +134 -62
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/request-transport.ts +125 -5
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +15 -5
- package/src/server/responses/sidecar-execution.ts +30 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +10 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/usage/expected-prices.ts +1 -1
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
|
@@ -14,6 +14,7 @@ import { projectLinkStatus, type LinkStatusDto } from "../../link/status-project
|
|
|
14
14
|
import { isLinkPort } from "../../link/ports";
|
|
15
15
|
import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
|
|
16
16
|
import { clientLinkTunnelStatus } from "../../client/link-tunnel";
|
|
17
|
+
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
17
18
|
import type { ManagementContext } from "./context";
|
|
18
19
|
import { readManagementJsonBodyOr } from "./body";
|
|
19
20
|
import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
|
|
@@ -228,10 +229,12 @@ async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState,
|
|
|
228
229
|
if (!revoked) {
|
|
229
230
|
try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
|
|
230
231
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
withConfigMutationLockSync(() => {
|
|
233
|
+
const current = readStoreFor(ctx);
|
|
234
|
+
if (!current.links.some(link => link.id === record.id)) {
|
|
235
|
+
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
235
238
|
} catch {
|
|
236
239
|
// The in-memory marker still makes the residual visible when persistence is unavailable.
|
|
237
240
|
}
|
|
@@ -239,11 +242,16 @@ async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState,
|
|
|
239
242
|
}
|
|
240
243
|
try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
|
|
241
244
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
245
|
+
// Read→filter→write stays one section of the shared mutation lock: a sibling
|
|
246
|
+
// route's create must not land between the read and the rename and vanish.
|
|
247
|
+
const next = withConfigMutationLockSync(() => {
|
|
248
|
+
const current = readStoreFor(ctx);
|
|
249
|
+
const filtered = { ...current, links: current.links.filter(link => link.id !== record.id) };
|
|
250
|
+
if (filtered.links.length !== current.links.length) {
|
|
251
|
+
writeStoreFor(ctx, filtered);
|
|
252
|
+
}
|
|
253
|
+
return filtered;
|
|
254
|
+
});
|
|
247
255
|
clearCompensationFailed(record.id, compensationPath());
|
|
248
256
|
state.compensationFailures?.delete(record.id);
|
|
249
257
|
if (next.links.length === 0) await state.listener.close();
|
|
@@ -362,14 +370,20 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
|
|
|
362
370
|
const record = { id, alias: body.alias, direction: "hub-initiated" as const, hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
|
|
363
371
|
let store: LinkStore;
|
|
364
372
|
try {
|
|
365
|
-
|
|
366
|
-
|
|
373
|
+
// Duplicate-check and append under one mutation-lock section: two writers
|
|
374
|
+
// racing this check could otherwise both pass it and append, losing one link.
|
|
375
|
+
const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
|
|
376
|
+
const current = readStoreFor(ctx);
|
|
377
|
+
if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) return "dup";
|
|
378
|
+
store = { ...current, links: [...current.links, record] };
|
|
379
|
+
writeStoreFor(ctx, store);
|
|
380
|
+
return "written";
|
|
381
|
+
});
|
|
382
|
+
if (writeOutcome === "dup") {
|
|
367
383
|
const compensation = await compensateNewLink(ctx, state, record);
|
|
368
384
|
if (compensation) return compensation;
|
|
369
385
|
return fail("link_exists", "A link for this alias already exists.", 409);
|
|
370
386
|
}
|
|
371
|
-
store = { ...current, links: [...current.links, record] };
|
|
372
|
-
writeStoreFor(ctx, store);
|
|
373
387
|
await state.listener.ensureStarted();
|
|
374
388
|
if (state.listener.status().state !== "listening") {
|
|
375
389
|
const compensation = await compensateNewLink(ctx, state, record);
|
|
@@ -430,13 +444,18 @@ async function issue(ctx: ManagementContext): Promise<Response> {
|
|
|
430
444
|
const record = { id, alias: body.alias, direction: "client-initiated" as const, hostKeyFingerprint: null, tunnelPort: body.tunnelPort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
|
|
431
445
|
let failureCode = "link_issue_failed";
|
|
432
446
|
try {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
447
|
+
// Same atomic duplicate-check+append as the hub-initiated branch.
|
|
448
|
+
const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
|
|
449
|
+
const current = readStoreFor(ctx);
|
|
450
|
+
if (current.links.some(link => link.id === id)) throw new Error("link id collision");
|
|
451
|
+
if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) return "dup";
|
|
452
|
+
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
453
|
+
return "written";
|
|
454
|
+
});
|
|
455
|
+
if (writeOutcome === "dup") {
|
|
436
456
|
const compensation = await compensateNewLink(ctx, state, record);
|
|
437
457
|
return compensation ?? fail("link_exists", "A link for this alias already exists.", 409);
|
|
438
458
|
}
|
|
439
|
-
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
440
459
|
await state.listener.ensureStarted();
|
|
441
460
|
if (state.listener.status().state !== "listening") {
|
|
442
461
|
failureCode = "listener_unavailable";
|
|
@@ -538,9 +557,12 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
|
|
|
538
557
|
if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
|
|
539
558
|
let next: LinkStore;
|
|
540
559
|
try {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
560
|
+
next = withConfigMutationLockSync(() => {
|
|
561
|
+
const latest = readStoreFor(ctx);
|
|
562
|
+
const filtered = { ...latest, links: latest.links.filter(link => link.id !== id) };
|
|
563
|
+
if (filtered.links.length !== latest.links.length) writeStoreFor(ctx, filtered);
|
|
564
|
+
return filtered;
|
|
565
|
+
});
|
|
544
566
|
clearCompensationFailed(id, compensationPath());
|
|
545
567
|
} catch { return fail("link_remove_failed", "The link record could not be removed.", 503); }
|
|
546
568
|
state.compensationFailures?.delete(id);
|
|
@@ -361,8 +361,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
361
361
|
{ method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false },
|
|
362
362
|
{ method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false },
|
|
363
363
|
{ 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/
|
|
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/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
|
|
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/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
|
|
366
366
|
// server/management/request-history-routes
|
|
367
367
|
{ method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false },
|
|
368
368
|
// server/management/routing-analytics-routes
|
|
@@ -364,7 +364,7 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
|
|
|
364
364
|
/** Shared Desktop profile DTO builder for the management API and CLI. */
|
|
365
365
|
export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) {
|
|
366
366
|
const { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
367
|
-
const {
|
|
367
|
+
const { routeSupportsOneMillion } = await import("../../claude/long-context");
|
|
368
368
|
const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile");
|
|
369
369
|
const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
|
|
370
370
|
const profileModels: DesktopProfileModel[] = [
|
|
@@ -413,8 +413,8 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
|
|
|
413
413
|
...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}),
|
|
414
414
|
effortSupported: effortByRoute.get(route) ?? false,
|
|
415
415
|
// Read-only view of the 1M capability the written Desktop config already emits,
|
|
416
|
-
// derived from the SAME
|
|
417
|
-
supports1m: (modelByRoute.get(route)?.contextWindow
|
|
416
|
+
// derived from the SAME predicate so the dashboard chip can never disagree.
|
|
417
|
+
supports1m: routeSupportsOneMillion(route, modelByRoute.get(route)?.contextWindow),
|
|
418
418
|
assignment: profile.assignments[route]!,
|
|
419
419
|
}));
|
|
420
420
|
return {
|
|
@@ -139,19 +139,20 @@ export async function resolveNativeOAuthBindingForInstance(
|
|
|
139
139
|
const routeDecision = currentRoute(instance, config, model);
|
|
140
140
|
const manualSelectionGeneration = routing.captureAnthropicManualSelectionGeneration();
|
|
141
141
|
const selection = captureOAuthAccountSelection(instance);
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
assertOwner();
|
|
145
|
-
if (!routeIsCurrent(instance, config, model, routeDecision)) continue;
|
|
146
|
-
const proposed = routing.resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model);
|
|
147
|
-
// A lost selection commit discards a recovery proposal: a newer manual/policy choice wins.
|
|
142
|
+
// A recovery candidate may replace a terminal active credential, including pool-off.
|
|
143
|
+
// A newer committed selection or route discards the proposal and uses normal admission.
|
|
148
144
|
const recoverySelectionMatches = !!options.expectedRecoverySelection
|
|
149
145
|
&& selection?.accountId === options.expectedRecoverySelection.accountId
|
|
150
146
|
&& selection?.revision === options.expectedRecoverySelection.revision;
|
|
151
147
|
const recoveryRouteMatches = options.expectedRecoveryRouteDecision === undefined
|
|
152
148
|
|| JSON.stringify(options.expectedRecoveryRouteDecision) === JSON.stringify(routeDecision);
|
|
153
|
-
const
|
|
154
|
-
? options.candidateAccountId :
|
|
149
|
+
const recoveryAccountId = attempt === 0 && options.candidateAccountId && recoverySelectionMatches && recoveryRouteMatches
|
|
150
|
+
? options.candidateAccountId : undefined;
|
|
151
|
+
const accountId = recoveryAccountId
|
|
152
|
+
?? await routing.resolveAnthropicDispatchAccountId(config, sessionKey, routeDecision, model);
|
|
153
|
+
assertOwner();
|
|
154
|
+
if (!routeIsCurrent(instance, config, model, routeDecision)) continue;
|
|
155
|
+
const proposed = routing.resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model);
|
|
155
156
|
if (!selection) continue;
|
|
156
157
|
if (!routeCandidates(routing.getEligibleAnthropicAccounts(Date.now(), model), routeDecision).includes(accountId)) {
|
|
157
158
|
throw new NativeOAuthSelectionChangedError();
|
|
@@ -36,11 +36,12 @@ import {
|
|
|
36
36
|
buildAnthropicMessagesPassthroughRequest,
|
|
37
37
|
type AnthropicMessagesPassthroughRequest,
|
|
38
38
|
} from "../adapters/anthropic/passthrough";
|
|
39
|
-
import { resolveInboundModel } from "../claude/inbound";
|
|
40
|
-
import { anthropicErrorBody, anthropicErrorResponse, collectAnthropicMessage } from "../claude/outbound";
|
|
39
|
+
import { effortFromOutputConfig, resolveInboundModel } from "../claude/inbound";
|
|
40
|
+
import { anthropicErrorBody, anthropicErrorResponse, claudeOverflowSsePayload, claudePromptTooLongMessage, collectAnthropicMessage, isContextOverflowText, isThroughputLimitText } from "../claude/outbound";
|
|
41
41
|
import type { AdmissionLease } from "../lib/admission";
|
|
42
42
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
43
43
|
import { classifyError } from "../lib/errors";
|
|
44
|
+
import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite";
|
|
44
45
|
import { redactSecretString } from "../lib/redact";
|
|
45
46
|
import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
46
47
|
import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
|
|
@@ -107,6 +108,7 @@ import {
|
|
|
107
108
|
import {
|
|
108
109
|
noteProviderAttemptSend,
|
|
109
110
|
recordAttemptCredentialSource,
|
|
111
|
+
recordAttemptRequestedEffort,
|
|
110
112
|
recordFirstOutput,
|
|
111
113
|
recordKeyAttemptFailure,
|
|
112
114
|
recordKeyWireAttemptUsage,
|
|
@@ -119,6 +121,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
|
|
|
119
121
|
import { sseFieldValue } from "../lib/sse-decoder";
|
|
120
122
|
import { admissionModelDeniedResponse, type AdmissionModelScope } from "./admission-model-scope";
|
|
121
123
|
import { nativeMessagesToolScopeDenial } from "./messages-native-scope";
|
|
124
|
+
import { retainUpstreamMessagesRequestId } from "./messages-response-headers";
|
|
122
125
|
|
|
123
126
|
export {
|
|
124
127
|
isNativeMessagesRouteEligible,
|
|
@@ -317,6 +320,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
317
320
|
logCtx.requestedModel = requestedModel;
|
|
318
321
|
if (route.routeReason === "model-alias" || route.modelId !== requestedModel) logCtx.requestedAlias = requestedModel;
|
|
319
322
|
logCtx.requestedServiceTier = typeof body.service_tier === "string" ? body.service_tier : undefined;
|
|
323
|
+
if (logCtx.requestedEffort === undefined) {
|
|
324
|
+
const effort = effortFromOutputConfig(body.output_config);
|
|
325
|
+
const thinking = isRec(body.thinking) ? body.thinking : undefined;
|
|
326
|
+
const budget = thinking?.budget_tokens;
|
|
327
|
+
if (effort !== undefined) logCtx.requestedEffort = effort;
|
|
328
|
+
else if (thinking?.type === "disabled") logCtx.requestedEffort = "none";
|
|
329
|
+
else if (thinking?.type === "enabled" && typeof budget === "number"
|
|
330
|
+
&& Number.isSafeInteger(budget) && budget > 0) logCtx.requestedEffort = `budget:${budget}`;
|
|
331
|
+
}
|
|
320
332
|
// Reserve spend the way native Chat does: an input estimate that never enters usage, and the
|
|
321
333
|
// caller's own output ceiling.
|
|
322
334
|
if (logCtx.usageLogInputTokens === undefined) {
|
|
@@ -332,6 +344,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
332
344
|
adapter: "anthropic",
|
|
333
345
|
});
|
|
334
346
|
attemptHandle.seal(logCtx.accountLogLabel);
|
|
347
|
+
recordAttemptRequestedEffort(logCtx);
|
|
335
348
|
const { attempt } = attemptHandle;
|
|
336
349
|
const finalLog = createFinalRequestLog(logIds, logCtx);
|
|
337
350
|
const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => {
|
|
@@ -500,7 +513,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
500
513
|
&& nativeMessagesDeclineReason({ ...route, provider: routed }, body, config, selector) === undefined;
|
|
501
514
|
};
|
|
502
515
|
|
|
503
|
-
const send = async (recovery?: "rate-limit-429" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
|
|
516
|
+
const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
|
|
504
517
|
const remaining = remainingTransientSends();
|
|
505
518
|
if (requestTransientPolicy && remaining <= 0) {
|
|
506
519
|
throw new Error("native Messages transient send budget exhausted before recovery dispatch");
|
|
@@ -672,7 +685,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
672
685
|
const oauthRetryKey = {};
|
|
673
686
|
const triedAccountIds = new Set<string>();
|
|
674
687
|
let oauthFailovers = 0;
|
|
675
|
-
while (oauthBinding && (response.status === 429 || response.status === 403)) {
|
|
688
|
+
while (oauthBinding && (response.status === 429 || response.status === 403 || response.status === 401)) {
|
|
676
689
|
const sendingBinding = oauthBinding;
|
|
677
690
|
const expectedRecoverySelection = sendingBinding.selection;
|
|
678
691
|
triedAccountIds.add(sendingBinding.snapshot.accountId);
|
|
@@ -690,7 +703,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
690
703
|
canRetry: transientSendAvailable() && oauthFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
|
|
691
704
|
});
|
|
692
705
|
if (!nextAccountId) break;
|
|
693
|
-
const recovery = response.status === 403 ? "oauth-account-403" : "rate-limit-429";
|
|
706
|
+
const recovery = response.status === 401 ? "oauth-401" : response.status === 403 ? "oauth-account-403" : "rate-limit-429";
|
|
694
707
|
discard(response);
|
|
695
708
|
oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget,
|
|
696
709
|
sessionKey: options.sessionKey, model: route.modelId, candidateAccountId: nextAccountId,
|
|
@@ -739,7 +752,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
739
752
|
upstream.abort();
|
|
740
753
|
return fail(499, "Client cancelled request", "api_error");
|
|
741
754
|
}
|
|
742
|
-
return nativeMessagesErrorResponse(response, bodyText, finishLog);
|
|
755
|
+
return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers);
|
|
743
756
|
}
|
|
744
757
|
|
|
745
758
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
@@ -764,10 +777,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
764
777
|
releaseStreamTurn();
|
|
765
778
|
}
|
|
766
779
|
}, bodyGuard);
|
|
767
|
-
|
|
780
|
+
// A configured Messages provider words an oversized-input refusal its own way; Claude Code
|
|
781
|
+
// compacts only on Anthropic's wording (devlog/_plan/261009_claude_1m_default/010). The
|
|
782
|
+
// rewrite buffers whole frames, so it sits after the tap: stall detection keeps timing raw
|
|
783
|
+
// upstream bytes. Real Anthropic pools refuse pre-stream in that wording and keep one relay.
|
|
784
|
+
const clientStream = nativeInstance ? relayed : relaySseWithPayloadRewrite(relayed, claudeOverflowSsePayload, translatorBudget);
|
|
785
|
+
return retainUpstreamMessagesRequestId(new Response(clientStream, {
|
|
768
786
|
status: 200,
|
|
769
787
|
headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
770
|
-
});
|
|
788
|
+
}), response.headers);
|
|
771
789
|
}
|
|
772
790
|
// A non-streaming caller whose upstream streamed anyway: fold the stream into one message.
|
|
773
791
|
const tapState: { closeReason?: FinalRequestLogMeta["closeReason"]; meta?: FinalRequestLogMeta } = {};
|
|
@@ -789,10 +807,11 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
789
807
|
// as the streaming lane's row; a plain upstream error event keeps the non_stream row.
|
|
790
808
|
const tapMeta = tapState.meta;
|
|
791
809
|
if (tapMeta && (tapMeta.terminalStatus || tapMeta.closeReason !== "terminal")) finishLog(502, text, tapMeta);
|
|
810
|
+
if (isNativeOverflowError(error, text)) return fail(400, claudePromptTooLongMessage(text), "invalid_request_error");
|
|
792
811
|
return fail(502, text, "api_error");
|
|
793
812
|
}
|
|
794
813
|
finishLog(200);
|
|
795
|
-
return Response.json(message);
|
|
814
|
+
return retainUpstreamMessagesRequestId(Response.json(message), response.headers);
|
|
796
815
|
} catch (error) {
|
|
797
816
|
cleanupAbort();
|
|
798
817
|
upstream.abort();
|
|
@@ -850,12 +869,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
850
869
|
const serialized = JSON.stringify(message);
|
|
851
870
|
finishLog(200);
|
|
852
871
|
if (requestedStream) {
|
|
853
|
-
return new Response(messageAsSse(message), {
|
|
872
|
+
return retainUpstreamMessagesRequestId(new Response(messageAsSse(message), {
|
|
854
873
|
status: 200,
|
|
855
874
|
headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
|
|
856
|
-
});
|
|
875
|
+
}), response.headers);
|
|
857
876
|
}
|
|
858
|
-
return new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } });
|
|
877
|
+
return retainUpstreamMessagesRequestId(new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } }), response.headers);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** A folded stream error that refuses an oversized input (same gate as the SSE rewrite). */
|
|
881
|
+
function isNativeOverflowError(error: Rec, message: string): boolean {
|
|
882
|
+
const sized = error.type === "invalid_request_error" || error.type === "request_too_large";
|
|
883
|
+
return sized && !isThroughputLimitText(message)
|
|
884
|
+
&& (error.code === "context_length_exceeded" || isContextOverflowText(message));
|
|
859
885
|
}
|
|
860
886
|
|
|
861
887
|
/**
|
|
@@ -865,11 +891,13 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
865
891
|
*/
|
|
866
892
|
function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response {
|
|
867
893
|
let upstreamType: string | undefined;
|
|
894
|
+
let upstreamCode: string | undefined;
|
|
868
895
|
let upstreamMessage: string | undefined;
|
|
869
896
|
try {
|
|
870
897
|
const parsed = JSON.parse(bodyText) as Rec;
|
|
871
898
|
const details = isRec(parsed.error) ? parsed.error : parsed;
|
|
872
899
|
if (typeof details.type === "string") upstreamType = details.type;
|
|
900
|
+
if (typeof details.code === "string") upstreamCode = details.code;
|
|
873
901
|
if (typeof details.message === "string" && details.message.trim()) {
|
|
874
902
|
upstreamMessage = redactSecretString(details.message.trim());
|
|
875
903
|
}
|
|
@@ -898,9 +926,17 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis
|
|
|
898
926
|
if (retryAfter) headers.set("Retry-After", retryAfter);
|
|
899
927
|
else if (transient) headers.set("Retry-After", "2");
|
|
900
928
|
if (replayRefusal) applyReplayRefusalClientHeaders(headers);
|
|
929
|
+
// The native envelope keeps its shape (no added code); only an overflow's wording changes, so
|
|
930
|
+
// Claude Code recognizes it and compacts (010).
|
|
931
|
+
// Only a 400/413 refusal: the shared classifier also files a 429 "too many tokens per minute"
|
|
932
|
+
// under context_length_exceeded, and that one must stay a rate limit.
|
|
933
|
+
const overflow = !replayRefusal && (response.status === 400 || response.status === 413)
|
|
934
|
+
&& !isThroughputLimitText(safeMessage)
|
|
935
|
+
&& (classified.code === "context_length_exceeded" || upstreamCode === "context_length_exceeded"
|
|
936
|
+
|| isContextOverflowText(safeMessage));
|
|
901
937
|
const out = new Response(JSON.stringify(anthropicErrorBody(
|
|
902
938
|
status,
|
|
903
|
-
safeMessage,
|
|
939
|
+
overflow ? claudePromptTooLongMessage(safeMessage) : safeMessage,
|
|
904
940
|
transient ? "overloaded_error" : upstreamType,
|
|
905
941
|
replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
|
|
906
942
|
)), { status, headers });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Only an opaque Anthropic request id is carried; arbitrary upstream headers stay private. */
|
|
2
|
+
export function upstreamMessagesRequestIdHeaders(upstream: Headers): Record<string, string> {
|
|
3
|
+
const requestId = upstream.get("request-id");
|
|
4
|
+
return requestId && /^req_[A-Za-z0-9_-]{1,128}$/.test(requestId)
|
|
5
|
+
? { "request-id": requestId }
|
|
6
|
+
: {};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The response is newly constructed by the Messages handler; preserve its body and markers. */
|
|
10
|
+
export function retainUpstreamMessagesRequestId(response: Response, upstream: Headers): Response {
|
|
11
|
+
const requestId = upstreamMessagesRequestIdHeaders(upstream)["request-id"];
|
|
12
|
+
if (requestId) response.headers.set("request-id", requestId);
|
|
13
|
+
return response;
|
|
14
|
+
}
|
|
@@ -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 {
|
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;
|
|
@@ -37,6 +37,47 @@ export function normalizeLogConversationId(raw: string | undefined | null): stri
|
|
|
37
37
|
return createHash("sha256").update(trimmed).digest("hex").slice(0, LOG_CONVERSATION_ID_LEN);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const CODEX_THREAD_LINK_PREFIX = "codex://threads/";
|
|
41
|
+
/** Real thread links stay well under this bound; larger pastes skip unwrapping so per-row matching stays cheap. */
|
|
42
|
+
const LOG_CONVERSATION_LINK_MAX = 512;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Unwrap a pasted `codex://threads/<id>` deep link to the bare thread id. Codex's
|
|
46
|
+
* "copy session link" clipboard carries that URI form while clients send the bare id
|
|
47
|
+
* on the wire, so Logs search accepts either paste without changing what is persisted.
|
|
48
|
+
* Query/fragment metadata (e.g. `?hostId=…`) belongs to the link, not the id, and is
|
|
49
|
+
* dropped. The scan is a bounded linear walk — no ambiguous regex — because this runs
|
|
50
|
+
* per log row on every filter evaluation.
|
|
51
|
+
*/
|
|
52
|
+
export function unwrapLogConversationQuery(query: string): string {
|
|
53
|
+
const trimmed = query.trim();
|
|
54
|
+
if (
|
|
55
|
+
trimmed.length > LOG_CONVERSATION_LINK_MAX ||
|
|
56
|
+
trimmed.length <= CODEX_THREAD_LINK_PREFIX.length ||
|
|
57
|
+
!trimmed.toLowerCase().startsWith(CODEX_THREAD_LINK_PREFIX)
|
|
58
|
+
) {
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
const rest = trimmed.slice(CODEX_THREAD_LINK_PREFIX.length);
|
|
62
|
+
const metaIndex = rest.search(/[?#]/);
|
|
63
|
+
const segment = (metaIndex === -1 ? rest : rest.slice(0, metaIndex)).replace(/\/+$/, "");
|
|
64
|
+
// A thread id is a single path segment; deeper paths or embedded whitespace keep the
|
|
65
|
+
// whole paste as the query, so foreign `codex://` URIs still fail to match.
|
|
66
|
+
return segment !== "" && !/[/\s]/.test(segment) ? segment : trimmed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The candidate ids a conversation query may legitimately match. Unwrapping a pasted
|
|
71
|
+
* `codex://threads/<id>` link must add a match path, not replace one: a client can
|
|
72
|
+
* literally send a `codex://threads/…` session id, which is hashed whole for storage,
|
|
73
|
+
* so the untouched paste stays a candidate too.
|
|
74
|
+
*/
|
|
75
|
+
export function logConversationQueryCandidates(query: string): string[] {
|
|
76
|
+
const trimmed = query.trim();
|
|
77
|
+
const unwrapped = unwrapLogConversationQuery(trimmed);
|
|
78
|
+
return unwrapped === trimmed ? [unwrapped] : [unwrapped, trimmed];
|
|
79
|
+
}
|
|
80
|
+
|
|
40
81
|
/**
|
|
41
82
|
* Filter match: accept either the persisted digest or the original preimage
|
|
42
83
|
* (so pasting from Logs detail or the client-facing session id both work).
|
|
@@ -46,11 +87,13 @@ export function matchesLogConversationId(
|
|
|
46
87
|
query: string | undefined | null,
|
|
47
88
|
): boolean {
|
|
48
89
|
if (!stored) return false;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
90
|
+
for (const candidate of logConversationQueryCandidates(typeof query === "string" ? query : "")) {
|
|
91
|
+
if (!candidate) continue;
|
|
92
|
+
if (stored === candidate) return true;
|
|
93
|
+
const hashed = normalizeLogConversationId(candidate);
|
|
94
|
+
if (hashed !== undefined && stored === hashed) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
54
97
|
}
|
|
55
98
|
|
|
56
99
|
/**
|
|
@@ -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
|