@buyi1net/pi-toolkit 0.0.1
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/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// 智谱取数与解析。端点细节来自两个已核验来源(见 docs/共享能力/供应商余额与订阅查询适配指南.md):
|
|
2
|
+
// - Coding Plan 额度:cc-switch src-tauri/src/services/coding_plan.rs:313-380
|
|
3
|
+
// 裸 key 鉴权(不加 Bearer);percentage 是已用百分比;type 兼容 TOKENS_LIMIT/CREDIT_LIMIT;
|
|
4
|
+
// 窗口只认 unit(3=5h,6=周),不能用 reset 时间排序(官方 issue #3036 会标反)
|
|
5
|
+
// - API 余额:cc-toolkit src/usage-query/custom/Zhipu-GLM/index.js(Bearer 鉴权,code!=200 视为失败)
|
|
6
|
+
|
|
7
|
+
import type { QuotaInfo, QuotaWindow } from './provider-contracts.ts';
|
|
8
|
+
|
|
9
|
+
export interface ZhipuLimit {
|
|
10
|
+
type?: string;
|
|
11
|
+
unit?: number;
|
|
12
|
+
number?: number;
|
|
13
|
+
percentage?: number;
|
|
14
|
+
nextResetTime?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 额度响应 → QuotaInfo。解析失败返回 null(上层收起行 3) */
|
|
18
|
+
export function parseZhipuQuota(json: any): QuotaInfo | null {
|
|
19
|
+
const limits: ZhipuLimit[] = json?.data?.limits;
|
|
20
|
+
if (!Array.isArray(limits) || limits.length === 0) return null;
|
|
21
|
+
|
|
22
|
+
const valid = limits.filter((l) => {
|
|
23
|
+
const t = (l.type ?? '').toUpperCase();
|
|
24
|
+
return t === 'TOKENS_LIMIT' || t === 'CREDIT_LIMIT';
|
|
25
|
+
});
|
|
26
|
+
if (valid.length === 0) return null;
|
|
27
|
+
|
|
28
|
+
const toWindow = (label: string, l: ZhipuLimit | null): QuotaWindow | null => {
|
|
29
|
+
if (!l || typeof l.percentage !== 'number') return null;
|
|
30
|
+
return {
|
|
31
|
+
label,
|
|
32
|
+
remainingPercent: Math.max(0, Math.min(100, 100 - l.percentage)),
|
|
33
|
+
resetMs: typeof l.nextResetTime === 'number' ? l.nextResetTime : null,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let w5 = valid.find((l) => l.unit === 3) ?? null;
|
|
38
|
+
let w7 = valid.find((l) => l.unit === 6) ?? null;
|
|
39
|
+
|
|
40
|
+
// unit 缺失的老套餐兜底:单条视为 5h;多条按「无 reset 归 5h,其余 reset 升序填空」
|
|
41
|
+
if (!w5 && !w7) {
|
|
42
|
+
if (valid.length === 1) {
|
|
43
|
+
w5 = valid[0];
|
|
44
|
+
} else {
|
|
45
|
+
const sorted = [...valid].sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
|
|
46
|
+
w5 = sorted.find((l) => l.nextResetTime == null) ?? sorted[0];
|
|
47
|
+
w7 = sorted.filter((l) => l !== w5)[0] ?? null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const windows = [toWindow('5h', w5), toWindow('7d', w7)].filter(
|
|
52
|
+
(window): window is QuotaWindow => window != null,
|
|
53
|
+
);
|
|
54
|
+
return windows.length > 0 ? { provider: 'zhipu', windows } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 余额响应 → 数值。失败返回 null */
|
|
58
|
+
export function parseZhipuBalance(json: any): number | null {
|
|
59
|
+
if (json?.code !== 200 || typeof json?.data?.balance !== 'number') return null;
|
|
60
|
+
return json.data.balance;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** host 路由(借 cc-switch zhipu_quota_base 规则):bigmodel.cn → 国内站,api.z.ai → 国际站 */
|
|
64
|
+
export function zhipuHost(baseUrl: string): string {
|
|
65
|
+
try {
|
|
66
|
+
const hostname = new URL(baseUrl).hostname;
|
|
67
|
+
if (hostname === 'bigmodel.cn' || hostname.endsWith('.bigmodel.cn')) return 'https://open.bigmodel.cn';
|
|
68
|
+
} catch {
|
|
69
|
+
/* 无效地址不走国内站 */
|
|
70
|
+
}
|
|
71
|
+
return 'https://api.z.ai';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchZhipuQuota(
|
|
75
|
+
baseUrl: string,
|
|
76
|
+
apiKey: string,
|
|
77
|
+
request: typeof fetch = fetch,
|
|
78
|
+
): Promise<QuotaInfo | null> {
|
|
79
|
+
const url = `${zhipuHost(baseUrl)}/api/monitor/usage/quota/limit`;
|
|
80
|
+
const res = await request(url, {
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: apiKey, // 裸 key:智谱监控端点不带 Bearer 前缀
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
'Accept-Language': 'en-US,en',
|
|
85
|
+
},
|
|
86
|
+
signal: AbortSignal.timeout(15_000),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) return null;
|
|
89
|
+
const quota = parseZhipuQuota(await res.json().catch(() => null));
|
|
90
|
+
return quota && zhipuHost(baseUrl) === 'https://api.z.ai' ? { ...quota, provider: 'z.ai' } : quota;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 智谱团队套餐:固定国内站,type=2,并附加组织/项目头。 */
|
|
94
|
+
export async function fetchZhipuTeamQuota(
|
|
95
|
+
apiKey: string,
|
|
96
|
+
organizationId: string,
|
|
97
|
+
projectId: string,
|
|
98
|
+
request: typeof fetch = fetch,
|
|
99
|
+
): Promise<QuotaInfo | null> {
|
|
100
|
+
if (!apiKey || !organizationId || !projectId) return null;
|
|
101
|
+
const res = await request('https://open.bigmodel.cn/api/monitor/usage/quota/limit?type=2', {
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: apiKey,
|
|
104
|
+
'bigmodel-organization': organizationId,
|
|
105
|
+
'bigmodel-project': projectId,
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
'Accept-Language': 'en-US,en',
|
|
108
|
+
},
|
|
109
|
+
signal: AbortSignal.timeout(15_000),
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) return null;
|
|
112
|
+
return parseZhipuQuota(await res.json().catch(() => null));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function fetchZhipuBalance(apiKey: string, request: typeof fetch = fetch): Promise<number | null> {
|
|
116
|
+
const res = await request('https://bigmodel.cn/api/biz/account/query-customer-account-report', {
|
|
117
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
118
|
+
signal: AbortSignal.timeout(15_000),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) return null;
|
|
121
|
+
return parseZhipuBalance(await res.json().catch(() => null));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface DeepSeekBalance {
|
|
125
|
+
amount: number;
|
|
126
|
+
currency: 'CNY' | 'USD';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseDeepSeekBalance(json: any): DeepSeekBalance | null {
|
|
130
|
+
const infos = json?.balance_infos;
|
|
131
|
+
if (!Array.isArray(infos) || infos.length === 0) return null;
|
|
132
|
+
const pick = infos.find((i: any) => i?.currency === 'CNY') ?? infos[0];
|
|
133
|
+
const amount = Number(pick?.total_balance);
|
|
134
|
+
if (!Number.isFinite(amount)) return null;
|
|
135
|
+
return { amount, currency: pick?.currency === 'USD' ? 'USD' : 'CNY' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** DeepSeek 余额:官方 GET /user/balance,balance_infos[] 多币种行,状态栏优先显示主账户常用的 CNY 行。 */
|
|
139
|
+
export async function fetchDeepSeekBalance(
|
|
140
|
+
apiKey: string,
|
|
141
|
+
request: typeof fetch = fetch,
|
|
142
|
+
): Promise<DeepSeekBalance | null> {
|
|
143
|
+
const res = await request('https://api.deepseek.com/user/balance', {
|
|
144
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
145
|
+
signal: AbortSignal.timeout(15_000),
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) return null;
|
|
148
|
+
return parseDeepSeekBalance(await res.json().catch(() => null));
|
|
149
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CC_SWITCH_CLAUDE_PRESET_COUNT,
|
|
3
|
+
findProviderById,
|
|
4
|
+
findProviderByUrl,
|
|
5
|
+
OFFICIAL_PROVIDERS,
|
|
6
|
+
PROVIDER_BRANDS,
|
|
7
|
+
PROVIDER_CATALOG,
|
|
8
|
+
RELAY_PROVIDERS,
|
|
9
|
+
} from "../shared/provider-catalog.ts";
|
|
10
|
+
import {
|
|
11
|
+
displayProviderName,
|
|
12
|
+
inferModelProviderName,
|
|
13
|
+
} from "../shared/provider-display.ts";
|
|
14
|
+
|
|
15
|
+
// 供应商目录与品牌展示是共享领域层公开词汇,宿主不得深路径直连 shared 内部模块。
|
|
16
|
+
export {
|
|
17
|
+
CC_SWITCH_CLAUDE_PRESET_COUNT,
|
|
18
|
+
findProviderById,
|
|
19
|
+
findProviderByUrl,
|
|
20
|
+
OFFICIAL_PROVIDERS,
|
|
21
|
+
PROVIDER_BRANDS,
|
|
22
|
+
PROVIDER_CATALOG,
|
|
23
|
+
RELAY_PROVIDERS,
|
|
24
|
+
displayProviderName,
|
|
25
|
+
inferModelProviderName,
|
|
26
|
+
};
|
|
27
|
+
import type {
|
|
28
|
+
BillingMode as SharedBillingMode,
|
|
29
|
+
ProviderCredentials,
|
|
30
|
+
ProviderQueryConfig,
|
|
31
|
+
QuotaWindow as SharedQuotaWindow,
|
|
32
|
+
} from "../shared/provider-contracts.ts";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
parseProviderCredentials,
|
|
36
|
+
parseProviderQueries,
|
|
37
|
+
ProviderConfigValidationError,
|
|
38
|
+
} from "../shared/provider-contracts.ts";
|
|
39
|
+
export type {
|
|
40
|
+
BalanceValue,
|
|
41
|
+
ProviderCredentials,
|
|
42
|
+
ProviderQueryConfig,
|
|
43
|
+
QuotaInfo,
|
|
44
|
+
RelayQueryProtocol,
|
|
45
|
+
} from "../shared/provider-contracts.ts";
|
|
46
|
+
|
|
47
|
+
export type BillingMode = SharedBillingMode;
|
|
48
|
+
export type UsageFreshness = "fresh" | "stale";
|
|
49
|
+
|
|
50
|
+
export interface ProviderIdentity {
|
|
51
|
+
providerId: string;
|
|
52
|
+
endpoint: string;
|
|
53
|
+
accountFingerprint: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ProviderAccess {
|
|
57
|
+
identity: ProviderIdentity;
|
|
58
|
+
credential: string;
|
|
59
|
+
options?: ProviderAccessOptions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ProviderQueryAccess = ProviderQueryConfig;
|
|
63
|
+
|
|
64
|
+
export interface ProviderAccessOptions {
|
|
65
|
+
modelId?: string;
|
|
66
|
+
authKind?: "api-key" | "oauth";
|
|
67
|
+
accountId?: string;
|
|
68
|
+
githubDomain?: string;
|
|
69
|
+
query?: ProviderQueryAccess | null;
|
|
70
|
+
credentials?: ProviderCredentials;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Balance {
|
|
74
|
+
amount: number;
|
|
75
|
+
currency: "CNY" | "USD";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type QuotaWindow = SharedQuotaWindow;
|
|
79
|
+
|
|
80
|
+
export interface UsageSnapshot {
|
|
81
|
+
provider: {
|
|
82
|
+
id: string;
|
|
83
|
+
brandName: string;
|
|
84
|
+
};
|
|
85
|
+
billingMode: BillingMode;
|
|
86
|
+
balance: Balance | null;
|
|
87
|
+
windows: QuotaWindow[];
|
|
88
|
+
fetchedAt: number;
|
|
89
|
+
freshness: UsageFreshness;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface NormalizedUsageInput {
|
|
93
|
+
providerId: string;
|
|
94
|
+
brandName?: string;
|
|
95
|
+
billingMode: Exclude<BillingMode, "unknown">;
|
|
96
|
+
balance?: Balance;
|
|
97
|
+
windows?: readonly QuotaWindow[];
|
|
98
|
+
fetchedAt?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeEndpoint(endpoint: string): string {
|
|
102
|
+
const raw = endpoint.trim();
|
|
103
|
+
if (!raw) return "";
|
|
104
|
+
try {
|
|
105
|
+
const url = new URL(raw);
|
|
106
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
107
|
+
return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${path}`;
|
|
108
|
+
} catch {
|
|
109
|
+
return raw.toLowerCase().replace(/\/+$/, "");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resolveProviderMetadata(
|
|
114
|
+
endpoint: string,
|
|
115
|
+
providerHint = "",
|
|
116
|
+
modelId = "",
|
|
117
|
+
): { providerId: string; brandName: string } {
|
|
118
|
+
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
|
119
|
+
const catalog =
|
|
120
|
+
(normalizedEndpoint ? findProviderByUrl(normalizedEndpoint) : undefined) ??
|
|
121
|
+
(providerHint ? findProviderById(providerHint) : undefined);
|
|
122
|
+
const inferredProviderId = !catalog ? inferModelProviderName(modelId) : undefined;
|
|
123
|
+
const providerId = catalog?.brandId ?? inferredProviderId ?? (providerHint.trim() || "unknown");
|
|
124
|
+
return {
|
|
125
|
+
providerId,
|
|
126
|
+
brandName: catalog?.displayName ?? (inferredProviderId ? displayProviderName(providerId) : ""),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clampPercent(value: number): number {
|
|
131
|
+
return Math.max(0, Math.min(100, value));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const MAX_QUOTA_WINDOW_LABEL_LENGTH = 24;
|
|
135
|
+
|
|
136
|
+
export function sanitizeQuotaWindowLabel(label: unknown): string {
|
|
137
|
+
const safe = String(label ?? "")
|
|
138
|
+
.replace(/(?:\x1b[\]PX^_]|[\u0090\u009d\u009e\u009f])[\s\S]*?(?:\x07|\x1b\\|\u009c)/g, "")
|
|
139
|
+
.replace(/(?:\x1b[\]PX^_]|[\u0090\u009d\u009e\u009f])[\s\S]*$/g, "")
|
|
140
|
+
.replace(/(?:\x1b\[|\u009b)[0-?]*[ -\/]*[@-~]/g, "")
|
|
141
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
142
|
+
.replace(/\s+/g, " ")
|
|
143
|
+
.trim();
|
|
144
|
+
return [...(safe || "Quota")].slice(0, MAX_QUOTA_WINDOW_LABEL_LENGTH).join("");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function normalizeUsageSnapshot(input: NormalizedUsageInput): UsageSnapshot {
|
|
148
|
+
return {
|
|
149
|
+
provider: {
|
|
150
|
+
id: input.providerId,
|
|
151
|
+
brandName: input.brandName ?? displayProviderName(input.providerId),
|
|
152
|
+
},
|
|
153
|
+
billingMode: input.billingMode,
|
|
154
|
+
balance: input.balance ?? null,
|
|
155
|
+
windows: (input.windows ?? []).map((window) => ({
|
|
156
|
+
label: sanitizeQuotaWindowLabel(window.label),
|
|
157
|
+
remainingPercent: clampPercent(window.remainingPercent),
|
|
158
|
+
resetMs: window.resetMs,
|
|
159
|
+
})),
|
|
160
|
+
fetchedAt: input.fetchedAt ?? Date.now(),
|
|
161
|
+
freshness: "fresh",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function markUsageSnapshotStale(snapshot: UsageSnapshot): UsageSnapshot {
|
|
166
|
+
return snapshot.freshness === "stale" ? snapshot : { ...snapshot, freshness: "stale" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// —— 刷新状态机与缓存时效判定(零 IO 纯函数;文件读写由查询运行层承担) ——
|
|
170
|
+
|
|
171
|
+
export interface CacheEntry<T> {
|
|
172
|
+
ts: number;
|
|
173
|
+
value: T;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 缓存条目是否超过 TTL;缺失视为过期。 */
|
|
177
|
+
export function isCacheStale<T>(entry: CacheEntry<T> | null, ttlMs: number, now = Date.now()): boolean {
|
|
178
|
+
return !entry || now - entry.ts > ttlMs;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface SourceIdentity {
|
|
182
|
+
providerKey: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface SourceState extends SourceIdentity {
|
|
186
|
+
status: "pending" | "backoff" | "ready";
|
|
187
|
+
/** 最近一次启动刷新进程的时间,用于失败退避 */
|
|
188
|
+
attemptTs: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface SourceRefreshPlan {
|
|
192
|
+
/** 当前缓存是否属于当前供应商,可以用于布局 */
|
|
193
|
+
usable: boolean;
|
|
194
|
+
/** 本轮是否启动一次旁路刷新 */
|
|
195
|
+
refresh: boolean;
|
|
196
|
+
/** 需要先落盘的新状态;null 表示状态不变 */
|
|
197
|
+
nextState: SourceState | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const SOURCE_RETRY_MS = 30_000;
|
|
201
|
+
|
|
202
|
+
export function usageRetryDue(retryAfter: number, now: number): boolean {
|
|
203
|
+
return !(retryAfter > now);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function isLastGoodUsable(
|
|
207
|
+
fetchedAt: number | null,
|
|
208
|
+
keepLastGoodMs: number,
|
|
209
|
+
now: number,
|
|
210
|
+
): boolean {
|
|
211
|
+
return fetchedAt !== null && now - fetchedAt <= keepLastGoodMs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface UsageFailurePlan {
|
|
215
|
+
status: "stale" | "error";
|
|
216
|
+
retryAfter: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function planUsageFailure(
|
|
220
|
+
lastGoodAt: number | null,
|
|
221
|
+
keepLastGoodMs: number,
|
|
222
|
+
retryMs: number,
|
|
223
|
+
now: number,
|
|
224
|
+
): UsageFailurePlan {
|
|
225
|
+
return {
|
|
226
|
+
status: isLastGoodUsable(lastGoodAt, keepLastGoodMs, now) ? "stale" : "error",
|
|
227
|
+
retryAfter: now + retryMs,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface ScopedSnapshots {
|
|
232
|
+
[scopeKey: string]: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type ModelSnapshots = ScopedSnapshots;
|
|
236
|
+
|
|
237
|
+
function recordScopedValue(
|
|
238
|
+
cached: ScopedSnapshots,
|
|
239
|
+
scopeKey: string,
|
|
240
|
+
valueKey: string,
|
|
241
|
+
maxEntries: number,
|
|
242
|
+
): { changed: boolean; value: ScopedSnapshots } {
|
|
243
|
+
if (cached[scopeKey] === valueKey) return { changed: false, value: cached };
|
|
244
|
+
const value = { ...cached };
|
|
245
|
+
delete value[scopeKey];
|
|
246
|
+
value[scopeKey] = valueKey;
|
|
247
|
+
const keys = Object.keys(value);
|
|
248
|
+
for (const key of keys.slice(0, Math.max(0, keys.length - maxEntries))) delete value[key];
|
|
249
|
+
return { changed: true, value };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 按会话记录模型,避免多个并行会话使用不同模型时互相触发变化。 */
|
|
253
|
+
export function recordModel(
|
|
254
|
+
cached: ModelSnapshots,
|
|
255
|
+
scopeKey: string,
|
|
256
|
+
modelKey: string,
|
|
257
|
+
maxEntries = 20,
|
|
258
|
+
): { changed: boolean; value: ModelSnapshots } {
|
|
259
|
+
return recordScopedValue(cached, scopeKey, modelKey, maxEntries);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 宿主提供稳定事件键;同一会话的同一轮结束只触发一次刷新。 */
|
|
263
|
+
export function recordRefreshEvent(
|
|
264
|
+
cached: ScopedSnapshots,
|
|
265
|
+
scopeKey: string,
|
|
266
|
+
eventKey: string | null,
|
|
267
|
+
maxEntries = 20,
|
|
268
|
+
): { changed: boolean; value: ScopedSnapshots } {
|
|
269
|
+
return eventKey ? recordScopedValue(cached, scopeKey, eventKey, maxEntries) : { changed: false, value: cached };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 按计费模式判断当前所需的余额或订阅额度缓存是否超龄。 */
|
|
273
|
+
export function usageRefreshDue(
|
|
274
|
+
mode: BillingMode | null,
|
|
275
|
+
quotaStale: boolean,
|
|
276
|
+
balanceStale: boolean,
|
|
277
|
+
): boolean {
|
|
278
|
+
if (!mode) return true;
|
|
279
|
+
return (
|
|
280
|
+
((mode === "subscription" || mode === "hybrid") && quotaStale) ||
|
|
281
|
+
((mode === "api" || mode === "hybrid") && balanceStale)
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function matches(current: SourceIdentity, cached: SourceState): boolean {
|
|
286
|
+
return current.providerKey === cached.providerKey;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 数据源变化时立刻隔离旧缓存;同一数据源的 TTL 过期只后台更新,继续显示旧值。
|
|
291
|
+
* pending 会每秒尝试抢刷新锁;只有实际请求失败进入 backoff 后才按 retryMs 退避。
|
|
292
|
+
*/
|
|
293
|
+
export function planSourceRefresh(
|
|
294
|
+
current: SourceIdentity,
|
|
295
|
+
cached: SourceState | null,
|
|
296
|
+
dataStale: boolean,
|
|
297
|
+
now = Date.now(),
|
|
298
|
+
retryMs = SOURCE_RETRY_MS,
|
|
299
|
+
forceRefresh = false,
|
|
300
|
+
): SourceRefreshPlan {
|
|
301
|
+
if (
|
|
302
|
+
!cached ||
|
|
303
|
+
!matches(current, cached) ||
|
|
304
|
+
(cached.status !== "pending" && cached.status !== "backoff" && cached.status !== "ready")
|
|
305
|
+
) {
|
|
306
|
+
return {
|
|
307
|
+
usable: false,
|
|
308
|
+
refresh: true,
|
|
309
|
+
nextState: { ...current, status: "pending", attemptTs: now },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const retryDue = !Number.isFinite(cached.attemptTs) || usageRetryDue(cached.attemptTs + retryMs, now);
|
|
314
|
+
if (cached.status === "pending") {
|
|
315
|
+
return { usable: false, refresh: true, nextState: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (cached.status === "backoff") {
|
|
319
|
+
return {
|
|
320
|
+
usable: false,
|
|
321
|
+
refresh: retryDue,
|
|
322
|
+
nextState: retryDue ? { ...cached, status: "pending", attemptTs: now } : null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (dataStale && (forceRefresh || retryDue)) {
|
|
327
|
+
return {
|
|
328
|
+
usable: true,
|
|
329
|
+
refresh: true,
|
|
330
|
+
nextState: { ...cached, attemptTs: now },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { usable: true, refresh: false, nextState: null };
|
|
335
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// 声明式供应商查询路由表:每家供应商一条记录,新增供应商只需加一行。
|
|
2
|
+
// 表内只放数据:解析器与专用连接器以字符串标识登记在查询运行层(usage-node),
|
|
3
|
+
// 本文件因此保持零 IO,安全归属共享领域层。
|
|
4
|
+
|
|
5
|
+
import type { BuiltinQueryKind } from "../shared/provider-catalog.ts";
|
|
6
|
+
|
|
7
|
+
/** 查询步骤可依赖的凭据来源;任一缺失时该步骤被跳过,不发请求。 */
|
|
8
|
+
export type CredentialSource =
|
|
9
|
+
| "token"
|
|
10
|
+
| "openrouter-management"
|
|
11
|
+
| "volcengine-aksk"
|
|
12
|
+
| "zhipu-team";
|
|
13
|
+
|
|
14
|
+
/** 步骤执行条件;不满足时跳过该步骤(与凭据缺失同样视为不可执行)。 */
|
|
15
|
+
export interface StepCondition {
|
|
16
|
+
/** baseUrl 的主机名不等于该值时才执行。 */
|
|
17
|
+
hostNot?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface RouteStepBase {
|
|
21
|
+
onlyWhen?: StepCondition;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 简单 JSON 查询步骤:运行层按声明构造请求,把响应交给绑定解析器。 */
|
|
25
|
+
export interface JsonQueryStep extends RouteStepBase {
|
|
26
|
+
type: "json";
|
|
27
|
+
/** 固定端点,或取 baseUrl 的 origin 拼接路径。 */
|
|
28
|
+
url: { fixed: string } | { originPath: string };
|
|
29
|
+
/** Bearer 鉴权使用的凭据来源;为空时跳过该步骤。 */
|
|
30
|
+
credential: "token" | "openrouter-management";
|
|
31
|
+
/** 响应解析器标识;具体实现登记在查询运行层。 */
|
|
32
|
+
parser: JsonParserId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 专用连接器步骤:OAuth 订阅、HMAC 签名、gRPC 等复杂协议由运行层连接器执行。 */
|
|
36
|
+
export interface ConnectorStep extends RouteStepBase {
|
|
37
|
+
type: "connector";
|
|
38
|
+
/** 连接器标识;具体实现登记在查询运行层。 */
|
|
39
|
+
connector: ConnectorId;
|
|
40
|
+
/** 全部就绪才执行该步骤的凭据来源。 */
|
|
41
|
+
requires?: readonly CredentialSource[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ProviderRouteStep = JsonQueryStep | ConnectorStep;
|
|
45
|
+
|
|
46
|
+
export interface ProviderRouteRecord {
|
|
47
|
+
kind: BuiltinQueryKind;
|
|
48
|
+
/** 依次尝试的回退链:前一步成功即返回,全部失败返回 null。 */
|
|
49
|
+
steps: readonly ProviderRouteStep[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 简单 JSON 端点的解析器标识;登记处见 usage-node/provider-usage.ts。 */
|
|
53
|
+
export type JsonParserId =
|
|
54
|
+
| "stepfun-balance"
|
|
55
|
+
| "siliconflow-balance"
|
|
56
|
+
| "openrouter-balance"
|
|
57
|
+
| "novita-balance"
|
|
58
|
+
| "kimi-balance"
|
|
59
|
+
| "kimi-quota"
|
|
60
|
+
| "minimax-quota"
|
|
61
|
+
| "sub2api-usage";
|
|
62
|
+
|
|
63
|
+
/** 专用协议连接器标识;登记处见 usage-node/provider-usage.ts。 */
|
|
64
|
+
export type ConnectorId =
|
|
65
|
+
| "claude-subscription"
|
|
66
|
+
| "codex-subscription"
|
|
67
|
+
| "gemini-subscription"
|
|
68
|
+
| "copilot-subscription"
|
|
69
|
+
| "grok-subscription"
|
|
70
|
+
| "zhipu-quota"
|
|
71
|
+
| "zhipu-balance"
|
|
72
|
+
| "deepseek-balance"
|
|
73
|
+
| "volcengine-quota";
|
|
74
|
+
|
|
75
|
+
export const PROVIDER_ROUTES: readonly ProviderRouteRecord[] = [
|
|
76
|
+
{
|
|
77
|
+
kind: "claude-subscription",
|
|
78
|
+
steps: [{ type: "connector", connector: "claude-subscription", requires: ["token"] }],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
kind: "codex-subscription",
|
|
82
|
+
steps: [{ type: "connector", connector: "codex-subscription", requires: ["token"] }],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
kind: "gemini-subscription",
|
|
86
|
+
steps: [{ type: "connector", connector: "gemini-subscription", requires: ["token"] }],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
kind: "copilot-subscription",
|
|
90
|
+
steps: [{ type: "connector", connector: "copilot-subscription", requires: ["token"] }],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
kind: "grok-subscription",
|
|
94
|
+
steps: [{ type: "connector", connector: "grok-subscription", requires: ["token"] }],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
kind: "zhipu",
|
|
98
|
+
steps: [
|
|
99
|
+
{ type: "connector", connector: "zhipu-quota", requires: ["token"] },
|
|
100
|
+
// 国际站没有已确认的余额接口;不能把国际站 Key 发送到国内 bigmodel.cn
|
|
101
|
+
{ type: "connector", connector: "zhipu-balance", requires: ["token"], onlyWhen: { hostNot: "api.z.ai" } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
kind: "kimi-api",
|
|
106
|
+
steps: [
|
|
107
|
+
{ type: "json", url: { originPath: "/v1/users/me/balance" }, credential: "token", parser: "kimi-balance" },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
kind: "kimi-coding",
|
|
112
|
+
steps: [
|
|
113
|
+
// Kimi Code 与开放平台是两套计费产品:套餐接口返回有效窗口即展示订阅,
|
|
114
|
+
// 失败时同一 Key 仍可能有普通余额,依次尝试开放平台余额接口。
|
|
115
|
+
{ type: "json", url: { fixed: "https://api.kimi.com/coding/v1/usages" }, credential: "token", parser: "kimi-quota" },
|
|
116
|
+
{ type: "json", url: { fixed: "https://api.moonshot.cn/v1/users/me/balance" }, credential: "token", parser: "kimi-balance" },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: "minimax-cn",
|
|
121
|
+
steps: [
|
|
122
|
+
// Token Plan 新接口优先;保留旧 Coding Plan 路径兼容仍使用旧版接口的账户
|
|
123
|
+
{ type: "json", url: { fixed: "https://www.minimaxi.com/v1/token_plan/remains" }, credential: "token", parser: "minimax-quota" },
|
|
124
|
+
{ type: "json", url: { fixed: "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" }, credential: "token", parser: "minimax-quota" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
kind: "minimax-en",
|
|
129
|
+
steps: [
|
|
130
|
+
{ type: "json", url: { fixed: "https://www.minimax.io/v1/token_plan/remains" }, credential: "token", parser: "minimax-quota" },
|
|
131
|
+
{ type: "json", url: { fixed: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" }, credential: "token", parser: "minimax-quota" },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
kind: "deepseek",
|
|
136
|
+
steps: [{ type: "connector", connector: "deepseek-balance", requires: ["token"] }],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
kind: "stepfun",
|
|
140
|
+
steps: [
|
|
141
|
+
{ type: "json", url: { fixed: "https://api.stepfun.com/v1/accounts" }, credential: "token", parser: "stepfun-balance" },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
kind: "siliconflow-cn",
|
|
146
|
+
steps: [
|
|
147
|
+
{ type: "json", url: { fixed: "https://api.siliconflow.cn/v1/user/info" }, credential: "token", parser: "siliconflow-balance" },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
kind: "siliconflow-en",
|
|
152
|
+
steps: [
|
|
153
|
+
{ type: "json", url: { fixed: "https://api.siliconflow.com/v1/user/info" }, credential: "token", parser: "siliconflow-balance" },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
kind: "openrouter",
|
|
158
|
+
steps: [
|
|
159
|
+
// 余额只使用独立 Management Key,不复用推理 Key
|
|
160
|
+
{ type: "json", url: { fixed: "https://openrouter.ai/api/v1/credits" }, credential: "openrouter-management", parser: "openrouter-balance" },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
kind: "novita",
|
|
165
|
+
steps: [
|
|
166
|
+
{ type: "json", url: { fixed: "https://api.novita.ai/openapi/v1/billing/balance/detail" }, credential: "token", parser: "novita-balance" },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
kind: "sub2api",
|
|
171
|
+
steps: [
|
|
172
|
+
{ type: "json", url: { originPath: "/v1/usage" }, credential: "token", parser: "sub2api-usage" },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
kind: "volcengine-agent",
|
|
177
|
+
steps: [{ type: "connector", connector: "volcengine-quota", requires: ["volcengine-aksk"] }],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
kind: "volcengine-coding",
|
|
181
|
+
steps: [{ type: "connector", connector: "volcengine-quota", requires: ["volcengine-aksk"] }],
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
export function findProviderRoute(kind: string): ProviderRouteRecord | null {
|
|
186
|
+
return PROVIDER_ROUTES.find((route) => route.kind === kind) ?? null;
|
|
187
|
+
}
|