@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +33 -18
  2. package/dist/types/auth-gateway/types.d.ts +5 -0
  3. package/dist/types/auth-storage.d.ts +35 -32
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/providers/amazon-bedrock.d.ts +7 -0
  6. package/dist/types/providers/claude-code-fingerprint.d.ts +22 -3
  7. package/dist/types/providers/google-gemini-cli.d.ts +0 -2
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +2 -2
  9. package/dist/types/usage/claude-api.d.ts +22 -0
  10. package/dist/types/usage/claude-reset.d.ts +44 -0
  11. package/dist/types/usage.d.ts +111 -5
  12. package/dist/types/utils/schema/json-schema-validator.d.ts +5 -2
  13. package/dist/types/utils/tool-call-loop-guard.d.ts +1 -1
  14. package/package.json +6 -6
  15. package/src/auth/sqlite-credential-store.ts +44 -1
  16. package/src/auth-broker/remote-store.ts +6 -6
  17. package/src/auth-broker/wire-schemas.ts +14 -0
  18. package/src/auth-gateway/server.ts +4 -0
  19. package/src/auth-gateway/types.ts +5 -0
  20. package/src/auth-storage.ts +263 -140
  21. package/src/error/flags.ts +10 -0
  22. package/src/index.ts +1 -0
  23. package/src/providers/amazon-bedrock.ts +55 -5
  24. package/src/providers/anthropic.ts +45 -11
  25. package/src/providers/aws-credentials.ts +124 -11
  26. package/src/providers/claude-code-fingerprint.ts +55 -3
  27. package/src/providers/gitlab-duo.ts +20 -4
  28. package/src/providers/google-gemini-cli.ts +0 -8
  29. package/src/providers/google-shared.ts +1 -18
  30. package/src/providers/openai-chat-server-schema.ts +1 -1
  31. package/src/providers/openai-chat-server.ts +3 -1
  32. package/src/providers/openai-codex-responses.ts +27 -5
  33. package/src/providers/openai-completions.ts +122 -19
  34. package/src/providers/pi-native-server.ts +1 -0
  35. package/src/registry/oauth/anthropic.ts +2 -3
  36. package/src/stream.ts +13 -4
  37. package/src/usage/alibaba-token-plan.ts +7 -1
  38. package/src/usage/claude-api.ts +66 -0
  39. package/src/usage/claude-reset.ts +638 -0
  40. package/src/usage/claude.ts +37 -59
  41. package/src/usage/kimi.ts +32 -1
  42. package/src/usage.ts +52 -5
  43. package/src/utils/schema/json-schema-validator.ts +23 -10
  44. package/src/utils/tool-call-loop-guard.ts +2 -2
  45. package/src/utils/validation.ts +145 -50
@@ -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 CLAUDE_HEADERS = {
29
- accept: "application/json, text/plain, */*",
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: Record<string, string> = {
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 claudeUsageBaseUrls(params.baseUrl)) {
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
- if (limits.length === 0) return null;
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
- ...(credential.orgId ? { orgId: credential.orgId } : {}),
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: "unknown" };
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 `wham/rate-limit-reset-credits`). Callers that
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 "saved rate limit resets"). The redeem itself is a
96
- * separate, provider-specific action; this is the read-only count for display.
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 available to redeem right now. */
122
+ /** Number of banked resets, including grants that are not currently usable. */
100
123
  availableCount: number;
101
- /** Individual credit details (expiry dates, etc.) when the provider exposes them. */
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
- let selectedCount = 0;
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 (isTagSelectedBranch(branch, value)) {
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 && selectedCount === 1 && selectedIssues && selectedIssues.length > 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(schema: unknown, value: unknown): JsonSchemaValidationResult {
745
- return validateSchemaValueInRoot(schema, value, schema);
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
- export function isJsonSchemaValueValid(schema: unknown, value: unknown): boolean {
749
- return validateJsonSchemaValue(schema, value).success;
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 returns the threshold hit, if any. */
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 !== this.#threshold) return null;
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",