feat(usage): CodingPlan.site 自动识别 NewAPI + 账单查询体验修复

- detect_provider 新增 codingplan.site → balance:newapi(该站为
  NewAPI 实例,实测确认),保存补齐 TS 侧缺失的 balance:newapi 断言
- 凭据缺失等确定性错误改走 Ok(failure)(原 Err 被前端一律显示为
  「网络异常」),错误消息按 kind 前缀包裹后仍可本地化映射
- formatAmount 收口到 usage-display 统一复用:¤ 占位符号不再渲染、
  金额统一两位小数(配置弹窗此前直接拼原始数值)
This commit is contained in:
KimiSwitch Dev committed 2026-09-05 16:04:38 +08:00
1 parent 81699e757d
commit 1a11cfbaaf
6 files changed
+198 -31

No files matched your search

+73 -10
View File
@@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") { if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter); kinds.push(UsageKind::BalanceOpenrouter);
} }
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
}
if url.contains("api.stepfun.com") { if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun); kinds.push(UsageKind::BalanceStepfun);
} }
@@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
kinds kinds
} }
/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。
/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。
fn newapi_creds(
usage_config: Option<&crate::models::UsageConfig>,
) -> Option<(&str, &str)> {
usage_config.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
}
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的 /// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。 /// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id), /// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
@@ -155,16 +173,13 @@ pub async fn query_kind(
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
} }
UsageKind::BalanceNewapi => { UsageKind::BalanceNewapi => {
let (token, uid) = usage_config // 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
.and_then(|c| { // 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
c.access_token let Some((token, uid)) = newapi_creds(usage_config) else {
.as_deref() return Ok(UsageResult::failure(
.zip(c.user_id.as_deref()) "newapi template requires accessToken and userId".to_string(),
.filter(|(t, u)| !t.is_empty() && !u.is_empty()) ));
}) };
.ok_or_else(|| {
"newapi template requires accessToken and userId".to_string()
})?;
let url = usage_config let url = usage_config
.and_then(|c| c.base_url.as_deref()) .and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@@ -262,6 +277,28 @@ mod tests {
); );
} }
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
}
#[test] #[test]
fn usage_kind_string_roundtrip() { fn usage_kind_string_roundtrip() {
use std::str::FromStr; use std::str::FromStr;
@@ -272,4 +309,30 @@ mod tests {
assert!(UsageKind::from_str("balance:unknown").is_err()); assert!(UsageKind::from_str("balance:unknown").is_err());
assert!(UsageKind::from_str("").is_err()); assert!(UsageKind::from_str("").is_err());
} }
#[test]
fn newapi_creds_requires_both_and_nonempty() {
use crate::models::UsageConfig;
let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig {
enabled: true,
template_type: "newapi".to_string(),
base_url: None,
access_token: token.map(str::to_string),
user_id: uid.map(str::to_string),
auto_query_interval_minutes: None,
timeout_seconds: None,
};
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None);
assert_eq!(newapi_creds(Some(&cfg(None, None))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None);
assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None);
// 两项齐全 → Some
assert_eq!(
newapi_creds(Some(&cfg(Some("tok"), Some("uid")))),
Some(("tok", "uid"))
);
}
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react"; import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError, planLabel } from "../lib/usage-display"; import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent, Provider, UsageConfig } from "../types"; import type { Agent, Provider, UsageConfig } from "../types";
interface UsageResult { interface UsageResult {
@@ -134,7 +134,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
.map((d) => { .map((d) => {
const parts: string[] = []; const parts: string[] = [];
if (d.planName) parts.push(planLabel(d.planName, t)); if (d.planName) parts.push(planLabel(d.planName, t));
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`); if (d.remaining != null) parts.push(`${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`);
if (d.used != null && d.total != null && d.total > 0) if (d.used != null && d.total != null && d.total > 0)
parts.push(`${Math.round((d.used / d.total) * 100)}%`); parts.push(`${Math.round((d.used / d.total) * 100)}%`);
return parts.join(" "); return parts.join(" ");
+1 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { localizeUsageError, planLabel } from "../lib/usage-display"; import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps { interface UsageFooterProps {
@@ -36,17 +36,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
return null; return null;
}; };
const formatAmount = (
value: number | null | undefined,
unit?: string | null
): string => {
if (value == null) return "—";
const symbol =
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
return `${symbol}${num}`;
};
const formatReset = (resetsAt?: string | null): string | null => { const formatReset = (resetsAt?: string | null): string | null => {
if (!resetsAt) return null; if (!resetsAt) return null;
const ms = new Date(resetsAt).getTime() - now; const ms = new Date(resetsAt).getTime() - now;
+2
View File
@@ -14,6 +14,7 @@ export type UsageKind =
| "balance:stepfun" | "balance:stepfun"
| "balance:novita" | "balance:novita"
| "balance:kimi" | "balance:kimi"
| "balance:newapi"
| "plan:kimi_coding" | "plan:kimi_coding"
| "plan:zhipu" | "plan:zhipu"
| "plan:minimax" | "plan:minimax"
@@ -30,6 +31,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:stepfun", "balance:stepfun",
"balance:novita", "balance:novita",
"balance:kimi", "balance:kimi",
"balance:newapi",
"plan:kimi_coding", "plan:kimi_coding",
"plan:zhipu", "plan:zhipu",
"plan:minimax", "plan:minimax",
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { formatAmount, localizeUsageError, planLabel } from "./usage-display";
// Minimal TranslateFn: return the key itself so assertions read the key.
const t = (key: string) => key;
describe("localizeUsageError", () => {
it("maps known Rust error prefixes to their localization keys", () => {
expect(
localizeUsageError("unsupported provider: no usage query available", t)
).toBe("usageUnsupportedProvider");
expect(localizeUsageError("no API key configured", t)).toBe("usageErrNoKey");
expect(localizeUsageError("usage query disabled in config panel", t)).toBe(
"usageErrDisabled"
);
expect(
localizeUsageError("no Kimi Code OAuth credentials found; run `kimi login` first", t)
).toBe("usageErrNoOauth");
expect(
localizeUsageError("newapi template requires accessToken and userId", t)
).toBe("usageErrNewapiCreds");
expect(localizeUsageError("Authentication failed: 401 Unauthorized", t)).toBe(
"usageInvalidKey"
);
});
it("login expired / refresh failed match case-insensitively anywhere", () => {
expect(localizeUsageError("LOGIN expired at 12:00", t)).toBe("usageErrLoginExpired");
expect(localizeUsageError("oauth refresh failed: 500", t)).toBe("usageErrLoginExpired");
});
it("strips the kinds-loop `{kind}: ` wrapper before matching", () => {
// commands.rs 的 kinds 循环把错误包成 "balance:newapi: ..." —— 必须仍命中。
expect(
localizeUsageError(
"balance:newapi: newapi template requires accessToken and userId",
t
)
).toBe("usageErrNewapiCreds");
expect(localizeUsageError("plan:zhipu: Authentication failed: 401", t)).toBe(
"usageInvalidKey"
);
expect(localizeUsageError("balance:deepseek: no API key configured", t)).toBe(
"usageErrNoKey"
);
});
it("passes unknown errors through unchanged (kind prefix retained)", () => {
const raw = "balance:openrouter: HTTP 429 too many requests";
expect(localizeUsageError(raw, t)).toBe(raw);
expect(localizeUsageError("HTTP 500 Something broke", t)).toBe(
"HTTP 500 Something broke"
);
});
});
describe("planLabel", () => {
it("maps known tier ids and passes unknown names through", () => {
expect(planLabel("five_hour", t)).toBe("usageTier5h");
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥");
});
});
describe("formatAmount", () => {
it("maps CNY and USD to ¥/$ with two decimals", () => {
expect(formatAmount(1.5, "CNY")).toBe("¥1.50");
expect(formatAmount(1.5, "USD")).toBe("$1.50");
});
it("treats ¤ (U+00A4) as no configured currency symbol", () => {
expect(formatAmount(136907472.01013, "¤")).toBe("136907472.01");
expect(formatAmount(136907472.01013, "\u{00A4}")).toBe("136907472.01");
});
it("prefixes arbitrary units without a symbol", () => {
// Non-integer so the two-decimal rule applies; rounds to 3.00.
expect(formatAmount(3.004, "credit")).toBe("credit 3.00");
});
it("renders null/undefined values as —", () => {
expect(formatAmount(null, "¤")).toBe("—");
expect(formatAmount(undefined, "CNY")).toBe("—");
});
it("keeps integers without decimals", () => {
expect(formatAmount(42, "CNY")).toBe("¥42");
expect(formatAmount(100, "USD")).toBe("$100");
expect(formatAmount(7, "")).toBe("7");
});
});
+27 -7
View File
@@ -35,12 +35,32 @@ export function planLabel(name: string, t: TranslateFn): string {
* src-tauri/src/commands.rs and src-tauri/src/services/*. * src-tauri/src/commands.rs and src-tauri/src/services/*.
*/ */
export function localizeUsageError(error: string, t: TranslateFn): string { export function localizeUsageError(error: string, t: TranslateFn): string {
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider"); // commands.rs 的 kinds 循环把单条错误包成 `{kind}: {msg}`(kind 含数字,
if (error.startsWith("no API key configured")) return t("usageErrNoKey"); // 如 plan:zhipu),先剥离再按前缀匹配;未知错误仍回退按原文展示(保留 kind
if (error.startsWith("usage query disabled")) return t("usageErrDisabled"); // 前缀,属诊断细节)。
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired"); const stripped = error.replace(/^(balance|plan):[a-z0-9_]+:\s*/, "");
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth"); if (stripped.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds"); if (stripped.startsWith("no API key configured")) return t("usageErrNoKey");
if (error.startsWith("Authentication failed")) return t("usageInvalidKey"); if (stripped.startsWith("usage query disabled")) return t("usageErrDisabled");
if (/login expired|refresh failed/i.test(stripped)) return t("usageErrLoginExpired");
if (stripped.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
if (stripped.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
if (stripped.startsWith("Authentication failed")) return t("usageInvalidKey");
return error; return error;
} }
/**
* Format a usage amount with its unit. `¤` (U+00A4) is NewAPI's placeholder
* when the site has no custom currency symbol configured — treat it as "no
* symbol" and render only the number.
*/
export function formatAmount(
value: number | null | undefined,
unit?: string | null
): string {
if (value == null) return "—";
const symbol =
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit && unit.trim() !== "¤" ? `${unit} ` : "";
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
return `${symbol}${num}`;
}