Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scripts/shots/core/en/overview.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"providers": [
{
"auth_header": "x-api-key",
"balance": null,
"base_url": "https://api.anthropic.com",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -179,6 +180,7 @@
},
{
"auth_header": "x-api-key",
"balance": null,
"base_url": "https://relay.example.com",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -209,6 +211,7 @@
},
{
"auth_header": "authorization",
"balance": null,
"base_url": "https://chatgpt.com/backend-api/codex",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -245,6 +248,7 @@
},
{
"auth_header": "authorization",
"balance": null,
"base_url": "https://openrouter.ai/api/v1",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -276,6 +280,7 @@
},
{
"auth_header": "x-api-key",
"balance": null,
"base_url": "https://api.deepseek.com/anthropic",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -303,6 +308,7 @@
},
{
"auth_header": "x-goog-api-key",
"balance": null,
"base_url": "https://generativelanguage.googleapis.com",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -335,6 +341,7 @@
},
{
"auth_header": "authorization",
"balance": null,
"base_url": "http://127.0.0.1:11434/v1",
"billing": "free",
"disabled": false,
Expand Down
7 changes: 7 additions & 0 deletions scripts/shots/core/zh/overview.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"providers": [
{
"auth_header": "x-api-key",
"balance": null,
"base_url": "https://api.anthropic.com",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -179,6 +180,7 @@
},
{
"auth_header": "x-api-key",
"balance": null,
"base_url": "https://relay.example.com",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -209,6 +211,7 @@
},
{
"auth_header": "authorization",
"balance": null,
"base_url": "https://chatgpt.com/backend-api/codex",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -245,6 +248,7 @@
},
{
"auth_header": "authorization",
"balance": null,
"base_url": "https://openrouter.ai/api/v1",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -276,6 +280,7 @@
},
{
"auth_header": "x-api-key",
"balance": null,
"base_url": "https://api.deepseek.com/anthropic",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -303,6 +308,7 @@
},
{
"auth_header": "x-goog-api-key",
"balance": null,
"base_url": "https://generativelanguage.googleapis.com",
"billing": "per-token",
"disabled": false,
Expand Down Expand Up @@ -335,6 +341,7 @@
},
{
"auth_header": "authorization",
"balance": null,
"base_url": "http://127.0.0.1:11434/v1",
"billing": "free",
"disabled": false,
Expand Down
1 change: 1 addition & 0 deletions scripts/shots/mock/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler<N> } = {
SetModelSpec: refuse,
SetManualModels: refuse,
RefreshProviderModels: (_req, [name]) => providerModels(name!) ?? notFound(`Upstream ${name}`),
RefreshBalance: refuse,
RefreshStaleModels: () => ({ providers: [] }),

CreateProxy: refuse,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ webview_endpoints![
// 手动添加的模型:上游能服务、却没列进清单的
SetManualModels,
RefreshProviderModels,
// 余额:上游自己的接口报的钱包、额度、窗口限额,立刻重读一次
RefreshBalance,
RefreshStaleModels,
CreateProxy,
TestProxy,
Expand Down
1 change: 1 addition & 0 deletions src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const WEBVIEW_ENDPOINTS = [
"SetModelSpec",
"SetManualModels",
"RefreshProviderModels",
"RefreshBalance",
"RefreshStaleModels",
"CreateProxy",
"TestProxy",
Expand Down
116 changes: 113 additions & 3 deletions src/generated/tw-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,84 @@ profile?: string | null,
*/
region?: string | null, };

/**
* 一家上游的余额:最近一次从它的余额接口读到的。**每个数都是上游给的**,没有一个是
* 我们按请求记录推算的。
*
* 读失败了 `error` 是原因,**其余字段留着上一次读到的**(从没读到过就是空的),
* `read_at_ms` 也还是那一次的。
*/
export type Balance = { source: BalanceSource,
/**
* 这些数是什么时候读到的,Unix 毫秒。从没读到过时是最近一次去读的时刻
*/
read_at_ms: number,
/**
* 还能花的钱:账户余额、钱包余额
*/
wallet: Money | null,
/**
* 一笔总的额度和用了多少:密钥的额度、套餐的总额
*/
quota: BalanceQuota | null,
/**
* 按时间窗口算的额度,每个窗口一项。没有是空的
*/
windows: Array<BalanceWindow>,
/**
* 密钥或套餐什么时候到期,Unix 毫秒。不会到期、上游没说都是空
*/
expires_at_ms: number | null,
/**
* 已经花了多少,和算的是哪一段时间:没有上限可比时说得出的就是它
*/
spent: Spent | null,
/**
* 最近一次没读成的原因。读成了是空
*/
error: Msg | null, };

/**
* 一笔总的额度。
*/
export type BalanceQuota = { limit: number, used: number,
/**
* `USD` / `CNY` / `tokens` / `requests`
*/
unit: string, };

/**
* 企业网关的一条限额管的是谁。
*/
export type BalanceScope = "key" | "user";

/**
* 上游的余额从哪个接口读。每一种都用这家上游自己的密钥、走它自己的出站设置。
*/
export type BalanceSource = "openrouter" | "deepseek" | "moonshot" | "sub2api" | "newapi" | "thinkwatch";

/**
* 一个时间窗口里的额度。
*/
export type BalanceWindow = {
/**
* `5h` / `1d` / `7d` / `30d` / `daily` / `weekly` / `monthly`(企业网关还有 `1m`、`5m`、
* `1h`、`1w`),上游怎么叫就是什么
*/
window: string, limit: number, used: number,
/**
* `USD` / `CNY` / `tokens` / `requests`
*/
unit: string,
/**
* 什么时候重置,Unix 毫秒。上游没说是空
*/
resets_at_ms: number | null,
/**
* 管的是谁:这把密钥,还是它所属的用户。只有企业网关说,别的来源是空
*/
scope: BalanceScope | null, };

/**
* `load-balance` 组按什么分请求:配置里 `balance_by` 写的那个词。
*
Expand Down Expand Up @@ -1504,7 +1582,7 @@ detail: SecurityHitDetail, at_ms: number, } | { "kind": "request_priced", id: nu
/**
* `open` = 熔断中,不进候选链;`closed` = 可以用
*/
state: BreakerState, at_ms: number, } | { "kind": "models_changed", id: number, provider: string, at_ms: number, } | { "kind": "proxy_changed", id: number, proxy: string,
state: BreakerState, at_ms: number, } | { "kind": "models_changed", id: number, provider: string, at_ms: number, } | { "kind": "balance_updated", id: number, provider: string, at_ms: number, } | { "kind": "proxy_changed", id: number, proxy: string,
/**
* `unreachable` = 刚刚检查不通;`reachable` = 又通了
*/
Expand Down Expand Up @@ -2690,6 +2768,15 @@ base_version?: string | null, };
*/
export type ModelsRefreshing = { providers: Array<string>, };

/**
* 一笔钱。
*/
export type Money = { amount: number,
/**
* `USD`、`CNY` 这样的货币代码,上游怎么写就是什么
*/
currency: string, };

/**
* 一条给人看的话:一个稳定的码、填进句子的参数,以及英文原句。
*
Expand Down Expand Up @@ -3477,7 +3564,12 @@ via?: string | null,
/**
* 失败的原因,和下一步该查什么
*/
error: Msg | null, };
error: Msg | null,
/**
* 顺带读到的余额(`balance` 是 `auto` 时先认出是哪一种中转站)。没有余额可读、
* 检测没通过时是空
*/
balance?: Balance | null, };

/**
* 一个上游。**设置是配置里写的原样**,密钥也不打码:编辑对话框回填的就是它,
Expand Down Expand Up @@ -3600,7 +3692,13 @@ pricing: string | null,
/**
* 同时最多发给这家几个请求。不限是空
*/
max_concurrent?: number | null, };
max_concurrent?: number | null,
/**
* 最近一次读到的余额。**是空的:这家没有余额可读**(关掉了、账号上游和 Bedrock、
* 认不出是哪一种中转站),或者还没读完第一次。读到了、读失败了都报
* [`Event::BalanceUpdated`]
*/
balance: Balance | null, };

