@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/error/aws.d.ts +5 -1
  3. package/dist/types/providers/aws-credentials.d.ts +4 -3
  4. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  5. package/dist/types/providers/cursor-pi-args.d.ts +14 -0
  6. package/dist/types/providers/openai-shared.d.ts +9 -1
  7. package/dist/types/types.d.ts +7 -0
  8. package/dist/types/usage/cursor.d.ts +11 -0
  9. package/dist/types/utils/block-symbols.d.ts +12 -0
  10. package/package.json +5 -5
  11. package/src/dialect/owned-stream.ts +3 -0
  12. package/src/error/aws.ts +5 -1
  13. package/src/providers/amazon-bedrock.ts +38 -0
  14. package/src/providers/aws-credentials.ts +222 -29
  15. package/src/providers/cursor/exec-modern.ts +1 -0
  16. package/src/providers/cursor-pi-args.ts +22 -0
  17. package/src/providers/cursor.ts +81 -1
  18. package/src/providers/google-gemini-cli.ts +49 -15
  19. package/src/providers/google-shared.ts +7 -1
  20. package/src/providers/openai-codex/request-transformer.ts +38 -17
  21. package/src/providers/openai-codex-responses.ts +2 -3
  22. package/src/providers/openai-responses.ts +4 -0
  23. package/src/providers/openai-shared.ts +55 -1
  24. package/src/providers/pi-native-server.ts +1 -0
  25. package/src/providers/register-builtins.ts +18 -14
  26. package/src/registry/aws.ts +13 -6
  27. package/src/registry/oauth/callback-server.ts +93 -5
  28. package/src/stream.ts +1 -0
  29. package/src/types.ts +7 -0
  30. package/src/usage/cursor.ts +174 -42
  31. package/src/usage/kimi.ts +29 -5
  32. package/src/usage/openai-codex-reset.ts +2 -1
  33. package/src/usage/openai-codex.ts +2 -1
  34. package/src/usage/zai.ts +2 -1
  35. package/src/utils/aws-profile.ts +39 -1
  36. package/src/utils/block-symbols.ts +18 -0
  37. package/src/utils/leaked-thinking-stream.ts +3 -0
  38. 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: Bun.Server<unknown>; redirectUri: string; launchUrl: string | undefined }> {
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: Bun.Server<unknown>): number {
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 server for OAuth callback.
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): Bun.Server<unknown> {
283
- const hostname = this.callbackHostname === DEFAULT_HOSTNAME ? "127.0.0.1" : this.callbackHostname;
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
@@ -73,44 +73,145 @@ function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
73
73
  return undefined;
74
74
  }
75
75
 
76
- export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
77
- if (!isRecord(payload) || !isRecord(payload.individualUsage) || !isRecord(payload.individualUsage.overall)) {
78
- return null;
79
- }
80
- const individual = payload.individualUsage.overall;
81
- if (individual.enabled === false) return null;
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(individual.used);
84
- const reportedRemaining = toNumber(individual.remaining);
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(individual.limit);
87
+ const limit = toNumber(bucket.limit);
88
88
 
89
- let amount: UsageAmount;
90
- if (individual.limit === null || individual.limit === undefined) {
89
+ if (bucket.limit === null || bucket.limit === undefined) {
91
90
  if (!hasValidUsed) return null;
92
- amount = { used: reportedUsed / 100, unit: "usd" };
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
- 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;
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
- return null;
160
+ const cents = parseCursorCentsBucket(bucket);
161
+ if (cents) result.fallback = cents;
104
162
  }
105
- const remaining = Math.max(0, limit - used);
106
- amount = {
107
- used: used / 100,
108
- limit: limit / 100,
109
- remaining: remaining / 100,
110
- usedFraction: used / limit,
111
- remainingFraction: remaining / limit,
112
- unit: "usd",
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 limitEntry: UsageLimit = {
123
- id: "cursor:usd:individual-overall",
124
- label: "Personal Usage",
125
- scope: {
126
- provider: "cursor",
127
- windowId: window.id,
128
- },
129
- window,
130
- amount,
131
- ...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}),
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: [limitEntry],
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 * 60_000;
90
- else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * 3_600_000;
91
- else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * 86_400_000;
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: duration !== undefined && timeUnit ? `${duration}${timeUnit.toLowerCase()}` : "default",
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) rows.push(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": "OpenCode-Status-Plugin/1.0",
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": "OpenCode-Status-Plugin/1.0",
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": "OpenCode-Status-Plugin/1.0",
232
+ "User-Agent": USER_AGENT,
232
233
  };
233
234
 
234
235
  let payload: ZaiQuotaPayload | null = null;
@@ -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
- const merged = { ...(configIni?.[selectedProfile] ?? {}), ...(credentialsIni?.[selectedProfile] ?? {}) };
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 packageJson from "../../package.json" with { type: "json" };
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": `Oh-My-Pi/${packageJson.version}`,
5
+ "User-Agent": USER_AGENT,
6
6
  "HTTP-Referer": "https://omp.sh/",
7
- "X-OpenRouter-Title": "Oh-My-Pi",
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",