@kenz1117/dsh-ui-usage-billing 1.6.1 → 1.6.3
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/README.en.md +58 -40
- package/README.md +36 -27
- package/lib/client.js +1 -1
- package/lib/index.js +230 -135
- package/lib/types/balance.d.ts +4 -2
- package/lib/types/client/budget-store.d.ts +4 -0
- package/lib/types/declarative.d.ts +3 -2
- package/lib/types/index.d.ts +8 -5
- package/lib/types/relay.d.ts +9 -8
- package/lib/types/route-keys.d.ts +20 -0
- package/lib/types/subscriptions.d.ts +32 -5
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -4045,6 +4045,35 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
4045
4045
|
};
|
|
4046
4046
|
}
|
|
4047
4047
|
//#endregion
|
|
4048
|
+
//#region lib/types/route-keys.js
|
|
4049
|
+
/**
|
|
4050
|
+
* 宿主 llm-pi-ai 新代际的路由 key 约定(订阅额度 / 余额 / 中转站 / 声明端点共用)。
|
|
4051
|
+
*
|
|
4052
|
+
* 新版宿主设置页把路由 key 直接存成凭据 refs(命名 = 路由 id 大写下划线 +
|
|
4053
|
+
* `_API_KEY`),providers 配置里只剩 models 列表;chat 认证走 pi-ai 目录路由的
|
|
4054
|
+
* ambient 发现,不再需要显式 `apiKeyEnv`。插件侧所有「按路由取 key」的读取口
|
|
4055
|
+
* 共用本模块的候选名推导与顺序解析——显式引用优先,约定名补后。
|
|
4056
|
+
*/
|
|
4057
|
+
/** 候选 ref 名:路由 id 约定名(`<ROUTE 大写下划线>_API_KEY`)优先,其后补宿主
|
|
4058
|
+
* pi-ai 目录的 ambient env 名(仅当与约定名不同——目录把 kimi-coding 指到
|
|
4059
|
+
* KIMI_API_KEY、opencode-go 与 opencode 同用 OPENCODE_API_KEY)。 */
|
|
4060
|
+
function routeKeyRefCandidates(route) {
|
|
4061
|
+
return [`${route.replaceAll("-", "_").toUpperCase()}_API_KEY`, ...{
|
|
4062
|
+
"kimi-coding": ["KIMI_API_KEY"],
|
|
4063
|
+
"opencode-go": ["OPENCODE_API_KEY"]
|
|
4064
|
+
}[route] ?? []];
|
|
4065
|
+
}
|
|
4066
|
+
/** 依次解析候选 ref,返回第一个解析出非空值的 ref 与值;全部落空返回 undefined。 */
|
|
4067
|
+
async function firstResolvedRef(refs, resolveRef) {
|
|
4068
|
+
for (const ref of refs) {
|
|
4069
|
+
const value = await resolveRef(ref);
|
|
4070
|
+
if (value !== void 0) return {
|
|
4071
|
+
ref,
|
|
4072
|
+
value
|
|
4073
|
+
};
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
//#endregion
|
|
4048
4077
|
//#region lib/types/resilience.js
|
|
4049
4078
|
/**
|
|
4050
4079
|
* 上游网络请求的稳定性工具:有限重试(指数退避)与每平台熔断冷却门。
|
|
@@ -4650,32 +4679,51 @@ const QUERIERS = [
|
|
|
4650
4679
|
];
|
|
4651
4680
|
/**
|
|
4652
4681
|
* Query every configured provider's account balance. A provider is queried only
|
|
4653
|
-
* when its llm-pi-ai route has an `apiKeyEnv
|
|
4654
|
-
*
|
|
4682
|
+
* when its llm-pi-ai route has a key: an explicit `apiKeyEnv`, or — on hosts
|
|
4683
|
+
* whose settings UI stores route keys as credentials refs — the route's
|
|
4684
|
+
* conventional ref (route-keys.ts). Absent routes answer `unconfigured` so the
|
|
4685
|
+
* dashboard shows a stable state instead of dropping the row.
|
|
4655
4686
|
* @param ctx - host context carrying the credentials seam.
|
|
4656
4687
|
* @param providers - the llm-pi-ai providers dict (`<route> → { apiKeyEnv? }`).
|
|
4657
4688
|
* @returns the balance rows (one per provider).
|
|
4658
4689
|
*/
|
|
4659
4690
|
async function queryBalances(ctx, providers) {
|
|
4660
|
-
const
|
|
4661
|
-
|
|
4691
|
+
const resolveRef = async (name) => {
|
|
4692
|
+
try {
|
|
4693
|
+
const hit = await ctx.credentials.resolve(credentialRef(name));
|
|
4694
|
+
return hit?.value !== void 0 && hit.value !== "" ? hit.value : void 0;
|
|
4695
|
+
} catch {
|
|
4696
|
+
return;
|
|
4697
|
+
}
|
|
4698
|
+
};
|
|
4699
|
+
const resolved = await Promise.all(QUERIERS.map(async ({ route, querier, displayName }) => {
|
|
4662
4700
|
const env = providers[route]?.apiKeyEnv;
|
|
4663
|
-
const
|
|
4664
|
-
const
|
|
4665
|
-
|
|
4666
|
-
byName.set(displayName, {
|
|
4701
|
+
const explicit = typeof env === "string" && env !== "" ? env : void 0;
|
|
4702
|
+
const hit = await firstResolvedRef([...explicit !== void 0 ? [explicit] : [], ...routeKeyRefCandidates(route)], resolveRef);
|
|
4703
|
+
return {
|
|
4667
4704
|
displayName,
|
|
4668
4705
|
querier,
|
|
4669
|
-
|
|
4706
|
+
ref: hit?.ref ?? explicit,
|
|
4707
|
+
configured: explicit !== void 0 || hit !== void 0
|
|
4708
|
+
};
|
|
4709
|
+
}));
|
|
4710
|
+
const byName = /* @__PURE__ */ new Map();
|
|
4711
|
+
for (const item of resolved) {
|
|
4712
|
+
const existing = byName.get(item.displayName);
|
|
4713
|
+
if (existing !== void 0 && existing.ref !== void 0) continue;
|
|
4714
|
+
byName.set(item.displayName, {
|
|
4715
|
+
displayName: item.displayName,
|
|
4716
|
+
querier: item.querier,
|
|
4717
|
+
ref: item.configured ? item.ref : void 0
|
|
4670
4718
|
});
|
|
4671
4719
|
}
|
|
4672
|
-
return await Promise.all([...byName.values()].map(({
|
|
4673
|
-
if (
|
|
4720
|
+
return await Promise.all([...byName.values()].map(({ displayName, querier, ref }) => {
|
|
4721
|
+
if (ref === void 0) return Promise.resolve({
|
|
4674
4722
|
provider: displayName,
|
|
4675
4723
|
displayName,
|
|
4676
4724
|
error: "unconfigured"
|
|
4677
4725
|
});
|
|
4678
|
-
return querier(ctx,
|
|
4726
|
+
return querier(ctx, ref);
|
|
4679
4727
|
}));
|
|
4680
4728
|
}
|
|
4681
4729
|
/**
|
|
@@ -4913,8 +4961,9 @@ async function queryCustomBalances(ctx, configs) {
|
|
|
4913
4961
|
* 2. `path` 必须单斜杠绝对路径:`//evil.example/x` 是协议相对 URL,`new URL()` 会
|
|
4914
4962
|
* 把它解析到别的主机;构造后还会再校验一次 origin。
|
|
4915
4963
|
* 3. 只发 GET,无请求体,无自定义 method / headers。
|
|
4916
|
-
* 4.
|
|
4917
|
-
*
|
|
4964
|
+
* 4. 凭据仍只从匹配 provider 自己的凭据取:显式 `apiKeyEnv` 优先,缺省按路由
|
|
4965
|
+
* 约定 ref(新版宿主的存放形态,见 route-keys.ts)经同一凭据 seam 解析;声明
|
|
4966
|
+
* 不能指定任何凭据。
|
|
4918
4967
|
* 5. 跨源重定向直接失败,不跟随——那是绕过第 1 条最省事的办法。
|
|
4919
4968
|
* 6. 响应体有大小上限与共享超时,坏 / 恶意端点拖不住面板。
|
|
4920
4969
|
* 7. 声明不能覆盖内置读法:只在内置表答不上来时它才轮到。
|
|
@@ -5052,10 +5101,21 @@ function compileDeclaredEndpoint(decl) {
|
|
|
5052
5101
|
*/
|
|
5053
5102
|
async function queryDeclaredEndpoints(ctx, providers, declarations) {
|
|
5054
5103
|
const byOrigin = /* @__PURE__ */ new Map();
|
|
5055
|
-
for (const entry of Object.
|
|
5104
|
+
for (const [route, entry] of Object.entries(providers)) {
|
|
5056
5105
|
const origin = entry.baseURL === void 0 ? void 0 : normalizeDeclaredOrigin(entry.baseURL);
|
|
5057
|
-
if (origin !== void 0 && !byOrigin.has(origin)) byOrigin.set(origin,
|
|
5106
|
+
if (origin !== void 0 && !byOrigin.has(origin)) byOrigin.set(origin, {
|
|
5107
|
+
route,
|
|
5108
|
+
...entry.apiKeyEnv !== void 0 ? { apiKeyEnv: entry.apiKeyEnv } : {}
|
|
5109
|
+
});
|
|
5058
5110
|
}
|
|
5111
|
+
const resolveRef = async (name) => {
|
|
5112
|
+
try {
|
|
5113
|
+
const hit = await ctx.credentials.resolve(credentialRef(name));
|
|
5114
|
+
return hit?.value !== void 0 && hit.value !== "" ? hit.value : void 0;
|
|
5115
|
+
} catch {
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5118
|
+
};
|
|
5059
5119
|
return await Promise.all(declarations.map(async (decl) => {
|
|
5060
5120
|
const displayName = typeof decl.displayName === "string" && decl.displayName !== "" ? decl.displayName : "已声明";
|
|
5061
5121
|
const provider = `declared:${displayName}`;
|
|
@@ -5083,8 +5143,8 @@ async function queryDeclaredEndpoints(ctx, providers, declarations) {
|
|
|
5083
5143
|
declared: true,
|
|
5084
5144
|
error: "unreachable"
|
|
5085
5145
|
};
|
|
5086
|
-
const hit = await
|
|
5087
|
-
if (hit === void 0
|
|
5146
|
+
const hit = await firstResolvedRef([...matched.apiKeyEnv !== void 0 ? [matched.apiKeyEnv] : [], ...routeKeyRefCandidates(matched.route)], resolveRef);
|
|
5147
|
+
if (hit === void 0) return {
|
|
5088
5148
|
provider,
|
|
5089
5149
|
displayName,
|
|
5090
5150
|
declared: true,
|
|
@@ -5712,7 +5772,11 @@ function swallowed(reason, error) {
|
|
|
5712
5772
|
* other subscription providers the harness recognizes (volcengine / baidu /
|
|
5713
5773
|
* qwen / xiaomi token plans, agent plans…) are identified and listed with a
|
|
5714
5774
|
* "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
|
|
5715
|
-
* settings namespace
|
|
5775
|
+
* settings namespace resolved through the credentials seam: the classic
|
|
5776
|
+
* `apiKeyEnv` refs, or — on hosts whose settings UI stores route keys as
|
|
5777
|
+
* credentials refs named after the route (`<ROUTE>_API_KEY`, catalog ambient
|
|
5778
|
+
* names as fallback) — the conventional refs, matched against the same
|
|
5779
|
+
* subscription-class route ids.
|
|
5716
5780
|
*/
|
|
5717
5781
|
/** 每平台熔断门:某个订阅适配器连续可重试失败(网络 / 5xx / 429)达阈值后
|
|
5718
5782
|
* 短路由不可用,避免 30 秒轮询对已故障的上游反复打满超时。鉴权失败(unauthorized)
|
|
@@ -5761,6 +5825,88 @@ const EMPTY_SUBSCRIPTION_KEYS = {
|
|
|
5761
5825
|
tencentCloudApi: "",
|
|
5762
5826
|
zaiRegion: "global"
|
|
5763
5827
|
};
|
|
5828
|
+
/** 订阅 provider id(llm-pi-ai 设置键)→ SubscriptionKeys 字段的映射。 */
|
|
5829
|
+
const SUBSCRIPTION_KEY_SOURCES = [
|
|
5830
|
+
{
|
|
5831
|
+
provider: "kimi-coding",
|
|
5832
|
+
key: "kimiApiKey"
|
|
5833
|
+
},
|
|
5834
|
+
{
|
|
5835
|
+
provider: "zai-coding-cn",
|
|
5836
|
+
key: "zaiCnApiKey"
|
|
5837
|
+
},
|
|
5838
|
+
{
|
|
5839
|
+
provider: "zai-coding",
|
|
5840
|
+
key: "zaiApiKey"
|
|
5841
|
+
},
|
|
5842
|
+
{
|
|
5843
|
+
provider: "opencode",
|
|
5844
|
+
key: "opencodeApiKey"
|
|
5845
|
+
},
|
|
5846
|
+
{
|
|
5847
|
+
provider: "opencode-go",
|
|
5848
|
+
key: "opencodeApiKey"
|
|
5849
|
+
},
|
|
5850
|
+
{
|
|
5851
|
+
provider: "minimax",
|
|
5852
|
+
key: "minmaxApiKey"
|
|
5853
|
+
},
|
|
5854
|
+
{
|
|
5855
|
+
provider: "minimax-token-plan",
|
|
5856
|
+
key: "minmaxApiKey"
|
|
5857
|
+
},
|
|
5858
|
+
{
|
|
5859
|
+
provider: "minimax-token-plan-cn",
|
|
5860
|
+
key: "minmaxApiKey"
|
|
5861
|
+
},
|
|
5862
|
+
{
|
|
5863
|
+
provider: "minimax-cn",
|
|
5864
|
+
key: "minmaxApiKey"
|
|
5865
|
+
},
|
|
5866
|
+
{
|
|
5867
|
+
provider: "openrouter",
|
|
5868
|
+
key: "openrouterApiKey"
|
|
5869
|
+
},
|
|
5870
|
+
{
|
|
5871
|
+
provider: "commandcode",
|
|
5872
|
+
key: "commandcodeApiKey"
|
|
5873
|
+
},
|
|
5874
|
+
{
|
|
5875
|
+
provider: "tencent-token-plan",
|
|
5876
|
+
key: "tencentCloudApi"
|
|
5877
|
+
}
|
|
5878
|
+
];
|
|
5879
|
+
/**
|
|
5880
|
+
* key 解析的可测核心(无文件 I/O):先显式 `apiKeyEnv`(旧口径,优先),key 仍缺
|
|
5881
|
+
* 时按约定 ref 名解析(新版宿主的存放形态,见 route-keys.ts)。`keyConfigured`
|
|
5882
|
+
* 回报每条路由的 key 是否解析成功,供识别层作为第三配置信号(refResolved)。
|
|
5883
|
+
* @param providers - llm-pi-ai 的 providers 视图。
|
|
5884
|
+
* @param resolveRef - 凭据 seam:ref 名 → 非空 key 值;未配置/失败返回 undefined。
|
|
5885
|
+
*/
|
|
5886
|
+
async function resolveSubscriptionAccess(providers, resolveRef) {
|
|
5887
|
+
const keys = { ...EMPTY_SUBSCRIPTION_KEYS };
|
|
5888
|
+
const keyConfigured = /* @__PURE__ */ new Set();
|
|
5889
|
+
for (const { provider, key } of SUBSCRIPTION_KEY_SOURCES) {
|
|
5890
|
+
const env = providers[provider]?.apiKeyEnv;
|
|
5891
|
+
if (typeof env === "string" && env !== "") {
|
|
5892
|
+
const value = await resolveRef(env);
|
|
5893
|
+
if (value !== void 0) {
|
|
5894
|
+
keys[key] = value;
|
|
5895
|
+
keyConfigured.add(provider);
|
|
5896
|
+
continue;
|
|
5897
|
+
}
|
|
5898
|
+
}
|
|
5899
|
+
const hit = await firstResolvedRef(routeKeyRefCandidates(provider), resolveRef);
|
|
5900
|
+
if (hit !== void 0) {
|
|
5901
|
+
keys[key] = hit.value;
|
|
5902
|
+
keyConfigured.add(provider);
|
|
5903
|
+
}
|
|
5904
|
+
}
|
|
5905
|
+
return {
|
|
5906
|
+
keys,
|
|
5907
|
+
keyConfigured
|
|
5908
|
+
};
|
|
5909
|
+
}
|
|
5764
5910
|
/** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
|
|
5765
5911
|
const SUBSCRIPTION_DISPLAY_NAMES = {
|
|
5766
5912
|
"kimi-coding": "Kimi For Coding",
|
|
@@ -5814,14 +5960,15 @@ const SUBSCRIPTION_ADAPTERS = {
|
|
|
5814
5960
|
/** 有额度适配器的 provider id 集合(识别用)。 */
|
|
5815
5961
|
const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
|
|
5816
5962
|
/**
|
|
5817
|
-
* 从 llm-pi-ai 设置里识别订阅套餐:带订阅类 id
|
|
5963
|
+
* 从 llm-pi-ai 设置里识别订阅套餐:带订阅类 id 且任一配置信号成立的 provider
|
|
5964
|
+
* (显式 apiKeyEnv / models 已配置 / 约定 ref 解析出了 key)。
|
|
5818
5965
|
* @param providers - the `providers` map of the llm-pi-ai settings namespace.
|
|
5819
5966
|
* @returns identified plans in configuration order.
|
|
5820
5967
|
*/
|
|
5821
5968
|
function identifySubscriptionPlans(providers) {
|
|
5822
5969
|
const out = [];
|
|
5823
5970
|
for (const [id, config] of Object.entries(providers ?? {})) {
|
|
5824
|
-
if (typeof config?.apiKeyEnv
|
|
5971
|
+
if (!(typeof config?.apiKeyEnv === "string" && config.apiKeyEnv !== "" || config?.hasModels === true || config?.refResolved === true)) continue;
|
|
5825
5972
|
if (!isSubscriptionProviderId(id)) continue;
|
|
5826
5973
|
out.push({
|
|
5827
5974
|
provider: id,
|
|
@@ -6552,9 +6699,10 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
|
|
|
6552
6699
|
*
|
|
6553
6700
|
* 适用场景:用户把某条 llm-pi-ai provider 路由的 `baseURL` 指向第三方中转站
|
|
6554
6701
|
* (New API / One API / VoAPI / Sub2API 等)。这类站点不卖官方余额,卖的是
|
|
6555
|
-
* 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL
|
|
6556
|
-
* apiKeyEnv
|
|
6557
|
-
*
|
|
6702
|
+
* 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL 且 key 可得**
|
|
6703
|
+
* (显式 `apiKeyEnv`,或新版宿主存进凭据 refs 的路由约定名,见 route-keys.ts)
|
|
6704
|
+
* 的路由逐个探测两个已知端点,能解析出额度就返回;解析不出的静默标记
|
|
6705
|
+
* unavailable,绝不臆造金额(与 balance/subscriptions 一致的姿态)。
|
|
6558
6706
|
*
|
|
6559
6707
|
* 探测顺序:先 Sub2API `/v1/usage`(标准化程度高),再 New API `/api/status`;
|
|
6560
6708
|
* 404 = 不是该套程序,继续试下一种;401/403 = 是但 key 不对(unauthorized);
|
|
@@ -6744,7 +6892,7 @@ async function readRelayByKind(baseURL, apiKey, kind) {
|
|
|
6744
6892
|
/**
|
|
6745
6893
|
* 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
|
|
6746
6894
|
* @param ctx - host context carrying the credentials seam.
|
|
6747
|
-
* @param route - 待探测的路由(baseURL +
|
|
6895
|
+
* @param route - 待探测的路由(baseURL + 凭据 ref 候选)。
|
|
6748
6896
|
* @returns 该路由的一行额度结果(status 标记成败)。
|
|
6749
6897
|
*/
|
|
6750
6898
|
async function queryRelayQuota(ctx, route) {
|
|
@@ -6753,32 +6901,32 @@ async function queryRelayQuota(ctx, route) {
|
|
|
6753
6901
|
origin: originOf(route.baseURL),
|
|
6754
6902
|
displayName: route.displayName ?? route.route
|
|
6755
6903
|
};
|
|
6756
|
-
const gateKey = `${route.baseURL}::${route.
|
|
6904
|
+
const gateKey = `${route.baseURL}::${route.route}`;
|
|
6757
6905
|
if (!relayGate.check(gateKey)) return {
|
|
6758
6906
|
...base,
|
|
6759
6907
|
kind: "unknown",
|
|
6760
6908
|
status: "unavailable"
|
|
6761
6909
|
};
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
if (hit === void 0 || hit.value === "") return {
|
|
6910
|
+
const resolveRef = async (name) => {
|
|
6911
|
+
try {
|
|
6912
|
+
const hit = await ctx.credentials.resolve(credentialRef(name));
|
|
6913
|
+
return hit?.value !== void 0 && hit.value !== "" ? hit.value : void 0;
|
|
6914
|
+
} catch {
|
|
6915
|
+
return;
|
|
6916
|
+
}
|
|
6917
|
+
};
|
|
6918
|
+
const hit = await firstResolvedRef(route.apiKeyRefs, resolveRef);
|
|
6919
|
+
if (hit === void 0) return {
|
|
6773
6920
|
...base,
|
|
6774
6921
|
kind: "unknown",
|
|
6775
6922
|
status: "not-configured"
|
|
6776
6923
|
};
|
|
6924
|
+
const apiKey = hit.value;
|
|
6777
6925
|
const cached = fingerprintCache.get(base.origin);
|
|
6778
6926
|
const knownKind = cached !== void 0 && Date.now() - cached.at < FINGERPRINT_TTL_MS ? cached.kind : void 0;
|
|
6779
6927
|
try {
|
|
6780
6928
|
if (knownKind === "sub2api" || knownKind === "new-api") {
|
|
6781
|
-
const parsed = await readRelayByKind(route.baseURL,
|
|
6929
|
+
const parsed = await readRelayByKind(route.baseURL, apiKey, knownKind);
|
|
6782
6930
|
if (parsed !== null) {
|
|
6783
6931
|
relayGate.success(gateKey);
|
|
6784
6932
|
return {
|
|
@@ -6796,7 +6944,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
6796
6944
|
status: "invalid-response"
|
|
6797
6945
|
};
|
|
6798
6946
|
}
|
|
6799
|
-
const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"),
|
|
6947
|
+
const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), apiKey);
|
|
6800
6948
|
if (sub2.ok) {
|
|
6801
6949
|
const parsed = parseSub2ApiUsage(sub2.data);
|
|
6802
6950
|
if (parsed !== null) {
|
|
@@ -6820,7 +6968,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
6820
6968
|
status: "invalid-response"
|
|
6821
6969
|
};
|
|
6822
6970
|
}
|
|
6823
|
-
const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"),
|
|
6971
|
+
const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), apiKey);
|
|
6824
6972
|
if (na.ok) {
|
|
6825
6973
|
const parsed = parseNewApiStatus(na.data);
|
|
6826
6974
|
if (parsed !== null) {
|
|
@@ -6862,7 +7010,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
6862
7010
|
/**
|
|
6863
7011
|
* 批量查询多个中转站路由的额度(每个独立成败,互不影响)。
|
|
6864
7012
|
* @param ctx - host context carrying the credentials seam.
|
|
6865
|
-
* @param routes - 配了 baseURL 且
|
|
7013
|
+
* @param routes - 配了 baseURL 且 key 候选可得(显式引用存在或约定 ref 解析出值)的路由列表。
|
|
6866
7014
|
* @returns 每个路由一行的额度结果。
|
|
6867
7015
|
*/
|
|
6868
7016
|
async function queryRelayQuotas(ctx, routes) {
|
|
@@ -7584,61 +7732,6 @@ const inject = [
|
|
|
7584
7732
|
"credentials",
|
|
7585
7733
|
"settings"
|
|
7586
7734
|
];
|
|
7587
|
-
/**
|
|
7588
|
-
* 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
|
|
7589
|
-
* 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
|
|
7590
|
-
*/
|
|
7591
|
-
/** key 只取字符串凭据字段:zaiRegion 是区域枚举,由下方区域逻辑单独赋值。 */
|
|
7592
|
-
const SUBSCRIPTION_KEY_SOURCES = [
|
|
7593
|
-
{
|
|
7594
|
-
provider: "kimi-coding",
|
|
7595
|
-
key: "kimiApiKey"
|
|
7596
|
-
},
|
|
7597
|
-
{
|
|
7598
|
-
provider: "zai-coding-cn",
|
|
7599
|
-
key: "zaiCnApiKey"
|
|
7600
|
-
},
|
|
7601
|
-
{
|
|
7602
|
-
provider: "zai-coding",
|
|
7603
|
-
key: "zaiApiKey"
|
|
7604
|
-
},
|
|
7605
|
-
{
|
|
7606
|
-
provider: "opencode",
|
|
7607
|
-
key: "opencodeApiKey"
|
|
7608
|
-
},
|
|
7609
|
-
{
|
|
7610
|
-
provider: "opencode-go",
|
|
7611
|
-
key: "opencodeApiKey"
|
|
7612
|
-
},
|
|
7613
|
-
{
|
|
7614
|
-
provider: "minimax",
|
|
7615
|
-
key: "minmaxApiKey"
|
|
7616
|
-
},
|
|
7617
|
-
{
|
|
7618
|
-
provider: "minimax-token-plan",
|
|
7619
|
-
key: "minmaxApiKey"
|
|
7620
|
-
},
|
|
7621
|
-
{
|
|
7622
|
-
provider: "minimax-token-plan-cn",
|
|
7623
|
-
key: "minmaxApiKey"
|
|
7624
|
-
},
|
|
7625
|
-
{
|
|
7626
|
-
provider: "minimax-cn",
|
|
7627
|
-
key: "minmaxApiKey"
|
|
7628
|
-
},
|
|
7629
|
-
{
|
|
7630
|
-
provider: "openrouter",
|
|
7631
|
-
key: "openrouterApiKey"
|
|
7632
|
-
},
|
|
7633
|
-
{
|
|
7634
|
-
provider: "commandcode",
|
|
7635
|
-
key: "commandcodeApiKey"
|
|
7636
|
-
},
|
|
7637
|
-
{
|
|
7638
|
-
provider: "tencent-token-plan",
|
|
7639
|
-
key: "tencentCloudApi"
|
|
7640
|
-
}
|
|
7641
|
-
];
|
|
7642
7735
|
/** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
|
|
7643
7736
|
* 余额与订阅查询复用同一份来源:部署为某个 provider 配一次,多 surface 共享。
|
|
7644
7737
|
* @param settings - the settings service (reads the llm-pi-ai namespace).
|
|
@@ -7651,10 +7744,12 @@ async function readPiAiProviders(settings) {
|
|
|
7651
7744
|
for (const [route, entry] of Object.entries(providers ?? {})) {
|
|
7652
7745
|
if (entry === null || typeof entry !== "object") continue;
|
|
7653
7746
|
const { apiKeyEnv, baseURL, displayName } = entry;
|
|
7747
|
+
const models = entry.models;
|
|
7654
7748
|
out[route] = {
|
|
7655
7749
|
...typeof apiKeyEnv === "string" ? { apiKeyEnv } : {},
|
|
7656
7750
|
...typeof baseURL === "string" ? { baseURL } : {},
|
|
7657
|
-
...typeof displayName === "string" ? { displayName } : {}
|
|
7751
|
+
...typeof displayName === "string" ? { displayName } : {},
|
|
7752
|
+
...Array.isArray(models) && models.length > 0 ? { hasModels: true } : {}
|
|
7658
7753
|
};
|
|
7659
7754
|
}
|
|
7660
7755
|
return out;
|
|
@@ -7761,51 +7856,47 @@ function buildWorkspaceTitleResolver(ctx) {
|
|
|
7761
7856
|
};
|
|
7762
7857
|
}
|
|
7763
7858
|
/**
|
|
7764
|
-
*
|
|
7765
|
-
*
|
|
7766
|
-
* 同时识别出用户配置了 key 的订阅套餐(供面板只显示已识别的)。
|
|
7767
|
-
* @param settings - the settings service (reads the llm-pi-ai namespace).
|
|
7768
|
-
* @param credentials - the credentials service (resolves the env refs).
|
|
7859
|
+
* 凭据 seam 的宽容解析:ref 名 → 非空值;失败/缺失返回 undefined(多 surface 共用,
|
|
7860
|
+
* 失败按结构化静默记录)。
|
|
7769
7861
|
*/
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
let providers;
|
|
7773
|
-
try {
|
|
7774
|
-
providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
|
|
7775
|
-
} catch {
|
|
7776
|
-
return {
|
|
7777
|
-
keys,
|
|
7778
|
-
identified: []
|
|
7779
|
-
};
|
|
7780
|
-
}
|
|
7781
|
-
for (const { provider, key } of SUBSCRIPTION_KEY_SOURCES) {
|
|
7782
|
-
const env = providers?.[provider]?.apiKeyEnv;
|
|
7783
|
-
if (typeof env !== "string" || env === "") continue;
|
|
7862
|
+
function refResolver(credentials) {
|
|
7863
|
+
return async (name) => {
|
|
7784
7864
|
try {
|
|
7785
|
-
const resolved = await credentials.resolve(credentialRef(
|
|
7786
|
-
|
|
7865
|
+
const resolved = await credentials.resolve(credentialRef(name));
|
|
7866
|
+
return resolved?.value !== void 0 && resolved.value !== "" ? resolved.value : void 0;
|
|
7787
7867
|
} catch (error) {
|
|
7788
7868
|
swallowed("credential-resolve-failed", error);
|
|
7869
|
+
return;
|
|
7789
7870
|
}
|
|
7790
|
-
}
|
|
7791
|
-
|
|
7871
|
+
};
|
|
7872
|
+
}
|
|
7873
|
+
/**
|
|
7874
|
+
* 解析订阅适配器需要的 API Key:经典形态从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
|
|
7875
|
+
* 读引用(如 kimi-coding → KIMI_CODING_API_KEY);新版宿主设置页把 key 直接存成
|
|
7876
|
+
* 凭据 refs(路由约定名 `<ROUTE>_API_KEY`,pi-ai 目录 ambient 名补充),显式引用
|
|
7877
|
+
* 解析不到时按约定名找回。同时识别出已配置的订阅套餐(供面板只显示已识别的)。
|
|
7878
|
+
* @param settings - the settings service (reads the llm-pi-ai namespace).
|
|
7879
|
+
* @param credentials - the credentials service (resolves the env refs).
|
|
7880
|
+
*/
|
|
7881
|
+
async function resolveSubscriptionKeys(settings, credentials) {
|
|
7882
|
+
const providers = await readPiAiProviders(settings);
|
|
7883
|
+
const { keys, keyConfigured } = await resolveSubscriptionAccess(providers, refResolver(credentials));
|
|
7884
|
+
if (keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
|
|
7792
7885
|
if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
|
|
7793
7886
|
keys.anthropicApiKey = await readClaudeOAuthToken();
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
};
|
|
7805
|
-
}
|
|
7887
|
+
const identified = identifySubscriptionPlans(Object.fromEntries(Object.entries(providers).map(([route, entry]) => [route, {
|
|
7888
|
+
...entry.apiKeyEnv !== void 0 ? { apiKeyEnv: entry.apiKeyEnv } : {},
|
|
7889
|
+
...entry.hasModels === true ? { hasModels: true } : {},
|
|
7890
|
+
...keyConfigured.has(route) ? { refResolved: true } : {}
|
|
7891
|
+
}])));
|
|
7892
|
+
if (keys.anthropicApiKey !== "" && !identified.some((plan) => plan.provider === "anthropic")) identified.push({
|
|
7893
|
+
provider: "anthropic",
|
|
7894
|
+
displayName: "Claude (Anthropic)",
|
|
7895
|
+
adapter: true
|
|
7896
|
+
});
|
|
7806
7897
|
return {
|
|
7807
7898
|
keys,
|
|
7808
|
-
identified
|
|
7899
|
+
identified
|
|
7809
7900
|
};
|
|
7810
7901
|
}
|
|
7811
7902
|
/** 读本机 OpenCode 客户端的凭据 token;取不到返回空串(安静退回)。 */
|
|
@@ -8341,14 +8432,18 @@ function apply(ctx, config = {}) {
|
|
|
8341
8432
|
};
|
|
8342
8433
|
const refreshRelay = async () => {
|
|
8343
8434
|
const providers = await readPiAiProviders(ctx.settings);
|
|
8435
|
+
const resolve = refResolver(ctx.credentials);
|
|
8344
8436
|
const routes = [];
|
|
8345
8437
|
for (const [route, entry] of Object.entries(providers)) {
|
|
8346
|
-
if (entry.baseURL === void 0
|
|
8438
|
+
if (entry.baseURL === void 0) continue;
|
|
8347
8439
|
if (isOfficialBaseUrl(entry.baseURL)) continue;
|
|
8440
|
+
const apiKeyRefs = [.../* @__PURE__ */ new Set([...entry.apiKeyEnv !== void 0 ? [entry.apiKeyEnv] : [], ...routeKeyRefCandidates(route)])];
|
|
8441
|
+
const hit = await firstResolvedRef(apiKeyRefs, resolve);
|
|
8442
|
+
if (entry.apiKeyEnv === void 0 && hit === void 0) continue;
|
|
8348
8443
|
routes.push({
|
|
8349
8444
|
route,
|
|
8350
8445
|
baseURL: entry.baseURL,
|
|
8351
|
-
|
|
8446
|
+
apiKeyRefs,
|
|
8352
8447
|
...entry.displayName === void 0 ? {} : { displayName: entry.displayName }
|
|
8353
8448
|
});
|
|
8354
8449
|
}
|
package/lib/types/balance.d.ts
CHANGED
|
@@ -24,8 +24,10 @@ import type { CustomBalanceConfig, CustomBalanceExtract, ProviderBalance } from
|
|
|
24
24
|
export declare function pickTokenDanceBalanceCny(data: unknown): number | undefined;
|
|
25
25
|
/**
|
|
26
26
|
* Query every configured provider's account balance. A provider is queried only
|
|
27
|
-
* when its llm-pi-ai route has an `apiKeyEnv
|
|
28
|
-
*
|
|
27
|
+
* when its llm-pi-ai route has a key: an explicit `apiKeyEnv`, or — on hosts
|
|
28
|
+
* whose settings UI stores route keys as credentials refs — the route's
|
|
29
|
+
* conventional ref (route-keys.ts). Absent routes answer `unconfigured` so the
|
|
30
|
+
* dashboard shows a stable state instead of dropping the row.
|
|
29
31
|
* @param ctx - host context carrying the credentials seam.
|
|
30
32
|
* @param providers - the llm-pi-ai providers dict (`<route> → { apiKeyEnv? }`).
|
|
31
33
|
* @returns the balance rows (one per provider).
|
|
@@ -33,6 +33,10 @@ export type BudgetPrefsActions = {
|
|
|
33
33
|
setModelBudget: (d: BudgetPrefsState, key: string, value: number) => void;
|
|
34
34
|
markModelTierAlerted: (d: BudgetPrefsState, marks: readonly string[], day: string) => void;
|
|
35
35
|
};
|
|
36
|
+
/** 读取兜底:v1.5.x 老持久化状态缺 `modelBudgets` 键(模块级常量,引用稳定不触发重渲染)。 */
|
|
37
|
+
export declare const EMPTY_MODEL_BUDGETS: Readonly<Record<string, number>>;
|
|
38
|
+
/** 读取兜底:v1.5.x 老持久化状态缺 `modelTierAlertDays` 键(同上)。 */
|
|
39
|
+
export declare const EMPTY_MODEL_TIER_ALERT_DAYS: Readonly<Record<string, string>>;
|
|
36
40
|
/**
|
|
37
41
|
* Declare the budget-preferences store handle.
|
|
38
42
|
* @returns the store handle for the register call's store seat.
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* 2. `path` 必须单斜杠绝对路径:`//evil.example/x` 是协议相对 URL,`new URL()` 会
|
|
15
15
|
* 把它解析到别的主机;构造后还会再校验一次 origin。
|
|
16
16
|
* 3. 只发 GET,无请求体,无自定义 method / headers。
|
|
17
|
-
* 4.
|
|
18
|
-
*
|
|
17
|
+
* 4. 凭据仍只从匹配 provider 自己的凭据取:显式 `apiKeyEnv` 优先,缺省按路由
|
|
18
|
+
* 约定 ref(新版宿主的存放形态,见 route-keys.ts)经同一凭据 seam 解析;声明
|
|
19
|
+
* 不能指定任何凭据。
|
|
19
20
|
* 5. 跨源重定向直接失败,不跟随——那是绕过第 1 条最省事的办法。
|
|
20
21
|
* 6. 响应体有大小上限与共享超时,坏 / 恶意端点拖不住面板。
|
|
21
22
|
* 7. 声明不能覆盖内置读法:只在内置表答不上来时它才轮到。
|
package/lib/types/index.d.ts
CHANGED
|
@@ -41,12 +41,14 @@ export declare const inject: string[];
|
|
|
41
41
|
* @param settings - the settings service (reads the llm-pi-ai namespace).
|
|
42
42
|
* @returns the providers dict; empty when the namespace is unreadable.
|
|
43
43
|
*/
|
|
44
|
-
/** 一个 llm-pi-ai provider
|
|
45
|
-
*
|
|
44
|
+
/** 一个 llm-pi-ai provider 路由的读取视图:apiKeyEnv(凭据引用)、baseURL(中转站
|
|
45
|
+
* 零配置发现)、displayName(站点显示名)、hasModels(新版宿主设置页保存的
|
|
46
|
+
* models 列表——订阅识别的第二配置信号)。 */
|
|
46
47
|
export interface PiAiProviderRoute {
|
|
47
48
|
apiKeyEnv?: string;
|
|
48
49
|
baseURL?: string;
|
|
49
50
|
displayName?: string;
|
|
51
|
+
hasModels?: boolean;
|
|
50
52
|
}
|
|
51
53
|
/** 同步读取 provider 路由的 baseURL 视图(中转站零配置发现来源)。
|
|
52
54
|
* `settings.describe` 是同步调用,聚合器每次折叠取最新站点映射,无需缓存/过期。
|
|
@@ -75,9 +77,10 @@ export declare function readRegisteredProviderIds(get: ((name: string) => unknow
|
|
|
75
77
|
reason?: string;
|
|
76
78
|
};
|
|
77
79
|
/**
|
|
78
|
-
|
|
79
|
-
* 读引用(如 kimi-coding → KIMI_CODING_API_KEY
|
|
80
|
-
*
|
|
80
|
+
* 解析订阅适配器需要的 API Key:经典形态从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
|
|
81
|
+
* 读引用(如 kimi-coding → KIMI_CODING_API_KEY);新版宿主设置页把 key 直接存成
|
|
82
|
+
* 凭据 refs(路由约定名 `<ROUTE>_API_KEY`,pi-ai 目录 ambient 名补充),显式引用
|
|
83
|
+
* 解析不到时按约定名找回。同时识别出已配置的订阅套餐(供面板只显示已识别的)。
|
|
81
84
|
* @param settings - the settings service (reads the llm-pi-ai namespace).
|
|
82
85
|
* @param credentials - the credentials service (resolves the env refs).
|
|
83
86
|
*/
|
package/lib/types/relay.d.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 适用场景:用户把某条 llm-pi-ai provider 路由的 `baseURL` 指向第三方中转站
|
|
5
5
|
* (New API / One API / VoAPI / Sub2API 等)。这类站点不卖官方余额,卖的是
|
|
6
|
-
* 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL
|
|
7
|
-
* apiKeyEnv
|
|
8
|
-
*
|
|
6
|
+
* 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL 且 key 可得**
|
|
7
|
+
* (显式 `apiKeyEnv`,或新版宿主存进凭据 refs 的路由约定名,见 route-keys.ts)
|
|
8
|
+
* 的路由逐个探测两个已知端点,能解析出额度就返回;解析不出的静默标记
|
|
9
|
+
* unavailable,绝不臆造金额(与 balance/subscriptions 一致的姿态)。
|
|
9
10
|
*
|
|
10
11
|
* 探测顺序:先 Sub2API `/v1/usage`(标准化程度高),再 New API `/api/status`;
|
|
11
12
|
* 404 = 不是该套程序,继续试下一种;401/403 = 是但 key 不对(unauthorized);
|
|
@@ -13,14 +14,14 @@
|
|
|
13
14
|
*/
|
|
14
15
|
import type { Context } from '@deepseek-ai/cordis';
|
|
15
16
|
import type { RelayQuota, SubscriptionWindow } from './pricing-shared.ts';
|
|
16
|
-
/** 一个待探测的中转站路由(来自 llm-pi-ai providers 的 baseURL +
|
|
17
|
+
/** 一个待探测的中转站路由(来自 llm-pi-ai providers 的 baseURL + key 候选 ref)。 */
|
|
17
18
|
export interface RelayRoute {
|
|
18
19
|
/** llm-pi-ai providers 路由名(key)。 */
|
|
19
20
|
route: string;
|
|
20
21
|
/** 该路由配置的端点地址(origin 来源)。 */
|
|
21
22
|
baseURL: string;
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/** 该路由的凭据 ref 候选(按优先序:显式 apiKeyEnv 在首,路由约定名补后)。 */
|
|
24
|
+
apiKeyRefs: readonly string[];
|
|
24
25
|
/** 站点显示名;缺省用路由名。 */
|
|
25
26
|
displayName?: string;
|
|
26
27
|
}
|
|
@@ -53,14 +54,14 @@ export declare function isOfficialBaseUrl(baseURL: string): boolean;
|
|
|
53
54
|
/**
|
|
54
55
|
* 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
|
|
55
56
|
* @param ctx - host context carrying the credentials seam.
|
|
56
|
-
* @param route - 待探测的路由(baseURL +
|
|
57
|
+
* @param route - 待探测的路由(baseURL + 凭据 ref 候选)。
|
|
57
58
|
* @returns 该路由的一行额度结果(status 标记成败)。
|
|
58
59
|
*/
|
|
59
60
|
export declare function queryRelayQuota(ctx: Context, route: RelayRoute): Promise<RelayQuota>;
|
|
60
61
|
/**
|
|
61
62
|
* 批量查询多个中转站路由的额度(每个独立成败,互不影响)。
|
|
62
63
|
* @param ctx - host context carrying the credentials seam.
|
|
63
|
-
* @param routes - 配了 baseURL 且
|
|
64
|
+
* @param routes - 配了 baseURL 且 key 候选可得(显式引用存在或约定 ref 解析出值)的路由列表。
|
|
64
65
|
* @returns 每个路由一行的额度结果。
|
|
65
66
|
*/
|
|
66
67
|
export declare function queryRelayQuotas(ctx: Context, routes: readonly RelayRoute[]): Promise<readonly RelayQuota[]>;
|