@gajae-code/ai 0.13.3 → 0.14.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/CHANGELOG.md +45 -2
- package/dist/types/auth-broker/client.d.ts +9 -1
- package/dist/types/auth-broker/redact.d.ts +7 -0
- package/dist/types/auth-broker/remote-store.d.ts +50 -9
- package/dist/types/auth-broker/types.d.ts +14 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
- package/dist/types/auth-storage.d.ts +200 -6
- package/dist/types/core.d.ts +1 -0
- package/dist/types/model-cache.d.ts +4 -1
- package/dist/types/model-manager.d.ts +11 -0
- package/dist/types/provider-models/openai-compat.d.ts +5 -0
- package/dist/types/providers/anthropic.d.ts +31 -0
- package/dist/types/providers/cursor.d.ts +9 -1
- package/dist/types/providers/mock.d.ts +7 -1
- package/dist/types/providers/transform-messages.d.ts +18 -0
- package/dist/types/types.d.ts +28 -14
- package/dist/types/usage/grok-cli.d.ts +5 -0
- package/dist/types/usage.d.ts +6 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
- package/dist/types/utils/event-stream.d.ts +4 -2
- package/dist/types/utils/fallback-transport.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +1 -0
- package/dist/types/utils/idle-iterator.d.ts +13 -1
- package/dist/types/utils/oauth/callback-server.d.ts +13 -0
- package/dist/types/utils/parse-bind.d.ts +8 -5
- package/dist/types/utils/tool-call-healing.d.ts +7 -0
- package/dist/types/utils/tool-choice-capability.d.ts +11 -0
- package/package.json +3 -2
- package/src/auth-broker/client.ts +30 -0
- package/src/auth-broker/redact.ts +15 -0
- package/src/auth-broker/refresher.ts +4 -2
- package/src/auth-broker/remote-store.ts +693 -70
- package/src/auth-broker/server.ts +57 -12
- package/src/auth-broker/types.ts +16 -0
- package/src/auth-broker/wire-schemas.ts +21 -0
- package/src/auth-gateway/server.ts +84 -19
- package/src/auth-storage.ts +985 -41
- package/src/core.ts +1 -0
- package/src/model-cache.ts +23 -4
- package/src/model-manager.ts +70 -11
- package/src/model-thinking.ts +21 -1
- package/src/models.json +1733 -392
- package/src/provider-models/descriptors.ts +5 -1
- package/src/provider-models/openai-compat.ts +52 -28
- package/src/providers/amazon-bedrock.ts +2 -1
- package/src/providers/anthropic.ts +824 -29
- package/src/providers/cursor.ts +83 -3
- package/src/providers/mock.ts +13 -3
- package/src/providers/ollama.ts +9 -2
- package/src/providers/openai-codex-responses.ts +16 -9
- package/src/providers/openai-completions.ts +5 -3
- package/src/providers/openai-responses-shared.ts +175 -21
- package/src/providers/register-builtins.ts +5 -2
- package/src/providers/transform-messages.ts +64 -1
- package/src/stream.ts +12 -2
- package/src/types.ts +28 -13
- package/src/usage/grok-cli.ts +86 -1
- package/src/usage.ts +7 -0
- package/src/utils/discovery/openai-compatible.ts +89 -4
- package/src/utils/event-stream.ts +11 -2
- package/src/utils/fallback-transport.ts +44 -2
- package/src/utils/http-inspector.ts +1 -0
- package/src/utils/idle-iterator.ts +29 -6
- package/src/utils/oauth/callback-server.ts +31 -1
- package/src/utils/parse-bind.ts +27 -0
- package/src/utils/tool-call-healing.ts +13 -2
- package/src/utils/tool-choice-capability.ts +386 -6
|
@@ -106,12 +106,30 @@ export function resolveOpenAISdkRequestTimeoutMs(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export type Watchdog = NodeJS.Timeout | undefined;
|
|
109
|
+
export interface FirstEventTimeoutFacts {
|
|
110
|
+
requestBytes?: number;
|
|
111
|
+
firstEventElapsedMs?: number;
|
|
112
|
+
firstEventTimeoutMs?: number;
|
|
113
|
+
endpointClass?: "canonical" | "custom";
|
|
114
|
+
retryMaxAttempts?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
109
117
|
export class FirstEventTimeoutError extends Error {
|
|
110
118
|
readonly providerCode = STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
|
|
119
|
+
readonly requestBytes?: number;
|
|
120
|
+
readonly firstEventElapsedMs?: number;
|
|
121
|
+
readonly firstEventTimeoutMs?: number;
|
|
122
|
+
readonly endpointClass?: "canonical" | "custom";
|
|
123
|
+
readonly retryMaxAttempts?: number;
|
|
111
124
|
|
|
112
|
-
constructor(message: string) {
|
|
125
|
+
constructor(message: string, facts: FirstEventTimeoutFacts = {}) {
|
|
113
126
|
super(message);
|
|
114
127
|
this.name = "FirstEventTimeoutError";
|
|
128
|
+
this.requestBytes = facts.requestBytes;
|
|
129
|
+
this.firstEventElapsedMs = facts.firstEventElapsedMs;
|
|
130
|
+
this.firstEventTimeoutMs = facts.firstEventTimeoutMs;
|
|
131
|
+
this.endpointClass = facts.endpointClass;
|
|
132
|
+
this.retryMaxAttempts = facts.retryMaxAttempts;
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
|
|
@@ -202,6 +220,8 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
202
220
|
}
|
|
203
221
|
};
|
|
204
222
|
let lastProgressAt = Date.now();
|
|
223
|
+
const firstItemDeadlineAt =
|
|
224
|
+
firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
|
|
205
225
|
|
|
206
226
|
const noTimeoutEnforced =
|
|
207
227
|
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
|
|
@@ -210,7 +230,8 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
210
230
|
while (true) {
|
|
211
231
|
let activeTimeoutMs: number | undefined;
|
|
212
232
|
if (awaitingFirstItem) {
|
|
213
|
-
activeTimeoutMs =
|
|
233
|
+
activeTimeoutMs =
|
|
234
|
+
firstItemDeadlineAt === undefined ? undefined : Math.max(0, firstItemDeadlineAt - Date.now());
|
|
214
235
|
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
|
|
215
236
|
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
216
237
|
// The idle deadline may already have elapsed because the *consumer*
|
|
@@ -235,10 +256,7 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
235
256
|
|
|
236
257
|
let timer: NodeJS.Timeout | undefined;
|
|
237
258
|
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
|
|
238
|
-
const enforceTimeout =
|
|
239
|
-
!noTimeoutEnforced &&
|
|
240
|
-
activeTimeoutMs !== undefined &&
|
|
241
|
-
(awaitingFirstItem ? activeTimeoutMs > 0 : activeTimeoutMs >= 0);
|
|
259
|
+
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs >= 0;
|
|
242
260
|
if (enforceTimeout) {
|
|
243
261
|
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
|
|
244
262
|
resolveTimeout = resolve;
|
|
@@ -283,6 +301,11 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
283
301
|
if (outcome.kind === "error") {
|
|
284
302
|
throw outcome.error;
|
|
285
303
|
}
|
|
304
|
+
if (awaitingFirstItem && firstItemDeadlineAt !== undefined && Date.now() >= firstItemDeadlineAt) {
|
|
305
|
+
options.onFirstItemTimeout?.();
|
|
306
|
+
closeIterator();
|
|
307
|
+
throw new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
|
|
308
|
+
}
|
|
286
309
|
if (outcome.result.done) {
|
|
287
310
|
markFirstItemReceived();
|
|
288
311
|
return;
|
|
@@ -36,6 +36,17 @@ export interface OAuthCallbackFlowOptions {
|
|
|
36
36
|
* `onManualCodeInput` handler on the controller.
|
|
37
37
|
*/
|
|
38
38
|
skipCallbackServer?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Expected authorization-server issuer recorded from validated metadata
|
|
41
|
+
* (RFC 9207 / MCP 2026-07-28). When set, a present `iss` that differs
|
|
42
|
+
* rejects the response before any other parameter is acted on.
|
|
43
|
+
*/
|
|
44
|
+
expectedIssuer?: string;
|
|
45
|
+
/**
|
|
46
|
+
* `authorization_response_iss_parameter_supported` from the same metadata.
|
|
47
|
+
* When true, a response WITHOUT `iss` is rejected.
|
|
48
|
+
*/
|
|
49
|
+
issuerResponseIssSupported?: boolean;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
@@ -48,6 +59,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
48
59
|
callbackHostname: string;
|
|
49
60
|
callbackBindHostname: string;
|
|
50
61
|
redirectUri?: string;
|
|
62
|
+
expectedIssuer?: string;
|
|
63
|
+
issuerResponseIssSupported?: boolean;
|
|
51
64
|
readonly #skipCallbackServer: boolean;
|
|
52
65
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
53
66
|
#callbackReject?: (error: string) => void;
|
|
@@ -72,6 +85,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
72
85
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
73
86
|
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
|
|
74
87
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
88
|
+
this.expectedIssuer = preferredPortOrOptions.expectedIssuer;
|
|
89
|
+
this.issuerResponseIssSupported = preferredPortOrOptions.issuerResponseIssSupported;
|
|
75
90
|
this.#skipCallbackServer = preferredPortOrOptions.skipCallbackServer === true;
|
|
76
91
|
}
|
|
77
92
|
|
|
@@ -202,12 +217,27 @@ export abstract class OAuthCallbackFlow {
|
|
|
202
217
|
const state = url.searchParams.get("state") || "";
|
|
203
218
|
const error = url.searchParams.get("error") || "";
|
|
204
219
|
const errorDescription = url.searchParams.get("error_description") || error;
|
|
220
|
+
const iss = url.searchParams.get("iss");
|
|
205
221
|
|
|
206
222
|
type OkState = { ok: true; code: string; state: string };
|
|
207
223
|
type ErrorState = { ok?: false; error?: string };
|
|
208
224
|
let resultState: OkState | ErrorState;
|
|
209
225
|
|
|
210
|
-
|
|
226
|
+
// RFC 9207 §2.4 (MCP 2026-07-28): validate the response issuer before acting
|
|
227
|
+
// on any other parameter; on mismatch, server-supplied error details must not
|
|
228
|
+
// be acted on or displayed, so the failure message is generic by design.
|
|
229
|
+
let issuerFailure: string | null = null;
|
|
230
|
+
if (this.expectedIssuer) {
|
|
231
|
+
if (iss !== null && iss !== this.expectedIssuer) {
|
|
232
|
+
issuerFailure = "Authorization response issuer mismatch";
|
|
233
|
+
} else if (iss === null && this.issuerResponseIssSupported === true) {
|
|
234
|
+
issuerFailure = "Authorization response missing required issuer (iss)";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (issuerFailure) {
|
|
239
|
+
resultState = { ok: false, error: issuerFailure };
|
|
240
|
+
} else if (error) {
|
|
211
241
|
resultState = { ok: false, error: `Authorization failed: ${errorDescription}` };
|
|
212
242
|
} else if (!code) {
|
|
213
243
|
resultState = { ok: false, error: "Missing authorization code" };
|
package/src/utils/parse-bind.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* paths. Centralized so the two servers can't drift on what they accept (the
|
|
4
4
|
* gateway used to silently allow empty hostnames; this fixes it).
|
|
5
5
|
*/
|
|
6
|
+
import { isIP } from "node:net";
|
|
6
7
|
|
|
7
8
|
export interface ParsedBind {
|
|
8
9
|
hostname: string;
|
|
@@ -52,3 +53,29 @@ export function parseBind(raw: string): ParsedBind {
|
|
|
52
53
|
}
|
|
53
54
|
return { hostname: hostPart, port: parsePort(portPart, raw) };
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/** True for loopback-only hostnames the auth servers may bind without credentials. */
|
|
58
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
59
|
+
const normalized = hostname
|
|
60
|
+
.trim()
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/^\[|\]$/g, "");
|
|
63
|
+
if (normalized === "localhost" || normalized === "::1") return true;
|
|
64
|
+
// Strict numeric IPv4 loopback literals only: a bare prefix match would
|
|
65
|
+
// accept attacker-controlled names like `127.evil.example`, and short/hex
|
|
66
|
+
// IPv4 forms (`127.1`, `0x7f.1`) are rejected by the parser anyway.
|
|
67
|
+
return isIP(normalized) === 4 && normalized.split(".")[0] === "127";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fail closed when an unauthenticated auth server (empty bearer token set)
|
|
72
|
+
* would bind a non-loopback address: that exposes credential operations to the
|
|
73
|
+
* network with no proof of possession.
|
|
74
|
+
*/
|
|
75
|
+
export function assertAuthenticatedOrLoopback(bind: ParsedBind, bearerTokenCount: number, serverName: string): void {
|
|
76
|
+
if (bearerTokenCount > 0) return;
|
|
77
|
+
if (isLoopbackHostname(bind.hostname)) return;
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${serverName} refuses to bind ${bind.hostname}:${bind.port} without bearer tokens; unauthenticated mode is loopback-only.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* the end of a chunk is held back until the next chunk arrives.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { parseJsonWithRepair } from "./json-parse";
|
|
20
|
+
import { findUnnecessaryUnicodeEscape, parseJsonWithRepair } from "./json-parse";
|
|
21
21
|
|
|
22
22
|
const TOK_SECTION_BEGIN = "<|tool_calls_section_begin|>";
|
|
23
23
|
const TOK_SECTION_END = "<|tool_calls_section_end|>";
|
|
@@ -34,6 +34,13 @@ export interface HealedToolCall {
|
|
|
34
34
|
readonly id: string;
|
|
35
35
|
readonly name: string;
|
|
36
36
|
readonly arguments: string;
|
|
37
|
+
/**
|
|
38
|
+
* Whether the raw leaked payload spelled a printable non-ASCII character as a
|
|
39
|
+
* `\uXXXX` escape. Captured BEFORE the normalizing round-trip below, which
|
|
40
|
+
* decodes escapes into literal characters and would otherwise erase the only
|
|
41
|
+
* evidence that the text is unverifiable.
|
|
42
|
+
*/
|
|
43
|
+
readonly escapedNonAsciiArguments: boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|
|
@@ -230,6 +237,10 @@ export class ToolCallHealer {
|
|
|
230
237
|
const name = normalizeFunctionName(rawId);
|
|
231
238
|
const id = generateHealedToolCallId();
|
|
232
239
|
|
|
240
|
+
// Sample the raw payload first: the round-trip below decodes `\uXXXX` into
|
|
241
|
+
// literal characters, so checking `argsJson` afterwards always reports clean.
|
|
242
|
+
const escapedNonAsciiArguments = findUnnecessaryUnicodeEscape(rawArgs) !== undefined;
|
|
243
|
+
|
|
233
244
|
let argsJson = rawArgs;
|
|
234
245
|
if (rawArgs.length > 0) {
|
|
235
246
|
try {
|
|
@@ -242,7 +253,7 @@ export class ToolCallHealer {
|
|
|
242
253
|
argsJson = "{}";
|
|
243
254
|
}
|
|
244
255
|
|
|
245
|
-
this.#completed.push({ id, name, arguments: argsJson });
|
|
256
|
+
this.#completed.push({ id, name, arguments: argsJson, escapedNonAsciiArguments });
|
|
246
257
|
this.#inCall = false;
|
|
247
258
|
this.#inArgs = false;
|
|
248
259
|
this.#pendingId = "";
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { getToolChoiceCapabilityCachePath } from "@gajae-code/utils/dirs";
|
|
1
6
|
import { extractHttpStatusFromError } from "@gajae-code/utils/fetch-retry";
|
|
2
7
|
import * as logger from "@gajae-code/utils/logger";
|
|
3
8
|
import type { Api, Model, ToolChoice, ToolChoiceCompat, ToolChoiceSupport, ToolChoiceSupportSource } from "../types";
|
|
@@ -11,6 +16,26 @@ const supportRank: Record<ToolChoiceSupport, number> = {
|
|
|
11
16
|
|
|
12
17
|
const registry = new Map<string, ToolChoiceSupport>();
|
|
13
18
|
const loggedRegistryKeys = new Set<string>();
|
|
19
|
+
const registryExpiresAt = new Map<string, number>();
|
|
20
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
21
|
+
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const EMPTY_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
23
|
+
const CACHE_MAX_ENTRIES = 256;
|
|
24
|
+
const REGISTRY_MAX_ENTRIES = 256;
|
|
25
|
+
const CACHE_MUTATION_LOCK_STALE_MS = 5_000;
|
|
26
|
+
|
|
27
|
+
type NativeExactUnlinkBindings = Pick<typeof import("@gajae-code/natives"), "exactUnlink">;
|
|
28
|
+
|
|
29
|
+
let nativeExactUnlinkBindings: NativeExactUnlinkBindings | undefined;
|
|
30
|
+
|
|
31
|
+
let cachePathOverride: string | undefined;
|
|
32
|
+
let nowForTests: (() => number) | undefined;
|
|
33
|
+
let beforeExpiredDeleteForTests: (() => void) | undefined;
|
|
34
|
+
let beforeMalformedDeleteForTests: (() => void) | undefined;
|
|
35
|
+
let onCacheOpenForTests: (() => void) | undefined;
|
|
36
|
+
let simulateCacheOperationErrorForTests: (() => Error | undefined) | undefined;
|
|
37
|
+
let beforeCorruptRetireForTests: (() => void) | undefined;
|
|
38
|
+
let beforeLockExactUnlinkForTests: ((lockPath: string) => void) | undefined;
|
|
14
39
|
|
|
15
40
|
/**
|
|
16
41
|
* Claude Mythos accepts tools but rejects forced tool use (Anthropic 400:
|
|
@@ -45,21 +70,55 @@ export function toolChoiceRegistryKey(model: Model<Api>): string {
|
|
|
45
70
|
|
|
46
71
|
/** Returns the current runtime tool-choice capability override for a model. */
|
|
47
72
|
export function getToolChoiceCapabilityOverride(model: Model<Api>): ToolChoiceSupport | undefined {
|
|
48
|
-
|
|
73
|
+
const key = toolChoiceRegistryKey(model);
|
|
74
|
+
hydrateToolChoiceCapability(key);
|
|
75
|
+
return registry.get(key);
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
/** Clears runtime tool-choice capability overrides for tests. */
|
|
52
79
|
export function clearToolChoiceIncapabilityRegistryForTests(): void {
|
|
53
80
|
registry.clear();
|
|
54
81
|
loggedRegistryKeys.clear();
|
|
82
|
+
registryExpiresAt.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Overrides durable-cache dependencies for isolated tests. */
|
|
86
|
+
export function configureToolChoiceCapabilityCacheForTests(options?: {
|
|
87
|
+
path?: string;
|
|
88
|
+
now?: () => number;
|
|
89
|
+
beforeExpiredDelete?: () => void;
|
|
90
|
+
beforeMalformedDelete?: () => void;
|
|
91
|
+
onCacheOpen?: () => void;
|
|
92
|
+
simulateOperationError?: () => Error | undefined;
|
|
93
|
+
beforeCorruptRetire?: () => void;
|
|
94
|
+
beforeLockExactUnlink?: (lockPath: string) => void;
|
|
95
|
+
}): void {
|
|
96
|
+
cachePathOverride = options?.path;
|
|
97
|
+
nowForTests = options?.now;
|
|
98
|
+
beforeExpiredDeleteForTests = options?.beforeExpiredDelete;
|
|
99
|
+
beforeMalformedDeleteForTests = options?.beforeMalformedDelete;
|
|
100
|
+
onCacheOpenForTests = options?.onCacheOpen;
|
|
101
|
+
simulateCacheOperationErrorForTests = options?.simulateOperationError;
|
|
102
|
+
beforeCorruptRetireForTests = options?.beforeCorruptRetire;
|
|
103
|
+
beforeLockExactUnlinkForTests = options?.beforeLockExactUnlink;
|
|
104
|
+
clearToolChoiceIncapabilityRegistryForTests();
|
|
55
105
|
}
|
|
56
106
|
|
|
57
107
|
/** Records a discovered maximum supported tool-choice level for a model. */
|
|
58
108
|
export function markToolChoiceIncapability(model: Model<Api>, maxSupport: ToolChoiceSupport, reason?: string): void {
|
|
59
109
|
const key = toolChoiceRegistryKey(model);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
110
|
+
const releaseMutationLock = acquireCapabilityCacheMutationLock();
|
|
111
|
+
try {
|
|
112
|
+
hydrateToolChoiceCapability(key);
|
|
113
|
+
const existing = registry.get(key);
|
|
114
|
+
const next = existing && supportRank[existing] < supportRank[maxSupport] ? existing : maxSupport;
|
|
115
|
+
registry.set(key, next);
|
|
116
|
+
const persisted = persistToolChoiceCapability(key, next);
|
|
117
|
+
if (persisted) registry.set(key, persisted.support);
|
|
118
|
+
registryExpiresAt.set(key, (persisted?.observedAt ?? currentTime()) + CACHE_TTL_MS);
|
|
119
|
+
} finally {
|
|
120
|
+
releaseMutationLock?.();
|
|
121
|
+
}
|
|
63
122
|
|
|
64
123
|
if (!loggedRegistryKeys.has(key)) {
|
|
65
124
|
loggedRegistryKeys.add(key);
|
|
@@ -74,6 +133,100 @@ export function markToolChoiceIncapability(model: Model<Api>, maxSupport: ToolCh
|
|
|
74
133
|
}
|
|
75
134
|
}
|
|
76
135
|
|
|
136
|
+
function acquireCapabilityCacheMutationLock(): (() => void) | undefined {
|
|
137
|
+
if (process.env.NODE_ENV === "test" && cachePathOverride === undefined) return;
|
|
138
|
+
const cachePath = cachePathOverride ?? getToolChoiceCapabilityCachePath();
|
|
139
|
+
const lockPath = `${cachePath}.mutation.lock`;
|
|
140
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
141
|
+
const owner = `${process.pid}:${crypto.randomUUID()}`;
|
|
142
|
+
while (true) {
|
|
143
|
+
try {
|
|
144
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
145
|
+
const descriptor = fs.openSync(lockPath, "wx", 0o600);
|
|
146
|
+
fs.writeFileSync(descriptor, owner);
|
|
147
|
+
fs.closeSync(descriptor);
|
|
148
|
+
return () => {
|
|
149
|
+
try {
|
|
150
|
+
exactUnlinkCapabilityLock(lockPath, owner);
|
|
151
|
+
} catch {
|
|
152
|
+
// A crashed owner was already reaped.
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!(error && typeof error === "object" && (error as { code?: unknown }).code === "EEXIST")) return;
|
|
157
|
+
try {
|
|
158
|
+
const lockOwner = fs.readFileSync(lockPath, "utf8");
|
|
159
|
+
const ownerPid = Number(lockOwner.split(":", 1)[0]);
|
|
160
|
+
const ownerIsAlive = Number.isSafeInteger(ownerPid) && ownerPid > 0 && isProcessAlive(ownerPid);
|
|
161
|
+
const staleOwner =
|
|
162
|
+
!ownerIsAlive && Date.now() - fs.statSync(lockPath).mtimeMs > CACHE_MUTATION_LOCK_STALE_MS;
|
|
163
|
+
if (staleOwner && !exactUnlinkCapabilityLock(lockPath, lockOwner)) {
|
|
164
|
+
Atomics.wait(sleeper, 0, 0, 10);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// The lock changed while this waiter was being inspected.
|
|
169
|
+
}
|
|
170
|
+
Atomics.wait(sleeper, 0, 0, 10);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function exactUnlinkCapabilityLock(lockPath: string, expectedOwner: string): boolean {
|
|
176
|
+
// Open the lock first and read bytes + identity from the SAME descriptor: the
|
|
177
|
+
// pinned inode cannot be recycled or substituted while the handle is open, so
|
|
178
|
+
// a replacement owner that lands at the pathname after the read can never be
|
|
179
|
+
// mistaken for the record whose bytes authorized this removal.
|
|
180
|
+
const descriptor = fs.openSync(lockPath, "r");
|
|
181
|
+
let bytes: Buffer;
|
|
182
|
+
let stat: import("node:fs").BigIntStats;
|
|
183
|
+
try {
|
|
184
|
+
bytes = fs.readFileSync(descriptor);
|
|
185
|
+
if (bytes.toString("utf8") !== expectedOwner) return false;
|
|
186
|
+
stat = fs.fstatSync(descriptor, { bigint: true });
|
|
187
|
+
beforeLockExactUnlinkForTests?.(lockPath);
|
|
188
|
+
if (!stat.isFile()) return false;
|
|
189
|
+
} finally {
|
|
190
|
+
fs.closeSync(descriptor);
|
|
191
|
+
}
|
|
192
|
+
const parent = fs.statSync(path.dirname(lockPath), { bigint: true });
|
|
193
|
+
if (!parent.isDirectory()) return false;
|
|
194
|
+
if (!nativeExactUnlinkBindings)
|
|
195
|
+
nativeExactUnlinkBindings = require("@gajae-code/natives") as NativeExactUnlinkBindings;
|
|
196
|
+
const bindings = nativeExactUnlinkBindings;
|
|
197
|
+
const result = bindings.exactUnlink(lockPath, {
|
|
198
|
+
dev: stat.dev,
|
|
199
|
+
ino: stat.ino,
|
|
200
|
+
nlink: stat.nlink,
|
|
201
|
+
parentDev: parent.dev,
|
|
202
|
+
parentIno: parent.ino,
|
|
203
|
+
size: stat.size,
|
|
204
|
+
mtimeNs: stat.mtimeNs,
|
|
205
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
206
|
+
quarantineName: `.tool-choice-capability-lock-${crypto.randomUUID()}`,
|
|
207
|
+
});
|
|
208
|
+
return (
|
|
209
|
+
result.ok ||
|
|
210
|
+
(result.code === "cleanup_pending" &&
|
|
211
|
+
result.payloadDurable === true &&
|
|
212
|
+
result.detachedPath !== undefined &&
|
|
213
|
+
result.retainedSuccessorPath === undefined &&
|
|
214
|
+
result.retainedUnknownPath === undefined)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isProcessAlive(pid: number): boolean {
|
|
219
|
+
try {
|
|
220
|
+
process.kill(pid, 0);
|
|
221
|
+
return true;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// EPERM means the process exists but may not be signalable; only ESRCH
|
|
224
|
+
// proves the pid is gone. Any other outcome is treated as alive so an
|
|
225
|
+
// uncertain owner is never reaped as stale.
|
|
226
|
+
return (error as { code?: string }).code !== "ESRCH";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
77
230
|
/**
|
|
78
231
|
* Resolves a requested tool_choice against static and runtime capability limits.
|
|
79
232
|
* `compat` overrides `model.compat` for transports that layer URL/provider
|
|
@@ -85,12 +238,13 @@ export function resolveToolChoice(
|
|
|
85
238
|
compat?: ToolChoiceCompat,
|
|
86
239
|
): ResolveToolChoiceResult {
|
|
87
240
|
const derived = deriveToolChoiceSupport(compat ?? model.compat);
|
|
88
|
-
const
|
|
241
|
+
const registryKey = toolChoiceRegistryKey(model);
|
|
242
|
+
hydrateToolChoiceCapability(registryKey);
|
|
243
|
+
const runtime = registry.get(registryKey);
|
|
89
244
|
const support = runtime && supportRank[runtime] < supportRank[derived.support] ? runtime : derived.support;
|
|
90
245
|
const supportSource: ToolChoiceSupportSource = support === derived.support ? derived.source : "runtime";
|
|
91
246
|
const requestedInfo = requestedToolChoiceLevel(requested);
|
|
92
247
|
const clampLevel = requestedInfo.requestedLevel === "none" ? "auto" : requestedInfo.requestedLevel;
|
|
93
|
-
const registryKey = toolChoiceRegistryKey(model);
|
|
94
248
|
|
|
95
249
|
if (requested === undefined) {
|
|
96
250
|
return {
|
|
@@ -251,3 +405,229 @@ function safeHostname(baseUrl: string): string | undefined {
|
|
|
251
405
|
return undefined;
|
|
252
406
|
}
|
|
253
407
|
}
|
|
408
|
+
|
|
409
|
+
function hydrateToolChoiceCapability(registryKey: string): void {
|
|
410
|
+
const now = currentTime();
|
|
411
|
+
const expiresAt = registryExpiresAt.get(registryKey);
|
|
412
|
+
if (expiresAt !== undefined && now < expiresAt) return;
|
|
413
|
+
registry.delete(registryKey);
|
|
414
|
+
registryExpiresAt.delete(registryKey);
|
|
415
|
+
const hydrated = withCapabilityCache(database => {
|
|
416
|
+
const digest = capabilityKeyDigest(registryKey);
|
|
417
|
+
const row = database
|
|
418
|
+
.query("SELECT max_support, support_rank, observed_at FROM tool_choice_capabilities WHERE key_digest = ?")
|
|
419
|
+
.get(digest) as { max_support?: unknown; support_rank?: unknown; observed_at?: unknown } | null;
|
|
420
|
+
if (!row) return false;
|
|
421
|
+
if (
|
|
422
|
+
!isToolChoiceSupport(row.max_support) ||
|
|
423
|
+
row.support_rank !== supportRank[row.max_support] ||
|
|
424
|
+
!isValidObservedAt(row.observed_at, now)
|
|
425
|
+
) {
|
|
426
|
+
beforeMalformedDeleteForTests?.();
|
|
427
|
+
database.run(
|
|
428
|
+
"DELETE FROM tool_choice_capabilities WHERE key_digest = ? AND max_support = ? AND support_rank = ? AND observed_at = ?",
|
|
429
|
+
[
|
|
430
|
+
digest,
|
|
431
|
+
typeof row.max_support === "string" ? row.max_support : String(row.max_support),
|
|
432
|
+
typeof row.support_rank === "number" ? row.support_rank : -1,
|
|
433
|
+
typeof row.observed_at === "number" ? row.observed_at : -1,
|
|
434
|
+
],
|
|
435
|
+
);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (now - row.observed_at >= CACHE_TTL_MS) {
|
|
439
|
+
beforeExpiredDeleteForTests?.();
|
|
440
|
+
database.run("DELETE FROM tool_choice_capabilities WHERE key_digest = ? AND observed_at = ?", [
|
|
441
|
+
digest,
|
|
442
|
+
row.observed_at,
|
|
443
|
+
]);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
registry.set(registryKey, row.max_support);
|
|
447
|
+
registryExpiresAt.set(registryKey, row.observed_at + CACHE_TTL_MS);
|
|
448
|
+
return true;
|
|
449
|
+
});
|
|
450
|
+
if (hydrated === false && !registryExpiresAt.has(registryKey)) {
|
|
451
|
+
registryExpiresAt.set(registryKey, now + EMPTY_CACHE_TTL_MS);
|
|
452
|
+
}
|
|
453
|
+
pruneRegistry(now);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function persistToolChoiceCapability(
|
|
457
|
+
registryKey: string,
|
|
458
|
+
maxSupport: ToolChoiceSupport,
|
|
459
|
+
): { support: ToolChoiceSupport; observedAt: number } | undefined {
|
|
460
|
+
return withCapabilityCache(database => {
|
|
461
|
+
const write = database.transaction(() => {
|
|
462
|
+
const digest = capabilityKeyDigest(registryKey);
|
|
463
|
+
const observedAt = currentTime();
|
|
464
|
+
database.run(
|
|
465
|
+
"INSERT INTO tool_choice_capabilities (key_digest, max_support, support_rank, observed_at) VALUES (?, ?, ?, ?) ON CONFLICT(key_digest) DO UPDATE SET max_support = excluded.max_support, support_rank = excluded.support_rank, observed_at = excluded.observed_at WHERE excluded.support_rank <= tool_choice_capabilities.support_rank",
|
|
466
|
+
[digest, maxSupport, supportRank[maxSupport], observedAt],
|
|
467
|
+
);
|
|
468
|
+
database.run(
|
|
469
|
+
"DELETE FROM tool_choice_capabilities WHERE key_digest NOT IN (SELECT key_digest FROM tool_choice_capabilities ORDER BY observed_at DESC, key_digest DESC LIMIT ?)",
|
|
470
|
+
[CACHE_MAX_ENTRIES],
|
|
471
|
+
);
|
|
472
|
+
const row = database
|
|
473
|
+
.query("SELECT max_support, observed_at FROM tool_choice_capabilities WHERE key_digest = ?")
|
|
474
|
+
.get(digest) as { max_support?: unknown; observed_at?: unknown } | null;
|
|
475
|
+
return row && isToolChoiceSupport(row.max_support) && typeof row.observed_at === "number"
|
|
476
|
+
? { support: row.max_support, observedAt: row.observed_at }
|
|
477
|
+
: undefined;
|
|
478
|
+
});
|
|
479
|
+
return write();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function withCapabilityCache<T>(operation: (database: Database) => T): T | undefined {
|
|
484
|
+
if (process.env.NODE_ENV === "test" && cachePathOverride === undefined) return;
|
|
485
|
+
const cachePath = cachePathOverride ?? getToolChoiceCapabilityCachePath();
|
|
486
|
+
let openedFileSize: number | undefined;
|
|
487
|
+
try {
|
|
488
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
489
|
+
try {
|
|
490
|
+
openedFileSize = fs.statSync(cachePath).size;
|
|
491
|
+
} catch {
|
|
492
|
+
// File does not exist yet (first run); nothing to identity-check on retirement.
|
|
493
|
+
}
|
|
494
|
+
const database = openCapabilityCache(cachePath);
|
|
495
|
+
try {
|
|
496
|
+
try {
|
|
497
|
+
fs.chmodSync(cachePath, 0o600);
|
|
498
|
+
} catch {
|
|
499
|
+
// Cache access remains fail-open on filesystems without POSIX modes.
|
|
500
|
+
}
|
|
501
|
+
const simulatedError = simulateCacheOperationErrorForTests?.();
|
|
502
|
+
if (simulatedError) throw simulatedError;
|
|
503
|
+
return operation(database);
|
|
504
|
+
} finally {
|
|
505
|
+
database.close();
|
|
506
|
+
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
logger.debug("Tool-choice capability cache unavailable", {
|
|
509
|
+
cachePath: path.basename(cachePath),
|
|
510
|
+
error: error instanceof Error ? error.message : String(error),
|
|
511
|
+
});
|
|
512
|
+
if (isCorruptCapabilityCacheError(error)) {
|
|
513
|
+
beforeCorruptRetireForTests?.();
|
|
514
|
+
retireCorruptCapabilityCache(cachePath, openedFileSize);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function openCapabilityCache(cachePath: string): Database {
|
|
520
|
+
onCacheOpenForTests?.();
|
|
521
|
+
const database = new Database(cachePath, { create: true, strict: true });
|
|
522
|
+
try {
|
|
523
|
+
database.run("PRAGMA busy_timeout = 3000");
|
|
524
|
+
database.run("PRAGMA journal_mode = WAL");
|
|
525
|
+
database.run("PRAGMA synchronous = FULL");
|
|
526
|
+
const initialize = database.transaction(() => {
|
|
527
|
+
const version = database.query("PRAGMA user_version").get() as { user_version?: number } | null;
|
|
528
|
+
const schemaVersion = version?.user_version ?? 0;
|
|
529
|
+
if (schemaVersion === 0) {
|
|
530
|
+
database.run(`
|
|
531
|
+
CREATE TABLE IF NOT EXISTS tool_choice_capabilities (
|
|
532
|
+
key_digest TEXT PRIMARY KEY NOT NULL,
|
|
533
|
+
max_support TEXT NOT NULL,
|
|
534
|
+
support_rank INTEGER NOT NULL,
|
|
535
|
+
observed_at INTEGER NOT NULL
|
|
536
|
+
) STRICT
|
|
537
|
+
`);
|
|
538
|
+
assertCapabilityCacheSchema(database);
|
|
539
|
+
database.run(`PRAGMA user_version = ${CACHE_SCHEMA_VERSION}`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (schemaVersion !== CACHE_SCHEMA_VERSION) {
|
|
543
|
+
throw new CapabilityCacheCorruptionError("unsupported cache schema version");
|
|
544
|
+
}
|
|
545
|
+
assertCapabilityCacheSchema(database);
|
|
546
|
+
});
|
|
547
|
+
initialize();
|
|
548
|
+
return database;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
database.close();
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function assertCapabilityCacheSchema(database: Database): void {
|
|
556
|
+
const columns = database.query("PRAGMA table_info(tool_choice_capabilities)").all() as Array<{
|
|
557
|
+
name?: unknown;
|
|
558
|
+
type?: unknown;
|
|
559
|
+
notnull?: unknown;
|
|
560
|
+
pk?: unknown;
|
|
561
|
+
}>;
|
|
562
|
+
const actual = columns.map(column => [column.name, column.type, column.notnull, column.pk]);
|
|
563
|
+
const expected = [
|
|
564
|
+
["key_digest", "TEXT", 1, 1],
|
|
565
|
+
["max_support", "TEXT", 1, 0],
|
|
566
|
+
["support_rank", "INTEGER", 1, 0],
|
|
567
|
+
["observed_at", "INTEGER", 1, 0],
|
|
568
|
+
];
|
|
569
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
570
|
+
throw new CapabilityCacheCorruptionError("invalid cache schema");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
class CapabilityCacheCorruptionError extends Error {}
|
|
575
|
+
|
|
576
|
+
function isCorruptCapabilityCacheError(error: unknown): boolean {
|
|
577
|
+
if (error instanceof CapabilityCacheCorruptionError) return true;
|
|
578
|
+
if (!error || typeof error !== "object") return false;
|
|
579
|
+
const code = (error as { code?: unknown }).code;
|
|
580
|
+
return code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Removes a confirmed-corrupt cache file and its WAL/SHM siblings. The file
|
|
585
|
+
* size captured before the corrupt open (`openedFileSize`) is checked before
|
|
586
|
+
* unlinking so a concurrently recreated valid replacement database with a
|
|
587
|
+
* different file size is never deleted.
|
|
588
|
+
*/
|
|
589
|
+
function retireCorruptCapabilityCache(cachePath: string, openedFileSize?: number): void {
|
|
590
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
591
|
+
const target = `${cachePath}${suffix}`;
|
|
592
|
+
try {
|
|
593
|
+
if (openedFileSize !== undefined && suffix === "") {
|
|
594
|
+
const stat = fs.statSync(target);
|
|
595
|
+
if (stat.size !== openedFileSize) continue;
|
|
596
|
+
}
|
|
597
|
+
fs.rmSync(target, { force: true });
|
|
598
|
+
} catch {
|
|
599
|
+
// A best-effort cache reset must never break provider fallback behavior.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function capabilityKeyDigest(registryKey: string): string {
|
|
605
|
+
return crypto.createHash("sha256").update(registryKey).digest("hex");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function isToolChoiceSupport(value: unknown): value is ToolChoiceSupport {
|
|
609
|
+
return value === "none" || value === "auto" || value === "required" || value === "named";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function isValidObservedAt(value: unknown, now: number): value is number {
|
|
613
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= now;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function currentTime(): number {
|
|
617
|
+
return nowForTests?.() ?? Date.now();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function pruneRegistry(now: number): void {
|
|
621
|
+
for (const [key, expiresAt] of registryExpiresAt) {
|
|
622
|
+
if (expiresAt <= now) {
|
|
623
|
+
registryExpiresAt.delete(key);
|
|
624
|
+
registry.delete(key);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
while (registryExpiresAt.size > REGISTRY_MAX_ENTRIES) {
|
|
628
|
+
const oldestKey = registryExpiresAt.keys().next().value;
|
|
629
|
+
if (typeof oldestKey !== "string") break;
|
|
630
|
+
registryExpiresAt.delete(oldestKey);
|
|
631
|
+
registry.delete(oldestKey);
|
|
632
|
+
}
|
|
633
|
+
}
|