@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.
- package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +39 -7
- package/src/adapters/cursor/tool-definitions.ts +48 -0
- package/src/adapters/google.ts +18 -12
- package/src/adapters/openai-chat.ts +106 -13
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +3 -0
- package/src/bridge.ts +16 -5
- package/src/chat/inbound.ts +5 -11
- package/src/claude/context-windows.ts +5 -1
- package/src/claude/desktop-3p.ts +11 -6
- package/src/claude/inbound.ts +39 -1
- package/src/claude/model-info.ts +28 -8
- package/src/cli/account-api.ts +5 -1
- package/src/cli/claude-desktop.ts +3 -0
- package/src/cli/config-command.ts +37 -14
- package/src/codex/app-server-restart-service.ts +1 -1
- package/src/codex/auth-api.ts +5 -0
- package/src/codex/auth-context.ts +43 -2
- package/src/codex/catalog/metadata.ts +55 -8
- package/src/codex/catalog/native-models.ts +32 -2
- package/src/codex/catalog/parsing.ts +21 -7
- package/src/codex/catalog/provider-fetch.ts +35 -7
- package/src/codex/catalog/sync.ts +40 -18
- package/src/codex/catalog-refresh-status.ts +21 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +23 -2
- package/src/codex/desired-state.ts +1 -1
- package/src/codex/inject.ts +38 -7
- package/src/codex/injected-marker.ts +28 -0
- package/src/codex/journal.ts +40 -1
- package/src/codex/management-convergence.ts +55 -2
- package/src/codex/quota-rejection.ts +61 -1
- package/src/codex/quota.ts +60 -6
- package/src/codex/routing.ts +30 -3
- package/src/combos/failover.ts +20 -0
- package/src/config.ts +271 -4
- package/src/generated/compatibility-version.json +86 -74
- package/src/grok/sync.ts +3 -1
- package/src/lab/artifacts/sanitize.ts +1 -1
- package/src/lab/live/manifest.ts +1 -1
- package/src/lib/codex-restart-contract.ts +1 -1
- package/src/lib/config-ownership.ts +1 -0
- package/src/lib/errors.ts +9 -0
- package/src/lib/lab-activation.ts +1 -1
- package/src/lib/optional-shutdown-hooks.ts +1 -1
- package/src/providers/quota.ts +10 -4
- package/src/providers/registry.ts +2 -2
- package/src/responses/parser.ts +42 -7
- package/src/responses/provider-opaque-metadata.ts +1 -1
- package/src/responses/thought-signature-replay.ts +261 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/provider-slot.ts +1 -1
- package/src/routing/evaluator.ts +12 -2
- package/src/routing/health.ts +16 -5
- package/src/routing/history/schema.ts +1 -1
- package/src/routing/trace.ts +1 -1
- package/src/server/auth-cors.ts +56 -21
- package/src/server/chat-completions.ts +6 -2
- package/src/server/index.ts +5 -3
- package/src/server/management/agent-settings-routes.ts +26 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/provider-routes.ts +19 -0
- package/src/server/management/shared.ts +3 -3
- package/src/server/management-api.ts +13 -6
- package/src/server/passive-route-linker.ts +1 -1
- package/src/server/relay.ts +16 -0
- package/src/server/responses/compact.ts +10 -3
- package/src/server/responses/core.ts +160 -33
- package/src/server/responses/fetch-helpers.ts +34 -2
- package/src/server/responses/input-admission.ts +17 -9
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/system-env.ts +4 -2
- package/src/service.ts +14 -7
- 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/
|
|
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/
|
|
19
|
+
* See devlog/_fin/260814_lab_core_decoupling/010_lifecycle_shutdown_registry.md
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
type ShutdownHook = () => void;
|
package/src/providers/quota.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
245
|
+
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
|
|
246
246
|
const numeric = Number(trimmed);
|
|
247
|
-
|
|
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/
|
|
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/
|
|
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 } },
|
package/src/responses/parser.ts
CHANGED
|
@@ -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
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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/
|
|
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";
|
package/src/routing/evaluator.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/routing/health.ts
CHANGED
|
@@ -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
|
|
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 *
|
|
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/
|
|
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;
|
package/src/routing/trace.ts
CHANGED
|
@@ -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/
|
|
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,
|