Files
metona-ai-desktop/electron/harness/adapters/deepseek.adapter.ts
T
thzxx 3a30e8f5b4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 9m59s
fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
背景:v0.5.2 工具调用失效修复后,对模型全能力矩阵(工具/思考/多模态/流式/
压缩/摘要/记忆/故障转移/余额)做契约级核查,发现并修复两处同类时序/边界缺陷。

P1 MCP 工具运行中增删不同步引擎:
- 症状:运行中添加/启用 MCP server 后,已打开会话拿不到新工具;断开
  server 后已有引擎仍持有失效工具定义(模型发起调用才报 Unknown tool)
- 根因:setToolsAll 仅在启动期与工具开关时调用,MCP 连接/断开路径缺失
  (与 v0.5.2 修复的懒创建陷阱同类——"变更点 × 同步路径"未全覆盖)
- 修复:MCPManager 新增 setOnToolsChanged 回调,connectServer 注册完成 /
  disconnectServer 注销完成后触发;main.ts 注入回调同步全部已存在引擎。
  懒创建引擎由 createEngine 实时拉取(v0.5.2),三条路径(启动/懒创建/
  运行中变更)全覆盖。README"无需重启动态发现"的宣称至此真实成立。

P1 maxTokens 超模型上限直接 400:
- 症状:引擎默认 maxTokens=63488,OpenAI gpt-4o(16384)/gpt-4.1(32768)、
  Anthropic opus/haiku(32000)、MiMo standard(32768) 每次请求 400,等于不可用
- 修复:五个 adapter(DeepSeek/Agnes/MiMo/OpenAI/Anthropic)统一按
  MODEL_INFO.maxOutputTokens 钳制;MiMo 保留 thinking 兜底 32768 语义;
  Anthropic thinking budget 在钳制后的 max_tokens 内二分,自动跟随

测试(224 → 236 用例):
- 新增 maxTokens 钳制契约测试 ×9(max-tokens-clamp.test.ts):mock fetch
  记录真实请求体断言——超限钳制(MiMo standard/OpenAI gpt-4o/Anthropic
  opus)/ 未超限原样传递(DeepSeek/Agnes/MiMo pro/o3-mini/sonnet)/
  推理模型字段名 / 未配置默认值安全性
- 新增 MCP 动态同步端到端测试 ×3(mcp-tools-sync.test.ts):mock MCP SDK
  + 真实 MCPManager/ToolRegistry/AgentEngineManager——先建引擎再连
  server,断言同一会话请求的 tools 动态更新 / 断开后移除失效定义 /
  回调异常不阻断 MCP 主流程

能力矩阵核查结论(无回归确认): 工具调用主链路 ✓(v0.5.2)/ SubAgent
工具 ✓(delegate 实时 resolveTools)/ thinking 热更新 ✓(baseConfig 合并
路径无懒创建陷阱)/ 多模态当轮 ✓ / 压缩与孤立 tool 消息配对 ✓ / 摘要分层 ✓ /
记忆注入 ✓ / 故障转移 ✓ / 余额 ✓(v0.5.2)。已知设计限制:历史轮图片不
回传(attachments 仅存缩略图,图片只在发送当轮注入上下文)。

验证: lint 0 / typecheck 双工程 0 / test:electron 236 全过 / build 成功
2026-08-21 22:36:52 +08:00

