@oh-my-pi/pi-ai 18.1.18 → 18.1.20

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 CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.20] - 2026-09-13
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Windows OAuth sign-in failing on every attempt after an upgrade when a previous run left a stale native callback registration behind; handlers registered by older binaries are now recognized as owned and rolled back instead of blocking recovery ([#11967](https://github.com/can1357/oh-my-pi/pull/11967) by [@H4vC](https://github.com/H4vC)).
10
+
11
+ ## [18.1.19] - 2026-09-12
12
+
13
+ ### Added
14
+
15
+ - Charm Hyper accounts now report their remaining prepaid credit balance in `/usage` ([#11656](https://github.com/can1357/oh-my-pi/pull/11656) by [@oldschoola](https://github.com/oldschoola)).
16
+
17
+ ### Fixed
18
+
19
+ - Fixed Kimi Code's 7-day rate-limiting window being mislabeled as "Total quota" in `omp usage`, causing accounts whose monthly subscription pool is exhausted to appear 100% free while chat completions fail; parsed `totalQuota` add-on packs into the true "Total quota" row when present, and recognized Kimi's HTTP 403 `access_terminated_error` as a credential-rotatable usage limit. ([#11827](https://github.com/can1357/oh-my-pi/pull/11827) by [@revofusion](https://github.com/revofusion))
20
+ - Fixed provider streams that die after emitting `toolcall_start` but before any argument content failing validation with empty `{}` arguments; the uncommitted attempt is now discarded and retried ([#11823](https://github.com/can1357/oh-my-pi/pull/11823) by [@justdoGIT](https://github.com/justdoGIT)).
21
+ - Fixed Windows `zcode://` (Z.AI coding-plan) OAuth sign-in never completing after a successful browser authorization: the native callback handler is now registered with a path the Windows shell can launch, so the `zcode://zai-auth/callback` redirect reaches omp instead of being silently dropped by the browser ([#11907](https://github.com/can1357/oh-my-pi/pull/11907) by [@oldschoola](https://github.com/oldschoola)).
22
+ - Codex OAuth login now accepts valid account tokens that expose an email but omit `chatgpt_account_id`, without fabricating a workspace header ([#11847](https://github.com/can1357/oh-my-pi/pull/11847) by [@nguyennguyenit](https://github.com/nguyennguyenit)).
23
+ - Fixed Muse Code login failing when Meta returns no assigned subscription tier (`subs_tier_id`/`subs_tier_name` as null); sign-in now succeeds and usage is reported without a tier ([#11843](https://github.com/can1357/oh-my-pi/pull/11843) by [@John-Cusack](https://github.com/John-Cusack)).
24
+
5
25
  ## [18.1.18] - 2026-09-11
6
26
 
7
27
  ### Added
@@ -35,7 +55,9 @@
35
55
 
36
56
  ### Fixed
37
57
 
58
+ - Anthropic `credits_required` responses now rotate to another account instead of retrying the same one: the entitlement wall is a quota outcome, so a session no longer repeats the request against an account that cannot serve the model ([#11333](https://github.com/can1357/oh-my-pi/pull/11333) by [@AshishKumar4](https://github.com/AshishKumar4)).
38
59
  - Codex SSE streams that end without a terminal completion event now retry when replay-safe and remain transient errors when partial output prevents replay ([#11349](https://github.com/can1357/oh-my-pi/issues/11349)).
60
+ - Anthropic subscription usage now falls back to the canonical `api.anthropic.com` OAuth usage endpoint when a custom provider `baseUrl` does not serve it, instead of leaving the report to rate-limit headers — those carry the model-scoped weekly window only on responses for that model family, so `/usage` could report a scoped window far below its real utilization.
39
61
 
40
62
  ## [18.1.15] - 2026-09-08
41
63
 
@@ -6,8 +6,8 @@ declare const museCodeKeyResponseSchema: import("@oh-my-pi/omptype").FluentType<
6
6
  is_subs_active?: boolean | undefined;
7
7
  require_payment?: boolean | undefined;
8
8
  require_payment_action_url?: string | undefined;
9
- subs_tier_id?: string | undefined;
10
- subs_tier_name?: string | undefined;
9
+ subs_tier_id?: string | null | undefined;
10
+ subs_tier_name?: string | null | undefined;
11
11
  subs_usage?: {
12
12
  weekly?: {
13
13
  resets_at?: string | number | undefined;
@@ -28,8 +28,8 @@ declare const museCodeKeyResponseSchema: import("@oh-my-pi/omptype").FluentType<
28
28
  is_subs_active?: boolean | undefined;
29
29
  require_payment?: boolean | undefined;
30
30
  require_payment_action_url?: string | undefined;
31
- subs_tier_id?: string | undefined;
32
- subs_tier_name?: string | undefined;
31
+ subs_tier_id?: string | null | undefined;
32
+ subs_tier_name?: string | null | undefined;
33
33
  subs_usage?: {
34
34
  weekly?: {
35
35
  resets_at?: string | number | undefined;
@@ -0,0 +1,2 @@
1
+ import type { UsageProvider } from "../usage.js";
2
+ export declare const charmHyperUsageProvider: UsageProvider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.1.18",
3
+ "version": "18.1.20",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -124,11 +124,11 @@
124
124
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
125
125
  },
126
126
  "dependencies": {
127
- "@oh-my-pi/omptype": "18.1.18",
128
- "@oh-my-pi/pi-catalog": "18.1.18",
129
- "@oh-my-pi/pi-natives": "18.1.18",
130
- "@oh-my-pi/pi-utils": "18.1.18",
131
- "@oh-my-pi/pi-wire": "18.1.18"
127
+ "@oh-my-pi/omptype": "18.1.20",
128
+ "@oh-my-pi/pi-catalog": "18.1.20",
129
+ "@oh-my-pi/pi-natives": "18.1.20",
130
+ "@oh-my-pi/pi-utils": "18.1.20",
131
+ "@oh-my-pi/pi-wire": "18.1.20"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/bun": "^1.3.14"
@@ -51,6 +51,7 @@ import type {
51
51
  } from "./usage";
52
52
  import { resolveUsedFraction } from "./usage";
53
53
  import { alibabaTokenPlanRankingStrategy, alibabaTokenPlanUsageProvider } from "./usage/alibaba-token-plan";
54
+ import { charmHyperUsageProvider } from "./usage/charm-hyper";
54
55
  import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
55
56
  import { clinePassUsageProvider } from "./usage/cline-pass";
56
57
  import { cursorUsageProvider } from "./usage/cursor";
@@ -677,6 +678,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
677
678
  syntheticUsageProvider,
678
679
  xaiOauthUsageProvider,
679
680
  devinUsageProvider,
681
+ charmHyperUsageProvider,
680
682
  ];
681
683
 
682
684
  const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
@@ -30,6 +30,13 @@ const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
30
30
  // "credits exhausted". Account-local, so rotate to a sibling credential.
31
31
  const CREDITS_EXHAUSTED_PATTERN =
32
32
  /\b(?:exceed\w*|insufficient|not enough)\b[^\n]{0,40}\bcredits?\b|\bcredits?\b[^\n]{0,40}\b(?:exhausted|depleted)\b/i;
33
+ // Anthropic subscription entitlement wall: "Usage credits are required for this
34
+ // model" with `error_code: credits_required`. The account cannot serve the model
35
+ // at all, so rotate to a sibling rather than backing off on this one. Bounded to
36
+ // the documented sentence and the exact code: bare "usage credits" also appears
37
+ // in unrelated diagnostics ("Failed to fetch usage credits from billing
38
+ // service"), which must not rotate a healthy credential.
39
+ const ANTHROPIC_CREDITS_REQUIRED_PATTERN = /\busage credits are required\b|\bcredits_required\b/i;
33
40
  const SPEND_LIMIT_PATTERN = /spend.?limit/i;
34
41
  const SUBSCRIPTION_CAP_PATTERN =
35
42
  /\b(?:subscription|plan|membership)\b[^\n]{0,80}\b(?:rate.?limits?|quota|cap)\b|\b(?:rate.?limits?|quota|cap)\b[^\n]{0,80}\b(?:subscription|plan|membership)\b/i;
@@ -234,6 +241,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
234
241
  return "QUOTA_EXHAUSTED";
235
242
  }
236
243
 
244
+ if (ANTHROPIC_CREDITS_REQUIRED_PATTERN.test(errorMessage)) {
245
+ return "QUOTA_EXHAUSTED";
246
+ }
247
+
237
248
  if (
238
249
  lower.includes("per minute") ||
239
250
  lower.includes("rate limit") ||
@@ -253,6 +264,7 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
253
264
  lower.includes("out of credits") ||
254
265
  lower.includes("spending-limit") ||
255
266
  lower.includes("spending limit") ||
267
+ lower.includes("access_terminated_error") ||
256
268
  INSUFFICIENT_BALANCE_PATTERN.test(errorMessage) ||
257
269
  CREDITS_EXHAUSTED_PATTERN.test(errorMessage)
258
270
  ) {
@@ -294,7 +306,7 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
294
306
 
295
307
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
296
308
  const USAGE_LIMIT_PATTERN =
297
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked|clinepass limit|free limit reached on model/i;
309
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked|clinepass limit|free limit reached on model|access_terminated_error/i;
298
310
 
299
311
  /**
300
312
  * HTTP status codes that, absent richer body classification, represent an
@@ -397,6 +409,7 @@ export function matchesUsageLimitText(errorMessage: string): boolean {
397
409
  if (isDashScopeTokenLimitText(errorMessage)) return false;
398
410
  return (
399
411
  USAGE_LIMIT_PATTERN.test(errorMessage) ||
412
+ ANTHROPIC_CREDITS_REQUIRED_PATTERN.test(errorMessage) ||
400
413
  CREDITS_EXHAUSTED_PATTERN.test(errorMessage) ||
401
414
  (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) ||
402
415
  SPEND_LIMIT_PATTERN.test(errorMessage) ||
@@ -28,8 +28,8 @@ const museCodeKeyResponseSchema = type({
28
28
  "user_email?": "string",
29
29
  "user_id?": "string",
30
30
  "is_subs_active?": "boolean",
31
- "subs_tier_id?": "string",
32
- "subs_tier_name?": "string",
31
+ "subs_tier_id?": "string | null",
32
+ "subs_tier_name?": "string | null",
33
33
  "subs_usage?": subscriptionUsageSchema.or("null"),
34
34
  });
35
35
  export type MuseCodeKeyResponse = typeof museCodeKeyResponseSchema.infer;
@@ -90,8 +90,8 @@ function describeTokenEndpointValue(value: unknown): string | undefined {
90
90
  export const openAICodexProfileHook: AfterExchangeHook = async (credentials, context) => {
91
91
  const idToken = isRecord(context.raw) && typeof context.raw.id_token === "string" ? context.raw.id_token : undefined;
92
92
  const { accountId, email, planType } = getTokenProfile(credentials.access, idToken);
93
- if (context.phase === "login" && !accountId) {
94
- throw new AIError.OAuthError("Failed to extract accountId from token", {
93
+ if (context.phase === "login" && !accountId && !email) {
94
+ throw new AIError.OAuthError("Failed to extract account identity from token", {
95
95
  kind: "validation",
96
96
  provider: context.provider,
97
97
  });
@@ -190,18 +190,17 @@ async function exchangeCodeForToken(
190
190
  }
191
191
 
192
192
  const { accountId, email, planType } = getTokenProfile(tokenData.access_token, tokenData.id_token);
193
- if (!accountId) {
194
- throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation" });
193
+ if (!accountId && !email) {
194
+ throw new AIError.OAuthError("Failed to extract account identity from token", { kind: "validation" });
195
195
  }
196
196
 
197
197
  return {
198
198
  access: tokenData.access_token,
199
199
  refresh: tokenData.refresh_token,
200
200
  expires: Date.now() + tokenData.expires_in * 1000,
201
- accountId,
202
- email,
203
- orgId: accountId,
204
- orgName: planType,
201
+ ...(accountId ? { accountId, orgId: accountId } : {}),
202
+ ...(email ? { email } : {}),
203
+ ...(planType ? { orgName: planType } : {}),
205
204
  };
206
205
  }
207
206
 
@@ -0,0 +1,95 @@
1
+ import { normalizeCharmHyperBaseUrl } from "@oh-my-pi/pi-catalog/wire/charm-hyper";
2
+ import { ProviderHttpError } from "../error";
3
+ import type { UsageFetchContext, UsageFetchParams, UsageLimit, UsageProvider, UsageReport } from "../usage";
4
+ import { isRecord } from "../utils";
5
+
6
+ const PROVIDER = "charm-hyper";
7
+ const CREDITS_PATH = "/credits";
8
+
9
+ /**
10
+ * Charm Hyper sells prepaid credits: `/v1/credits` answers `{"balance": N}` and
11
+ * nothing else — no allowance, no spend-to-date, no reset window — so the limit
12
+ * is remaining-only by construction. Synthesizing a total from the first
13
+ * observed balance would misreport every later top-up, so we report only what
14
+ * the API states.
15
+ *
16
+ * The balance is **account-wide**, not per-key: spending 1.216 credits through
17
+ * one key dropped a second key's reported balance from 95 to 94 (verified
18
+ * 2026-09-11), and Hyper issues several keys per account. The endpoint exposes
19
+ * no account identity to group them by, so the limit is marked
20
+ * `scope.shared` — every credential reports the same pool, and consumers must
21
+ * collapse rather than sum it.
22
+ */
23
+ async function fetchCharmHyperUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
24
+ if (params.provider !== PROVIDER) return null;
25
+ const credential = params.credential;
26
+ if (credential.type !== "api_key" || !credential.apiKey) return null;
27
+
28
+ // Honor a configured proxy base: inference and discovery already route
29
+ // through it, and sending the stored key to the canonical host would both
30
+ // fail for a proxy-scoped credential and disclose it off-site. Shared with
31
+ // discovery and the model-cache namespace so all three agree on the
32
+ // endpoint — including for a blank override, which means "not configured".
33
+ const creditsUrl = `${normalizeCharmHyperBaseUrl(params.baseUrl)}${CREDITS_PATH}`;
34
+
35
+ let payload: unknown;
36
+ try {
37
+ const response = await ctx.fetch(creditsUrl, {
38
+ headers: {
39
+ Authorization: `Bearer ${credential.apiKey}`,
40
+ Accept: "application/json",
41
+ },
42
+ signal: params.signal,
43
+ });
44
+ if (!response.ok) {
45
+ // A revoked key must invalidate the cached balance rather than let
46
+ // the last-good report be re-served: only a thrown auth status
47
+ // purges it, while `null` is the transient-failure path.
48
+ if (response.status === 401 || response.status === 403) {
49
+ throw new ProviderHttpError(
50
+ `Charm Hyper credits endpoint returned ${response.status} ${response.statusText}`.trim(),
51
+ response.status,
52
+ );
53
+ }
54
+ ctx.logger?.warn("Charm Hyper usage fetch failed", {
55
+ status: response.status,
56
+ statusText: response.statusText,
57
+ });
58
+ return null;
59
+ }
60
+ payload = await response.json();
61
+ } catch (error) {
62
+ if (error instanceof ProviderHttpError) throw error;
63
+ ctx.logger?.warn("Charm Hyper usage fetch error", { error: String(error) });
64
+ return null;
65
+ }
66
+
67
+ if (!isRecord(payload)) return null;
68
+ const balance = payload.balance;
69
+ if (typeof balance !== "number" || !Number.isFinite(balance)) return null;
70
+
71
+ const limit: UsageLimit = {
72
+ id: "charm-hyper:credits",
73
+ label: "Credit balance",
74
+ // Windowless and shared: the label already says "balance", and the
75
+ // shared flag tells renderers this is one account-level pool seen once
76
+ // per stored key.
77
+ scope: { provider: params.provider, windowId: "balance", shared: true },
78
+ amount: { remaining: balance, unit: "credits" },
79
+ };
80
+
81
+ return {
82
+ provider: params.provider,
83
+ fetchedAt: Date.now(),
84
+ limits: [limit],
85
+ metadata: { endpoint: creditsUrl },
86
+ raw: payload,
87
+ };
88
+ }
89
+
90
+ export const charmHyperUsageProvider: UsageProvider = {
91
+ id: PROVIDER,
92
+ fetchUsage: fetchCharmHyperUsage,
93
+ supports: params => params.provider === PROVIDER && params.credential.type === "api_key",
94
+ validatesCredentials: true,
95
+ };
@@ -53,6 +53,25 @@ function normalizeClaudeBaseUrl(baseUrl?: string): string {
53
53
  return `${url.origin}${path}/api/oauth`;
54
54
  }
55
55
 
56
+ /**
57
+ * Subscription usage is served by Anthropic's OAuth API, which a custom
58
+ * `baseUrl` pointed at a Messages-only endpoint does not expose. Probe the
59
+ * configured host first so a full mirror keeps answering (including its own
60
+ * `/profile` identity), then fall back to the canonical endpoint — but only
61
+ * when the configured host answered that it has no usage endpoint there (see
62
+ * {@link ClaudeUsagePayloadResult.endpointAbsent}), so a host that refuses the
63
+ * credential or fails transiently keeps the request.
64
+ *
65
+ * Without the fallback the report degrades to rate-limit headers, and those
66
+ * carry the model-scoped weekly row only on responses for that model family,
67
+ * so a scoped window can read far below its real utilization until a request
68
+ * hits the family again.
69
+ */
70
+ function claudeUsageBaseUrls(baseUrl?: string): readonly string[] {
71
+ const configured = normalizeClaudeBaseUrl(baseUrl);
72
+ return configured === DEFAULT_ENDPOINT ? [DEFAULT_ENDPOINT] : [configured, DEFAULT_ENDPOINT];
73
+ }
74
+
56
75
  interface ClaudeUsageBucket {
57
76
  utilization?: number;
58
77
  resets_at?: string;
@@ -274,38 +293,93 @@ async function waitBeforeRetry(
274
293
  }
275
294
  }
276
295
 
296
+ /** Statuses that answer "this host does not implement the endpoint". */
297
+ const ENDPOINT_ABSENT_STATUSES = new Set([404, 405, 410, 501]);
298
+
299
+ interface ClaudeUsagePayloadResult {
300
+ /** Best payload seen; may lack usage data when the retries gave up. */
301
+ payload: ClaudeUsageResponse | null;
302
+ /** The host answered, but does not serve subscription usage at this path. */
303
+ endpointAbsent: boolean;
304
+ }
305
+
306
+ /**
307
+ * A body with none of the usage keys is a host answering something else at this
308
+ * path (an error document, an index page), not an account whose windows are all
309
+ * empty — the latter still carries the keys.
310
+ */
311
+ function looksLikeUsagePayload(payload: ClaudeUsageResponse): boolean {
312
+ return (
313
+ "five_hour" in payload ||
314
+ "seven_day" in payload ||
315
+ "limits" in payload ||
316
+ "extra_usage" in payload ||
317
+ "spend" in payload
318
+ );
319
+ }
320
+
277
321
  async function fetchUsagePayload(
278
322
  url: string,
279
323
  headers: Record<string, string>,
280
324
  ctx: UsageFetchContext,
281
325
  signal?: AbortSignal,
282
- ): Promise<ClaudeUsageResponse | null> {
283
- if (signal?.aborted) return null;
326
+ ): Promise<ClaudeUsagePayloadResult> {
327
+ if (signal?.aborted) return { payload: null, endpointAbsent: false };
284
328
 
285
329
  let lastPayload: ClaudeUsageResponse | null = null;
330
+ let endpointAbsent = false;
286
331
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
287
332
  try {
288
333
  const response = await ctx.fetch(url, { headers, signal });
289
334
 
290
335
  if (!response.ok) {
291
- const retryable = isRetryableStatus(response.status);
336
+ // Absence outranks the generic transient classification: 501 is a 5xx,
337
+ // but "not implemented" does not become implemented on replay.
338
+ const absent = ENDPOINT_ABSENT_STATUSES.has(response.status);
339
+ const retryable = !absent && isRetryableStatus(response.status);
292
340
  ctx.logger?.warn("Claude usage fetch failed", {
293
341
  status: response.status,
294
342
  statusText: response.statusText,
295
343
  attempt,
296
344
  willRetry: retryable && attempt < MAX_ATTEMPTS - 1,
297
345
  });
298
- if (!retryable) return null;
346
+ if (!retryable) return { payload: null, endpointAbsent: absent };
299
347
  const retryAfter = response.headers.get("retry-after");
300
348
  if (!(await waitBeforeRetry(attempt, retryAfter, signal, ctx.retryWait))) break;
301
349
  continue;
302
350
  }
303
351
 
304
- const parsed = (await response.json()) as unknown;
352
+ const body = await response.text();
353
+ if (body.trim().length === 0) {
354
+ // An empty 2xx serves nothing here; a gateway answering unknown paths
355
+ // this way has no usage endpoint to poll.
356
+ return { payload: lastPayload, endpointAbsent: true };
357
+ }
358
+ let parsed: unknown;
359
+ try {
360
+ parsed = JSON.parse(body) as unknown;
361
+ } catch {
362
+ // A non-JSON 2xx is this host answering something else at this path
363
+ // (an index page, a plain-text notice). A body that claims JSON and
364
+ // fails to parse is truncated or garbled instead, so keep retrying it
365
+ // against this host rather than moving the request.
366
+ const claimsJson = /json/i.test(response.headers.get("content-type") ?? "");
367
+ ctx.logger?.warn("Claude usage response was not JSON", {
368
+ contentType: response.headers.get("content-type") ?? undefined,
369
+ attempt,
370
+ willRetry: claimsJson && attempt < MAX_ATTEMPTS - 1,
371
+ });
372
+ if (!claimsJson) return { payload: lastPayload, endpointAbsent: true };
373
+ if (!(await waitBeforeRetry(attempt, null, signal, ctx.retryWait))) break;
374
+ continue;
375
+ }
305
376
  if (isRecord(parsed)) {
306
377
  const payload = parsed as ClaudeUsageResponse;
307
378
  lastPayload = payload;
308
- if (hasUsageData(payload)) return payload;
379
+ if (hasUsageData(payload)) return { payload, endpointAbsent: false };
380
+ endpointAbsent = !looksLikeUsagePayload(payload);
381
+ } else {
382
+ endpointAbsent = true;
309
383
  }
310
384
 
311
385
  ctx.logger?.warn("Claude usage response missing usage data", {
@@ -314,7 +388,7 @@ async function fetchUsagePayload(
314
388
  });
315
389
  if (!(await waitBeforeRetry(attempt, null, signal, ctx.retryWait))) break;
316
390
  } catch (error) {
317
- if (isAbortError(error, signal)) return null;
391
+ if (isAbortError(error, signal)) return { payload: null, endpointAbsent: false };
318
392
  ctx.logger?.warn("Claude usage fetch error", {
319
393
  error: String(error),
320
394
  attempt,
@@ -324,7 +398,7 @@ async function fetchUsagePayload(
324
398
  }
325
399
  }
326
400
 
327
- return lastPayload;
401
+ return { payload: lastPayload, endpointAbsent };
328
402
  }
329
403
 
330
404
  interface ClaudeProfile {
@@ -608,15 +682,35 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
608
682
  const credential = params.credential;
609
683
  if (credential.type !== "oauth" || !credential.accessToken) return null;
610
684
 
611
- const baseUrl = normalizeClaudeBaseUrl(params.baseUrl);
612
- const url = `${baseUrl}/usage`;
613
685
  const headers: Record<string, string> = {
614
686
  ...CLAUDE_HEADERS,
615
687
  authorization: `Bearer ${credential.accessToken}`,
616
688
  };
617
689
 
618
- const payload = await fetchUsagePayload(url, headers, ctx, params.signal);
619
- if (!payload || !isRecord(payload)) return null;
690
+ let baseUrl: string | undefined;
691
+ let payload: ClaudeUsageResponse | null = null;
692
+ for (const candidate of claudeUsageBaseUrls(params.baseUrl)) {
693
+ const result = await fetchUsagePayload(`${candidate}/usage`, headers, ctx, params.signal);
694
+ if (result.payload && hasUsageData(result.payload)) {
695
+ baseUrl = candidate;
696
+ payload = result.payload;
697
+ break;
698
+ }
699
+ // Usage-shaped body without numbers: retries already gave up on fresher
700
+ // numbers here, so hold it while the remaining candidate is probed.
701
+ if (result.payload && !payload) {
702
+ baseUrl = candidate;
703
+ payload = result.payload;
704
+ }
705
+ if (params.signal?.aborted) break;
706
+ // Only a host that answered "no usage endpoint here" justifies moving the
707
+ // request off the configured one. A refused credential (401/403) or a
708
+ // transient failure is that host's answer about this account, so it stands
709
+ // and the next poll retries it.
710
+ if (!result.endpointAbsent) break;
711
+ }
712
+ if (!payload || baseUrl === undefined) return null;
713
+ const url = `${baseUrl}/usage`;
620
714
 
621
715
  const apiLimitEntries = parseApiLimitEntries(payload.limits);
622
716
  const fiveHour = parseBucket(payload.five_hour) ?? apiLimitEntries.find(entry => entry.kind === "session")?.bucket;
package/src/usage/kimi.ts CHANGED
@@ -22,6 +22,7 @@ const USAGE_PATH = "usages";
22
22
  interface KimiUsagePayload {
23
23
  usage?: unknown;
24
24
  limits?: unknown;
25
+ totalQuota?: unknown;
25
26
  }
26
27
 
27
28
  type KimiUsageRow = {
@@ -195,7 +196,7 @@ function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRo
195
196
  const rows: KimiUsageRow[] = [];
196
197
 
197
198
  if (isRecord(data.usage)) {
198
- const summary = buildUsageRow(data.usage, "Total quota", nowMs);
199
+ const summary = buildUsageRow(data.usage, "Weekly limit", nowMs);
199
200
  if (summary) {
200
201
  // Kimi Code's aggregate quota resets weekly, but the payload carries
201
202
  // only `resetTime` and no duration. Attach the canonical weekly
@@ -205,6 +206,15 @@ function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRo
205
206
  }
206
207
  }
207
208
 
209
+ if (isRecord(data.totalQuota)) {
210
+ const windowData = isRecord(data.totalQuota.window) ? data.totalQuota.window : {};
211
+ const total = buildUsageRow(data.totalQuota, "Total quota", nowMs);
212
+ if (total) {
213
+ total.window = buildWindow(windowData, nowMs);
214
+ rows.push(total);
215
+ }
216
+ }
217
+
208
218
  if (Array.isArray(data.limits)) {
209
219
  data.limits.forEach((item, idx) => {
210
220
  if (!isRecord(item)) return;
@@ -4,8 +4,10 @@
4
4
  * A provider attempt can be discarded only until meaningful assistant output is
5
5
  * emitted. Pre-output markers are buffered so transient transport failures and
6
6
  * benign empty completions can re-issue a fresh request without duplicating
7
- * content; the first text, thinking, image, or tool event commits the attempt
8
- * and restores live streaming.
7
+ * content; the first text, thinking, image, or tool-call-delta event commits
8
+ * the attempt and restores live streaming. `toolcall_start` and `toolcall_end`
9
+ * markers alone do not commit — if the stream dies before any argument content
10
+ * arrives, the buffered markers are discarded and the attempt retried.
9
11
  *
10
12
  * Empty-completion retries remain opt-in because a normal empty stop can be a
11
13
  * valid provider result. Transient-error retries use the shared provider error
@@ -35,7 +37,7 @@ export function hasVisibleAssistantContent(message: AssistantMessage): boolean {
35
37
  return false;
36
38
  }
37
39
 
38
- /** A streamed event that delivers content worth committing the attempt for. */
40
+ /** A streamed event that delivers content worth committing the attempt for. `toolcall_start` and `toolcall_end` markers are excluded: they carry no argument data, so a stream that dies after the start but before any delta content should be retried rather than committed. A `toolcall_delta` with a non-empty delta string is what commits a tool call — string-arg hosts emit `{}` itself as a delta, so completed zero-argument calls commit on their args. Object-arg hosts merge `{}` delta-free (the flush is suppressed and both sweeps finalize through the same `finishToolCallBlock`), so a completed call there is event-identical to an unfilled one and bounded-retries instead. Committing on `toolcall_end` would also commit mid-args transport failures that today recover invisibly via retry. Safe either way: buffered output never reached the consumer and the tool never executed. */
39
41
  function isMeaningfulCompletionEvent(event: AssistantMessageEvent): boolean {
40
42
  switch (event.type) {
41
43
  case "text_delta":
@@ -49,7 +51,7 @@ function isMeaningfulCompletionEvent(event: AssistantMessageEvent): boolean {
49
51
  return true;
50
52
  case "toolcall_start":
51
53
  case "toolcall_end":
52
- return true;
54
+ return false;
53
55
  default:
54
56
  return false;
55
57
  }
@@ -380,14 +380,15 @@ export async function connectProxiedSocket(
380
380
  }
381
381
 
382
382
  const tlsOptions = options?.tls;
383
- tunnelSocket = tls.connect({
383
+ const newTunnelSocket: tls.TLSSocket = tls.connect({
384
384
  ...tlsOptions,
385
385
  socket: rawSocket,
386
386
  servername: tlsOptions?.servername ?? targetHost,
387
387
  ALPNProtocols: tlsOptions?.ALPNProtocols ?? ["h2"],
388
388
  });
389
- tunnelSocket.once("secureConnect", onTunnelReady);
390
- tunnelSocket.once("error", onTunnelError);
389
+ tunnelSocket = newTunnelSocket;
390
+ newTunnelSocket.once("secureConnect", onTunnelReady);
391
+ newTunnelSocket.once("error", onTunnelError);
391
392
  };
392
393
  const onProxyReady = (): void => {
393
394
  if (!rawSocket) return;