@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.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/dist/types/auth-broker/client.d.ts +7 -1
- package/dist/types/auth-broker/remote-store.d.ts +4 -1
- package/dist/types/auth-broker/types.d.ts +6 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +41 -0
- package/dist/types/auth-storage.d.ts +55 -0
- package/dist/types/providers/anthropic.d.ts +1 -7
- package/dist/types/providers/claude-code-fingerprint.d.ts +15 -0
- package/dist/types/providers/cursor.d.ts +30 -7
- package/dist/types/providers/openai-codex/request-transformer.d.ts +5 -4
- package/dist/types/providers/openai-codex-responses.d.ts +3 -0
- package/dist/types/registry/alibaba-token-plan.d.ts +11 -0
- package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
- package/dist/types/registry/oauth/anthropic.d.ts +1 -0
- package/dist/types/registry/oauth/index.d.ts +1 -0
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/types.d.ts +71 -1
- package/dist/types/usage/minimax-code.d.ts +1 -0
- package/dist/types/usage.d.ts +10 -0
- package/dist/types/utils/idle-iterator.d.ts +6 -4
- package/package.json +4 -4
- package/src/auth-broker/client.ts +24 -1
- package/src/auth-broker/remote-store.ts +12 -0
- package/src/auth-broker/server.ts +7 -0
- package/src/auth-broker/types.ts +7 -0
- package/src/auth-broker/wire-schemas.ts +23 -0
- package/src/auth-storage.ts +279 -29
- package/src/error/flags.ts +1 -1
- package/src/providers/__tests__/kimi-code-thinking.test.ts +40 -5
- package/src/providers/amazon-bedrock.ts +3 -4
- package/src/providers/anthropic.ts +98 -32
- package/src/providers/azure-openai-responses.ts +36 -5
- package/src/providers/claude-code-fingerprint.ts +19 -0
- package/src/providers/cursor.ts +525 -40
- package/src/providers/openai-codex/request-transformer.ts +27 -7
- package/src/providers/openai-codex-responses.ts +37 -14
- package/src/providers/openai-completions.ts +2 -1
- package/src/providers/openai-responses.ts +16 -9
- package/src/providers/openai-shared.ts +12 -1
- package/src/registry/alibaba-token-plan.ts +103 -21
- package/src/registry/oauth/anthropic-constants.ts +12 -0
- package/src/registry/oauth/anthropic.ts +3 -1
- package/src/registry/oauth/index.ts +1 -0
- package/src/registry/oauth/types.ts +8 -0
- package/src/stream.ts +7 -2
- package/src/types.ts +79 -1
- package/src/usage/alibaba-token-plan.ts +34 -6
- package/src/usage/claude.ts +7 -2
- package/src/usage/minimax-code.ts +280 -19
- package/src/usage/openai-codex.ts +61 -14
- package/src/usage.ts +10 -0
- package/src/utils/idle-iterator.ts +8 -6
- 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({
|
|
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(
|
|
155
|
-
parseResetTime(
|
|
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(
|
|
163
|
-
parseResetTime(
|
|
190
|
+
parseUsedFraction(responseData.per1WeekPercentage),
|
|
191
|
+
parseResetTime(responseData.per1WeekResetTime),
|
|
164
192
|
accountId,
|
|
165
193
|
),
|
|
166
194
|
].filter((limit): limit is UsageLimit => limit !== undefined);
|
package/src/usage/claude.ts
CHANGED
|
@@ -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/
|
|
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 {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* MiniMax
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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,
|
|
15
|
-
if (params.provider !==
|
|
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:
|
|
287
|
+
id: INTL_PROVIDER,
|
|
26
288
|
fetchUsage: fetchMiniMaxCodeUsage,
|
|
27
|
-
supports:
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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 =
|
|
593
|
+
const direct = limits.find(l => l.id === `openai-codex:${key}`);
|
|
547
594
|
if (direct) return direct;
|
|
548
|
-
const byId =
|
|
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
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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 `
|
|
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
|
|
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
|
-
//
|
|
207
|
-
//
|
|
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
|
|