@oh-my-pi/pi-ai 17.1.2 → 17.1.4

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 (54) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/types/auth-broker/client.d.ts +7 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +4 -1
  5. package/dist/types/auth-broker/types.d.ts +6 -1
  6. package/dist/types/auth-broker/wire-schemas.d.ts +41 -0
  7. package/dist/types/auth-storage.d.ts +55 -0
  8. package/dist/types/providers/anthropic.d.ts +1 -7
  9. package/dist/types/providers/claude-code-fingerprint.d.ts +15 -0
  10. package/dist/types/providers/cursor.d.ts +30 -7
  11. package/dist/types/providers/openai-codex/request-transformer.d.ts +5 -4
  12. package/dist/types/providers/openai-codex-responses.d.ts +3 -0
  13. package/dist/types/registry/alibaba-token-plan.d.ts +11 -0
  14. package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
  15. package/dist/types/registry/oauth/anthropic.d.ts +1 -0
  16. package/dist/types/registry/oauth/index.d.ts +1 -0
  17. package/dist/types/registry/oauth/types.d.ts +8 -0
  18. package/dist/types/types.d.ts +71 -1
  19. package/dist/types/usage/minimax-code.d.ts +1 -0
  20. package/dist/types/usage.d.ts +10 -0
  21. package/dist/types/utils/idle-iterator.d.ts +6 -4
  22. package/package.json +4 -4
  23. package/src/auth-broker/client.ts +24 -1
  24. package/src/auth-broker/remote-store.ts +12 -0
  25. package/src/auth-broker/server.ts +7 -0
  26. package/src/auth-broker/types.ts +7 -0
  27. package/src/auth-broker/wire-schemas.ts +23 -0
  28. package/src/auth-storage.ts +279 -29
  29. package/src/error/flags.ts +1 -1
  30. package/src/providers/__tests__/kimi-code-thinking.test.ts +40 -5
  31. package/src/providers/amazon-bedrock.ts +3 -4
  32. package/src/providers/anthropic.ts +98 -32
  33. package/src/providers/azure-openai-responses.ts +36 -5
  34. package/src/providers/claude-code-fingerprint.ts +19 -0
  35. package/src/providers/cursor.ts +525 -40
  36. package/src/providers/openai-codex/request-transformer.ts +27 -7
  37. package/src/providers/openai-codex-responses.ts +37 -14
  38. package/src/providers/openai-completions.ts +2 -1
  39. package/src/providers/openai-responses.ts +16 -9
  40. package/src/providers/openai-shared.ts +12 -1
  41. package/src/registry/alibaba-token-plan.ts +103 -21
  42. package/src/registry/oauth/anthropic-constants.ts +12 -0
  43. package/src/registry/oauth/anthropic.ts +3 -1
  44. package/src/registry/oauth/index.ts +1 -0
  45. package/src/registry/oauth/types.ts +8 -0
  46. package/src/stream.ts +7 -2
  47. package/src/types.ts +79 -1
  48. package/src/usage/alibaba-token-plan.ts +34 -6
  49. package/src/usage/claude.ts +7 -2
  50. package/src/usage/minimax-code.ts +280 -19
  51. package/src/usage/openai-codex.ts +61 -14
  52. package/src/usage.ts +10 -0
  53. package/src/utils/idle-iterator.ts +8 -6
  54. package/src/utils.ts +5 -5
@@ -14,13 +14,21 @@ const PROVIDER = "alibaba-token-plan";
14
14
  const CONSOLE_ORIGIN = "https://home.qwencloud.com";
15
15
  const DASHBOARD_URL = `${CONSOLE_ORIGIN}/billing/subscription/token-plan-individual`;
16
16
  const USER_INFO_URL = `${CONSOLE_ORIGIN}/tool/user/info.json`;
17
- const USAGE_URL = `${CONSOLE_ORIGIN}/data/api.json?product=sfm_bailian&action=IntlBroadScopeAspnGateway`;
18
17
  const GATEWAY_ACTION = "IntlBroadScopeAspnGateway";
19
18
  const USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
19
+ const USAGE_URL = `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=${GATEWAY_ACTION}&api=${encodeURIComponent(USAGE_API)}`;
20
20
  const BROWSER_USER_AGENT =
21
21
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
22
22
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
23
23
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
24
+ const CONSOLE_CORNERSTONE_PARAM = {
25
+ domain: "home.qwencloud.com",
26
+ consoleSite: "QWENCLOUD",
27
+ console: "ONE_CONSOLE",
28
+ xsp_lang: "en-US",
29
+ protocol: "V2",
30
+ productCode: "p_efm",
31
+ } as const;
24
32
 
