v0.6.0: 预设供应商 + 供应商账单查询
## 新增 - 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic / DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun / SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio / 火山方舟),通过 PresetPickerModal 一键填表 - 供应商余额/套餐查询(卡片底部 UsageFooter):余额类 (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) + 套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL + force_refresh + 并发 ≤3 - Rust 端 services/ 模块(balance / coding_plan / usage_types), 参考 cc-switch(MIT,© Jason Young)实现 - query_provider_usage 命令:Rust 端从 config 加载 key, 不经 IPC 序列化 API key - usageKinds 持久化到 SQLite settings(JSON 数组), load 时合并回 config,导出 config.toml 时不写 - detect_provider 启发式:旧用户升级后自动获得账单支持 - save-time 校验:未完成供应商(缺 api_key / base_url / 模型) 保存时弹 confirm 列出原因;新增未提交 back 时静默 drop - docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档) - docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单) ## 修复 - handleDuplicateProvider 的 alias.slice 漏洞:老数据 非规范 alias 复制不再错位 - handleSelectPreset 不再 auto-save,半成品不会落盘 - handleSwitchProvider 校验取消时自动 refresh 回滚, 不会出现内存改 / 盘没动的脏态 ## 文档 - README / README_EN 致谢段补 cc-switch(预设 + 账单) - README 已知限制与后续建议段(/reload 上游限制调研结论) - i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key 详见 docs/PROPOSAL-presets-and-usage.md 与 docs/VERIFICATION-CHECKLIST.md
This commit is contained in:
1 parent
a1fdc6c93d
commit
2614f80d0f
27 files changed
+3442
-67
No files matched your search
@@ -0,0 +1,427 @@
|
||||
// Provider presets: one-click form filling for well-known providers.
|
||||
// Preset data structure inspired by cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
|
||||
import type { Model, Provider, ProviderType } from "../types";
|
||||
import { getModelRef, capabilitiesFromRef } from "../lib/models-dev";
|
||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
|
||||
/** Usage query kinds supported by the Rust usage layer (v1 union). */
|
||||
export type UsageKind =
|
||||
| "balance:deepseek"
|
||||
| "balance:siliconflow"
|
||||
| "balance:openrouter"
|
||||
| "balance:stepfun"
|
||||
| "balance:novita"
|
||||
| "plan:kimi_coding"
|
||||
| "plan:zhipu"
|
||||
| "plan:minimax";
|
||||
|
||||
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
|
||||
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:deepseek",
|
||||
"balance:siliconflow",
|
||||
"balance:openrouter",
|
||||
"balance:stepfun",
|
||||
"balance:novita",
|
||||
"plan:kimi_coding",
|
||||
"plan:zhipu",
|
||||
"plan:minimax",
|
||||
]);
|
||||
|
||||
export type PresetCategory =
|
||||
| "official"
|
||||
| "cn_official"
|
||||
| "third_party"
|
||||
| "aggregator"
|
||||
| "custom";
|
||||
|
||||
export interface ProviderPreset {
|
||||
/** Unique key, also used as the default Provider name, e.g. "deepseek". */
|
||||
id: string;
|
||||
/** Fallback display name (English). */
|
||||
name: string;
|
||||
/** i18n key; when present the UI prefers t(nameKey). */
|
||||
nameKey?: string;
|
||||
/** Official website / console URL. */
|
||||
websiteUrl?: string;
|
||||
apiKeyUrl?: string;
|
||||
/** Category: sorting & badge. */
|
||||
category: PresetCategory;
|
||||
/** Written directly to provider.provider_type. */
|
||||
providerType: ProviderType;
|
||||
/** Preset base_url; `null` means no default (e.g. Anthropic). */
|
||||
baseUrl: string | null;
|
||||
/** Icon key from src/icons/extracted (falls back to initials when absent). */
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
/**
|
||||
* Pre-filled model mappings. The first entry becomes the default model.
|
||||
* `model` holds the real model id; the alias is NOT hand-written here —
|
||||
* presetToProviderAndModels() forces `${providerName}/${modelId}` so that
|
||||
* handleDuplicateProvider's `alias.slice(name.length)` keeps working.
|
||||
*/
|
||||
models: Array<{
|
||||
/** Real model id sent to the provider API. */
|
||||
model: string;
|
||||
/** Optional display name override; defaults to models.dev derivation. */
|
||||
displayName?: string;
|
||||
/** Optional context override; defaults to the priority chain below. */
|
||||
maxContextSize?: number;
|
||||
/** Defaults to ["thinking"]. */
|
||||
capabilities?: string[];
|
||||
}>;
|
||||
/**
|
||||
* Billing/usage query kinds; omitted means "do not query".
|
||||
* Persisted to SQLite settings by the Rust side on save, never to
|
||||
* config.toml.
|
||||
*/
|
||||
usageKinds?: ReadonlyArray<UsageKind>;
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
nameKey: "presetNameAnthropic",
|
||||
websiteUrl: "https://www.anthropic.com",
|
||||
category: "official",
|
||||
providerType: "anthropic",
|
||||
// No default base_url; Kimi Code automatically targets api.anthropic.com.
|
||||
baseUrl: null,
|
||||
icon: "anthropic",
|
||||
models: [
|
||||
{ model: "claude-opus-4-5" },
|
||||
{ model: "claude-sonnet-4-5" },
|
||||
{ model: "claude-haiku-4-5" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
name: "Kimi For Coding",
|
||||
nameKey: "presetNameKimiCoding",
|
||||
websiteUrl: "https://www.kimi.com/code",
|
||||
category: "official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
icon: "kimi",
|
||||
models: [
|
||||
// kimi-for-coding is not in the models.dev snapshot; context comes
|
||||
// from the regex fallback in model-defaults (262144).
|
||||
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
|
||||
{ model: "kimi-k2.7-code" },
|
||||
],
|
||||
usageKinds: ["plan:kimi_coding"],
|
||||
},
|
||||
{
|
||||
id: "moonshot",
|
||||
name: "Moonshot AI",
|
||||
nameKey: "presetNameMoonshot",
|
||||
websiteUrl: "https://platform.moonshot.ai",
|
||||
category: "cn_official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
icon: "kimi",
|
||||
models: [
|
||||
{ model: "kimi-k2.7-code" },
|
||||
{ model: "kimi-k2-thinking-turbo" },
|
||||
{ model: "kimi-k2.6" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
nameKey: "presetNameDeepseek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
icon: "deepseek",
|
||||
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
|
||||
usageKinds: ["balance:deepseek"],
|
||||
},
|
||||
{
|
||||
id: "zhipu",
|
||||
name: "Zhipu GLM",
|
||||
nameKey: "presetNameZhipu",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [
|
||||
{ model: "glm-4.7" },
|
||||
{ model: "glm-4.6" },
|
||||
{ model: "glm-4.5-air" },
|
||||
],
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
name: "z.ai",
|
||||
nameKey: "presetNameZai",
|
||||
websiteUrl: "https://z.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
id: "bailian",
|
||||
name: "Alibaba Bailian",
|
||||
nameKey: "presetNameBailian",
|
||||
websiteUrl: "https://bailian.console.aliyun.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
icon: "bailian",
|
||||
models: [
|
||||
{ model: "qwen3-max" },
|
||||
{ model: "qwen3-coder-plus" },
|
||||
{ model: "qwen3.5-plus" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
nameKey: "presetNameMinimax",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
icon: "minimax",
|
||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||
usageKinds: ["plan:minimax"],
|
||||
},
|
||||
{
|
||||
id: "stepfun",
|
||||
name: "StepFun",
|
||||
nameKey: "presetNameStepfun",
|
||||
websiteUrl: "https://platform.stepfun.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.stepfun.com/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
usageKinds: ["balance:stepfun"],
|
||||
},
|
||||
{
|
||||
id: "siliconflow",
|
||||
name: "SiliconFlow",
|
||||
nameKey: "presetNameSiliconflow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.siliconflow.cn/v1",
|
||||
icon: "siliconflow",
|
||||
// The extracted icon has no metadata defaultColor; set it explicitly.
|
||||
iconColor: "#6E29F6",
|
||||
models: [
|
||||
{ model: "Qwen/Qwen3-235B-A22B" },
|
||||
{ model: "moonshotai/Kimi-K2.5" },
|
||||
// Not covered by the snapshot; fill context/displayName explicitly.
|
||||
{
|
||||
model: "deepseek-ai/DeepSeek-V3.2",
|
||||
displayName: "DeepSeek V3.2",
|
||||
maxContextSize: 163840,
|
||||
},
|
||||
],
|
||||
usageKinds: ["balance:siliconflow"],
|
||||
},
|
||||
{
|
||||
id: "novita",
|
||||
name: "Novita AI",
|
||||
nameKey: "presetNameNovita",
|
||||
websiteUrl: "https://novita.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
// Note: /v3, not /v1.
|
||||
baseUrl: "https://api.novita.ai/v3",
|
||||
icon: "novita",
|
||||
models: [
|
||||
// Not covered by the snapshot; fill context/displayName explicitly.
|
||||
{
|
||||
model: "deepseek/deepseek-v3.2",
|
||||
displayName: "DeepSeek V3.2",
|
||||
maxContextSize: 163840,
|
||||
},
|
||||
{ model: "moonshotai/kimi-k2-thinking" },
|
||||
{ model: "zai-org/glm-4.6" },
|
||||
],
|
||||
usageKinds: ["balance:novita"],
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
nameKey: "presetNameOpenrouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
category: "aggregator",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
icon: "openrouter",
|
||||
models: [
|
||||
// OpenRouter uses dotted versions which the snapshot does not index;
|
||||
// fill context/displayName explicitly for this one.
|
||||
{
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
displayName: "Claude Opus 4.5",
|
||||
maxContextSize: 200000,
|
||||
},
|
||||
{ model: "openai/gpt-5.2" },
|
||||
{ model: "google/gemini-3-pro-preview" },
|
||||
],
|
||||
usageKinds: ["balance:openrouter"],
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
nameKey: "presetNameOpenai",
|
||||
websiteUrl: "https://platform.openai.com",
|
||||
category: "official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
icon: "openai",
|
||||
models: [
|
||||
{ model: "gpt-5.2" },
|
||||
{ model: "gpt-5.1-codex-max" },
|
||||
{ model: "gpt-5-mini" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "google-genai",
|
||||
name: "Google AI Studio",
|
||||
nameKey: "presetNameGoogleGenai",
|
||||
websiteUrl: "https://aistudio.google.com",
|
||||
category: "official",
|
||||
providerType: "google-genai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
icon: "google",
|
||||
models: [
|
||||
{ model: "gemini-3-pro-preview" },
|
||||
{ model: "gemini-3-flash-preview" },
|
||||
{ model: "gemini-2.5-flash" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "volcengine",
|
||||
name: "Volcengine Ark",
|
||||
nameKey: "presetNameVolcengine",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
icon: "huoshan",
|
||||
// v1: inference only — plan queries need separate AK/SK (P2).
|
||||
models: [
|
||||
{
|
||||
model: "doubao-seed-1-6-250615",
|
||||
displayName: "Doubao Seed 1.6",
|
||||
},
|
||||
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface PresetConversionResult {
|
||||
provider: Provider;
|
||||
models: Model[];
|
||||
/** Alias to write into config.default_model ("" when no models). */
|
||||
defaultModel: string;
|
||||
usageKinds: ReadonlyArray<string> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a preset into concrete Provider + Model entries.
|
||||
*
|
||||
* - Provider name collisions get a "-2" / "-3" suffix.
|
||||
* - Model aliases are forced to `${name}/${modelId}` and deduped one by one
|
||||
* against existing aliases (also with "-2" / "-3" suffixes).
|
||||
* - max_context_size priority: preset override → models.dev snapshot →
|
||||
* regex rules → DEFAULT_MAX_CONTEXT_SIZE (the last two are both handled
|
||||
* inside getDefaultMaxContextSize).
|
||||
*/
|
||||
export function presetToProviderAndModels(
|
||||
preset: ProviderPreset,
|
||||
options: {
|
||||
existingProviderNames: Set<string>;
|
||||
existingModelAliases: Set<string>;
|
||||
},
|
||||
): PresetConversionResult {
|
||||
let name = preset.id;
|
||||
let n = 2;
|
||||
while (options.existingProviderNames.has(name)) {
|
||||
name = `${preset.id}-${n}`;
|
||||
n++;
|
||||
}
|
||||
|
||||
const models: Model[] = preset.models.map((m) => {
|
||||
let alias = `${name}/${m.model}`;
|
||||
let k = 2;
|
||||
while (options.existingModelAliases.has(alias)) {
|
||||
alias = `${name}/${m.model}-${k}`;
|
||||
k++;
|
||||
}
|
||||
|
||||
const ref = getModelRef(m.model);
|
||||
const refCaps = ref ? capabilitiesFromRef(ref) : [];
|
||||
const maxContextSize =
|
||||
m.maxContextSize ?? ref?.context ?? getDefaultMaxContextSize(m.model);
|
||||
|
||||
return {
|
||||
alias,
|
||||
provider: name,
|
||||
model: m.model,
|
||||
max_context_size: maxContextSize,
|
||||
display_name: m.displayName ?? ref?.name ?? alias,
|
||||
capabilities: m.capabilities ?? (refCaps.length > 0 ? refCaps : ["thinking"]),
|
||||
supports_1m: maxContextSize >= 1_000_000,
|
||||
raw_other: {},
|
||||
};
|
||||
});
|
||||
|
||||
const provider: Provider = {
|
||||
name,
|
||||
provider_type: preset.providerType,
|
||||
base_url: preset.baseUrl,
|
||||
api_key: null,
|
||||
env: {},
|
||||
note: null,
|
||||
official_url: preset.websiteUrl ?? null,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
icon: preset.icon ?? null,
|
||||
icon_color: preset.iconColor ?? null,
|
||||
usageKinds: preset.usageKinds ? [...preset.usageKinds] : undefined,
|
||||
raw_other: {},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
models,
|
||||
defaultModel: models[0]?.alias ?? "",
|
||||
usageKinds: preset.usageKinds,
|
||||
};
|
||||
}
|
||||
|
||||
// Dev-only cross-end drift check: assert that every usage kind referenced by
|
||||
// any preset is one the Rust `UsageKind` enum knows how to handle. The
|
||||
// runtime filter in `query_provider_usage` silently drops unknown kinds,
|
||||
// so without this guard a typo or schema drift would only surface as a
|
||||
// missing usage footer with no error message. Gated by Vite's DEV flag so
|
||||
// it costs nothing in production bundles.
|
||||
if (import.meta.env.DEV) {
|
||||
for (const preset of providerPresets) {
|
||||
if (!preset.usageKinds) continue;
|
||||
for (const kind of preset.usageKinds) {
|
||||
if (!SUPPORTED_USAGE_KINDS.has(kind)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[providerPresets] preset "${preset.id}" references unknown usage kind "${kind}". ` +
|
||||
`Add it to src-tauri/src/services/mod.rs UsageKind + SUPPORTED_USAGE_KINDS, or remove it from the preset.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user