287 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DeepSeek Provider Adapter
*
* 基于 OpenAI 兼容 API。支持 Tool Calling、Thinking 模式、流式输出。
* 模型: deepseek-v4-flash / deepseek-v4-pro1M 上下文,384K 最大输出)
*
* 独立继承 BaseAdapter,通过 shared/openai-format 和 shared/sse-stream 复用
* OpenAI 兼容格式构建和 SSE 流式解析逻辑。不与其他 Provider Adapter 耦合。
*
* @see apis/deepseek-api-docs-20260518.html
*/
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
export class DeepSeekAdapter extends BaseAdapter {
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'deepseek';
readonly supportedModels = ['deepseek-v4-pro', 'deepseek-v4-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'deepseek-v4-pro': {
id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用',
},
'deepseek-v4-flash': {
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 快速版,1M 上下文,低延迟推理',
},
};
// ===== POST /chat/completions (非流式) =====
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(
`${this.config.baseURL}/chat/completions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
...this.config.headers,
},
body: JSON.stringify(body),
},
this.config.timeoutMs ?? 120_000,
);
if (!response.ok) {
await this.throwHttpError(response, 'DeepSeek API error');
}
const data = (await response.json()) as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data);
return {
meta: {
requestId: request.meta.requestId,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
},
content: parsed.content,
reasoningContent: parsed.reasoningContent,
toolCalls: parsed.toolCalls,
usage: parsed.usage,
finishReason: parsed.finishReason as MetonaFinishReason,
};
}
// ===== POST /chat/completions (流式) =====
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(
`${this.config.baseURL}/chat/completions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
...this.config.headers,
},
body: JSON.stringify(body),
},
this.config.timeoutMs ?? 300_000,
);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'DeepSeek stream error');
}
yield* parseSSEStream(
// 非空断言:上方 if 已确保 response.body 不为 null
// TypeScript 无法通过 await Promise<never> 正确收窄,需显式断言
response.body!,
request.meta.requestId,
request.meta.sessionId,
request.meta.iteration,
);
}
// ===== GET /models =====
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 优先尝试从 API 获取实时模型列表,并合并本地 MODEL_INFO 元数据。
* API 不可用时回退到 supportedModels。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.config.baseURL}/models`, {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
const data = (await response.json()) as { data?: Array<{ id: string }> };
if (data.data?.length) {
// 合并 API 返回的模型 ID 与本地元数据
return data.data.map((m) => DeepSeekAdapter.MODEL_INFO[m.id] ?? { id: m.id });
}
}
} catch {
// API 不可用时降级
}
// 回退到 supportedModels(带本地元数据)
return this.supportedModels.map((id) => DeepSeekAdapter.MODEL_INFO[id] ?? { id });
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* v0.3.1: 优先使用配置注入的 contextWindow,回退到 MODEL_INFO 默认值。
* DeepSeek OpenAI 兼容 API 不支持 context_window 参数,此值仅用于
* Engine 压缩判断和前端 UI 显示。
*/
override getContextWindow(): number {
// v0.3.1: 优先使用配置注入的 contextWindow
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow;
}
// 回退到 MODEL_INFO
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ===== GET /user/balance =====
/**
* 查询账户余额
*
* v0.5.2 修复: DeepSeek 官方 API 实际返回 `balance_infos` 数组格式:
* { "is_available": true, "balance_infos": [{ "currency": "CNY",
* "total_balance": "110.00", "granted_balance": "10.00", "topped_up_balance": "100.00" }] }
* 此前按扁平字段解析(data.total_balance)→ 永远取到 undefined → 恒显示 0。
* 现优先取 balance_infos[0],回退扁平格式(兼容网关/代理的简化响应)。
*
* URL 规范化: 余额端点为 {root}/user/balance(无 /v1 前缀)。用户配置的
* baseURL 可能带 /v1 或尾斜杠(chat 端点两种写法都合法),此处剥离后拼接。
*/
async getBalance(): Promise<{
currency: string;
totalBalance: string;
grantedBalance: string;
toppedUpBalance: string;
} | null> {
try {
// 规范化 baseURL:去尾斜杠、去尾 /v1(余额端点在根路径下)
const root = this.config.baseURL.replace(/\/+$/, '').replace(/\/v1$/, '');
const response = await fetch(`${root}/user/balance`, {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return null;
const data = (await response.json()) as {
is_available?: boolean;
balance_infos?: Array<{
currency?: string;
total_balance?: string;
granted_balance?: string;
topped_up_balance?: string;
}>;
// 扁平格式字段(网关/代理兼容)
currency?: string;
total_balance?: string;
granted_balance?: string;
topped_up_balance?: string;
};
// 优先官方 balance_infos 数组,回退扁平格式
const info = data.balance_infos?.[0] ?? data;
return {
currency: info.currency ?? 'CNY',
totalBalance: info.total_balance ?? '0',
grantedBalance: info.granted_balance ?? '0',
toppedUpBalance: info.topped_up_balance ?? '0',
};
} catch {
return null;
}
}
// ========== 私有方法 ==========
/**
* 构建 DeepSeek 原生请求体
*
* DeepSeek 特有参数:
* - thinking: { type: "enabled" } — 启用思考模式
* - reasoning_effort — 思考强度映射
* - stream_options: { include_usage: true } — 流式返回 usage
*/
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
const messages = buildOpenAICompatibleMessages(request);
const tools = buildOpenAICompatibleTools(request.tools);
// v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
const maxOutput = modelInfo?.maxOutputTokens ?? 384_000;
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
const body: Record<string, unknown> = {
model: this.config.defaultModel,
messages,
temperature: request.params.temperature,
max_tokens: maxTokens,
stream,
};
if (stream) {
body.stream_options = { include_usage: true };
}
if (tools) {
body.tools = tools;
}
// Thinking 模式
// API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭
if (request.params.thinkingEnabled === false) {
body.thinking = { type: 'disabled' };
} else if (request.params.thinkingEnabled) {
body.thinking = { type: 'enabled' };
const effortMap: Record<string, string> = {
low: 'high',
medium: 'high',
high: 'high',
max: 'max',
};
// DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
}
// 停止序列
if (request.params.stopSequences?.length) {
body.stop = request.params.stopSequences;
}
return body;
}
}