25
33
  function extractCookieValue(header: string, name: string): string | undefined {
26
34
  for (const segment of header.split(";")) {
@@ -32,6 +40,21 @@ function extractCookieValue(header: string, name: string): string | undefined {
32
40
  return undefined;
33
41
  }
34
42
 
43
+ function unwrapGatewayData(value: Record<string, unknown>): Record<string, unknown> {
44
+ let current = value;
45
+ if (typeof current.Data === "string") {
46
+ try {
47
+ const parsed: unknown = JSON.parse(current.Data);
48
+ if (isRecord(parsed)) current = parsed;
49
+ } catch {
50
+ return current;
51
+ }
52
+ }
53
+ if (isRecord(current.DataV2) && isRecord(current.DataV2.data)) current = current.DataV2.data;
54
+ if (isRecord(current.data)) current = current.data;
55
+ return current;
56
+ }
57
+
35
58
  function parseResetTime(value: unknown): number | undefined {
36
59
  const parsed = toNumber(value);
37
60
  if (parsed === undefined || parsed <= 0) return undefined;
@@ -127,7 +150,11 @@ async function fetchAlibabaTokenPlanUsage(
127
150
  action: GATEWAY_ACTION,
128
151
  region: "ap-southeast-1",
129
152
  sec_token: secToken,
130
- params: JSON.stringify({ Api: USAGE_API, Data: {} }),
153
+ params: JSON.stringify({
154
+ Api: USAGE_API,
155
+ Data: { cornerstoneParam: CONSOLE_CORNERSTONE_PARAM },
156
+ V: "1.0",
157
+ }),
131
158
  });
132
159
  const usageResponse = await ctx.fetch(USAGE_URL, {
133
160
  method: "POST",
@@ -145,22 +172,23 @@ async function fetchAlibabaTokenPlanUsage(
145
172
  ctx.logger?.warn("QwenCloud usage response invalid", { provider: PROVIDER });
146
173
  return null;
147
174
  }
175
+ const responseData = unwrapGatewayData(payload.data);
148
176
  const accountId = accountIdFromUserData(userPayload.data);
149
177
  const limits = [
150
178
  buildLimit(
151
179
  "5h",
152
180
  "5 Hour Credits",
153
181
  FIVE_HOURS_MS,
154
- parseUsedFraction(payload.data.per5HourPercentage),
155
- parseResetTime(payload.data.per5HourResetTime),
182
+ parseUsedFraction(responseData.per5HourPercentage),
183
+ parseResetTime(responseData.per5HourResetTime),
156
184
  accountId,
157
185
  ),
158
186
  buildLimit(
159
187
  "7d",
160
188
  "7 Day Credits",
161
189
  SEVEN_DAYS_MS,
162
- parseUsedFraction(payload.data.per1WeekPercentage),
163
- parseResetTime(payload.data.per1WeekResetTime),
190
+ parseUsedFraction(responseData.per1WeekPercentage),
191
+ parseResetTime(responseData.per1WeekResetTime),
164
192
  accountId,
165
193
  ),
166
194
  ].filter((limit): limit is UsageLimit => limit !== undefined);
@@ -2,7 +2,7 @@ import { scheduler } from "node:timers/promises";
2
2
  import { bareModelId, parseAnthropicModel } from "@oh-my-pi/pi-catalog/identity";
3
3
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
4
4
  import * as AIError from "../error";
5
- import { claudeCodeVersion } from "../providers/anthropic";
5
+ import { claudeCodeVersion } from "../providers/claude-code-fingerprint";
6
6
  import {
7
7
  type CredentialRankingContext,
8
8
  type CredentialRankingStrategy,
@@ -153,6 +153,12 @@ function getApiLimitDisplayName(scope: unknown): string | undefined {
153
153
  * `seven_day_sonnet`) are permanently null. Model-scoped weekly caps now arrive
154
154
  * only through generic `limits[]` entries (`kind: "weekly_scoped"`) with the
155
155
  * model family named by `scope.model.display_name`.
156
+ *
157
+ * `is_active` is deliberately ignored: live payloads mark only the currently
158
+ * binding limit active (an account pinned at a 100% Fable cap reports its 77%
159
+ * shared weekly row as `is_active: false`), so it signals severity ranking,
160
+ * not bucket existence. Filtering on it hid real utilization — a scoped row
161
+ * at 5% with a live reset rendered as `not reported` in `omp usage`.
156
162
  */
157
163
  function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
158
164
  if (!Array.isArray(raw)) return [];
@@ -161,7 +167,6 @@ function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
161
167
  if (!isRecord(rawEntry)) continue;
162
168
  const entry = rawEntry as ClaudeApiLimitEntry;
163
169
  if (typeof entry.kind !== "string") continue;
164
- if (entry.is_active === false) continue;
165
170
  const utilization = toNumber(entry.percent);
166
171
  const resetsAt = parseIsoTime(typeof entry.resets_at === "string" ? entry.resets_at : undefined);
167
172
  if (utilization === undefined && resetsAt === undefined) continue;
@@ -1,30 +1,291 @@
1
- import type { UsageFetchContext, UsageFetchParams, UsageProvider, UsageReport } from "../usage";
1
+ import type {
2
+ UsageFetchContext,
3
+ UsageFetchParams,
4
+ UsageLimit,
5
+ UsageProvider,
6
+ UsageReport,
7
+ UsageStatus,
8
+ } from "../usage";
9
+ import { isRecord } from "../utils";
10
+ import { toNumber } from "./shared";
2
11
 
12
+ const INTL_PROVIDER = "minimax-code";
13
+ const INTL_BASE_URL = "https://api.minimax.io";
14
+ const REMAINS_PATH = "/v1/token_plan/remains";
15
+ const HOUR_MS = 60 * 60 * 1000;
16
+ /** `current_*_status` enum reported per window: 1 normal, 2 exhausted, 3 unlimited. */
17
+ const STATUS_EXHAUSTED = 2;
18
+ const STATUS_UNLIMITED = 3;
3
19
  /**
4
- * MiniMax Token Plan usage provider.
5
- *
6
- * MiniMax Token Plan is a subscription-based service with a rolling quota system.
7
- *
8
- * Currently, MiniMax does not expose a usage/quota API endpoint for the Token Plan.
9
- * Usage is tracked via the web dashboard at https://platform.minimax.io/user-center/payment/token-plan
20
+ * The plan-wide token quota every chat model draws from. It is a quota category,
21
+ * not a catalog model id, so its limits are scoped `shared`: `AuthStorage` has no
22
+ * MiniMax ranking strategy and would otherwise match `scope.modelId` against ids
23
+ * like `MiniMax-M3` and drop the "models with usage data" mapping. Category
24
+ * buckets such as `video` meter a separate quota and keep their own model scope.
25
+ */
26
+ const SHARED_BUCKET = "general";
27
+
28
+ /** One `model_remains[]` bucket: a plan quota tracked over a rolling interval plus a weekly window. */
29
+ interface TokenPlanBucket {
30
+ modelName: string;
31
+ intervalStart?: number;
32
+ intervalEnd?: number;
33
+ intervalRemainingPercent?: number;
34
+ intervalTotalCount?: number;
35
+ intervalUsageCount?: number;
36
+ intervalStatus?: number;
37
+ weeklyStart?: number;
38
+ weeklyEnd?: number;
39
+ weeklyRemainingPercent?: number;
40
+ weeklyTotalCount?: number;
41
+ weeklyUsageCount?: number;
42
+ weeklyStatus?: number;
43
+ }
44
+
45
+ /** MiniMax reports epoch milliseconds; tolerate seconds in case a deployment differs. */
46
+ function parseTimestamp(value: unknown): number | undefined {
47
+ const parsed = toNumber(value);
48
+ if (parsed === undefined || parsed <= 0) return undefined;
49
+ return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed;
50
+ }
51
+
52
+ /** `current_*_remaining_percent` is 0..100 remaining; usage fractions are 0..1 used. */
53
+ function usedFractionFromRemainingPercent(value: unknown): number | undefined {
54
+ const parsed = toNumber(value);
55
+ if (parsed === undefined || !Number.isFinite(parsed)) return undefined;
56
+ // (100 - p) / 100 keeps whole percentages exact; 1 - p / 100 does not (90 → 0.09999999999999998).
57
+ return Math.min(1, Math.max(0, (100 - parsed) / 100));
58
+ }
59
+
60
+ function usageStatus(usedFraction: number): UsageStatus {
61
+ if (usedFraction >= 1) return "exhausted";
62
+ if (usedFraction >= 0.9) return "warning";
63
+ return "ok";
64
+ }
65
+
66
+ function parseBucket(value: unknown): TokenPlanBucket | null {
67
+ if (!isRecord(value)) return null;
68
+ const modelName = typeof value.model_name === "string" ? value.model_name.trim() : "";
69
+ if (!modelName) return null;
70
+ return {
71
+ modelName,
72
+ intervalStart: parseTimestamp(value.start_time),
73
+ intervalEnd: parseTimestamp(value.end_time),
74
+ intervalRemainingPercent: toNumber(value.current_interval_remaining_percent),
75
+ intervalTotalCount: toNumber(value.current_interval_total_count),
76
+ intervalUsageCount: toNumber(value.current_interval_usage_count),
77
+ intervalStatus: toNumber(value.current_interval_status),
78
+ weeklyStart: parseTimestamp(value.weekly_start_time),
79
+ weeklyEnd: parseTimestamp(value.weekly_end_time),
80
+ weeklyRemainingPercent: toNumber(value.current_weekly_remaining_percent),
81
+ weeklyTotalCount: toNumber(value.current_weekly_total_count),
82
+ weeklyUsageCount: toNumber(value.current_weekly_usage_count),
83
+ weeklyStatus: toNumber(value.current_weekly_status),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * A model outside the current plan is reported as both windows "unlimited"
89
+ * with zero totals and 100% remaining, which would otherwise read as a pristine
90
+ * quota. MiniMax's own CLI treats exactly this shape as "not in plan"
91
+ * ([MiniMax-AI/cli#173](https://github.com/MiniMax-AI/cli/issues/173)), so the
92
+ * bucket is kept out of the limits and named in `metadata.unavailableModels`.
93
+ * Zero totals alone are not enough: a live plan reports `0/0` with status 1 and
94
+ * a real remaining percentage.
95
+ */
96
+ function isUnavailablePlan(bucket: TokenPlanBucket): boolean {
97
+ return (
98
+ bucket.intervalTotalCount === 0 &&
99
+ bucket.weeklyTotalCount === 0 &&
100
+ bucket.intervalStatus === STATUS_UNLIMITED &&
101
+ bucket.weeklyStatus === STATUS_UNLIMITED
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Interval length varies per bucket (text quotas roll every few hours, media
107
+ * quotas daily), so the window id follows the reported span instead of a
108
+ * hardcoded tier. Spans that are not whole hours are labelled in minutes
109
+ * rather than rounded into a wrong hour count.
110
+ */
111
+ function intervalWindowId(durationMs: number | undefined): { id: string; label: string } {
112
+ if (durationMs === undefined || durationMs <= 0) return { id: "interval", label: "Interval" };
113
+ if (durationMs % HOUR_MS === 0) {
114
+ const hours = durationMs / HOUR_MS;
115
+ return { id: `${hours}h`, label: `${hours} Hour` };
116
+ }
117
+ const minutes = Math.round(durationMs / 60_000);
118
+ if (minutes <= 0) return { id: "interval", label: "Interval" };
119
+ return { id: `${minutes}m`, label: `${minutes} Minute` };
120
+ }
121
+
122
+ function buildLimit(args: {
123
+ provider: string;
124
+ bucket: TokenPlanBucket;
125
+ windowId: string;
126
+ windowLabel: string;
127
+ durationMs?: number;
128
+ resetsAt?: number;
129
+ usedFraction: number | undefined;
130
+ windowStatus?: number;
131
+ usageCount?: number;
132
+ totalCount?: number;
133
+ accountId?: string;
134
+ }): UsageLimit | undefined {
135
+ // The endpoint's own status outranks the percentage: an exhausted window may
136
+ // omit it, or keep a stale one that would otherwise render as healthy quota.
137
+ const usedFraction = args.windowStatus === STATUS_EXHAUSTED ? 1 : args.usedFraction;
138
+ if (usedFraction === undefined) return undefined;
139
+ const totalCount = args.totalCount;
140
+ return {
141
+ id: `${args.bucket.modelName}:${args.windowId}`,
142
+ label: `${args.bucket.modelName.charAt(0).toUpperCase()}${args.bucket.modelName.slice(1)} ${args.windowLabel}`,
143
+ scope: {
144
+ provider: args.provider,
145
+ ...(args.accountId ? { accountId: args.accountId } : {}),
146
+ ...(args.bucket.modelName === SHARED_BUCKET ? { shared: true as const } : { modelId: args.bucket.modelName }),
147
+ windowId: args.windowId,
148
+ },
149
+ window: {
150
+ id: args.windowId,
151
+ label: args.windowLabel,
152
+ ...(args.durationMs !== undefined && args.durationMs > 0 ? { durationMs: args.durationMs } : {}),
153
+ ...(args.resetsAt ? { resetsAt: args.resetsAt } : {}),
154
+ },
155
+ amount: {
156
+ used: usedFraction * 100,
157
+ usedFraction,
158
+ remaining: 100 - usedFraction * 100,
159
+ remainingFraction: 1 - usedFraction,
160
+ unit: "percent",
161
+ },
162
+ status: usageStatus(usedFraction),
163
+ ...(totalCount !== undefined && totalCount > 0
164
+ ? { notes: [`Requests: ${args.usageCount ?? 0}/${totalCount}`] }
165
+ : {}),
166
+ };
167
+ }
168
+
169
+ function buildBucketLimits(provider: string, bucket: TokenPlanBucket, accountId: string | undefined): UsageLimit[] {
170
+ const intervalDuration =
171
+ bucket.intervalStart !== undefined && bucket.intervalEnd !== undefined
172
+ ? bucket.intervalEnd - bucket.intervalStart
173
+ : undefined;
174
+ const weeklyDuration =
175
+ bucket.weeklyStart !== undefined && bucket.weeklyEnd !== undefined
176
+ ? bucket.weeklyEnd - bucket.weeklyStart
177
+ : undefined;
178
+ const interval = intervalWindowId(intervalDuration);
179
+ return [
180
+ buildLimit({
181
+ provider,
182
+ bucket,
183
+ windowId: interval.id,
184
+ windowLabel: interval.label,
185
+ durationMs: intervalDuration,
186
+ resetsAt: bucket.intervalEnd,
187
+ usedFraction: usedFractionFromRemainingPercent(bucket.intervalRemainingPercent),
188
+ windowStatus: bucket.intervalStatus,
189
+ usageCount: bucket.intervalUsageCount,
190
+ totalCount: bucket.intervalTotalCount,
191
+ accountId,
192
+ }),
193
+ buildLimit({
194
+ provider,
195
+ bucket,
196
+ windowId: "7d",
197
+ windowLabel: "7 Day",
198
+ durationMs: weeklyDuration,
199
+ resetsAt: bucket.weeklyEnd,
200
+ usedFraction: usedFractionFromRemainingPercent(bucket.weeklyRemainingPercent),
201
+ windowStatus: bucket.weeklyStatus,
202
+ usageCount: bucket.weeklyUsageCount,
203
+ totalCount: bucket.weeklyTotalCount,
204
+ accountId,
205
+ }),
206
+ ].filter((limit): limit is UsageLimit => limit !== undefined);
207
+ }
208
+
209
+ /**
210
+ * MiniMax Token Plan usage provider (international, `api.minimax.io`).
10
211
  *
11
- * This provider exists to register support for the minimax-code provider in the
12
- * usage system. When MiniMax adds a usage API, this can be implemented.
212
+ * `GET /v1/token_plan/remains` returns one `model_remains[]` bucket per plan
213
+ * quota (text, media, …), each carrying a rolling interval window and a weekly
214
+ * window with the remaining percentage. MiniMax answers HTTP 200 even for
215
+ * rejected credentials, so `base_resp.status_code` is the real success signal.
13
216
  */
14
- async function fetchMiniMaxCodeUsage(params: UsageFetchParams, _ctx: UsageFetchContext): Promise<UsageReport | null> {
15
- if (params.provider !== "minimax-code" && params.provider !== "minimax-code-cn") {
217
+ async function fetchMiniMaxCodeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
218
+ if (params.provider !== INTL_PROVIDER) return null;
219
+ const apiKey = params.credential.apiKey;
220
+ if (params.credential.type !== "api_key" || !apiKey) return null;
221
+
222
+ try {
223
+ const configuredBaseUrl = params.baseUrl?.trim();
224
+ const baseUrl = configuredBaseUrl ? configuredBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "") : INTL_BASE_URL;
225
+ const response = await ctx.fetch(`${baseUrl}${REMAINS_PATH}`, {
226
+ headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
227
+ signal: params.signal,
228
+ });
229
+ if (!response.ok) {
230
+ ctx.logger?.warn("MiniMax Token Plan usage fetch failed", {
231
+ provider: params.provider,
232
+ status: response.status,
233
+ });
234
+ return null;
235
+ }
236
+ const payload: unknown = await response.json();
237
+ if (!isRecord(payload)) return null;
238
+ const statusCode = isRecord(payload.base_resp) ? toNumber(payload.base_resp.status_code) : undefined;
239
+ if (statusCode !== 0) {
240
+ ctx.logger?.warn("MiniMax Token Plan usage response rejected", {
241
+ provider: params.provider,
242
+ statusCode: statusCode ?? "missing",
243
+ });
244
+ return null;
245
+ }
246
+ if (!Array.isArray(payload.model_remains)) return null;
247
+
248
+ const accountId = params.credential.accountId;
249
+ const limits: UsageLimit[] = [];
250
+ const models: string[] = [];
251
+ const unavailableModels: string[] = [];
252
+ for (const entry of payload.model_remains) {
253
+ const bucket = parseBucket(entry);
254
+ if (!bucket) continue;
255
+ models.push(bucket.modelName);
256
+ if (isUnavailablePlan(bucket)) {
257
+ unavailableModels.push(bucket.modelName);
258
+ continue;
259
+ }
260
+ limits.push(...buildBucketLimits(params.provider, bucket, accountId));
261
+ }
262
+ if (limits.length === 0) return null;
263
+
264
+ return {
265
+ provider: params.provider,
266
+ fetchedAt: Date.now(),
267
+ limits,
268
+ metadata: {
269
+ source: "minimax-token-plan",
270
+ models,
271
+ ...(unavailableModels.length > 0 ? { unavailableModels } : {}),
272
+ ...(accountId ? { accountId } : {}),
273
+ },
274
+ raw: payload,
275
+ };
276
+ } catch (error) {
277
+ ctx.logger?.warn("MiniMax Token Plan usage request failed", {
278
+ provider: params.provider,
279
+ error: error instanceof Error ? error.name : "unknown",
280
+ });
16
281
  return null;
17
282
  }
18
-
19
- // MiniMax Token Plan does not currently expose a usage API
20
- // Users can check their usage via the web dashboard
21
- return null;
22
283
  }
23
284
 
285
+ /** MiniMax Token Plan (international, `api.minimax.io`). */
24
286
  export const minimaxCodeUsageProvider: UsageProvider = {
25
- id: "minimax-code",
287
+ id: INTL_PROVIDER,
26
288
  fetchUsage: fetchMiniMaxCodeUsage,
27
- supports: (params: UsageFetchParams) =>
28
- (params.provider === "minimax-code" || params.provider === "minimax-code-cn") &&
29
- params.credential.type === "api_key",
289
+ supports: params =>
290
+ params.provider === INTL_PROVIDER && params.credential.type === "api_key" && Boolean(params.credential.apiKey),
30
291
  };
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import type {
3
+ CredentialRankingContext,
3
4
  CredentialRankingStrategy,
4
5
  UsageAmount,
5
6
  UsageFetchContext,
@@ -275,7 +276,6 @@ function buildUsageLimit(args: {
275
276
  window: ParsedUsageWindow;
276
277
  accountId?: string;
277
278
  planType?: string;
278
- limitReached?: boolean;
279
279
  nowMs: number;
280
280
  }): UsageLimit {
281
281
  const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs);
@@ -290,7 +290,16 @@ function buildUsageLimit(args: {
290
290
  },
291
291
  window: usageWindow,
292
292
  amount,
293
- status: buildUsageStatus(amount.usedFraction, args.limitReached),
293
+ // Each chat window's status reflects ONLY its own usage. The account-level
294
+ // `rate_limit.limit_reached` flag is intentionally not applied here: Codex
295
+ // returns a single shared flag for the whole account, so threading it into
296
+ // both the primary (5h) and secondary (weekly) windows marked a window with
297
+ // real headroom `exhausted` purely because a different window (or a separate
298
+ // metered feature) was at its limit, which over-blocked sibling accounts
299
+ // during credential selection. `usedFraction >= 1` already marks a window
300
+ // that is genuinely full; a real enforced limit not reflected in
301
+ // `used_percent` is caught when the live request returns usage_limit_reached.
302
+ status: buildUsageStatus(amount.usedFraction),
294
303
  };
295
304
  }
296
305
  function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string }): string {
@@ -320,7 +329,6 @@ function buildAdditionalUsageLimit(args: {
320
329
  displayName: string;
321
330
  window: ParsedUsageWindow;
322
331
  accountId?: string;
323
- limitReached?: boolean;
324
332
  limitName?: string;
325
333
  meteredFeature?: string;
326
334
  nowMs: number;
@@ -340,7 +348,10 @@ function buildAdditionalUsageLimit(args: {
340
348
  },
341
349
  window: usageWindow,
342
350
  amount,
343
- status: buildUsageStatus(amount.usedFraction, args.limitReached),
351
+ // The additional meter exposes one account-level flag for both windows.
352
+ // Status must follow this window's own usage or a full weekly meter marks
353
+ // the shorter window exhausted and schedules a premature retry.
354
+ status: buildUsageStatus(amount.usedFraction),
344
355
  };
345
356
  }
346
357
 
@@ -428,6 +439,9 @@ export const openaiCodexUsageProvider: UsageProvider = {
428
439
  (isRecord(payload) && typeof payload.plan_type === "string" ? payload.plan_type : undefined);
429
440
 
430
441
  const limits: UsageLimit[] = [];
442
+ const meterStates: Record<string, { allowed?: boolean; limitReached?: boolean }> = {
443
+ chat: { allowed: parsed?.allowed, limitReached: parsed?.limitReached },
444
+ };
431
445
  if (parsed?.primary) {
432
446
  limits.push(
433
447
  buildUsageLimit({
@@ -435,7 +449,6 @@ export const openaiCodexUsageProvider: UsageProvider = {
435
449
  window: parsed.primary,
436
450
  accountId,
437
451
  planType,
438
- limitReached: parsed.limitReached,
439
452
  nowMs,
440
453
  }),
441
454
  );
@@ -447,7 +460,6 @@ export const openaiCodexUsageProvider: UsageProvider = {
447
460
  window: parsed.secondary,
448
461
  accountId,
449
462
  planType,
450
- limitReached: parsed.limitReached,
451
463
  nowMs,
452
464
  }),
453
465
  );
@@ -455,6 +467,7 @@ export const openaiCodexUsageProvider: UsageProvider = {
455
467
  for (const extra of parsed?.additional ?? []) {
456
468
  const slug = additionalLimitSlug({ limitName: extra.limitName, meteredFeature: extra.meteredFeature });
457
469
  const displayName = additionalDisplayName(slug, extra.limitName);
470
+ meterStates[slug] = { allowed: extra.allowed, limitReached: extra.limitReached };
458
471
  if (extra.primary) {
459
472
  limits.push(
460
473
  buildAdditionalUsageLimit({
@@ -463,7 +476,6 @@ export const openaiCodexUsageProvider: UsageProvider = {
463
476
  displayName,
464
477
  window: extra.primary,
465
478
  accountId,
466
- limitReached: extra.limitReached,
467
479
  limitName: extra.limitName,
468
480
  meteredFeature: extra.meteredFeature,
469
481
  nowMs,
@@ -478,7 +490,6 @@ export const openaiCodexUsageProvider: UsageProvider = {
478
490
  displayName,
479
491
  window: extra.secondary,
480
492
  accountId,
481
- limitReached: extra.limitReached,
482
493
  limitName: extra.limitName,
483
494
  meteredFeature: extra.meteredFeature,
484
495
  nowMs,
@@ -527,6 +538,7 @@ export const openaiCodexUsageProvider: UsageProvider = {
527
538
  limitReached: parsed?.limitReached,
528
539
  email,
529
540
  accountId,
541
+ meterStates,
530
542
  },
531
543
  raw: parsed?.raw ?? payload,
532
544
  };
@@ -537,18 +549,53 @@ export const openaiCodexUsageProvider: UsageProvider = {
537
549
 
538
550
  const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
539
551
 
552
+ // A Codex request gates only on the chat windows it actually consumes. A
553
+ // "-spark" model spends the separate Spark meter; every other Codex model spends
554
+ // the 5h/weekly chat windows. Scoping the gating set this way keeps an exhausted
555
+ // Spark meter from blocking a normal chat request (and vice versa), instead of
556
+ // OR-ing every window and meter in the report into one provider-wide block.
557
+ function scopeCodexLimitsForRequest(report: UsageReport, context?: CredentialRankingContext): UsageLimit[] {
558
+ const isSparkRequest = isCodexSparkRequest(context);
559
+ return report.limits.filter(limit => {
560
+ if (limit.id === "openai-codex:primary" || limit.id === "openai-codex:secondary") {
561
+ return !isSparkRequest;
562
+ }
563
+ // Additional metered features have ids of the form `openai-codex:<slug>:<key>`.
564
+ const slug = limit.id.split(":")[1];
565
+ return slug === "spark" ? isSparkRequest : false;
566
+ });
567
+ }
568
+
569
+ /** True when the requested model spends the separate Spark meter. */
570
+ function isCodexSparkRequest(context?: CredentialRankingContext): boolean {
571
+ return (context?.modelId ?? "").toLowerCase().includes("-spark");
572
+ }
573
+
540
574
  export const codexRankingStrategy: CredentialRankingStrategy = {
541
- blockScope() {
542
- return "shared";
575
+ scopeLimits: scopeCodexLimitsForRequest,
576
+ // A `usage_limit_reached` from a Spark request means the Spark meter is
577
+ // spent, not the chat windows, so the two back off under separate scopes;
578
+ // one shared block would let an exhausted Spark meter stop ordinary chat
579
+ // requests, and the reverse.
580
+ blockScope(context) {
581
+ return isCodexSparkRequest(context) ? "spark" : "chat";
582
+ },
583
+ // "shared" is the scope earlier versions persisted under, and it meant "block
584
+ // everything", so it stays honoured by every request and healed by
585
+ // reconciliation. Without a context (reconciliation) this is the full set.
586
+ blockScopes(context) {
587
+ if (!context) return ["chat", "spark", "shared"];
588
+ return [isCodexSparkRequest(context) ? "spark" : "chat", "shared"];
543
589
  },
544
- findWindowLimits(report) {
590
+ findWindowLimits(report, context) {
591
+ const limits = scopeCodexLimitsForRequest(report, context);
545
592
  const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => {
546
- const direct = report.limits.find(l => l.id === `openai-codex:${key}`);
593
+ const direct = limits.find(l => l.id === `openai-codex:${key}`);
547
594
  if (direct) return direct;
548
- const byId = report.limits.find(l => l.id.toLowerCase().includes(key));
595
+ const byId = limits.find(l => l.id.toLowerCase().includes(key));
549
596
  if (byId) return byId;
550
597
  const windowId = key === "secondary" ? "7d" : "1h";
551
- return report.limits.find(l => l.scope.windowId?.toLowerCase() === windowId);
598
+ return limits.find(l => l.scope.windowId?.toLowerCase() === windowId);
552
599
  };
553
600
  return { primary: findLimit("primary"), secondary: findLimit("secondary") };
554
601
  },
package/src/usage.ts CHANGED
@@ -391,6 +391,16 @@ export interface CredentialRankingStrategy {
391
391
  * not block unrelated families on the same OAuth credential.
392
392
  */
393
393
  blockScope?(context?: CredentialRankingContext): string | undefined;
394
+ /**
395
+ * Scopes that apply to a request, most specific first. With a context, the
396
+ * request's own scope plus any legacy catch-all scope whose blocks still
397
+ * apply to everything. Without one — reconciliation runs with no request —
398
+ * every scope whose blocks must be healed.
399
+ *
400
+ * A provider that scopes backoff by model family must implement this, or a
401
+ * block written under one scope is invisible to requests and to healing.
402
+ */
403
+ blockScopes?(context?: CredentialRankingContext): string[];
394
404
  /** Fallback window durations (ms) when limits don't specify durationMs. */
395
405
  windowDefaults: {
396
406
  primaryMs: number;
@@ -68,11 +68,13 @@ export function getStreamFirstEventTimeoutMs(
68
68
  * `"0"` disable) wins outright. Otherwise the resolved idle (caller-supplied
69
69
  * `idleTimeoutMs` — which itself already encompasses per-call
70
70
  * `streamIdleTimeoutMs` or `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` resolved
71
- * upstream) floors the first-event budget so slow local OpenAI-compatible
72
- * servers are not undercut by a shorter `PI_STREAM_FIRST_EVENT_TIMEOUT_MS`
73
- * or the global default during prompt processing.
71
+ * upstream) floors the first-event budget so slow OpenAI-compatible servers
72
+ * are not undercut by a shorter `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` or the
73
+ * global default during prompt processing. A zero per-provider fallback
74
+ * disables the first-event watchdog unless an environment override is set.
74
75
  *
75
- * Returns `undefined` when an explicit env knob disables the watchdog.
76
+ * Returns `0` when an explicit env knob or per-provider fallback disables the
77
+ * watchdog, preserving the sentinel through iterator timeout resolution.
76
78
  */
77
79
  export function getOpenAIStreamFirstEventTimeoutMs(
78
80
  idleTimeoutMs?: number,
@@ -80,10 +82,10 @@ export function getOpenAIStreamFirstEventTimeoutMs(
80
82
  ): number | undefined {
81
83
  const openAIFirstEventRaw = $env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS;
82
84
  if (openAIFirstEventRaw !== undefined) {
83
- return normalizeIdleTimeoutMs(openAIFirstEventRaw, fallbackMs);
85
+ return normalizeIdleTimeoutMs(openAIFirstEventRaw, fallbackMs) ?? 0;
84
86
  }
85
87
  const base = normalizeIdleTimeoutMs($env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS, fallbackMs);
86
- if (base === undefined) return undefined;
88
+ if (base === undefined || base <= 0) return 0;
87
89
  if (idleTimeoutMs === undefined || idleTimeoutMs <= 0) return base;
88
90
  return Math.max(base, idleTimeoutMs);
89
91
  }
package/src/utils.ts CHANGED
@@ -203,9 +203,12 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
203
203
  if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
204
204
  if (item.type === "reasoning") return sanitizeOpenAIResponsesReasoningItemForReplay(item);
205
205
 
206
- // Provider payload stores raw output items. Computer calls retain their stable
207
- // provider item ID; other replay items strip IDs and normalize call_id.
206
+ // Strip status only from item types whose replay input rejects output
207
+ // lifecycle metadata. Hosted built-in tool items require status for replay.
208
208
  const { id: _id, ...sanitizedItem } = item;
209
+ if (item.type === "message" || item.type === "function_call" || item.type === "custom_tool_call") {
210
+ delete sanitizedItem.status;
211
+ }
209
212
  if (item.type === "computer_call" && typeof item.id === "string") sanitizedItem.id = item.id;
210
213
  if (typeof item.call_id === "string") {
211
214
  sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds);
@@ -224,9 +227,6 @@ function sanitizeOpenAIResponsesReasoningItemForReplay(item: Record<string, unkn
224
227
  if (typeof item.encrypted_content === "string" || item.encrypted_content === null) {
225
228
  sanitizedItem.encrypted_content = item.encrypted_content;
226
229
  }
227
- if (item.status === "in_progress" || item.status === "completed" || item.status === "incomplete") {
228
- sanitizedItem.status = item.status;
229
- }
230
230
  return sanitizedItem as unknown as OpenAIResponsesReplayItem;
231
231
  }
232
232