@oh-my-pi/pi-ai 17.2.12 → 17.2.13
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 +19 -0
- package/dist/types/error/aws.d.ts +5 -1
- package/dist/types/providers/aws-credentials.d.ts +4 -3
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +14 -0
- package/dist/types/providers/openai-shared.d.ts +9 -1
- package/dist/types/types.d.ts +7 -0
- package/dist/types/usage/cursor.d.ts +11 -0
- package/dist/types/utils/block-symbols.d.ts +12 -0
- package/package.json +5 -5
- package/src/dialect/owned-stream.ts +3 -0
- package/src/error/aws.ts +5 -1
- package/src/providers/amazon-bedrock.ts +38 -0
- package/src/providers/aws-credentials.ts +222 -29
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +22 -0
- package/src/providers/cursor.ts +81 -1
- package/src/providers/google-gemini-cli.ts +49 -15
- package/src/providers/google-shared.ts +7 -1
- package/src/providers/openai-codex/request-transformer.ts +38 -17
- package/src/providers/openai-codex-responses.ts +2 -3
- package/src/providers/openai-responses.ts +4 -0
- package/src/providers/openai-shared.ts +55 -1
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/register-builtins.ts +18 -14
- package/src/registry/aws.ts +13 -6
- package/src/registry/oauth/callback-server.ts +93 -5
- package/src/stream.ts +1 -0
- package/src/types.ts +7 -0
- package/src/usage/cursor.ts +174 -42
- package/src/usage/kimi.ts +29 -5
- package/src/usage/openai-codex-reset.ts +2 -1
- package/src/usage/openai-codex.ts +2 -1
- package/src/usage/zai.ts +2 -1
- package/src/utils/aws-profile.ts +39 -1
- package/src/utils/block-symbols.ts +18 -0
- package/src/utils/leaked-thinking-stream.ts +3 -0
- package/src/utils/openrouter-headers.ts +3 -3
|
@@ -17,6 +17,15 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
17
17
|
const DEFAULT_TIMEOUT = 300_000;
|
|
18
18
|
const DEFAULT_HOSTNAME = "localhost";
|
|
19
19
|
const CALLBACK_PATH = "/callback";
|
|
20
|
+
const IPV4_LOOPBACK = "127.0.0.1";
|
|
21
|
+
const IPV6_LOOPBACK = "::1";
|
|
22
|
+
/**
|
|
23
|
+
* How many times a random-port bind may be redrawn when the ephemeral port it
|
|
24
|
+
* landed on is already held on {@link IPV6_LOOPBACK} by that exact address.
|
|
25
|
+
* Small on purpose: each redraw picks a fresh port, so a repeat collision is
|
|
26
|
+
* vanishingly unlikely.
|
|
27
|
+
*/
|
|
28
|
+
const IPV6_COMPANION_ATTEMPTS = 4;
|
|
20
29
|
/**
|
|
21
30
|
* Path served by {@link OAuthCallbackFlow} that 302-redirects to the pending
|
|
22
31
|
* authorization URL. Kept out of {@link OAuthCallbackFlowOptions} because it
|
|
@@ -28,6 +37,27 @@ const LAUNCH_PATH = "/launch";
|
|
|
28
37
|
|
|
29
38
|
export type CallbackResult = { code: string; state: string };
|
|
30
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Subset of {@link Bun.Server} this flow depends on, so a `localhost` flow can
|
|
42
|
+
* hand back one listener per loopback address family while still looking like a
|
|
43
|
+
* single server to callers.
|
|
44
|
+
*/
|
|
45
|
+
interface CallbackServer {
|
|
46
|
+
readonly port: Bun.Server<unknown>["port"];
|
|
47
|
+
stop: Bun.Server<unknown>["stop"];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a failed bind means "another process already holds this port".
|
|
52
|
+
* Bun surfaces `EADDRINUSE` on the error's `code` where the platform reports
|
|
53
|
+
* it, and otherwise only in the message, so both are checked.
|
|
54
|
+
*/
|
|
55
|
+
function isAddressInUse(error: unknown): boolean {
|
|
56
|
+
const code = (error as { code?: unknown } | null | undefined)?.code;
|
|
57
|
+
if (typeof code === "string") return code === "EADDRINUSE";
|
|
58
|
+
return error instanceof Error && /EADDRINUSE|in use/i.test(error.message);
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
export interface OAuthCallbackFlowOptions {
|
|
32
62
|
preferredPort: number;
|
|
33
63
|
callbackPath?: string;
|
|
@@ -192,7 +222,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
192
222
|
*/
|
|
193
223
|
async #startCallbackServer(
|
|
194
224
|
expectedState: string,
|
|
195
|
-
): Promise<{ server:
|
|
225
|
+
): Promise<{ server: CallbackServer; redirectUri: string; launchUrl: string | undefined }> {
|
|
196
226
|
try {
|
|
197
227
|
const server = this.#createServer(this.preferredPort, expectedState);
|
|
198
228
|
// `preferredPort: 0` opts into a random port — read the actual bound
|
|
@@ -233,7 +263,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
233
263
|
* but every callback flow uses TCP; a missing port here indicates a
|
|
234
264
|
* configuration error rather than a fallback case.
|
|
235
265
|
*/
|
|
236
|
-
#resolveServerPort(server:
|
|
266
|
+
#resolveServerPort(server: CallbackServer): number {
|
|
237
267
|
const port = server.port;
|
|
238
268
|
if (typeof port !== "number") {
|
|
239
269
|
throw new AIError.ConfigurationError(
|
|
@@ -277,10 +307,68 @@ export abstract class OAuthCallbackFlow {
|
|
|
277
307
|
}
|
|
278
308
|
|
|
279
309
|
/**
|
|
280
|
-
* Create HTTP
|
|
310
|
+
* Create the HTTP listener(s) for the OAuth callback.
|
|
311
|
+
*
|
|
312
|
+
* `localhost` is not a single endpoint: it resolves to both
|
|
313
|
+
* {@link IPV4_LOOPBACK} and {@link IPV6_LOOPBACK}, and clients commonly try
|
|
314
|
+
* `::1` first. Binding only the IPv4 literal hands the authorization code to
|
|
315
|
+
* whatever holds the IPv6 loopback on the same port — a dev server on
|
|
316
|
+
* `*:3000` is the common case — which answers from its own routes while this
|
|
317
|
+
* flow waits out the full {@link DEFAULT_TIMEOUT}. Nothing detects it either:
|
|
318
|
+
* a specific-address bind coexists with another process's wildcard bind, so
|
|
319
|
+
* `Bun.serve` reports the port as free and the random-port fallback in
|
|
320
|
+
* {@link #startCallbackServer} never runs.
|
|
321
|
+
*
|
|
322
|
+
* Binding both loopback literals fixes the delivery rather than dodging it:
|
|
323
|
+
* the kernel routes a connection to the most specific matching bind, so our
|
|
324
|
+
* `::1` listener receives `localhost` traffic that would otherwise reach a
|
|
325
|
+
* process bound to the `::` wildcard. Both listeners answer the same routes,
|
|
326
|
+
* so which family the client resolves stops mattering.
|
|
327
|
+
*
|
|
328
|
+
* A genuine collision — another process on exactly this loopback address and
|
|
329
|
+
* port — still raises EADDRINUSE and reaches the caller's in-use policy. A
|
|
330
|
+
* host that cannot bind `::1` at all (IPv6 disabled, address unavailable) is
|
|
331
|
+
* not a collision: the IPv4 listener is the only reachable endpoint there, so
|
|
332
|
+
* it serves alone.
|
|
281
333
|
*/
|
|
282
|
-
#createServer(port: number, expectedState: string):
|
|
283
|
-
|
|
334
|
+
#createServer(port: number, expectedState: string): CallbackServer {
|
|
335
|
+
if (this.callbackHostname !== DEFAULT_HOSTNAME) {
|
|
336
|
+
return this.#serve(this.callbackHostname, port, expectedState);
|
|
337
|
+
}
|
|
338
|
+
for (let attempt = 0; ; attempt++) {
|
|
339
|
+
const primary = this.#serve(IPV4_LOOPBACK, port, expectedState);
|
|
340
|
+
const boundPort = primary.port;
|
|
341
|
+
// A non-TCP endpoint has no port for the companion to target;
|
|
342
|
+
// #resolveServerPort reports that case precisely.
|
|
343
|
+
if (typeof boundPort !== "number") return primary;
|
|
344
|
+
let companion: Bun.Server<unknown>;
|
|
345
|
+
try {
|
|
346
|
+
companion = this.#serve(IPV6_LOOPBACK, boundPort, expectedState);
|
|
347
|
+
} catch (cause) {
|
|
348
|
+
if (!isAddressInUse(cause)) return primary;
|
|
349
|
+
void primary.stop(true);
|
|
350
|
+
// A pinned port has no alternative, so surface it as in use and let
|
|
351
|
+
// the caller apply its fallback or diagnostic policy. A random port
|
|
352
|
+
// can just be redrawn, since only that one number clashed.
|
|
353
|
+
if (port !== 0 || attempt >= IPV6_COMPANION_ATTEMPTS) throw cause;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// One server to callers. The IPv4 listener stays authoritative for
|
|
357
|
+
// `port` because the companion was bound to the port it resolved.
|
|
358
|
+
return {
|
|
359
|
+
get port() {
|
|
360
|
+
return primary.port;
|
|
361
|
+
},
|
|
362
|
+
stop: (closeActiveConnections?: boolean) => {
|
|
363
|
+
void companion.stop(closeActiveConnections);
|
|
364
|
+
return primary.stop(closeActiveConnections);
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Bind one loopback listener serving the callback and launch routes. */
|
|
371
|
+
#serve(hostname: string, port: number, expectedState: string): Bun.Server<unknown> {
|
|
284
372
|
return Bun.serve({
|
|
285
373
|
hostname,
|
|
286
374
|
port,
|
package/src/stream.ts
CHANGED
|
@@ -1508,6 +1508,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1508
1508
|
execHandlers: options?.execHandlers,
|
|
1509
1509
|
fetch: options?.fetch,
|
|
1510
1510
|
fallbacks: options?.fallbacks,
|
|
1511
|
+
acceptEmptyResponse: options?.acceptEmptyResponse,
|
|
1511
1512
|
...simpleProviderOptions,
|
|
1512
1513
|
};
|
|
1513
1514
|
|
package/src/types.ts
CHANGED
|
@@ -552,6 +552,13 @@ export interface StreamOptions {
|
|
|
552
552
|
* Optional retry delay hook for tests and transports that need custom scheduling.
|
|
553
553
|
*/
|
|
554
554
|
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
555
|
+
/**
|
|
556
|
+
* Accept a Google `STOP` response with no visible text or tool call as a
|
|
557
|
+
* successful completion. Passive callers such as advisors use this because
|
|
558
|
+
* silence is a valid result; interactive agent turns retain empty-response
|
|
559
|
+
* retries by default. Ignored by non-Google providers.
|
|
560
|
+
*/
|
|
561
|
+
acceptEmptyResponse?: boolean;
|
|
555
562
|
/**
|
|
556
563
|
* Optional `fetch` implementation override. Providers route every HTTP
|
|
557
564
|
* request — direct calls, SDK clients, and retry helpers — through this
|
package/src/usage/cursor.ts
CHANGED
|
@@ -73,44 +73,145 @@ function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
|
|
|
73
73
|
return undefined;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
76
|
+
/**
|
|
77
|
+
* Parse a Cursor cents bucket (`used`/`limit`/`remaining` in USD cents).
|
|
78
|
+
* Returns null for disabled or non-positive / malformed buckets.
|
|
79
|
+
*/
|
|
80
|
+
function parseCursorCentsBucket(bucket: Record<string, unknown>): UsageAmount | null {
|
|
81
|
+
if (bucket.enabled === false) return null;
|
|
82
82
|
|
|
83
|
-
const reportedUsed = toNumber(
|
|
84
|
-
const reportedRemaining = toNumber(
|
|
83
|
+
const reportedUsed = toNumber(bucket.used);
|
|
84
|
+
const reportedRemaining = toNumber(bucket.remaining);
|
|
85
85
|
const hasValidUsed = reportedUsed !== undefined && reportedUsed >= 0;
|
|
86
86
|
const hasValidRemaining = reportedRemaining !== undefined && reportedRemaining >= 0;
|
|
87
|
-
const limit = toNumber(
|
|
87
|
+
const limit = toNumber(bucket.limit);
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
if (individual.limit === null || individual.limit === undefined) {
|
|
89
|
+
if (bucket.limit === null || bucket.limit === undefined) {
|
|
91
90
|
if (!hasValidUsed) return null;
|
|
92
|
-
|
|
91
|
+
return { used: reportedUsed / 100, unit: "usd" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (limit === undefined || limit <= 0) return null;
|
|
95
|
+
let used: number;
|
|
96
|
+
if (reportedUsed !== undefined && reportedUsed > 0) {
|
|
97
|
+
used = reportedUsed;
|
|
98
|
+
} else if (hasValidRemaining && reportedRemaining < limit) {
|
|
99
|
+
used = Math.max(0, limit - reportedRemaining);
|
|
100
|
+
} else if (hasValidUsed) {
|
|
101
|
+
used = reportedUsed;
|
|
93
102
|
} else {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const remaining = Math.max(0, limit - used);
|
|
106
|
+
return {
|
|
107
|
+
used: used / 100,
|
|
108
|
+
limit: limit / 100,
|
|
109
|
+
remaining: remaining / 100,
|
|
110
|
+
usedFraction: used / limit,
|
|
111
|
+
remainingFraction: remaining / limit,
|
|
112
|
+
unit: "usd",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Cursor's dashboard does not treat plan.used / plan.limit as the visible %.
|
|
118
|
+
* Pro+ shows separate quota pools (not one shared percent):
|
|
119
|
+
* - Cursor Models ← autoPercentUsed
|
|
120
|
+
* (includes Cursor Grok 4.5 and Composer 2.5)
|
|
121
|
+
* - Other Models ← apiPercentUsed (separate included-$ pool; different quota)
|
|
122
|
+
* Prefer those fractions when present; fall back to cents only for older overall buckets.
|
|
123
|
+
*/
|
|
124
|
+
function parseCursorPlanDashboardAmounts(bucket: Record<string, unknown>): {
|
|
125
|
+
auto?: UsageAmount;
|
|
126
|
+
api?: UsageAmount;
|
|
127
|
+
fallback?: UsageAmount;
|
|
128
|
+
} {
|
|
129
|
+
if (bucket.enabled === false) return {};
|
|
130
|
+
|
|
131
|
+
const limitCents = toNumber(bucket.limit);
|
|
132
|
+
const limitUsd = limitCents !== undefined && limitCents > 0 ? limitCents / 100 : undefined;
|
|
133
|
+
const autoPct = toNumber(bucket.autoPercentUsed);
|
|
134
|
+
const apiPct = toNumber(bucket.apiPercentUsed);
|
|
135
|
+
const totalPct = toNumber(bucket.totalPercentUsed);
|
|
136
|
+
|
|
137
|
+
const fromPercent = (pct: number, withLimit: boolean): UsageAmount => {
|
|
138
|
+
const usedFraction = Math.max(0, pct) / 100;
|
|
139
|
+
if (withLimit && limitUsd !== undefined) {
|
|
140
|
+
const used = limitUsd * usedFraction;
|
|
141
|
+
return {
|
|
142
|
+
used,
|
|
143
|
+
limit: limitUsd,
|
|
144
|
+
remaining: Math.max(0, limitUsd - used),
|
|
145
|
+
usedFraction,
|
|
146
|
+
remainingFraction: Math.max(0, 1 - usedFraction),
|
|
147
|
+
unit: "usd",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { used: usedFraction * 100, usedFraction, unit: "percent" };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const result: { auto?: UsageAmount; api?: UsageAmount; fallback?: UsageAmount } = {};
|
|
154
|
+
if (autoPct !== undefined) result.auto = fromPercent(autoPct, false);
|
|
155
|
+
if (apiPct !== undefined) result.api = fromPercent(apiPct, true);
|
|
156
|
+
if (!result.auto && !result.api) {
|
|
157
|
+
if (totalPct !== undefined) {
|
|
158
|
+
result.fallback = fromPercent(totalPct, true);
|
|
102
159
|
} else {
|
|
103
|
-
|
|
160
|
+
const cents = parseCursorCentsBucket(bucket);
|
|
161
|
+
if (cents) result.fallback = cents;
|
|
104
162
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pushCursorPlanRails(limits: UsageLimit[], bucket: Record<string, unknown>, window: UsageWindow): void {
|
|
168
|
+
const rails = parseCursorPlanDashboardAmounts(bucket);
|
|
169
|
+
if (rails.auto) {
|
|
170
|
+
limits.push({
|
|
171
|
+
id: "cursor:usd:individual-auto",
|
|
172
|
+
label: "Cursor Models",
|
|
173
|
+
scope: { provider: "cursor", windowId: window.id },
|
|
174
|
+
window,
|
|
175
|
+
amount: rails.auto,
|
|
176
|
+
...(rails.auto.usedFraction !== undefined ? { status: usageStatus(rails.auto.usedFraction) } : {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (rails.api) {
|
|
180
|
+
limits.push({
|
|
181
|
+
id: "cursor:usd:individual-api",
|
|
182
|
+
label: "Other Models",
|
|
183
|
+
scope: { provider: "cursor", windowId: window.id },
|
|
184
|
+
window,
|
|
185
|
+
amount: rails.api,
|
|
186
|
+
...(rails.api.usedFraction !== undefined ? { status: usageStatus(rails.api.usedFraction) } : {}),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (rails.fallback) {
|
|
190
|
+
limits.push({
|
|
191
|
+
id: "cursor:usd:individual-plan",
|
|
192
|
+
label: "Personal Usage",
|
|
193
|
+
scope: { provider: "cursor", windowId: window.id },
|
|
194
|
+
window,
|
|
195
|
+
amount: rails.fallback,
|
|
196
|
+
...(rails.fallback.usedFraction !== undefined ? { status: usageStatus(rails.fallback.usedFraction) } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Cursor's `/api/usage-summary` has shipped two personal-bucket shapes:
|
|
203
|
+
* - Enterprise/team dashboards historically exposed `individualUsage.overall`
|
|
204
|
+
* - Current Pro / Pro+ / Ultra dashboards expose `individualUsage.plan`
|
|
205
|
+
* (plus optional `onDemand`)
|
|
206
|
+
*
|
|
207
|
+
* Prefer a *usable* overall bucket; if overall is absent/disabled/malformed,
|
|
208
|
+
* fall through to plan rails (`autoPercentUsed` / `apiPercentUsed`). Always
|
|
209
|
+
* consider on-demand afterward so a valid on-demand meter is not dropped when
|
|
210
|
+
* the included plan bucket is empty.
|
|
211
|
+
*/
|
|
212
|
+
export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
|
|
213
|
+
if (!isRecord(payload) || !isRecord(payload.individualUsage)) {
|
|
214
|
+
return null;
|
|
114
215
|
}
|
|
115
216
|
|
|
116
217
|
const resetsAt = deriveResetsAt(payload);
|
|
@@ -119,21 +220,52 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no
|
|
|
119
220
|
label: "Monthly",
|
|
120
221
|
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
121
222
|
};
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
amount
|
|
131
|
-
|
|
132
|
-
|
|
223
|
+
const limits: UsageLimit[] = [];
|
|
224
|
+
|
|
225
|
+
const overall = isRecord(payload.individualUsage.overall) ? payload.individualUsage.overall : null;
|
|
226
|
+
const plan = isRecord(payload.individualUsage.plan) ? payload.individualUsage.plan : null;
|
|
227
|
+
|
|
228
|
+
// Prefer a usable overall bucket; if it is disabled/malformed, fall through to plan.
|
|
229
|
+
let usedOverall = false;
|
|
230
|
+
if (overall) {
|
|
231
|
+
const amount = parseCursorCentsBucket(overall);
|
|
232
|
+
if (amount) {
|
|
233
|
+
usedOverall = true;
|
|
234
|
+
limits.push({
|
|
235
|
+
id: "cursor:usd:individual-overall",
|
|
236
|
+
label: "Personal Usage",
|
|
237
|
+
scope: { provider: "cursor", windowId: window.id },
|
|
238
|
+
window,
|
|
239
|
+
amount,
|
|
240
|
+
...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!usedOverall && plan) {
|
|
245
|
+
pushCursorPlanRails(limits, plan, window);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Keep on-demand even when the included plan/overall bucket is absent or unusable.
|
|
249
|
+
if (isRecord(payload.individualUsage.onDemand)) {
|
|
250
|
+
const onDemandAmount = parseCursorCentsBucket(payload.individualUsage.onDemand);
|
|
251
|
+
if (onDemandAmount && onDemandAmount.limit !== undefined && onDemandAmount.limit > 0) {
|
|
252
|
+
limits.push({
|
|
253
|
+
id: "cursor:usd:individual-ondemand",
|
|
254
|
+
label: "On-Demand Usage",
|
|
255
|
+
scope: { provider: "cursor", windowId: window.id },
|
|
256
|
+
window,
|
|
257
|
+
amount: onDemandAmount,
|
|
258
|
+
...(onDemandAmount.usedFraction !== undefined ? { status: usageStatus(onDemandAmount.usedFraction) } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (limits.length === 0) return null;
|
|
264
|
+
|
|
133
265
|
return {
|
|
134
266
|
provider: "cursor",
|
|
135
267
|
fetchedAt,
|
|
136
|
-
limits
|
|
268
|
+
limits,
|
|
137
269
|
raw: payload,
|
|
138
270
|
};
|
|
139
271
|
}
|
package/src/usage/kimi.ts
CHANGED
|
@@ -77,6 +77,23 @@ function formatDurationLabel(duration: number, timeUnit: string): string | undef
|
|
|
77
77
|
return undefined;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
const MINUTE_MS = 60_000;
|
|
81
|
+
const HOUR_MS = 3_600_000;
|
|
82
|
+
const DAY_MS = 86_400_000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Status-line and ranking consumers match on canonical window ids ("5h",
|
|
86
|
+
* "7d"), so derive the id from the reported span: the 300-minute burst window
|
|
87
|
+
* surfaces as "5h" instead of "300time_unit_minute". Mirrors the
|
|
88
|
+
* intervalWindowId convention in minimax-code.ts.
|
|
89
|
+
*/
|
|
90
|
+
function canonicalWindowId(durationMs: number): string {
|
|
91
|
+
if (durationMs > 0 && durationMs % DAY_MS === 0) return `${durationMs / DAY_MS}d`;
|
|
92
|
+
if (durationMs > 0 && durationMs % HOUR_MS === 0) return `${durationMs / HOUR_MS}h`;
|
|
93
|
+
const minutes = Math.round(durationMs / MINUTE_MS);
|
|
94
|
+
return minutes > 0 ? `${minutes}m` : "default";
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
function buildWindow(windowData: Record<string, unknown>, nowMs: number): UsageWindow | undefined {
|
|
81
98
|
const duration = toNumber(windowData.duration);
|
|
82
99
|
const timeUnit = typeof windowData.timeUnit === "string" ? windowData.timeUnit : "";
|
|
@@ -86,14 +103,15 @@ function buildWindow(windowData: Record<string, unknown>, nowMs: number): UsageW
|
|
|
86
103
|
if (duration === undefined && !label && !resetsAt) return undefined;
|
|
87
104
|
let durationMs: number | undefined;
|
|
88
105
|
if (duration !== undefined) {
|
|
89
|
-
if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration *
|
|
90
|
-
else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration *
|
|
91
|
-
else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration *
|
|
106
|
+
if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration * MINUTE_MS;
|
|
107
|
+
else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * HOUR_MS;
|
|
108
|
+
else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * DAY_MS;
|
|
109
|
+
else if (timeUnit.toUpperCase().includes("WEEK")) durationMs = duration * 7 * DAY_MS;
|
|
92
110
|
else if (timeUnit.toUpperCase().includes("SECOND")) durationMs = duration * 1000;
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
return {
|
|
96
|
-
id:
|
|
114
|
+
id: durationMs !== undefined ? canonicalWindowId(durationMs) : "default",
|
|
97
115
|
label: label ?? "Usage window",
|
|
98
116
|
durationMs,
|
|
99
117
|
resetsAt,
|
|
@@ -177,7 +195,13 @@ function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRo
|
|
|
177
195
|
|
|
178
196
|
if (isRecord(data.usage)) {
|
|
179
197
|
const summary = buildUsageRow(data.usage, "Total quota", nowMs);
|
|
180
|
-
if (summary)
|
|
198
|
+
if (summary) {
|
|
199
|
+
// Kimi Code's aggregate quota resets weekly, but the payload carries
|
|
200
|
+
// only `resetTime` and no duration. Attach the canonical weekly
|
|
201
|
+
// window explicitly so status-line/ranking consumers recognize it.
|
|
202
|
+
summary.window = { id: "7d", label: "7 Day", resetsAt: summary.resetsAt };
|
|
203
|
+
rows.push(summary);
|
|
204
|
+
}
|
|
181
205
|
}
|
|
182
206
|
|
|
183
207
|
if (Array.isArray(data.limits)) {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* share one wire contract.
|
|
20
20
|
*/
|
|
21
21
|
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
|
|
22
|
+
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
|
22
23
|
import type { FetchImpl } from "../types";
|
|
23
24
|
import { isRecord } from "../utils";
|
|
24
25
|
import { normalizeCodexBaseUrl } from "./openai-codex-base-url";
|
|
@@ -89,7 +90,7 @@ function buildUrl(baseUrl: string | undefined, routePath: string): string {
|
|
|
89
90
|
function buildHeaders(auth: CodexResetAuth, json: boolean): Record<string, string> {
|
|
90
91
|
const headers: Record<string, string> = {
|
|
91
92
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
92
|
-
"User-Agent":
|
|
93
|
+
"User-Agent": USER_AGENT,
|
|
93
94
|
};
|
|
94
95
|
if (auth.accountId) headers["ChatGPT-Account-Id"] = auth.accountId;
|
|
95
96
|
if (json) headers["Content-Type"] = "application/json";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
|
|
3
|
+
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
|
3
4
|
import type {
|
|
4
5
|
CredentialRankingContext,
|
|
5
6
|
CredentialRankingStrategy,
|
|
@@ -417,7 +418,7 @@ export const openaiCodexUsageProvider: UsageProvider = {
|
|
|
417
418
|
|
|
418
419
|
const headers: Record<string, string> = {
|
|
419
420
|
Authorization: `Bearer ${accessToken}`,
|
|
420
|
-
"User-Agent":
|
|
421
|
+
"User-Agent": USER_AGENT,
|
|
421
422
|
};
|
|
422
423
|
if (accountId) {
|
|
423
424
|
headers["ChatGPT-Account-Id"] = accountId;
|
package/src/usage/zai.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
|
|
2
|
+
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import type {
|
|
3
4
|
CredentialRankingStrategy,
|
|
4
5
|
UsageAmount,
|
|
@@ -228,7 +229,7 @@ async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext):
|
|
|
228
229
|
const headers: Record<string, string> = {
|
|
229
230
|
Authorization: token,
|
|
230
231
|
"Content-Type": "application/json",
|
|
231
|
-
"User-Agent":
|
|
232
|
+
"User-Agent": USER_AGENT,
|
|
232
233
|
};
|
|
233
234
|
|
|
234
235
|
let payload: ZaiQuotaPayload | null = null;
|
package/src/utils/aws-profile.ts
CHANGED
|
@@ -78,7 +78,45 @@ export function hasConfiguredAwsProfile(profile?: string): boolean {
|
|
|
78
78
|
const configPath = $env.AWS_CONFIG_FILE || path.join(os.homedir(), ".aws", "config");
|
|
79
79
|
const credentialsIni = readAwsIniSync(credentialsPath);
|
|
80
80
|
const configIni = shouldLoadAwsSharedConfig(profile) ? readAwsIniSync(configPath) : undefined;
|
|
81
|
-
|
|
81
|
+
return profileHasCredentialSource(selectedProfile, credentialsIni, configIni, new Set());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a profile terminates in a usable credential source. Mirrors the
|
|
86
|
+
* resolver's per-profile dispatch (static keys, SSO, `credential_process`,
|
|
87
|
+
* `role_arn` chaining) so the availability probe never diverges from what
|
|
88
|
+
* `resolveAwsCredentials` can actually resolve. `role_arn` chains follow
|
|
89
|
+
* `source_profile` recursively (cycle-guarded by `seen`); MFA-gated roles are
|
|
90
|
+
* treated as unusable because non-interactive resolution cannot supply a token.
|
|
91
|
+
*/
|
|
92
|
+
function profileHasCredentialSource(
|
|
93
|
+
profile: string,
|
|
94
|
+
credentialsIni: AwsIniFile | undefined,
|
|
95
|
+
configIni: AwsIniFile | undefined,
|
|
96
|
+
seen: Set<string>,
|
|
97
|
+
): boolean {
|
|
98
|
+
if (seen.has(profile)) return false;
|
|
99
|
+
seen.add(profile);
|
|
100
|
+
const merged = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
|
|
101
|
+
if (merged.role_arn) {
|
|
102
|
+
if (merged.web_identity_token_file) return true;
|
|
103
|
+
if (merged.mfa_serial) return false;
|
|
104
|
+
if (merged.credential_source) {
|
|
105
|
+
switch (merged.credential_source) {
|
|
106
|
+
case "Environment":
|
|
107
|
+
return !!($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY);
|
|
108
|
+
case "EcsContainer":
|
|
109
|
+
return !!($env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || $env.AWS_CONTAINER_CREDENTIALS_FULL_URI);
|
|
110
|
+
case "Ec2InstanceMetadata":
|
|
111
|
+
return $env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true";
|
|
112
|
+
default:
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (merged.source_profile)
|
|
117
|
+
return profileHasCredentialSource(merged.source_profile, credentialsIni, configIni, seen);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
82
120
|
if (merged.aws_access_key_id && merged.aws_secret_access_key) return true;
|
|
83
121
|
if (merged.credential_process) return true;
|
|
84
122
|
if (!merged.sso_account_id || !merged.sso_role_name) return false;
|
|
@@ -59,6 +59,24 @@ export const kCursorExecResolved = Symbol("provider.block.cursorExecResolved");
|
|
|
59
59
|
/** Carries the resolved marker without exposing a string-keyed property. */
|
|
60
60
|
export type CursorExecResolvedCarrier = object & { [kCursorExecResolved]?: true };
|
|
61
61
|
|
|
62
|
+
/** True when a toolCall block was already executed by Cursor's exec channel. */
|
|
63
|
+
export function isCursorExecResolved(block: CursorExecResolvedCarrier | null | undefined): boolean {
|
|
64
|
+
return block?.[kCursorExecResolved] === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Copy {@link kCursorExecResolved} onto a cloned/projected toolCall block.
|
|
69
|
+
*
|
|
70
|
+
* Stream projectors (owned/in-band dialect, leaked-thinking heal) rebuild
|
|
71
|
+
* toolCall objects field-by-field. Dropping this marker lets `agent-loop.ts`
|
|
72
|
+
* re-execute a call Cursor already settled — duplicate toolResults and a
|
|
73
|
+
* second bash/write/delete. Partial-JSON is already copied explicitly; this
|
|
74
|
+
* marker is the other load-bearing symbol that must survive the same way.
|
|
75
|
+
*/
|
|
76
|
+
export function copyCursorExecResolved(target: CursorExecResolvedCarrier, source: CursorExecResolvedCarrier): void {
|
|
77
|
+
if (source[kCursorExecResolved] === true) target[kCursorExecResolved] = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* Marks a text block synthesized by cross-model thinking demotion in
|
|
64
82
|
* `transformMessages`. Converters that flatten adjacent text blocks into one
|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
} from "../types";
|
|
37
37
|
import {
|
|
38
38
|
clearStreamingPartialJson,
|
|
39
|
+
copyCursorExecResolved,
|
|
39
40
|
getStreamingPartialJson,
|
|
40
41
|
type StreamingPartialJsonCarrier,
|
|
41
42
|
setStreamingPartialJson,
|
|
@@ -49,6 +50,7 @@ function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
|
|
|
49
50
|
const block: StreamingToolCall = { ...source, arguments: source.arguments };
|
|
50
51
|
const partialJson = getStreamingPartialJson(source);
|
|
51
52
|
if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
|
|
53
|
+
copyCursorExecResolved(block, source);
|
|
52
54
|
return block;
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -57,6 +59,7 @@ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): voi
|
|
|
57
59
|
const partialJson = getStreamingPartialJson(source);
|
|
58
60
|
if (partialJson === undefined) clearStreamingPartialJson(target);
|
|
59
61
|
else setStreamingPartialJson(target, partialJson);
|
|
62
|
+
copyCursorExecResolved(target, source);
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/**
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
|
2
2
|
|
|
3
3
|
export function getOpenRouterHeaders(): Record<string, string> {
|
|
4
4
|
return {
|
|
5
|
-
"User-Agent":
|
|
5
|
+
"User-Agent": USER_AGENT,
|
|
6
6
|
"HTTP-Referer": "https://omp.sh/",
|
|
7
|
-
"X-OpenRouter-Title": "
|
|
7
|
+
"X-OpenRouter-Title": "omp",
|
|
8
8
|
"X-OpenRouter-Categories": "cli-agent",
|
|
9
9
|
"X-OpenRouter-Cache": "true",
|
|
10
10
|
"X-OpenRouter-Cache-TTL": "3600",
|