@oh-my-pi/pi-ai 18.2.8 → 18.2.9
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 +33 -18
- package/dist/types/auth-gateway/types.d.ts +5 -0
- package/dist/types/auth-storage.d.ts +35 -32
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/amazon-bedrock.d.ts +7 -0
- package/dist/types/providers/claude-code-fingerprint.d.ts +22 -3
- package/dist/types/providers/google-gemini-cli.d.ts +0 -2
- package/dist/types/providers/openai-chat-server-schema.d.ts +2 -2
- package/dist/types/usage/claude-api.d.ts +22 -0
- package/dist/types/usage/claude-reset.d.ts +44 -0
- package/dist/types/usage.d.ts +111 -5
- package/dist/types/utils/schema/json-schema-validator.d.ts +5 -2
- package/dist/types/utils/tool-call-loop-guard.d.ts +1 -1
- package/package.json +6 -6
- package/src/auth/sqlite-credential-store.ts +44 -1
- package/src/auth-broker/remote-store.ts +6 -6
- package/src/auth-broker/wire-schemas.ts +14 -0
- package/src/auth-gateway/server.ts +4 -0
- package/src/auth-gateway/types.ts +5 -0
- package/src/auth-storage.ts +263 -140
- package/src/error/flags.ts +10 -0
- package/src/index.ts +1 -0
- package/src/providers/amazon-bedrock.ts +55 -5
- package/src/providers/anthropic.ts +45 -11
- package/src/providers/aws-credentials.ts +124 -11
- package/src/providers/claude-code-fingerprint.ts +55 -3
- package/src/providers/gitlab-duo.ts +20 -4
- package/src/providers/google-gemini-cli.ts +0 -8
- package/src/providers/google-shared.ts +1 -18
- package/src/providers/openai-chat-server-schema.ts +1 -1
- package/src/providers/openai-chat-server.ts +3 -1
- package/src/providers/openai-codex-responses.ts +27 -5
- package/src/providers/openai-completions.ts +122 -19
- package/src/providers/pi-native-server.ts +1 -0
- package/src/registry/oauth/anthropic.ts +2 -3
- package/src/stream.ts +13 -4
- package/src/usage/alibaba-token-plan.ts +7 -1
- package/src/usage/claude-api.ts +66 -0
- package/src/usage/claude-reset.ts +638 -0
- package/src/usage/claude.ts +37 -59
- package/src/usage/kimi.ts +32 -1
- package/src/usage.ts +52 -5
- package/src/utils/schema/json-schema-validator.ts +23 -10
- package/src/utils/tool-call-loop-guard.ts +2 -2
- package/src/utils/validation.ts +145 -50
package/src/usage/claude.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { scheduler } from "node:timers/promises";
|
|
|
2
2
|
import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
|
|
3
3
|
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
|
|
4
4
|
import * as AIError from "../error";
|
|
5
|
-
import { claudeCodeVersion } from "../providers/claude-code-fingerprint";
|
|
6
5
|
import {
|
|
7
6
|
type CredentialRankingContext,
|
|
8
7
|
type CredentialRankingStrategy,
|
|
@@ -13,66 +12,22 @@ import {
|
|
|
13
12
|
type UsageLimit,
|
|
14
13
|
type UsageProvider,
|
|
15
14
|
type UsageReport,
|
|
15
|
+
type UsageResetCredits,
|
|
16
16
|
type UsageStatus,
|
|
17
17
|
type UsageWindow,
|
|
18
18
|
} from "../usage";
|
|
19
19
|
import { isRecord } from "../utils";
|
|
20
|
+
import { buildClaudeOAuthHeaders, claudeOAuthBaseUrls } from "./claude-api";
|
|
21
|
+
import { listClaudeResetCredits, parseClaudeResetCreditsFromUsagePayload } from "./claude-reset";
|
|
20
22
|
import { HOUR_MS, parseIsoTimestamp, WEEK_MS } from "./shared";
|
|
21
23
|
|
|
22
|
-
const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
|
|
23
24
|
const MAX_ATTEMPTS = 3;
|
|
24
25
|
const BASE_RETRY_DELAY_MS = 500;
|
|
25
26
|
/** Shared windows that gate every Claude request, whatever the model. */
|
|
26
27
|
const CLAUDE_SHARED_GATE_WINDOW_IDS = ["5h", "7d"] as const;
|
|
27
28
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
"accept-encoding": "gzip, compress, deflate, br",
|
|
31
|
-
"anthropic-beta":
|
|
32
|
-
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11",
|
|
33
|
-
"content-type": "application/json",
|
|
34
|
-
"user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
|
|
35
|
-
connection: "keep-alive",
|
|
36
|
-
} as const;
|
|
37
|
-
|
|
38
|
-
function normalizeClaudeBaseUrl(baseUrl?: string): string {
|
|
39
|
-
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
|
40
|
-
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
|
41
|
-
const lower = trimmed.toLowerCase();
|
|
42
|
-
if (lower.endsWith("/api/oauth")) return trimmed;
|
|
43
|
-
let url: URL;
|
|
44
|
-
try {
|
|
45
|
-
url = new URL(trimmed);
|
|
46
|
-
} catch {
|
|
47
|
-
return DEFAULT_ENDPOINT;
|
|
48
|
-
}
|
|
49
|
-
let path = url.pathname.replace(/\/+$/, "");
|
|
50
|
-
if (path === "/") path = "";
|
|
51
|
-
if (path.toLowerCase().endsWith("/v1")) {
|
|
52
|
-
path = path.slice(0, -3);
|
|
53
|
-
}
|
|
54
|
-
if (!path) return `${url.origin}/api/oauth`;
|
|
55
|
-
return `${url.origin}${path}/api/oauth`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Subscription usage is served by Anthropic's OAuth API, which a custom
|
|
60
|
-
* `baseUrl` pointed at a Messages-only endpoint does not expose. Probe the
|
|
61
|
-
* configured host first so a full mirror keeps answering (including its own
|
|
62
|
-
* `/profile` identity), then fall back to the canonical endpoint — but only
|
|
63
|
-
* when the configured host answered that it has no usage endpoint there (see
|
|
64
|
-
* {@link ClaudeUsagePayloadResult.endpointAbsent}), so a host that refuses the
|
|
65
|
-
* credential or fails transiently keeps the request.
|
|
66
|
-
*
|
|
67
|
-
* Without the fallback the report degrades to rate-limit headers, and those
|
|
68
|
-
* carry the model-scoped weekly row only on responses for that model family,
|
|
69
|
-
* so a scoped window can read far below its real utilization until a request
|
|
70
|
-
* hits the family again.
|
|
71
|
-
*/
|
|
72
|
-
function claudeUsageBaseUrls(baseUrl?: string): readonly string[] {
|
|
73
|
-
const configured = normalizeClaudeBaseUrl(baseUrl);
|
|
74
|
-
return configured === DEFAULT_ENDPOINT ? [DEFAULT_ENDPOINT] : [configured, DEFAULT_ENDPOINT];
|
|
75
|
-
}
|
|
29
|
+
const CLAUDE_USAGE_BETAS =
|
|
30
|
+
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11";
|
|
76
31
|
|
|
77
32
|
interface ClaudeUsageBucket {
|
|
78
33
|
utilization?: number;
|
|
@@ -246,7 +201,8 @@ function hasUsageData(payload: ClaudeUsageResponse): boolean {
|
|
|
246
201
|
parseBucket(payload.seven_day_opus)?.utilization !== undefined ||
|
|
247
202
|
parseBucket(payload.seven_day_sonnet)?.utilization !== undefined ||
|
|
248
203
|
parseApiLimitEntries(payload.limits).some(entry => entry.bucket.utilization !== undefined) ||
|
|
249
|
-
buildClaudeExtraUsageLimit(payload) !== null
|
|
204
|
+
buildClaudeExtraUsageLimit(payload) !== null ||
|
|
205
|
+
parseClaudeResetCreditsFromUsagePayload(payload) !== null
|
|
250
206
|
);
|
|
251
207
|
}
|
|
252
208
|
|
|
@@ -316,7 +272,9 @@ function looksLikeUsagePayload(payload: ClaudeUsageResponse): boolean {
|
|
|
316
272
|
"seven_day" in payload ||
|
|
317
273
|
"limits" in payload ||
|
|
318
274
|
"extra_usage" in payload ||
|
|
319
|
-
"spend" in payload
|
|
275
|
+
"spend" in payload ||
|
|
276
|
+
"cedar_ember" in payload ||
|
|
277
|
+
"juniper_tide" in payload
|
|
320
278
|
);
|
|
321
279
|
}
|
|
322
280
|
|
|
@@ -684,14 +642,11 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
684
642
|
const credential = params.credential;
|
|
685
643
|
if (credential.type !== "oauth" || !credential.accessToken) return null;
|
|
686
644
|
|
|
687
|
-
const headers
|
|
688
|
-
...CLAUDE_HEADERS,
|
|
689
|
-
authorization: `Bearer ${credential.accessToken}`,
|
|
690
|
-
};
|
|
645
|
+
const headers = buildClaudeOAuthHeaders(credential.accessToken, { beta: CLAUDE_USAGE_BETAS });
|
|
691
646
|
|
|
692
647
|
let baseUrl: string | undefined;
|
|
693
648
|
let payload: ClaudeUsageResponse | null = null;
|
|
694
|
-
for (const candidate of
|
|
649
|
+
for (const candidate of claudeOAuthBaseUrls(params.baseUrl)) {
|
|
695
650
|
const result = await fetchUsagePayload(`${candidate}/usage`, headers, ctx, params.signal);
|
|
696
651
|
if (result.payload && hasUsageData(result.payload)) {
|
|
697
652
|
baseUrl = candidate;
|
|
@@ -766,7 +721,22 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
766
721
|
buildClaudeExtraUsageLimit(payload),
|
|
767
722
|
].filter((limit): limit is UsageLimit => limit !== null);
|
|
768
723
|
|
|
769
|
-
|
|
724
|
+
const resetCreditList =
|
|
725
|
+
parseClaudeResetCreditsFromUsagePayload(payload, credential.orgId, baseUrl) ??
|
|
726
|
+
(await listClaudeResetCredits({
|
|
727
|
+
accessToken: credential.accessToken,
|
|
728
|
+
...(credential.orgId ? { orgId: credential.orgId } : {}),
|
|
729
|
+
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
|
|
730
|
+
fetch: ctx.fetch,
|
|
731
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
732
|
+
}));
|
|
733
|
+
const hasResetInventory =
|
|
734
|
+
resetCreditList !== null &&
|
|
735
|
+
(resetCreditList.availableCount > 0 ||
|
|
736
|
+
resetCreditList.nextCreditId !== undefined ||
|
|
737
|
+
resetCreditList.credits.length > 0);
|
|
738
|
+
if (limits.length === 0 && !hasResetInventory) return null;
|
|
739
|
+
|
|
770
740
|
const identity = extractUsageIdentity(payload);
|
|
771
741
|
let accountId = identity.accountId ?? credential.accountId;
|
|
772
742
|
let email = identity.email ?? credential.email;
|
|
@@ -775,16 +745,24 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
775
745
|
accountId = accountId ?? profileIdentity.accountId;
|
|
776
746
|
email = email ?? profileIdentity.email;
|
|
777
747
|
}
|
|
748
|
+
let resetCredits: UsageResetCredits | undefined;
|
|
749
|
+
let reportOrgId = credential.orgId;
|
|
750
|
+
if (resetCreditList) {
|
|
751
|
+
const { orgId, baseUrl: _baseUrl, ...usageResetCredits } = resetCreditList;
|
|
752
|
+
resetCredits = usageResetCredits;
|
|
753
|
+
reportOrgId ??= orgId;
|
|
754
|
+
}
|
|
778
755
|
|
|
779
756
|
const report: UsageReport = {
|
|
780
757
|
provider: params.provider,
|
|
781
758
|
fetchedAt: Date.now(),
|
|
782
759
|
limits,
|
|
760
|
+
...(resetCredits ? { resetCredits } : {}),
|
|
783
761
|
metadata: {
|
|
784
762
|
endpoint: url,
|
|
785
763
|
...(accountId ? { accountId } : {}),
|
|
786
764
|
...(email ? { email } : {}),
|
|
787
|
-
...(
|
|
765
|
+
...(reportOrgId ? { orgId: reportOrgId } : {}),
|
|
788
766
|
},
|
|
789
767
|
raw: payload,
|
|
790
768
|
};
|
package/src/usage/kimi.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
UsageProvider,
|
|
11
11
|
UsageReport,
|
|
12
12
|
UsageWindow,
|
|
13
|
+
UsageUnit,
|
|
13
14
|
} from "../usage";
|
|
14
15
|
import { isRecord } from "../utils";
|
|
15
16
|
import { parseIsoTimestamp, usageStatus } from "./shared";
|
|
@@ -21,12 +22,14 @@ const USAGE_PATH = "usages";
|
|
|
21
22
|
|
|
22
23
|
interface KimiUsagePayload {
|
|
23
24
|
usage?: unknown;
|
|
25
|
+
usages?: unknown;
|
|
24
26
|
limits?: unknown;
|
|
25
27
|
totalQuota?: unknown;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
type KimiUsageRow = {
|
|
29
31
|
label: string;
|
|
32
|
+
unit: UsageUnit;
|
|
30
33
|
used?: number;
|
|
31
34
|
limit?: number;
|
|
32
35
|
remaining?: number;
|
|
@@ -137,6 +140,7 @@ function buildUsageRow(data: Record<string, unknown>, defaultLabel: string, nowM
|
|
|
137
140
|
: typeof data.title === "string" && data.title
|
|
138
141
|
? data.title
|
|
139
142
|
: defaultLabel,
|
|
143
|
+
unit: "unknown",
|
|
140
144
|
used,
|
|
141
145
|
limit,
|
|
142
146
|
remaining,
|
|
@@ -144,8 +148,26 @@ function buildUsageRow(data: Record<string, unknown>, defaultLabel: string, nowM
|
|
|
144
148
|
};
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
function buildAggregateUsageRow(key: string, data: Record<string, unknown>, nowMs: number): KimiUsageRow | null {
|
|
152
|
+
const usedRatio = toNumber(data.used_ratio);
|
|
153
|
+
if (usedRatio === undefined) return null;
|
|
154
|
+
|
|
155
|
+
const usedFraction = Math.min(Math.max(usedRatio, 0), 1);
|
|
156
|
+
const label = key === "limit_month_total" ? "Monthly total" : key === "limit_month_code" ? "Monthly code" : key;
|
|
157
|
+
const resetsAt = parseResetTime(data, nowMs);
|
|
158
|
+
return {
|
|
159
|
+
label,
|
|
160
|
+
unit: "percent",
|
|
161
|
+
used: usedFraction * 100,
|
|
162
|
+
limit: 100,
|
|
163
|
+
remaining: (1 - usedFraction) * 100,
|
|
164
|
+
resetsAt,
|
|
165
|
+
window: { id: key, label, resetsAt },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
function buildUsageAmount(row: KimiUsageRow): UsageAmount {
|
|
148
|
-
const amount: UsageAmount = { unit:
|
|
170
|
+
const amount: UsageAmount = { unit: row.unit };
|
|
149
171
|
if (row.limit !== undefined) amount.limit = row.limit;
|
|
150
172
|
if (row.used !== undefined) amount.used = row.used;
|
|
151
173
|
if (row.remaining !== undefined) amount.remaining = row.remaining;
|
|
@@ -236,6 +258,15 @@ function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRo
|
|
|
236
258
|
});
|
|
237
259
|
}
|
|
238
260
|
|
|
261
|
+
if (isRecord(data.usages)) {
|
|
262
|
+
for (const key in data.usages) {
|
|
263
|
+
const aggregate = data.usages[key];
|
|
264
|
+
if (key === "limit_5h" || !isRecord(aggregate)) continue;
|
|
265
|
+
const row = buildAggregateUsageRow(key, aggregate, nowMs);
|
|
266
|
+
if (row) rows.push(row);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
239
270
|
return { rows, raw: data };
|
|
240
271
|
}
|
|
241
272
|
|
package/src/usage.ts
CHANGED
|
@@ -75,11 +75,29 @@ export interface UsageLimit {
|
|
|
75
75
|
* Per-credit detail for a saved/banked rate-limit reset.
|
|
76
76
|
*
|
|
77
77
|
* Populated when the provider's listing endpoint returns individual credit
|
|
78
|
-
* metadata (e.g. OpenAI Codex
|
|
78
|
+
* metadata (e.g. OpenAI Codex credits or Claude Cedar grants). Callers that
|
|
79
79
|
* only need the count can ignore this; display layers use `expiresAt` to show
|
|
80
80
|
* when banked resets expire ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)).
|
|
81
81
|
*/
|
|
82
82
|
export interface UsageResetCreditDetail {
|
|
83
|
+
/** Opaque provider credit/grant identifier. */
|
|
84
|
+
id?: string;
|
|
85
|
+
/** Human-facing name for the reset. */
|
|
86
|
+
title?: string;
|
|
87
|
+
/** Provider reset program/family. */
|
|
88
|
+
program?: string;
|
|
89
|
+
/** Resets still banked in this credit/grant. */
|
|
90
|
+
remainingCount?: number;
|
|
91
|
+
/** Whether the provider says this credit can be redeemed now. */
|
|
92
|
+
usable?: boolean;
|
|
93
|
+
/** Whether redemption requires an exhausted covered limit. */
|
|
94
|
+
requiresLimit?: boolean;
|
|
95
|
+
/** Normalized {@link UsageLimit.id}s this credit resets. */
|
|
96
|
+
clears?: string[];
|
|
97
|
+
/** Normalized limit ids currently preventing redemption. */
|
|
98
|
+
blocking?: string[];
|
|
99
|
+
/** Used fractions for covered limits, keyed by normalized limit id. */
|
|
100
|
+
usedFractions?: Record<string, number>;
|
|
83
101
|
/** ISO timestamp when the credit was granted. */
|
|
84
102
|
grantedAt?: string;
|
|
85
103
|
/** ISO timestamp when the credit expires and can no longer be redeemed. */
|
|
@@ -88,17 +106,32 @@ export interface UsageResetCreditDetail {
|
|
|
88
106
|
status?: string;
|
|
89
107
|
}
|
|
90
108
|
|
|
109
|
+
/** Reset credit carrying the provider id required by its consume endpoint. */
|
|
110
|
+
export interface UsageResetCredit extends UsageResetCreditDetail {
|
|
111
|
+
id: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
/**
|
|
92
115
|
* Saved/banked rate-limit resets an account can redeem on demand.
|
|
93
116
|
*
|
|
94
117
|
* Surfaced by providers that let users defer a usage-window reset and spend it
|
|
95
|
-
* later (OpenAI Codex
|
|
96
|
-
* separate, provider-specific action; this is the read-only
|
|
118
|
+
* later (OpenAI Codex and Claude Cedar resets). The redeem itself is a
|
|
119
|
+
* separate, provider-specific action; this is the read-only state for display.
|
|
97
120
|
*/
|
|
98
121
|
export interface UsageResetCredits {
|
|
99
|
-
/** Number of resets
|
|
122
|
+
/** Number of banked resets, including grants that are not currently usable. */
|
|
100
123
|
availableCount: number;
|
|
101
|
-
/**
|
|
124
|
+
/** Number of resets the provider says can be redeemed now. */
|
|
125
|
+
redeemableCount?: number;
|
|
126
|
+
/** Provider-selected credit/grant eligible for the next redemption. */
|
|
127
|
+
nextCreditId?: string;
|
|
128
|
+
/** Whether this account is eligible for the reset program. */
|
|
129
|
+
eligible?: boolean;
|
|
130
|
+
/** Provider reason the program or its credits are unavailable. */
|
|
131
|
+
reason?: string;
|
|
132
|
+
/** ISO timestamp until which redemption is cooling down. */
|
|
133
|
+
cooldownUntil?: string;
|
|
134
|
+
/** Individual credit details (expiry dates, coverage, etc.) when exposed. */
|
|
102
135
|
credits?: UsageResetCreditDetail[];
|
|
103
136
|
}
|
|
104
137
|
|
|
@@ -287,6 +320,15 @@ export const usageLimitSchema = type({
|
|
|
287
320
|
});
|
|
288
321
|
|
|
289
322
|
export const usageResetCreditDetailSchema = type({
|
|
323
|
+
"id?": "string",
|
|
324
|
+
"title?": "string",
|
|
325
|
+
"program?": "string",
|
|
326
|
+
"remainingCount?": "number",
|
|
327
|
+
"usable?": "boolean",
|
|
328
|
+
"requiresLimit?": "boolean",
|
|
329
|
+
"clears?": "string[]",
|
|
330
|
+
"blocking?": "string[]",
|
|
331
|
+
"usedFractions?": { "[string]": "number" },
|
|
290
332
|
"grantedAt?": "string",
|
|
291
333
|
"expiresAt?": "string",
|
|
292
334
|
"status?": "string",
|
|
@@ -294,6 +336,11 @@ export const usageResetCreditDetailSchema = type({
|
|
|
294
336
|
|
|
295
337
|
export const usageResetCreditsSchema = type({
|
|
296
338
|
availableCount: "number",
|
|
339
|
+
"redeemableCount?": "number",
|
|
340
|
+
"nextCreditId?": "string",
|
|
341
|
+
"eligible?": "boolean",
|
|
342
|
+
"reason?": "string",
|
|
343
|
+
"cooldownUntil?": "string",
|
|
297
344
|
"credits?": usageResetCreditDetailSchema.array(),
|
|
298
345
|
});
|
|
299
346
|
|
|
@@ -98,6 +98,17 @@ function isTagSelectedBranch(branch: unknown, value: unknown): boolean {
|
|
|
98
98
|
return matched;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** The unique discriminator-selected branch, or undefined when selection is ambiguous or absent. */
|
|
102
|
+
export function getTagSelectedUnionBranch(branches: readonly unknown[], value: unknown): unknown {
|
|
103
|
+
let selected: unknown;
|
|
104
|
+
for (const branch of branches) {
|
|
105
|
+
if (!isTagSelectedBranch(branch, value)) continue;
|
|
106
|
+
if (selected !== undefined) return undefined;
|
|
107
|
+
selected = branch;
|
|
108
|
+
}
|
|
109
|
+
return selected;
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
function pushIssue(
|
|
102
113
|
issues: JsonSchemaValidationIssue[],
|
|
103
114
|
path: readonly PropertyKey[],
|
|
@@ -383,7 +394,7 @@ function validateSchemaNode(
|
|
|
383
394
|
let matches = 0;
|
|
384
395
|
let firstIssues: JsonSchemaValidationIssue[] | undefined;
|
|
385
396
|
let selectedIssues: JsonSchemaValidationIssue[] | undefined;
|
|
386
|
-
|
|
397
|
+
const selectedBranch = getTagSelectedUnionBranch(branches, value);
|
|
387
398
|
for (const branch of branches) {
|
|
388
399
|
const branchIssues: JsonSchemaValidationIssue[] = [];
|
|
389
400
|
if (validateSchemaNode(branch, value, path, ctx, branchIssues)) {
|
|
@@ -391,14 +402,11 @@ function validateSchemaNode(
|
|
|
391
402
|
continue;
|
|
392
403
|
}
|
|
393
404
|
if (!firstIssues) firstIssues = branchIssues;
|
|
394
|
-
if (
|
|
395
|
-
selectedCount += 1;
|
|
396
|
-
if (selectedCount === 1) selectedIssues = branchIssues;
|
|
397
|
-
}
|
|
405
|
+
if (branch === selectedBranch) selectedIssues = branchIssues;
|
|
398
406
|
}
|
|
399
407
|
const branchValid = keyword === "anyOf" ? matches > 0 : matches === 1;
|
|
400
408
|
if (!branchValid) {
|
|
401
|
-
if (matches === 0 &&
|
|
409
|
+
if (matches === 0 && selectedIssues && selectedIssues.length > 0) {
|
|
402
410
|
// A const/enum discriminator uniquely identifies the intended
|
|
403
411
|
// variant, so its diagnosis is authoritative: surface untagged and
|
|
404
412
|
// keep every repair (including lossy ones) available.
|
|
@@ -741,10 +749,15 @@ function validateSchemaValueInRoot(schema: unknown, value: unknown, root: unknow
|
|
|
741
749
|
return { success, issues };
|
|
742
750
|
}
|
|
743
751
|
|
|
744
|
-
export function validateJsonSchemaValue(
|
|
745
|
-
|
|
752
|
+
export function validateJsonSchemaValue(
|
|
753
|
+
schema: unknown,
|
|
754
|
+
value: unknown,
|
|
755
|
+
root: unknown = schema,
|
|
756
|
+
): JsonSchemaValidationResult {
|
|
757
|
+
return validateSchemaValueInRoot(schema, value, root);
|
|
746
758
|
}
|
|
747
759
|
|
|
748
|
-
|
|
749
|
-
|
|
760
|
+
/** Validate a subschema using its complete schema's local-reference context. */
|
|
761
|
+
export function isJsonSchemaValueValid(schema: unknown, value: unknown, root: unknown = schema): boolean {
|
|
762
|
+
return validateSchemaValueInRoot(schema, value, root).success;
|
|
750
763
|
}
|
|
@@ -76,7 +76,7 @@ export class ToolCallLoopGuard {
|
|
|
76
76
|
this.#exemptTools = new Set(options.exemptTools);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/** Records one completed turn and
|
|
79
|
+
/** Records one completed turn and reports repetitions at or beyond the threshold. */
|
|
80
80
|
recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null {
|
|
81
81
|
const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall");
|
|
82
82
|
if (toolCalls.length === 0) {
|
|
@@ -101,7 +101,7 @@ export class ToolCallLoopGuard {
|
|
|
101
101
|
this.#count = 1;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
if (this.#count
|
|
104
|
+
if (this.#count < this.#threshold) return null;
|
|
105
105
|
const reportCall = toolCalls.find(tc => !this.#exemptTools.has(tc.name)) ?? toolCalls[0]!;
|
|
106
106
|
return {
|
|
107
107
|
kind: "repeated_tool_call",
|