@bitkyc08/opencodex 2.22.0 → 2.23.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.
Files changed (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +5 -1
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +86 -74
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
package/src/lib/errors.ts CHANGED
@@ -143,6 +143,15 @@ export function classifyError(status: number, type: string, message: string): Oc
143
143
  if (type === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(text)) {
144
144
  return { message, type: "invalid_request_error", code: CYBER_POLICY_ERROR_CODE };
145
145
  }
146
+ // A LOCAL preflight refusal keeps its own code (#1524). The message necessarily says
147
+ // "context window" -- that is what it is refusing on -- so the generic remap below would
148
+ // rewrite it to `context_length_exceeded` and make it indistinguishable from an UPSTREAM
149
+ // verdict. The two need opposite fallback handling: ours means "this candidate does not
150
+ // fit", theirs means "the request is impossible", so collapsing them ended the chain at
151
+ // the first candidate that was merely too small.
152
+ if (type === "input_admission_refused") {
153
+ return { message, type: "invalid_request_error", code: "input_admission_refused" };
154
+ }
146
155
  if (
147
156
  text.includes("context_length_exceeded") ||
148
157
  text.includes("context window") ||
@@ -13,7 +13,7 @@
13
13
  * therefore completes before `startServer` returns, inside the same synchronous turn as
14
14
  * `Bun.serve`, so no request can observe an unregistered slot.
15
15
  *
16
- * See devlog/_plan/260814_lab_core_decoupling/080_activation_is_synchronous.md
16
+ * See devlog/_fin/260814_lab_core_decoupling/080_activation_is_synchronous.md
17
17
  *
18
18
  * Startup degrades, explicit operator action reports. This asymmetry is deliberate: an
19
19
  * invalid automation config disables automation with a warning here, but the management
@@ -16,7 +16,7 @@
16
16
  * absolute deadline, so a hook that throws must not prevent its siblings — or
17
17
  * `server.stop` — from running.
18
18
  *
19
- * See devlog/_plan/260814_lab_core_decoupling/010_lifecycle_shutdown_registry.md
19
+ * See devlog/_fin/260814_lab_core_decoupling/010_lifecycle_shutdown_registry.md
20
20
  */
21
21
 
22
22
  type ShutdownHook = () => void;
@@ -237,21 +237,27 @@ function providerLabel(providerId: string): string {
237
237
  }
238
238
 
239
239
  function normalizeResetAt(value: unknown): number | undefined {
240
- if (typeof value === "number" && Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
240
+ if (typeof value === "number" && Number.isFinite(value)) return epochMillis(value);
241
241
  if (typeof value === "string" && value.trim()) {
242
242
  const trimmed = value.trim();
243
243
  // Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
244
244
  // Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
245
- if (/^\d+(\.\d+)?$/.test(trimmed)) {
245
+ if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
246
246
  const numeric = Number(trimmed);
247
- if (Number.isFinite(numeric)) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
247
+ return epochMillis(numeric);
248
248
  }
249
249
  const parsed = Date.parse(trimmed);
250
- return Number.isFinite(parsed) ? parsed : undefined;
250
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
251
251
  }
252
252
  return undefined;
253
253
  }
254
254
 
255
+ /** Unix 0 / negative values are sentinels, not reset clocks (Command Code fiveHour.resetAt: 0). */
256
+ function epochMillis(value: number): number | undefined {
257
+ if (!Number.isFinite(value) || value <= 0) return undefined;
258
+ return value > 10_000_000_000 ? value : value * 1000;
259
+ }
260
+
255
261
  function toFiniteNumber(value: unknown): number | undefined {
256
262
  if (typeof value === "number" && Number.isFinite(value)) return value;
257
263
  if (typeof value === "string" && value.trim()) {
@@ -452,7 +452,7 @@ const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
452
452
  *
453
453
  * Zen's roster is discovered live while this list is static, so it is a dated
454
454
  * exception list, not a capability model. Re-probe before extending it.
455
- * Evidence: devlog/_plan/260805_bug_fix_stack/002_zen_modality_probe.md
455
+ * Evidence: devlog/_fin/260805_bug_fix_stack/002_zen_modality_probe.md
456
456
  */
457
457
  const OPENCODE_ZEN_TEXT_ONLY_MODELS = [
458
458
  "big-pickle",
@@ -1513,7 +1513,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1513
1513
  // finished (28-46 s of silence on long turns). The registry knob itself remains
1514
1514
  // for providers that need it — re-adding one line here restores the old policy.
1515
1515
  // Evidence: https://api-docs.deepseek.com/guides/responses_api/ +
1516
- // devlog/_plan/260807_deepseek_responses_streaming/000_plan.md.
1516
+ // devlog/_fin/260807_deepseek_responses_streaming/000_plan.md.
1517
1517
  // Current official streams normally carry a real terminal; retain a narrow grace
1518
1518
  // repair for the historical shape that closes after a complete graph without one.
1519
1519
  modelResponsesTerminalRepair: { "deepseek-v4-flash": { graceMs: 5_000 }, "deepseek-v4-pro": { graceMs: 5_000 } },
@@ -9,10 +9,12 @@ import type {
9
9
  OcxThinkingContent,
10
10
  OcxTool,
11
11
  OcxToolCall,
12
+ OcxReasoningReplayScopeRef,
12
13
  } from "../types";
13
14
  import { namespacedToolName } from "../types";
14
15
  import { responsesRequestSchema } from "./schema";
15
16
  import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
17
+ import { lookupReplayThoughtSignature } from "./thought-signature-replay";
16
18
  import { compactionItemToText } from "./compaction";
17
19
  import { previousResponseReplayPrefixLength } from "./state";
18
20
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
@@ -23,6 +25,21 @@ function isObj(v: unknown): v is Record<string, unknown> {
23
25
  return typeof v === "object" && v !== null && !Array.isArray(v);
24
26
  }
25
27
 
28
+ /**
29
+ * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call.
30
+ *
31
+ * The scope is REQUIRED for a hit. `parseRequest` runs before the route and account are
32
+ * chosen, so a caller that has not yet bound a replay scope gets nothing rather than a
33
+ * signature belonging to some other thread that happened to reuse the same `call_id`.
34
+ */
35
+ function replayThoughtSignatureMetadata(
36
+ callId: string,
37
+ scope: OcxReasoningReplayScopeRef | undefined,
38
+ ): { google: { thoughtSignature: string } } | undefined {
39
+ const signature = lookupReplayThoughtSignature(callId, scope);
40
+ return signature ? { google: { thoughtSignature: signature } } : undefined;
41
+ }
42
+
26
43
  type InputBlock =
27
44
  | { type: "input_text"; text: string }
28
45
  | { type: "text"; text: string }
@@ -165,13 +182,17 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
165
182
  }
166
183
  }
167
184
  else if (t.type === "custom" && typeof t.name === "string") {
168
- // Freeform custom tool (e.g. apply_patch). Chat models can't emit a lark grammar, so expose a
169
- // function with a single string `input` carrying the raw tool body; the bridge relays the model's
170
- // call back as a custom_tool_call (Codex's freeform handler rejects a function_call fatal abort).
185
+ // Freeform custom tools are lowered to a single string `input` because chat models cannot
186
+ // emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the
187
+ // tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches
188
+ // routed models that the nested helper name is itself a callable top-level tool.
189
+ const inputDescription = t.name === "apply_patch"
190
+ ? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope."
191
+ : "Raw freeform input for this tool.";
171
192
  out.push({
172
193
  name: t.name,
173
194
  description: (t.description as string) ?? "",
174
- parameters: { type: "object", properties: { input: { type: "string", description: "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." } }, required: ["input"] },
195
+ parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] },
175
196
  freeform: true,
176
197
  });
177
198
  }
@@ -294,7 +315,11 @@ function attachPendingReasoningToCallOwner(
294
315
 
295
316
  const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
296
317
 
297
- export function parseRequest(body: unknown): OcxParsedRequest {
318
+ export function parseRequest(
319
+ body: unknown,
320
+ parseOptions?: { replayCacheScope?: OcxReasoningReplayScopeRef },
321
+ ): OcxParsedRequest {
322
+ const replayCacheScope = parseOptions?.replayCacheScope;
298
323
  const replayedInputPrefixLength = previousResponseReplayPrefixLength(body);
299
324
  const parsed = responsesRequestSchema.safeParse(body);
300
325
  if (!parsed.success) {
@@ -522,8 +547,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
522
547
  };
523
548
  // Provider-opaque metadata (e.g. a Gemini thought signature) travels with the call so a
524
549
  // history-replayed or previous_response_id turn rebuilds the same signed part instead of
525
- // depending on the same-process replay cache (issue #1735).
526
- const providerMetadata = providerMetadataFromResponsesFunctionCall(call);
550
+ // depending on the same-process replay cache (issue #1735). Real clients do not echo
551
+ // extra_content on replay, so fall back to the proxy-side store keyed by call_id.
552
+ const providerMetadata = providerMetadataFromResponsesFunctionCall(call)
553
+ ?? (typeof call.call_id === "string"
554
+ ? replayThoughtSignatureMetadata(call.call_id, replayCacheScope)
555
+ : undefined);
527
556
  if (providerMetadata) toolCall.providerMetadata = providerMetadata;
528
557
  assistantHolderWithReasoning().content.push(toolCall);
529
558
  continue;
@@ -531,10 +560,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
531
560
 
532
561
  if (effectiveType === "custom_tool_call") {
533
562
  const call = item as { id?: string; call_id: string; name: string; input: string };
563
+ const remembered = typeof call.call_id === "string" ? replayThoughtSignatureMetadata(call.call_id, replayCacheScope) : undefined;
534
564
  const toolCall: OcxToolCall = {
535
565
  type: "toolCall", id: call.call_id, name: call.name,
536
566
  arguments: { input: call.input ?? "" },
537
567
  customWireName: call.name,
568
+ ...(remembered ? { providerMetadata: remembered } : {}),
538
569
  };
539
570
  assistantHolderWithReasoning().content.push(toolCall);
540
571
  continue;
@@ -547,9 +578,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
547
578
  const callId = call.call_id ?? call.id;
548
579
  if (callId) {
549
580
  const command = Array.isArray(call.action?.command) ? call.action.command : [];
581
+ const remembered = replayThoughtSignatureMetadata(callId, replayCacheScope);
550
582
  assistantHolderWithReasoning().content.push({
551
583
  type: "toolCall", id: callId, name: "shell",
552
584
  arguments: command.length > 0 ? { command } : {},
585
+ ...(remembered ? { providerMetadata: remembered } : {}),
553
586
  });
554
587
  }
555
588
  continue;
@@ -568,9 +601,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
568
601
  // history stays complete (otherwise the model re-issues tool_search forever).
569
602
  const call = item as { id?: string; call_id?: string; arguments?: unknown };
570
603
  const callId = call.call_id ?? call.id ?? "";
604
+ const remembered = callId ? replayThoughtSignatureMetadata(callId, replayCacheScope) : undefined;
571
605
  assistantHolderWithReasoning().content.push({
572
606
  type: "toolCall", id: callId, name: "tool_search",
573
607
  arguments: isObj(call.arguments) ? call.arguments : {},
608
+ ...(remembered ? { providerMetadata: remembered } : {}),
574
609
  });
575
610
  continue;
576
611
  }
@@ -30,7 +30,7 @@ function isObj(value: unknown): value is Record<string, unknown> {
30
30
  */
31
31
  const MAX_SIGNATURE_BYTES = 64 * 1024;
32
32
 
33
- function isCarryableSignature(value: unknown): value is string {
33
+ export function isCarryableSignature(value: unknown): value is string {
34
34
  if (typeof value !== "string" || value.length === 0) return false;
35
35
  // Cheap length pre-check: UTF-8 is at most 3 bytes per UTF-16 code unit for the BMP, so this
36
36
  // skips the encode for the overwhelmingly common short case.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Server-side thought-signature replay store (issue #1735 follow-up).
3
+ *
4
+ * Gemini issues a thoughtSignature on the function-call part of a response and requires it
5
+ * back when the same call is replayed in a later request. The Responses wire carries the
6
+ * signature in extra_content.google.thought_signature, and a conforming client echoes it on
7
+ * the replay. Real clients (codex-rs 0.144.x, Codex desktop) do NOT echo extra_content:
8
+ * they replay history as bare function_call / custom_tool_call items keyed by call_id.
9
+ * Without the signature Gemini rejects the replayed part with
10
+ * "Function call is missing a thought_signature in functionCall parts".
11
+ *
12
+ * This module is the proxy-side fallback: remember the signature we handed out and re-attach
13
+ * it on replay even when the client never echoes it. Values stay opaque (never parsed or
14
+ * re-encoded) and are bounded like the wire metadata.
15
+ *
16
+ * SCOPE: a client-visible `call_id` is NOT unique across conversations, accounts, providers
17
+ * or models. Keying on it alone let one thread's signature overwrite another's, and let a
18
+ * lookup hand a signature from a different account's turn to the current one. The key is the
19
+ * same identity the in-process reasoning cache already uses — thread plus exact provider
20
+ * destination, adapter, model and credential — so a signature can only ever be replayed into
21
+ * the turn that produced it.
22
+ */
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { atomicWriteFileAsync, getConfigDir } from "../config";
26
+ import type { OcxProviderOpaqueToolCallMetadata, OcxReasoningReplayScopeRef } from "../types";
27
+ import { isCarryableSignature, responsesExtraContentFromProviderMetadata } from "./provider-opaque-metadata";
28
+
29
+ const STORE_FILE_NAME = "thought-signature-replay.json";
30
+
31
+ /** Bound on remembered entries; real signatures are a few hundred bytes, so this stays small. */
32
+ const MAX_ENTRIES = 16_384;
33
+ /**
34
+ * Total bytes of remembered signature material.
35
+ *
36
+ * An entry count alone is not a memory bound: a single signature may be 64KiB, so 16,384
37
+ * entries is a ~1GiB ceiling. This is the bound that actually holds.
38
+ */
39
+ const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
40
+ /** A signature is needed for the immediate next turn; a long TTL also covers resumed threads. */
41
+ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
42
+
43
+ type StoredEntry = { sig: string; savedAt: number };
44
+
45
+ /** Outcome of a remember attempt. `conflict` is a real signal, not a no-op. */
46
+ export type ThoughtSignatureRememberResult =
47
+ | "stored"
48
+ | "already-equal"
49
+ | "conflict"
50
+ | "unscoped"
51
+ | "ignored";
52
+
53
+ let entries = new Map<string, StoredEntry>();
54
+ let totalBytes = 0;
55
+ let loaded = false;
56
+ let persistChain: Promise<void> = Promise.resolve();
57
+
58
+ function storePath(): string {
59
+ return join(getConfigDir(), STORE_FILE_NAME);
60
+ }
61
+
62
+ function nonEmpty(value: unknown): value is string {
63
+ return typeof value === "string" && value.trim().length > 0;
64
+ }
65
+
66
+ /**
67
+ * Durable key for one call, or `undefined` when the scope is incomplete.
68
+ *
69
+ * Incomplete scope means "do not remember" rather than "remember globally": a partially
70
+ * identified entry is exactly the cross-thread collision this store exists to prevent.
71
+ * The reasoning cache's identities are process-local HMACs, so this key deliberately uses
72
+ * only the stable, non-secret fields that survive a restart.
73
+ */
74
+ function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
75
+ const identity = scope?.current;
76
+ if (
77
+ !nonEmpty(callId)
78
+ || !nonEmpty(scope?.clientThreadId)
79
+ || !nonEmpty(identity?.providerName)
80
+ || !nonEmpty(identity?.adapterName)
81
+ || !nonEmpty(identity?.modelId)
82
+ ) return undefined;
83
+ return JSON.stringify([
84
+ scope.clientThreadId,
85
+ identity.providerName,
86
+ identity.adapterName,
87
+ identity.modelId,
88
+ callId,
89
+ ]);
90
+ }
91
+
92
+ function load(): void {
93
+ if (loaded) return;
94
+ loaded = true;
95
+ let raw: string;
96
+ try {
97
+ raw = readFileSync(storePath(), "utf8");
98
+ } catch {
99
+ return; // First run or unreadable file: start empty.
100
+ }
101
+ try {
102
+ const parsed: unknown = JSON.parse(raw);
103
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray((parsed as { entries?: unknown }).entries)) {
104
+ return;
105
+ }
106
+ const nowMs = Date.now();
107
+ for (const entry of (parsed as { entries: unknown[] }).entries) {
108
+ if (typeof entry !== "object" || entry === null) continue;
109
+ const { key, sig, savedAt } = entry as { key?: unknown; sig?: unknown; savedAt?: unknown };
110
+ if (typeof key !== "string" || typeof sig !== "string" || typeof savedAt !== "number") continue;
111
+ if (savedAt <= nowMs - TTL_MS) continue;
112
+ if (!isCarryableSignature(sig)) continue;
113
+ entries.set(key, { sig, savedAt });
114
+ totalBytes += sig.length;
115
+ }
116
+ } catch {
117
+ // Corrupt store: ignore it; a later remember() rewrites a clean snapshot.
118
+ }
119
+ // A loaded snapshot can already exceed the bounds if they were lowered, so enforce them
120
+ // here rather than waiting for the next write.
121
+ prune(Date.now());
122
+ }
123
+
124
+ function prune(nowMs: number): void {
125
+ for (const [key, entry] of entries) {
126
+ if (nowMs - entry.savedAt > TTL_MS) {
127
+ entries.delete(key);
128
+ totalBytes -= entry.sig.length;
129
+ }
130
+ }
131
+ if (entries.size <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) return;
132
+ const sorted = [...entries.entries()].sort((a, b) => a[1].savedAt - b[1].savedAt);
133
+ for (const [key, entry] of sorted) {
134
+ if (entries.size <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break;
135
+ entries.delete(key);
136
+ totalBytes -= entry.sig.length;
137
+ }
138
+ }
139
+
140
+ function persist(): Promise<void> {
141
+ persistChain = persistChain
142
+ .then(async () => {
143
+ const snapshot = JSON.stringify({
144
+ version: 2,
145
+ entries: [...entries].map(([key, entry]) => ({ key, sig: entry.sig, savedAt: entry.savedAt })),
146
+ });
147
+ await atomicWriteFileAsync(storePath(), snapshot);
148
+ })
149
+ .catch(() => {
150
+ // Best-effort persistence: the in-memory store still serves the running process.
151
+ });
152
+ return persistChain;
153
+ }
154
+
155
+ /**
156
+ * Record the signature that left the proxy on a function-call response item.
157
+ *
158
+ * Returns the outcome so a caller can await durability before exposing the item, and so a
159
+ * genuine conflict is observable instead of silently overwriting. A different signature under
160
+ * the SAME complete key means two different upstream turns claimed one identity: that is a
161
+ * corruption signal, and keeping the first value is the fail-closed choice.
162
+ */
163
+ export function rememberThoughtSignatureForReplay(
164
+ callId: string,
165
+ signature: string,
166
+ scope: OcxReasoningReplayScopeRef | undefined,
167
+ ): { result: ThoughtSignatureRememberResult; durable: Promise<void> } {
168
+ if (!callId || !isCarryableSignature(signature)) {
169
+ return { result: "ignored", durable: Promise.resolve() };
170
+ }
171
+ const key = keyFor(callId, scope);
172
+ if (key === undefined) return { result: "unscoped", durable: Promise.resolve() };
173
+ load();
174
+ const existing = entries.get(key);
175
+ if (existing) {
176
+ if (existing.sig === signature) return { result: "already-equal", durable: Promise.resolve() };
177
+ return { result: "conflict", durable: Promise.resolve() };
178
+ }
179
+ entries.set(key, { sig: signature, savedAt: Date.now() });
180
+ totalBytes += signature.length;
181
+ prune(Date.now());
182
+ return { result: "stored", durable: persist() };
183
+ }
184
+
185
+ /**
186
+ * Serialize provider metadata onto an outbound Responses function_call item AND remember the
187
+ * signature server-side, so a client that replays the call without echoing extra_content can
188
+ * still be served from the store.
189
+ */
190
+ export function rememberAndSerializeExtraContent(
191
+ callId: string,
192
+ metadata: OcxProviderOpaqueToolCallMetadata | undefined,
193
+ scope: OcxReasoningReplayScopeRef | undefined,
194
+ ): {
195
+ extra?: { extra_content: { google: { thought_signature: string } } };
196
+ durable: Promise<void>;
197
+ } {
198
+ const extra = responsesExtraContentFromProviderMetadata(metadata);
199
+ if (!extra) return { durable: Promise.resolve() };
200
+ const { durable } = rememberThoughtSignatureForReplay(
201
+ callId,
202
+ extra.extra_content.google.thought_signature,
203
+ scope,
204
+ );
205
+ return { extra, durable };
206
+ }
207
+
208
+ /**
209
+ * Remember the signature without serializing it onto the item. Used for freeform tools, whose
210
+ * Responses items are custom_tool_call blocks that cannot carry extra_content — the signature
211
+ * still must be stored so the replayed call (which comes back as custom_tool_call and never
212
+ * echoes metadata) can be re-signed server-side.
213
+ */
214
+ export function rememberExtraContentForReplay(
215
+ callId: string,
216
+ metadata: OcxProviderOpaqueToolCallMetadata | undefined,
217
+ scope: OcxReasoningReplayScopeRef | undefined,
218
+ ): Promise<void> {
219
+ const extra = responsesExtraContentFromProviderMetadata(metadata);
220
+ if (!extra) return Promise.resolve();
221
+ return rememberThoughtSignatureForReplay(
222
+ callId,
223
+ extra.extra_content.google.thought_signature,
224
+ scope,
225
+ ).durable;
226
+ }
227
+
228
+ /** Look up a signature previously handed out for this call in THIS scope, if still fresh. */
229
+ export function lookupReplayThoughtSignature(
230
+ callId: string,
231
+ scope: OcxReasoningReplayScopeRef | undefined,
232
+ ): string | undefined {
233
+ const key = keyFor(callId, scope);
234
+ if (key === undefined) return undefined;
235
+ load();
236
+ const entry = entries.get(key);
237
+ if (!entry) return undefined;
238
+ if (Date.now() - entry.savedAt > TTL_MS) {
239
+ entries.delete(key);
240
+ totalBytes -= entry.sig.length;
241
+ return undefined;
242
+ }
243
+ return entry.sig;
244
+ }
245
+
246
+ /** Test seams: clear in-memory state and the loaded flag without touching the file. */
247
+ export function resetThoughtSignatureReplayForTests(): void {
248
+ entries = new Map();
249
+ totalBytes = 0;
250
+ loaded = false;
251
+ persistChain = Promise.resolve();
252
+ }
253
+
254
+ export function thoughtSignatureReplayCountForTests(): number {
255
+ return entries.size;
256
+ }
257
+
258
+ /** Test seam: resolve after the queued snapshot write settles. */
259
+ export function flushThoughtSignatureReplayForTests(): Promise<void> {
260
+ return persistChain;
261
+ }
package/src/router.ts CHANGED
@@ -715,12 +715,17 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu
715
715
  for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) {
716
716
  if (prefixes.some(prefix => modelId.startsWith(prefix))) {
717
717
  const matchingProvider = Object.entries(config.providers).find(
718
- ([name]) => providerNames.some(providerName => name === providerName || name.startsWith(`${providerName}-`))
718
+ ([name, prov]) => prov.disabled !== true && providerNames.some(providerName => name === providerName || name.startsWith(`${providerName}-`))
719
719
  );
720
720
  if (matchingProvider) {
721
721
  const [provName, prov] = matchingProvider;
722
722
  return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern");
723
723
  }
724
+ // Deliberately no "first provider with an Anthropic adapter" fallback here. Picking by
725
+ // object insertion order, without checking `models`, `selectedModels`, `disabledModels` or
726
+ // discovery state, silently moves a request onto a provider the operator never chose, with
727
+ // its own privacy and billing consequences (#1697). A classifier turn that needs a specific
728
+ // target gets it from operator-declared `claudeCode.classifierModel` / `classifierFallbacks`.
724
729
  }
725
730
  }
726
731
  return undefined;
@@ -11,7 +11,7 @@
11
11
  * compatibility-gated routing profiles never register one, so the core evidence assembler
12
12
  * never reaches the Lab module graph.
13
13
  *
14
- * See devlog/_plan/260814_lab_core_decoupling/030_router_and_startup_activation.md
14
+ * See devlog/_fin/260814_lab_core_decoupling/030_router_and_startup_activation.md
15
15
  */
16
16
  import type { OcxConfig } from "../../types";
17
17
  import type { NormalizedRoutingProfile } from "../profile";
@@ -21,7 +21,7 @@ import {
21
21
  type Unknownable,
22
22
  } from "./trace";
23
23
  import { getRoutingProfile, policyModelId, type NormalizedRoutingProfile } from "./profile";
24
- import { healthScore } from "./health";
24
+ import { healthScore, latencyScoreFromEvidence } from "./health";
25
25
  import { quotaScore } from "./quota";
26
26
  import { costScore } from "./cost";
27
27
  import { evaluateCompatibilityForCandidate } from "./compatibility/policy";
@@ -398,10 +398,16 @@ export function evaluatePolicyProfile(
398
398
  const healthWeight = profile.optimize.health;
399
399
  const quotaWeight = profile.optimize.quota;
400
400
  const costWeight = profile.optimize.cost;
401
+ // `optimize.latency` was normalized into the weight sum but never spent, so whatever
402
+ // was allocated to it silently became configuredPriority -- i.e. declaration order.
403
+ // Spend it on the same p50-derived score the health composite already uses.
404
+ const latencyWeight = profile.optimize.latency;
405
+ const latencyValue = latencyWeight > 0 ? latencyScoreFromEvidence(health) : null;
401
406
  const spentHealth = healthValue !== null ? healthWeight : 0;
402
407
  const spentQuota = quotaValue !== null ? quotaWeight : 0;
403
408
  const spentCost = costValue !== null ? costWeight : 0;
404
- const priorityWeight = Math.max(0, 1 - spentHealth - spentQuota - spentCost);
409
+ const spentLatency = latencyValue !== null ? latencyWeight : 0;
410
+ const priorityWeight = Math.max(0, 1 - spentHealth - spentQuota - spentCost - spentLatency);
405
411
  const components: RouteScoreEvidence["components"] = { configuredPriority: priorityScore };
406
412
  let total = priorityWeight * priorityScore;
407
413
  if (healthWeight > 0 && healthValue !== null) {
@@ -416,6 +422,10 @@ export function evaluatePolicyProfile(
416
422
  total += costWeight * costValue;
417
423
  components.cost = costValue;
418
424
  }
425
+ if (latencyWeight > 0 && latencyValue !== null) {
426
+ total += latencyWeight * latencyValue;
427
+ components.latency = latencyValue;
428
+ }
419
429
  if (compatibilityValue !== null) {
420
430
  // Compatibility is a penalty-only dimension. A penalized candidate loses
421
431
  // a bounded fraction of its existing score; satisfied/allowed evidence
@@ -372,6 +372,20 @@ export function healthEvidenceForCandidate(input: HealthEvidenceInput): RouteHea
372
372
  return evidence;
373
373
  }
374
374
 
375
+ /**
376
+ * Deterministic latency score in [0,1] from the recorded p50, shared by the health
377
+ * composite and the standalone `optimize.latency` term so the two cannot drift apart.
378
+ *
379
+ * An unmeasured candidate scores the NEUTRAL midpoint, not 0. Punishing it into last
380
+ * place would make selection depend on which candidate happened to be exercised first,
381
+ * which is the order-dependence this scoring exists to remove.
382
+ */
383
+ export function latencyScoreFromEvidence(evidence: RouteHealthEvidence | undefined): number {
384
+ const p50 = evidence?.recentLatencyMs;
385
+ if (p50 === undefined) return 0.5;
386
+ return Math.max(0, Math.min(1, 1 - p50 / HEALTH_SCORE_CONSTANTS.LATENCY_TARGET_MS));
387
+ }
388
+
375
389
  /**
376
390
  * Deterministic health score in [0,1]. Returns null when evidence is unknown
377
391
  * (no samples) so callers can apply the profile's unknownEvidence policy.
@@ -383,15 +397,12 @@ export function healthScore(evidence: RouteHealthEvidence | undefined, now = Dat
383
397
  if (!evidence.sampleCount || evidence.sampleCount < 1) return null;
384
398
  const successRate = evidence.successRate ?? 0;
385
399
  const incompleteRate = evidence.incompleteStreamRate ?? 0;
386
- const p50 = evidence.recentLatencyMs;
387
- const latencyScore = p50 === undefined
388
- ? 0.5
389
- : Math.max(0, Math.min(1, 1 - p50 / HEALTH_SCORE_CONSTANTS.LATENCY_TARGET_MS));
400
+ const latency = latencyScoreFromEvidence(evidence);
390
401
  const consecutive = evidence.failures ?? 0;
391
402
  const recoveryScore = 1 - Math.min(1, consecutive / 5);
392
403
  const composite = HEALTH_SCORE_CONSTANTS.SUCCESS_WEIGHT * successRate
393
404
  + HEALTH_SCORE_CONSTANTS.INCOMPLETE_WEIGHT * (1 - incompleteRate)
394
- + HEALTH_SCORE_CONSTANTS.LATENCY_WEIGHT * latencyScore
405
+ + HEALTH_SCORE_CONSTANTS.LATENCY_WEIGHT * latency
395
406
  + HEALTH_SCORE_CONSTANTS.RECOVERY_WEIGHT * recoveryScore;
396
407
  const confidence = Math.min(1, evidence.sampleCount / HEALTH_SCORE_CONSTANTS.MIN_CONFIDENCE_SAMPLES);
397
408
  const softAvoid = evidence.softAvoidUntilMs !== undefined && evidence.softAvoidUntilMs > now
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `usage.jsonl` remains the canonical append-only evidence ledger;
5
5
  * `routing-history.sqlite` is a disposable, rebuildable query projection
6
- * (ADR-1/ADR-8 in devlog/_plan/260804_router_intelligence/000_master_plan.md).
6
+ * (ADR-1/ADR-8 in devlog/_fin/260804_router_intelligence/000_master_plan.md).
7
7
  */
8
8
 
9
9
  export const HISTORY_SCHEMA_VERSION = 1;
@@ -2,7 +2,7 @@
2
2
  * Route decision trace: bounded, versioned, privacy-safe evidence of WHY a
3
3
  * provider/model/account was selected for a request (RI-01).
4
4
  *
5
- * Contract rules (devlog/_plan/260804_router_intelligence/000_master_plan.md):
5
+ * Contract rules (devlog/_fin/260804_router_intelligence/000_master_plan.md):
6
6
  * - One trace per routing decision; fallback EXECUTION attempts stay in the
7
7
  * usage entry's existing `attempts[]` array, never in this trace.
8
8
  * - Never persists prompts, message bodies, tool payloads, credentials,