feat: 大模型模型改为下拉框,加入各家免费模型并自动关闭思考
- 新增硅基流动、Gemini、Groq、Cerebras 预设,智谱改用 glm-4.7-flash,默认服务商改为智谱(免费、国内直连) - 模型下拉框:精选模型标注免费/推荐,OpenRouter 再联网补充最新免费模型,末项可自定义 - 按模型附带关闭思考的参数(智谱、DeepSeek、Qwen3、Groq/Cerebras、OpenRouter),deepseek-flash 从约 7 秒降到 1 秒内 - OpenRouter 免费模型带两个备用模型,被限流时自动重试一次,仍失败给出明确提示 - 移除已停用的 deepseek-chat 预设 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdefc030d5
commit
4c436830be
14 files changed
+360
-28
No files matched your search
+3
-2
@@ -41,7 +41,7 @@
|
|||||||
"hotkey": "Alt+Q",
|
"hotkey": "Alt+Q",
|
||||||
"engine": "free", // "free" | "llm"
|
"engine": "free", // "free" | "llm"
|
||||||
"free": { "provider": "microsoft" }, // "microsoft" | "google"
|
"free": { "provider": "microsoft" }, // "microsoft" | "google"
|
||||||
"llm": { "provider": "deepseek", "baseUrl": "https://api.deepseek.com", "model": "deepseek-chat", "apiKey": "<加密存储>" },
|
"llm": { "provider": "zhipu", "baseUrl": "https://open.bigmodel.cn/api/paas/v4", "model": "glm-4.7-flash", "apiKey": "<加密存储>" },
|
||||||
"livePreview": true,
|
"livePreview": true,
|
||||||
"previewDelayMs": 500,
|
"previewDelayMs": 500,
|
||||||
"restoreClipboard": true,
|
"restoreClipboard": true,
|
||||||
@@ -62,7 +62,8 @@ API Key 用 Electron `safeStorage` 加密后存盘;永远不把明文 Key 发
|
|||||||
| `onShow(cb)` | 浮窗被热键呼出时:`cb({engine, direction})` |
|
| `onShow(cb)` | 浮窗被热键呼出时:`cb({engine, direction})` |
|
||||||
| `getConfig()` → `Promise<config>` | apiKey 被替换为 `apiKeySet: boolean` |
|
| `getConfig()` → `Promise<config>` | apiKey 被替换为 `apiKeySet: boolean` |
|
||||||
| `setConfig(partial)` → `Promise<config>` | 深合并;`llm.apiKey` 为空字符串/缺省表示不改;热键变更立即重新注册,失败则 reject 并保持旧热键 |
|
| `setConfig(partial)` → `Promise<config>` | 深合并;`llm.apiKey` 为空字符串/缺省表示不改;热键变更立即重新注册,失败则 reject 并保持旧热键 |
|
||||||
| `getProviders()` → `Promise<Provider[]>` | 大模型预设列表 |
|
| `getProviders()` → `Promise<Provider[]>` | 大模型预设列表;`models[]` 带 `free`(free / quota)和 `note`,请求参数 `extra` 只留在主进程 |
|
||||||
|
| `listModels(providerId)` → `Promise<Model[]>` | 设置页模型下拉框:预设精选 + OpenRouter 实时免费模型(`live: true`),拉取失败只返回预设 |
|
||||||
| `testEngine(partialConfig?)` → `Promise<{ok, sample, ms, error?}>` | 用当前或传入(未保存)配置翻译一句测试文本 |
|
| `testEngine(partialConfig?)` → `Promise<{ok, sample, ms, error?}>` | 用当前或传入(未保存)配置翻译一句测试文本 |
|
||||||
| `openSettings()` | 打开设置窗口 |
|
| `openSettings()` | 打开设置窗口 |
|
||||||
| `onConfigChanged(cb)` | 配置变更广播给所有窗口 |
|
| `onConfigChanged(cb)` | 配置变更广播给所有窗口 |
|
||||||
|
|||||||
+3
-3
@@ -8,9 +8,9 @@ const DEFAULT_CONFIG = {
|
|||||||
engine: 'free',
|
engine: 'free',
|
||||||
free: { provider: 'microsoft' },
|
free: { provider: 'microsoft' },
|
||||||
llm: {
|
llm: {
|
||||||
provider: 'deepseek',
|
provider: 'zhipu',
|
||||||
baseUrl: 'https://api.deepseek.com',
|
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||||
model: 'deepseek-chat',
|
model: 'glm-4.7-flash',
|
||||||
apiKey: '', // 当前服务商的 Key(仅内存,由 apiKeys 推出)
|
apiKey: '', // 当前服务商的 Key(仅内存,由 apiKeys 推出)
|
||||||
apiKeys: {}, // 每个服务商各存一份,切换服务商时不会把 A 的 Key 发给 B
|
apiKeys: {}, // 每个服务商各存一份,切换服务商时不会把 A 的 Key 发给 B
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-2
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { getProvider } = require('../providers');
|
const { getProvider, modelExtra, isFreeModel } = require('../providers');
|
||||||
const { buildPrompt, cleanOutput } = require('../prompt');
|
const { buildPrompt, cleanOutput } = require('../prompt');
|
||||||
const { fetchWithTimeout, httpFail } = require('./http');
|
const { fetchWithTimeout, httpFail } = require('./http');
|
||||||
const { fail } = require('../errors');
|
const { fail } = require('../errors');
|
||||||
@@ -19,6 +19,13 @@ function maxTokensFor(text) {
|
|||||||
return Math.min(8192, Math.max(2048, String(text).length * 4 + 256));
|
return Math.min(8192, Math.max(2048, String(text).length * 4 + 256));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(fail('CANCEL', {})); }, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function hostOf(baseUrl) {
|
function hostOf(baseUrl) {
|
||||||
try {
|
try {
|
||||||
return new URL(baseUrl).host || '大模型接口';
|
return new URL(baseUrl).host || '大模型接口';
|
||||||
@@ -71,6 +78,7 @@ async function translateLLM(opts) {
|
|||||||
headers = { 'content-type': 'application/json' };
|
headers = { 'content-type': 'application/json' };
|
||||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||||
body = {
|
body = {
|
||||||
|
...modelExtra(llm.provider, model),
|
||||||
model,
|
model,
|
||||||
temperature: prompt.temperature,
|
temperature: prompt.temperature,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
@@ -82,13 +90,21 @@ async function translateLLM(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetchWithTimeout(url, {
|
const send = () => fetchWithTimeout(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
}, { timeoutMs, service, fetchImpl: opts.fetchImpl });
|
}, { timeoutMs, service, fetchImpl: opts.fetchImpl });
|
||||||
|
|
||||||
|
let res = await send();
|
||||||
|
// 免费模型被限流往往一两秒就恢复,自动再试一次
|
||||||
|
const free = isFreeModel(model);
|
||||||
|
if (res.status === 429 && free) {
|
||||||
|
await sleep(opts.retryDelayMs ?? 1000, opts.signal);
|
||||||
|
res = await send();
|
||||||
|
}
|
||||||
|
if (res.status === 429 && free) throw fail('FREE_BUSY', { service, kind: 'llm' });
|
||||||
if (!res.ok) throw httpFail(res.status, service);
|
if (!res.ok) throw httpFail(res.status, service);
|
||||||
|
|
||||||
let accumulated = '';
|
let accumulated = '';
|
||||||
|
|||||||
+2
-1
@@ -40,8 +40,9 @@ function userMessage(err, ctx = {}) {
|
|||||||
if (err.code === 'NO_KEY') return '还没填大模型的 API Key,去设置里填一下';
|
if (err.code === 'NO_KEY') return '还没填大模型的 API Key,去设置里填一下';
|
||||||
if (err.code === 'NO_BASE_URL') return '还没填写大模型的接口地址';
|
if (err.code === 'NO_BASE_URL') return '还没填写大模型的接口地址';
|
||||||
if (err.code === 'NO_MODEL') return '还没填写模型名';
|
if (err.code === 'NO_MODEL') return '还没填写模型名';
|
||||||
if (err.code === 'REASONING_ONLY') return '这是推理模型,只顾着思考没给出译文。换成非推理模型(如 deepseek-chat)又快又稳';
|
if (err.code === 'REASONING_ONLY') return '这是推理模型,只顾着思考没给出译文。换成下拉框里标“推荐”的模型,已自动关闭思考,又快又稳';
|
||||||
if (err.code === 'EMPTY') return '模型没有返回译文,换个模型试试';
|
if (err.code === 'EMPTY') return '模型没有返回译文,换个模型试试';
|
||||||
|
if (err.code === 'FREE_BUSY') return '免费模型这会儿太忙(被上游限流),过一会儿再试,或在设置里换一个模型';
|
||||||
|
|
||||||
const status = Number(err.status || err.statusCode || 0);
|
const status = Number(err.status || err.statusCode || 0);
|
||||||
const kind = ctx.kind || err.kind;
|
const kind = ctx.kind || err.kind;
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,7 @@ app.disableHardwareAcceleration();
|
|||||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
|
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
|
||||||
|
|
||||||
const { PROVIDERS } = require('./providers');
|
const { PROVIDERS } = require('./providers');
|
||||||
|
const { listModels } = require('./models');
|
||||||
const { createConfigStore, mergeConfig } = require('./config');
|
const { createConfigStore, mergeConfig } = require('./config');
|
||||||
const { translate, warmup } = require('./engines');
|
const { translate, warmup } = require('./engines');
|
||||||
const { captureForeground, commitPaste } = require('./paste');
|
const { captureForeground, commitPaste } = require('./paste');
|
||||||
@@ -439,7 +440,10 @@ function registerIpc() {
|
|||||||
|
|
||||||
ipcMain.handle('xt:setConfig', (_event, partial) => setConfig(partial || {}));
|
ipcMain.handle('xt:setConfig', (_event, partial) => setConfig(partial || {}));
|
||||||
|
|
||||||
ipcMain.handle('xt:getProviders', () => PROVIDERS);
|
// 渲染进程只需要展示用的字段,请求参数(extra)留在主进程
|
||||||
|
ipcMain.handle('xt:getProviders', () => PROVIDERS.map((p) => ({ ...p, models: p.models.map(({ extra, ...m }) => m) })));
|
||||||
|
|
||||||
|
ipcMain.handle('xt:listModels', (_event, providerId) => listModels(String(providerId || ''), { warn }));
|
||||||
|
|
||||||
ipcMain.handle('xt:testEngine', async (_event, partial) => {
|
ipcMain.handle('xt:testEngine', async (_event, partial) => {
|
||||||
const cfg = mergeConfig(store.get(), partial || {});
|
const cfg = mergeConfig(store.get(), partial || {});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 设置页"模型"下拉框的选项:预设里精选的在前;OpenRouter 免费模型更新很快,再联网补上最新的。
|
||||||
|
const { getProvider, NO_THINK_OPENROUTER } = require('./providers');
|
||||||
|
const { fetchWithTimeout } = require('./engines/http');
|
||||||
|
|
||||||
|
const OPENROUTER_MODELS = 'https://openrouter.ai/api/v1/models';
|
||||||
|
const CACHE_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_EXTRA = 8;
|
||||||
|
// 审核、写代码、多模态这类专用模型不适合拿来翻译;4B 以下的小模型译文质量不行
|
||||||
|
const UNSUITABLE = /safety|guard|code|coder|embed|vision|-vl\b|omni|ocr|audio|image|poolside|lfm-|[-_][0-3](\.\d+)?b\b/i;
|
||||||
|
|
||||||
|
let cache = null; // { at, models }
|
||||||
|
|
||||||
|
function pickFreeModels(data, exclude, limit = MAX_EXTRA) {
|
||||||
|
const skip = new Set(exclude);
|
||||||
|
return (Array.isArray(data) ? data : [])
|
||||||
|
.filter((m) => m && typeof m.id === 'string' && m.id.endsWith(':free'))
|
||||||
|
.filter((m) => !skip.has(m.id) && !UNSUITABLE.test(m.id))
|
||||||
|
.sort((a, b) => (b.context_length || 0) - (a.context_length || 0))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((m) => ({ id: m.id, free: 'free', extra: NO_THINK_OPENROUTER, live: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOpenRouterFree(exclude, fetchImpl) {
|
||||||
|
if (cache && Date.now() - cache.at < CACHE_MS) return cache.models;
|
||||||
|
const res = await fetchWithTimeout(OPENROUTER_MODELS, {}, { timeoutMs: 6000, service: 'openrouter.ai', fetchImpl });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
const models = pickFreeModels(json && json.data, exclude);
|
||||||
|
cache = { at: Date.now(), models };
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<Array<{id: string, free?: string, note?: string, live?: boolean}>>} */
|
||||||
|
async function listModels(providerId, { fetchImpl, warn } = {}) {
|
||||||
|
const provider = getProvider(providerId);
|
||||||
|
const curated = (provider.models || []).map(({ extra, ...rest }) => rest);
|
||||||
|
if (provider.id !== 'openrouter') return curated;
|
||||||
|
try {
|
||||||
|
const live = await fetchOpenRouterFree(curated.map((m) => m.id), fetchImpl);
|
||||||
|
return [...curated, ...live.map(({ extra, ...rest }) => rest)];
|
||||||
|
} catch (err) {
|
||||||
|
if (warn) warn(`获取 OpenRouter 免费模型失败:${err && err.message}`);
|
||||||
|
return curated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listModels, pickFreeModels };
|
||||||
+105
-8
@@ -2,20 +2,117 @@
|
|||||||
|
|
||||||
// 大模型预设。用户选好预设后 baseUrl / model 仍可自由修改。
|
// 大模型预设。用户选好预设后 baseUrl / model 仍可自由修改。
|
||||||
// kind: 'openai' = OpenAI 兼容 /chat/completions;'anthropic' = Anthropic /v1/messages
|
// kind: 'openai' = OpenAI 兼容 /chat/completions;'anthropic' = Anthropic /v1/messages
|
||||||
|
// models[].free: 'free' = 长期免费;'quota' = 新用户赠送额度,用完收费
|
||||||
|
// models[].extra: 合并进请求体的额外参数,主要用来关掉思考——翻译不需要推理,开着只会慢
|
||||||
|
// 资料核对于 2026-10,免费政策变动快,以各家官网为准。
|
||||||
|
const NO_THINK_GLM = { thinking: { type: 'disabled' } };
|
||||||
|
const NO_THINK_OPENROUTER = { reasoning: { enabled: false } };
|
||||||
|
|
||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
{ id: 'deepseek', name: 'DeepSeek', kind: 'openai', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', models: ['deepseek-chat'], keyUrl: 'https://platform.deepseek.com/api_keys' },
|
{
|
||||||
{ id: 'qwen', name: '通义千问(阿里云百炼)', kind: 'openai', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', models: ['qwen-plus', 'qwen-turbo', 'qwen-max'], keyUrl: 'https://bailian.console.aliyun.com/' },
|
id: 'zhipu', name: '智谱 GLM · 免费', kind: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7-flash',
|
||||||
{ id: 'moonshot', name: 'Kimi(月之暗面)', kind: 'openai', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', models: ['moonshot-v1-8k'], keyUrl: 'https://platform.moonshot.cn/console/api-keys' },
|
keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', note: '国内直连,长期免费',
|
||||||
{ id: 'zhipu', name: '智谱 GLM', kind: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash', models: ['glm-4-flash', 'glm-4-plus'], keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
models: [
|
||||||
{ id: 'anthropic', name: 'Claude(Anthropic)', kind: 'anthropic', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', models: ['claude-haiku-4-5', 'claude-sonnet-5-5'], keyUrl: 'https://console.anthropic.com/settings/keys' },
|
{ id: 'glm-4.7-flash', free: 'free', note: '推荐', extra: NO_THINK_GLM },
|
||||||
{ id: 'openai', name: 'OpenAI', kind: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1-mini', models: ['gpt-4.1-mini', 'gpt-4.1'], keyUrl: 'https://platform.openai.com/api-keys' },
|
{ id: 'glm-4-flash-250414', free: 'free' },
|
||||||
{ id: 'openrouter', name: 'OpenRouter', kind: 'openai', baseUrl: 'https://openrouter.ai/api/v1', model: 'deepseek/deepseek-chat', models: [], keyUrl: 'https://openrouter.ai/keys' },
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'siliconflow', name: '硅基流动 · 免费', kind: 'openai', baseUrl: 'https://api.siliconflow.cn/v1', model: 'THUDM/GLM-4-9B-0414',
|
||||||
|
keyUrl: 'https://cloud.siliconflow.cn/account/ak', note: '国内直连,免费模型需实名认证',
|
||||||
|
models: [
|
||||||
|
{ id: 'THUDM/GLM-4-9B-0414', free: 'free', note: '推荐' },
|
||||||
|
{ id: 'Qwen/Qwen3-8B', free: 'free', extra: { enable_thinking: false } },
|
||||||
|
{ id: 'Qwen/Qwen2.5-7B-Instruct', free: 'free' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini', name: 'Google Gemini · 免费', kind: 'openai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.5-flash-lite',
|
||||||
|
keyUrl: 'https://aistudio.google.com/apikey', note: '国内需代理;免费层的内容可能被用于改进模型',
|
||||||
|
models: [
|
||||||
|
{ id: 'gemini-2.5-flash-lite', free: 'free', note: '推荐' },
|
||||||
|
{ id: 'gemini-3.5-flash-lite', free: 'free' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groq', name: 'Groq · 免费', kind: 'openai', baseUrl: 'https://api.groq.com/openai/v1', model: 'qwen/qwen3.8-27b',
|
||||||
|
keyUrl: 'https://console.groq.com/keys', note: '国内需代理,速度极快,免费层有每日限额',
|
||||||
|
models: [
|
||||||
|
{ id: 'qwen/qwen3.8-27b', free: 'free', note: '推荐', extra: { reasoning_effort: 'none' } },
|
||||||
|
{ id: 'openai/gpt-oss-20b', free: 'free', extra: { reasoning_effort: 'low' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cerebras', name: 'Cerebras · 免费', kind: 'openai', baseUrl: 'https://api.cerebras.ai/v1', model: 'qwen-3.8-27b',
|
||||||
|
keyUrl: 'https://cloud.cerebras.ai/', note: '国内需代理,速度极快,免费层每分钟 5 次',
|
||||||
|
models: [
|
||||||
|
{ id: 'qwen-3.8-27b', free: 'free', note: '推荐', extra: { reasoning_effort: 'none' } },
|
||||||
|
{ id: 'gpt-oss-120b', free: 'free', extra: { reasoning_effort: 'low' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openrouter', name: 'OpenRouter · 有免费模型', kind: 'openai', baseUrl: 'https://openrouter.ai/api/v1', model: 'google/gemma-4-31b-it:free',
|
||||||
|
keyUrl: 'https://openrouter.ai/keys', note: '免费模型每天有次数限制,高峰期可能排队',
|
||||||
|
models: [
|
||||||
|
// 顺序即备用顺序(取前面的当备用):前两个来自不同上游,一家限流时换另一家;
|
||||||
|
// nemotron 快但会译错词,放最后,不给别的模型当备用
|
||||||
|
{ id: 'google/gemma-4-31b-it:free', free: 'free', note: '推荐', extra: NO_THINK_OPENROUTER },
|
||||||
|
{ id: 'qwen/qwen3.8-27b:free', free: 'free', note: '推荐', extra: NO_THINK_OPENROUTER },
|
||||||
|
{ id: 'google/gemma-4-26b-a4b-it:free', free: 'free', extra: NO_THINK_OPENROUTER },
|
||||||
|
{ id: 'nvidia/nemotron-3.5-lightning:free', free: 'free', note: '最快', extra: NO_THINK_OPENROUTER },
|
||||||
|
{ id: 'deepseek/deepseek-chat' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qwen', name: '通义千问(阿里云百炼)', kind: 'openai', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-turbo',
|
||||||
|
keyUrl: 'https://bailian.console.aliyun.com/', note: '新用户每个模型送 100 万 token(90 天)',
|
||||||
|
models: [
|
||||||
|
{ id: 'qwen-turbo', free: 'quota', note: '推荐' },
|
||||||
|
{ id: 'qwen-flash' },
|
||||||
|
{ id: 'qwen-plus', free: 'quota' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek', name: 'DeepSeek', kind: 'openai', baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash',
|
||||||
|
keyUrl: 'https://platform.deepseek.com/api_keys', note: '按量付费,很便宜',
|
||||||
|
models: [
|
||||||
|
{ id: 'deepseek-flash', note: '已关闭思考', extra: NO_THINK_GLM },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moonshot', name: 'Kimi(月之暗面)', kind: 'openai', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', keyUrl: 'https://platform.moonshot.cn/console/api-keys',
|
||||||
|
models: [{ id: 'moonshot-v1-8k' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anthropic', name: 'Claude(Anthropic)', kind: 'anthropic', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', keyUrl: 'https://console.anthropic.com/settings/keys',
|
||||||
|
models: [{ id: 'claude-haiku-4-5' }, { id: 'claude-sonnet-5-5' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openai', name: 'OpenAI', kind: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1-mini', keyUrl: 'https://platform.openai.com/api-keys',
|
||||||
|
models: [{ id: 'gpt-4.1-mini' }, { id: 'gpt-4.1' }],
|
||||||
|
},
|
||||||
{ id: 'ollama', name: 'Ollama(本地)', kind: 'openai', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true },
|
{ id: 'ollama', name: 'Ollama(本地)', kind: 'openai', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true },
|
||||||
{ id: 'custom', name: '自定义(OpenAI 兼容)', kind: 'openai', baseUrl: '', model: '', models: [] },
|
{ id: 'custom', name: '自定义(OpenAI 兼容)', kind: 'openai', baseUrl: '', model: '', models: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function isFreeModel(model) {
|
||||||
|
return /:free$/.test(String(model));
|
||||||
|
}
|
||||||
|
|
||||||
function getProvider(id) {
|
function getProvider(id) {
|
||||||
return PROVIDERS.find((p) => p.id === id) || PROVIDERS.find((p) => p.id === 'custom');
|
return PROVIDERS.find((p) => p.id === id) || PROVIDERS.find((p) => p.id === 'custom');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { PROVIDERS, getProvider };
|
// 当前服务商预设里这个模型要附带的请求参数;自定义模型名没有
|
||||||
|
function modelExtra(providerId, model) {
|
||||||
|
const provider = getProvider(providerId);
|
||||||
|
const entry = (provider.models || []).find((m) => m.id === model);
|
||||||
|
if (provider.id === 'openrouter' && isFreeModel(model)) {
|
||||||
|
// 免费模型经常被上游限流:带上另外两个免费模型,OpenRouter 会自动换着试(最多 3 个)
|
||||||
|
const backups = provider.models.filter((m) => m.free === 'free' && m.id !== model).map((m) => m.id);
|
||||||
|
return { ...((entry && entry.extra) || NO_THINK_OPENROUTER), models: [model, ...backups].slice(0, 3) };
|
||||||
|
}
|
||||||
|
return (entry && entry.extra) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PROVIDERS, getProvider, modelExtra, isFreeModel, NO_THINK_OPENROUTER };
|
||||||
@@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld('xt', {
|
|||||||
getConfig: () => ipcRenderer.invoke('xt:getConfig'),
|
getConfig: () => ipcRenderer.invoke('xt:getConfig'),
|
||||||
setConfig: (partial) => ipcRenderer.invoke('xt:setConfig', partial),
|
setConfig: (partial) => ipcRenderer.invoke('xt:setConfig', partial),
|
||||||
getProviders: () => ipcRenderer.invoke('xt:getProviders'),
|
getProviders: () => ipcRenderer.invoke('xt:getProviders'),
|
||||||
|
listModels: (providerId) => ipcRenderer.invoke('xt:listModels', providerId),
|
||||||
testEngine: (partial) => ipcRenderer.invoke('xt:testEngine', partial),
|
testEngine: (partial) => ipcRenderer.invoke('xt:testEngine', partial),
|
||||||
openSettings: () => ipcRenderer.invoke('xt:openSettings'),
|
openSettings: () => ipcRenderer.invoke('xt:openSettings'),
|
||||||
onConfigChanged: (cb) => listen('xt:config', cb),
|
onConfigChanged: (cb) => listen('xt:config', cb),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ if (!window.xt) {
|
|||||||
const listeners = { partial: [], show: [], config: [] };
|
const listeners = { partial: [], show: [], config: [] };
|
||||||
let cfg = {
|
let cfg = {
|
||||||
hotkey: 'Alt+Q', engine: 'free', free: { provider: 'microsoft' },
|
hotkey: 'Alt+Q', engine: 'free', free: { provider: 'microsoft' },
|
||||||
llm: { provider: 'deepseek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', apiKeySet: false },
|
llm: { provider: 'zhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7-flash', apiKeySet: false },
|
||||||
livePreview: true, previewDelayMs: 500, restoreClipboard: true, launchAtLogin: false,
|
livePreview: true, previewDelayMs: 500, restoreClipboard: true, launchAtLogin: false,
|
||||||
};
|
};
|
||||||
const canned = { '我先撤了哈,明天见': "I'm heading out, see you tomorrow!", 'No worries, take your time!': '没事没事,你慢慢来!' };
|
const canned = { '我先撤了哈,明天见': "I'm heading out, see you tomorrow!", 'No worries, take your time!': '没事没事,你慢慢来!' };
|
||||||
@@ -40,11 +40,14 @@ if (!window.xt) {
|
|||||||
return structuredClone(cfg);
|
return structuredClone(cfg);
|
||||||
},
|
},
|
||||||
getProviders: async () => [
|
getProviders: async () => [
|
||||||
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', models: ['deepseek-chat'], keyUrl: 'https://platform.deepseek.com/api_keys' },
|
{ id: 'zhipu', name: '智谱 GLM · 免费', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7-flash', note: '国内直连,长期免费', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', models: [{ id: 'glm-4.7-flash', free: 'free', note: '推荐' }, { id: 'glm-4-flash-250414', free: 'free' }] },
|
||||||
{ id: 'anthropic', name: 'Claude(Anthropic)', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', models: ['claude-haiku-4-5', 'claude-sonnet-5-5'], keyUrl: 'https://console.anthropic.com/settings/keys' },
|
{ id: 'openrouter', name: 'OpenRouter · 有免费模型', baseUrl: 'https://openrouter.ai/api/v1', model: 'google/gemma-4-31b-it:free', keyUrl: 'https://openrouter.ai/keys', models: [{ id: 'google/gemma-4-31b-it:free', free: 'free', note: '推荐' }, { id: 'deepseek/deepseek-chat' }] },
|
||||||
{ id: 'ollama', name: 'Ollama(本地)', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true },
|
{ id: 'ollama', name: 'Ollama(本地)', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true },
|
||||||
{ id: 'custom', name: '自定义(OpenAI 兼容)', baseUrl: '', model: '', models: [] },
|
{ id: 'custom', name: '自定义(OpenAI 兼容)', baseUrl: '', model: '', models: [] },
|
||||||
],
|
],
|
||||||
|
listModels: async (id) => id === 'openrouter'
|
||||||
|
? [{ id: 'google/gemma-4-31b-it:free', free: 'free', note: '推荐' }, { id: 'deepseek/deepseek-chat' }, { id: 'qwen/qwen3.8-27b:free', free: 'free', live: true }]
|
||||||
|
: [],
|
||||||
testEngine: async ({ engine }) => engine === 'llm' && !cfg.llm.apiKeySet
|
testEngine: async ({ engine }) => engine === 'llm' && !cfg.llm.apiKeySet
|
||||||
? { ok: false, error: '还没填大模型的 API Key,去设置里填一下' }
|
? { ok: false, error: '还没填大模型的 API Key,去设置里填一下' }
|
||||||
: { ok: true, sample: "I'm heading out, see you tomorrow!", ms: 420 },
|
: { ok: true, sample: "I'm heading out, see you tomorrow!", ms: 420 },
|
||||||
|
|||||||
@@ -77,3 +77,4 @@ input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1
|
|||||||
.row { flex-wrap: wrap; }
|
.row { flex-wrap: wrap; }
|
||||||
.row > label { flex-basis: 100%; }
|
.row > label { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
|
[hidden] { display: none !important; }
|
||||||
@@ -47,9 +47,12 @@
|
|||||||
<input id="llm-base" type="url" spellcheck="false" placeholder="https://…">
|
<input id="llm-base" type="url" spellcheck="false" placeholder="https://…">
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="llm-model">模型</label>
|
<label for="llm-model-pick">模型</label>
|
||||||
<input id="llm-model" list="llm-models" spellcheck="false" placeholder="模型名">
|
<select id="llm-model-pick"></select>
|
||||||
<datalist id="llm-models"></datalist>
|
</div>
|
||||||
|
<div class="row" id="llm-model-row">
|
||||||
|
<label for="llm-model" id="llm-model-label">模型名</label>
|
||||||
|
<input id="llm-model" spellcheck="false" placeholder="填写服务商提供的模型 ID">
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="llm-key">API Key</label>
|
<label for="llm-key">API Key</label>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const $ = (id) => document.getElementById(id);
|
|||||||
const api = window.xt; // 不能叫 xt:contextBridge 已把 xt 定义为不可重声明的全局
|
const api = window.xt; // 不能叫 xt:contextBridge 已把 xt 定义为不可重声明的全局
|
||||||
let providers = [];
|
let providers = [];
|
||||||
let cfg = null;
|
let cfg = null;
|
||||||
|
const liveModels = {}; // 服务商 id → 联网拿到的完整模型列表(目前只有 OpenRouter 会多出实时免费模型)
|
||||||
|
const CUSTOM = '__custom__';
|
||||||
|
let customModel = false; // 用户在下拉框里选了"自定义模型…"
|
||||||
|
|
||||||
const errMsg = (e) => (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
const errMsg = (e) => (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||||
|
|
||||||
@@ -34,15 +37,15 @@ function render() {
|
|||||||
if (document.activeElement !== $('llm-base')) $('llm-base').value = llm.baseUrl || '';
|
if (document.activeElement !== $('llm-base')) $('llm-base').value = llm.baseUrl || '';
|
||||||
if (document.activeElement !== $('llm-model')) $('llm-model').value = llm.model || '';
|
if (document.activeElement !== $('llm-model')) $('llm-model').value = llm.model || '';
|
||||||
const p = providers.find((x) => x.id === llm.provider) || {};
|
const p = providers.find((x) => x.id === llm.provider) || {};
|
||||||
$('llm-models').replaceChildren(...(p.models || []).map((m) => Object.assign(document.createElement('option'), { value: m })));
|
renderModels(p, llm.model || '');
|
||||||
$('llm-key').placeholder = llm.apiKeySet ? '已保存(留空则不修改)' : (p.keyOptional ? '本地模型可不填' : 'sk-…');
|
$('llm-key').placeholder = llm.apiKeySet ? '已保存(留空则不修改)' : (p.keyOptional ? '本地模型可不填' : 'sk-…');
|
||||||
|
|
||||||
const tip = $('key-tip');
|
const tip = $('key-tip');
|
||||||
tip.replaceChildren();
|
tip.replaceChildren();
|
||||||
if (p.keyUrl) {
|
if (p.keyUrl) {
|
||||||
const a = Object.assign(document.createElement('a'), { href: p.keyUrl, target: '_blank', textContent: `去 ${p.name} 申请 Key` });
|
const a = Object.assign(document.createElement('a'), { href: p.keyUrl, target: '_blank', textContent: `去 ${p.name.split(' · ')[0]} 申请 Key` });
|
||||||
tip.append(a, ' Key 只加密保存在本机,只发给你选的服务商。', document.createElement('br'),
|
tip.append(a, ' Key 只加密保存在本机,只发给你选的服务商。');
|
||||||
'建议用非推理模型(如 deepseek-chat、qwen-plus、glm-4-flash),推理模型要先思考,出译文慢很多。');
|
if (p.note) tip.append(document.createElement('br'), p.note + '。');
|
||||||
} else if (p.id === 'ollama') {
|
} else if (p.id === 'ollama') {
|
||||||
tip.textContent = '需先在本机运行 Ollama 并拉取模型,例如 ollama pull qwen2.5:7b';
|
tip.textContent = '需先在本机运行 Ollama 并拉取模型,例如 ollama pull qwen2.5:7b';
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,49 @@ function render() {
|
|||||||
$('login').checked = !!cfg.launchAtLogin;
|
$('login').checked = !!cfg.launchAtLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelLabel(m) {
|
||||||
|
const tags = [];
|
||||||
|
if (m.free === 'free') tags.push('免费');
|
||||||
|
if (m.free === 'quota') tags.push('新用户免费额度');
|
||||||
|
if (m.note) tags.push(m.note);
|
||||||
|
return tags.length ? `${m.id}(${tags.join(' · ')})` : m.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function option(value, text) {
|
||||||
|
return Object.assign(document.createElement('option'), { value, textContent: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉框:精选模型 → 实时免费模型 → 自定义;当前模型不在列表里就落到"自定义"
|
||||||
|
function renderModels(p, model) {
|
||||||
|
const list = liveModels[p.id] || p.models || [];
|
||||||
|
const pick = $('llm-model-pick');
|
||||||
|
const curated = list.filter((m) => !m.live);
|
||||||
|
const live = list.filter((m) => m.live);
|
||||||
|
const children = curated.map((m) => option(m.id, modelLabel(m)));
|
||||||
|
if (live.length) {
|
||||||
|
const group = Object.assign(document.createElement('optgroup'), { label: '更多免费模型(实时)' });
|
||||||
|
group.append(...live.map((m) => option(m.id, modelLabel(m))));
|
||||||
|
children.push(group);
|
||||||
|
}
|
||||||
|
children.push(option(CUSTOM, '自定义模型…'));
|
||||||
|
pick.replaceChildren(...children);
|
||||||
|
|
||||||
|
const known = list.some((m) => m.id === model);
|
||||||
|
const custom = !list.length || customModel || !known;
|
||||||
|
pick.value = custom ? CUSTOM : model;
|
||||||
|
$('llm-model-pick').closest('.row').hidden = !list.length;
|
||||||
|
$('llm-model-row').hidden = !custom;
|
||||||
|
$('llm-model-label').textContent = list.length ? '模型名' : '模型';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels(id) {
|
||||||
|
try {
|
||||||
|
const list = await api.listModels(id);
|
||||||
|
if (Array.isArray(list) && list.length) liveModels[id] = list;
|
||||||
|
render();
|
||||||
|
} catch { /* 拉不到就只用预设 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// —— 引擎 ——
|
// —— 引擎 ——
|
||||||
for (const r of document.querySelectorAll('input[name=engine]')) {
|
for (const r of document.querySelectorAll('input[name=engine]')) {
|
||||||
r.addEventListener('change', () => save({ engine: r.value }));
|
r.addEventListener('change', () => save({ engine: r.value }));
|
||||||
@@ -68,7 +114,21 @@ $('llm-provider').addEventListener('change', (e) => {
|
|||||||
const llm = { provider: p.id };
|
const llm = { provider: p.id };
|
||||||
if (p.id !== 'custom') Object.assign(llm, { baseUrl: p.baseUrl, model: p.model });
|
if (p.id !== 'custom') Object.assign(llm, { baseUrl: p.baseUrl, model: p.model });
|
||||||
setResult($('llm-result'), '');
|
setResult($('llm-result'), '');
|
||||||
|
customModel = false;
|
||||||
save({ llm });
|
save({ llm });
|
||||||
|
if (!liveModels[p.id]) loadModels(p.id);
|
||||||
|
});
|
||||||
|
$('llm-model-pick').addEventListener('change', (e) => {
|
||||||
|
if (e.target.value === CUSTOM) {
|
||||||
|
customModel = true;
|
||||||
|
render();
|
||||||
|
$('llm-model').focus();
|
||||||
|
$('llm-model').select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customModel = false;
|
||||||
|
setResult($('llm-result'), '');
|
||||||
|
save({ llm: { model: e.target.value } });
|
||||||
});
|
});
|
||||||
$('llm-base').addEventListener('change', (e) => save({ llm: { baseUrl: e.target.value.trim() } }));
|
$('llm-base').addEventListener('change', (e) => save({ llm: { baseUrl: e.target.value.trim() } }));
|
||||||
$('llm-model').addEventListener('change', (e) => save({ llm: { model: e.target.value.trim() } }));
|
$('llm-model').addEventListener('change', (e) => save({ llm: { model: e.target.value.trim() } }));
|
||||||
@@ -168,4 +228,5 @@ api.onConfigChanged((c) => { cfg = c; render(); });
|
|||||||
$('llm-provider').replaceChildren(...providers.map((p) => Object.assign(document.createElement('option'), { value: p.id, textContent: p.name })));
|
$('llm-provider').replaceChildren(...providers.map((p) => Object.assign(document.createElement('option'), { value: p.id, textContent: p.name })));
|
||||||
cfg = await api.getConfig();
|
cfg = await api.getConfig();
|
||||||
render();
|
render();
|
||||||
|
loadModels(cfg.llm?.provider || 'zhipu');
|
||||||
})();
|
})();
|
||||||
+1
-1
@@ -62,7 +62,7 @@ test('加密可用时落盘不含明文,读回后内存里有 Key,公开接
|
|||||||
const pub = store.set({ llm: { apiKey: SECRET } });
|
const pub = store.set({ llm: { apiKey: SECRET } });
|
||||||
const disk = fs.readFileSync(file, 'utf8');
|
const disk = fs.readFileSync(file, 'utf8');
|
||||||
assert.equal(disk.includes(SECRET), false);
|
assert.equal(disk.includes(SECRET), false);
|
||||||
assert.match(disk, /"deepseek": "enc:/);
|
assert.match(disk, /"zhipu": "enc:/);
|
||||||
assert.equal(pub.llm.apiKeySet, true);
|
assert.equal(pub.llm.apiKeySet, true);
|
||||||
assert.equal(JSON.stringify(pub).includes(SECRET), false);
|
assert.equal(JSON.stringify(pub).includes(SECRET), false);
|
||||||
assert.equal(store.get().llm.apiKey, SECRET);
|
assert.equal(store.get().llm.apiKey, SECRET);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { PROVIDERS, getProvider, modelExtra } = require('../src/main/providers');
|
||||||
|
const { translateLLM } = require('../src/main/engines/llm');
|
||||||
|
const { pickFreeModels } = require('../src/main/models');
|
||||||
|
|
||||||
|
test('每个预设的默认模型都在自己的模型列表里,免费标记只用 free / quota', () => {
|
||||||
|
for (const p of PROVIDERS) {
|
||||||
|
if (!p.models.length) continue;
|
||||||
|
assert.ok(p.models.some((m) => m.id === p.model), p.id);
|
||||||
|
for (const m of p.models) assert.ok(m.free === undefined || m.free === 'free' || m.free === 'quota', m.id);
|
||||||
|
}
|
||||||
|
assert.equal(getProvider('nope').id, 'custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('预设模型的额外参数会进请求体,自定义模型名不带', async () => {
|
||||||
|
assert.deepEqual(modelExtra('zhipu', 'glm-4.7-flash'), { thinking: { type: 'disabled' } });
|
||||||
|
assert.deepEqual(modelExtra('zhipu', 'my-own-model'), {});
|
||||||
|
let body;
|
||||||
|
await translateLLM({
|
||||||
|
text: '你好',
|
||||||
|
direction: 'zh2en',
|
||||||
|
llm: { provider: 'deepseek', baseUrl: 'https://example.com', model: 'deepseek-flash', apiKey: 'k' },
|
||||||
|
fetchImpl: async (_url, options) => {
|
||||||
|
body = JSON.parse(options.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: new ReadableStream({ start(c) { c.enqueue(Buffer.from('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: [DONE]\n\n')); c.close(); } }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.thinking, { type: 'disabled' });
|
||||||
|
assert.equal(body.model, 'deepseek-flash');
|
||||||
|
assert.equal(body.stream, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenRouter 免费模型:只留 :free、去掉已推荐的和不适合翻译的,按上下文排序截断', () => {
|
||||||
|
const data = [
|
||||||
|
{ id: 'google/gemma-4-31b-it:free', name: 'Gemma 4 31B (free)', context_length: 262144 },
|
||||||
|
{ id: 'foo/bar:free', name: 'Bar (free)', context_length: 8000 },
|
||||||
|
{ id: 'foo/big:free', name: 'Big (free)', context_length: 128000 },
|
||||||
|
{ id: 'nvidia/nemotron-3.5-content-safety:free', name: 'Safety', context_length: 128000 },
|
||||||
|
{ id: 'cohere/north-mini-code:free', name: 'Code', context_length: 256000 },
|
||||||
|
{ id: 'paid/model', name: 'Paid', context_length: 999999 },
|
||||||
|
];
|
||||||
|
const out = pickFreeModels(data, ['google/gemma-4-31b-it:free'], 10);
|
||||||
|
assert.deepEqual(out.map((m) => m.id), ['foo/big:free', 'foo/bar:free']);
|
||||||
|
assert.equal(out[0].free, 'free');
|
||||||
|
assert.equal(out[0].extra.reasoning.enabled, false);
|
||||||
|
assert.equal(pickFreeModels(data, [], 1).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function okStream(text) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: new ReadableStream({ start(c) { c.enqueue(Buffer.from(`data: {"choices":[{"delta":{"content":"${text}"}}]}\n\ndata: [DONE]\n\n`)); c.close(); } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const busy = () => ({ ok: false, status: 429, text: async () => '' });
|
||||||
|
const orLLM = { provider: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'google/gemma-4-31b-it:free', apiKey: 'k' };
|
||||||
|
|
||||||
|
test('OpenRouter 免费模型带上最多 3 个备用模型并关闭思考;付费模型不带', () => {
|
||||||
|
const extra = modelExtra('openrouter', 'google/gemma-4-31b-it:free');
|
||||||
|
assert.equal(extra.models.length, 3);
|
||||||
|
assert.equal(extra.models[0], 'google/gemma-4-31b-it:free');
|
||||||
|
assert.equal(new Set(extra.models).size, 3);
|
||||||
|
assert.deepEqual(extra.reasoning, { enabled: false });
|
||||||
|
assert.equal(modelExtra('openrouter', 'some/new-model:free').models[0], 'some/new-model:free');
|
||||||
|
assert.deepEqual(modelExtra('openrouter', 'deepseek/deepseek-chat'), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('免费模型 429 时自动重试一次,成功就返回', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const text = await translateLLM({
|
||||||
|
text: '你好', direction: 'zh2en', llm: orLLM, retryDelayMs: 1,
|
||||||
|
fetchImpl: async () => (++calls === 1 ? busy() : okStream('Hi')),
|
||||||
|
});
|
||||||
|
assert.equal(text, 'Hi');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('免费模型重试后仍 429,报"免费模型太忙"', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(
|
||||||
|
translateLLM({ text: '你好', direction: 'zh2en', llm: orLLM, retryDelayMs: 1, fetchImpl: async () => { calls++; return busy(); } }),
|
||||||
|
(err) => err.code === 'FREE_BUSY',
|
||||||
|
);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
const { userMessage } = require('../src/main/errors');
|
||||||
|
assert.match(userMessage({ code: 'FREE_BUSY' }), /免费模型这会儿太忙/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user