/**
* 代理的用户名和密码。
Expand Down Expand Up @@ -4844,6 +4942,16 @@ providers?: Array<string>,
*/
model: string, };

/**
* 已经花掉的一笔钱。
*/
export type Spent = { amount: number, currency: string, period: SpentPeriod, };

/**
* 花费算的是哪一段时间。
*/
export type SpentPeriod = "today" | "month" | "total";

export type Status = { api_version: number,
/**
* 二进制的 CalVer
Expand Down Expand Up @@ -5401,6 +5509,7 @@ export const ENDPOINTS = {
SetModelSpec: { method: "PUT", path: "/provider-model-spec", params: [], format: "json" },
SetManualModels: { method: "PUT", path: "/provider-manual-models", params: [], format: "json" },
RefreshProviderModels: { method: "POST", path: "/providers/{name}/models/refresh", params: ["name"], format: "json" },
RefreshBalance: { method: "POST", path: "/providers/{name}/balance/refresh", params: ["name"], format: "json" },
RefreshStaleModels: { method: "POST", path: "/models/refresh", params: [], format: "json" },
CreateProxy: { method: "POST", path: "/proxies", params: [], format: "json" },
TestProxy: { method: "POST", path: "/proxy-test", params: [], format: "json" },
Expand Down Expand Up @@ -5529,6 +5638,7 @@ export type Endpoints = {
SetModelSpec: { req: ModelSpecSave; res: ConfigWritten };
SetManualModels: { req: ManualModelsSave; res: ConfigWritten };
RefreshProviderModels: { req: null; res: ProviderModelsView };
RefreshBalance: { req: null; res: Balance };
RefreshStaleModels: { req: null; res: ModelsRefreshing };
CreateProxy: { req: ProxySave; res: ConfigWritten };
TestProxy: { req: ProxyTest; res: L1Result };
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,14 @@ export function applyEvent(rows: Map<number, RequestRow>, ev: CoreEvent): void {
case "config_rejected":
case "health_changed":
case "models_changed":
case "balance_updated":
case "quota_seen":
case "quota_exhausted":
case "proxy_changed":
case "auth_changed":
case "credential_expired":
case "events_dropped":
// 都不进请求列表。配置事件、熔断、额度、凭据、代理说的都是「现在什么情况」,
// 都不进请求列表。配置事件、熔断、额度、余额、凭据、代理说的都是「现在什么情况」,
// 而这张表装的是「刚才发生过什么」。App 单独接。
break;
case "content_matched": {
Expand Down
30 changes: 25 additions & 5 deletions src/upstreams/ConnectionSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ms } from "@/format";
import { useText } from "@/i18n";
import type { Overview, ProviderPreview, ProviderTestResult, ProviderView } from "@/types";
import { connectionSectionText } from "./ConnectionSection.i18n";
import { balanceBrief } from "./balance";
import { useRemote } from "@/connection/useRemote";
import { remoteText } from "@/connection/remote.i18n";
import { HeaderEditor, type AuthRow } from "./HeaderEditor";
Expand Down Expand Up @@ -509,7 +510,12 @@ function OAuth({
);
}

/** 「连接正常 · 认证通过 · 响应 312ms · 经由 hk-socks · 发现 6 个模型」 */
/**
* 「连接正常 · 认证通过 · 响应 312ms · 经由 hk-socks · 发现 6 个模型」。
*
* 检测时一并读了余额的,下面再一行:从哪儿读的,和上游表那一格写的那几样(`balanceBrief`)。
* 读取失败时这一行是琥珀色的原因。
*/
export function TestLine({ result, bordered = true }: { result: ProviderTestResult; bordered?: boolean }) {
const t = useText(connectionSectionText);
if (!result.ok) {
Expand All @@ -529,11 +535,25 @@ export function TestLine({ result, bordered = true }: { result: ProviderTestResu
result.via ? t.via(result.via) : null,
describeModelList(result.models),
].filter(Boolean);
const balance = balanceBrief(result.balance, Date.now());
return (
<div className={cn("flex items-center gap-2 motion-fade", bordered && "border-t border-border pt-2.5")}>
<CircleCheckIcon className="size-4 shrink-0 text-success" />
<span className="tw-body font-medium">{t.ok}</span>
<span className="tw-label tw-num text-muted-foreground">{parts.join(" · ")}</span>
<div className={cn("flex flex-col gap-0.5 motion-fade", bordered && "border-t border-border pt-2.5")}>
<div className="flex items-center gap-2">
<CircleCheckIcon className="size-4 shrink-0 text-success" />
<span className="tw-body font-medium">{t.ok}</span>
<span className="tw-label tw-num text-muted-foreground">{parts.join(" · ")}</span>
</div>
{/* 和上一行的字对齐:图标 16px 加间距 8px */}
{balance && (
<p
className={cn(
"pl-6 tw-label tw-num break-words",
balance.failed ? "text-warning" : "text-muted-foreground",
)}
>
{balance.source} · {balance.text}
</p>
)}
</div>
);
}
7 changes: 6 additions & 1 deletion src/upstreams/QuotaBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export const QUOTA_WARN = 80;
/** 用满:这个窗口里已经用完(红) */
export const QUOTA_FULL = 100;

/** 用到这个比例的条是什么颜色:平时灰,八成琥珀,用满红 */
export function quotaTone(percent: number): "neutral" | "warn" | "error" {
return percent >= QUOTA_FULL ? "error" : percent >= QUOTA_WARN ? "warn" : "neutral";
}

/**
* 订阅额度的一根条:已用的比例。
*
Expand All @@ -18,7 +23,7 @@ export function QuotaBar({ percent, label, className }: { percent: number; label
size="sm"
value={percent}
max={100}
tone={percent >= QUOTA_FULL ? "error" : percent >= QUOTA_WARN ? "warn" : "neutral"}
tone={quotaTone(percent)}
label={label}
className={className}
/>
Expand Down
Loading
Loading