@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 +22 -0
- package/dist/types/registry/oauth/muse-code.d.ts +4 -4
- package/dist/types/usage/charm-hyper.d.ts +2 -0
- package/package.json +6 -6
- package/src/auth-storage.ts +2 -0
- package/src/error/rate-limit.ts +14 -1
- package/src/registry/oauth/muse-code.ts +2 -2
- package/src/registry/oauth/openai-codex.ts +7 -8
- package/src/usage/charm-hyper.ts +95 -0
- package/src/usage/claude.ts +106 -12
- package/src/usage/kimi.ts +11 -1
- package/src/utils/empty-completion-retry.ts +6 -4
- package/src/utils/proxy.ts +4 -3
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.1.
|
|
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.
|
|
128
|
-
"@oh-my-pi/pi-catalog": "18.1.
|
|
129
|
-
"@oh-my-pi/pi-natives": "18.1.
|
|
130
|
-
"@oh-my-pi/pi-utils": "18.1.
|
|
131
|
-
"@oh-my-pi/pi-wire": "18.1.
|
|
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"
|
package/src/auth-storage.ts
CHANGED
|
@@ -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>(
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/src/usage/claude.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
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, "
|
|
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
|
|
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
|
|
54
|
+
return false;
|
|
53
55
|
default:
|
|
54
56
|
return false;
|
|
55
57
|
}
|
package/src/utils/proxy.ts
CHANGED
|
@@ -380,14 +380,15 @@ export async function connectProxiedSocket(
|
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
const tlsOptions = options?.tls;
|
|
383
|
-
|
|
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
|
|
390
|
-
|
|
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;
|