P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
188 lines
6.8 KiB
TypeScript
188 lines
6.8 KiB
TypeScript
/**
|
||
* OpenAI 兼容 Provider 中间基类(v0.6.4 P3-1)
|
||
*
|
||
* 背景:deepseek / agnes-ai / mimo / openai 四家适配器各自复制了几乎逐字相同的
|
||
* ~60 行传输样板 —— send/sendStream 的 fetchWithTimeout 调用、Bearer 头构建、
|
||
* HTTP 错误桥接、非流式 JSON → MetonaResponse 的字段组装、SSE 流接入、以及
|
||
* "config.contextWindow → MODEL_INFO → 兜底" 的上下文窗口回退链。
|
||
* 任何行为修复都要改四处,是历史缺陷(如超时字段不一致)的直接来源。
|
||
*
|
||
* 收敛后职责划分:
|
||
* - 本基类拥有:send / sendStream / buildHeaders / 响应组装 / finishReason 映射 /
|
||
* getContextWindow 回退链;
|
||
* - 子类只声明差异:chatCompletionsUrl、toNativeRequest(协议参数映射)、
|
||
* sendTimeoutMs(个别 Provider 历史超时不同)、modelInfoTable。
|
||
*
|
||
* 外部类型穿透铁律不变:OpenAI 原生类型止步于本文件,向上只产出 Metona IR。
|
||
*/
|
||
|
||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../../types';
|
||
import { MetonaFinishReason } from '../../types';
|
||
import type { MetonaModelInfo } from '../../types/metona-adapter';
|
||
import { parseSSEStream, parseOpenAICompatibleResponse } from './sse-stream';
|
||
import { BaseAdapter } from '../base-adapter';
|
||
|
||
export abstract class OpenAICompatibleAdapter extends BaseAdapter {
|
||
/**
|
||
* POST /chat/completions 的完整端点。
|
||
* 绝大多数 Provider 为 `${baseURL}/chat/completions`;少数代理需要自定义。
|
||
*/
|
||
protected abstract chatCompletionsUrl(): string;
|
||
|
||
/**
|
||
* 子类特有的请求体参数映射(messages/tools/thinking/max_tokens 等差异点)。
|
||
* 返回不含 stream 字段的 body —— stream 由本基类统一注入。
|
||
*/
|
||
protected abstract toNativeRequest(
|
||
request: MetonaRequest,
|
||
stream: boolean,
|
||
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||
|
||
/** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120s;Agnes 历史 300s,保留其值。 */
|
||
protected abstract sendTimeoutMs(): number;
|
||
|
||
/** 模型元信息表(子类持有;用于 getContextWindow 回退链与钳制) */
|
||
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
|
||
|
||
/** getContextWindow 的最终兜底窗口(未配置且模型未知时使用) */
|
||
// v0.7.4 P4-5: 1M → 128K(与 base-adapter 兜底对齐)——未知模型按最保守主流窗口预算,
|
||
// 防止压缩阈值按 1M 计算导致实际小窗口模型先 413 再压缩
|
||
protected defaultContextWindowFallback(): number {
|
||
return 128_000;
|
||
}
|
||
|
||
// ===== 认证头 =====
|
||
|
||
protected buildHeaders(): Record<string, string> {
|
||
return {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${this.config.apiKey}`,
|
||
...this.config.headers,
|
||
};
|
||
}
|
||
|
||
// ===== POST {chatCompletionsUrl} (非流式) =====
|
||
|
||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||
const nativeRequest = await this.toNativeRequest(request, false);
|
||
|
||
const response = await this.fetchWithTimeout(
|
||
this.chatCompletionsUrl(),
|
||
{
|
||
method: 'POST',
|
||
headers: this.buildHeaders(),
|
||
body: JSON.stringify({ ...nativeRequest, stream: false }),
|
||
},
|
||
this.config.timeoutMs ?? this.sendTimeoutMs(),
|
||
);
|
||
|
||
if (!response.ok) {
|
||
await this.throwHttpError(response, `${this.providerLabel()} API error`);
|
||
}
|
||
|
||
const data = (await response.json()) as Record<string, unknown>;
|
||
return this.toMetonaResponseFromOpenAI(data, request.meta.requestId);
|
||
}
|
||
|
||
// ===== POST {chatCompletionsUrl} (流式) =====
|
||
|
||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||
const nativeRequest = await this.toNativeRequest(request, true);
|
||
|
||
const response = await this.fetchWithTimeout(
|
||
this.chatCompletionsUrl(),
|
||
{
|
||
method: 'POST',
|
||
headers: this.buildHeaders(),
|
||
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
||
},
|
||
this.config.timeoutMs ?? 300_000,
|
||
);
|
||
|
||
if (!response.ok || !response.body) {
|
||
await this.throwHttpError(response, `${this.providerLabel()} stream error`);
|
||
}
|
||
|
||
yield* parseSSEStream(
|
||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||
response.body!,
|
||
request.meta.requestId,
|
||
request.meta.sessionId,
|
||
request.meta.iteration,
|
||
);
|
||
}
|
||
|
||
// ===== 共享装配 =====
|
||
|
||
/** Provider 展示名(错误上下文用):默认取 providerId,子类可覆盖 */
|
||
protected providerLabel(): string {
|
||
return this.providerId;
|
||
}
|
||
|
||
/**
|
||
* 非流式响应组装 —— OpenAI 原生结构到 MetonaResponse 的唯一映射点
|
||
* (此前在四个子类各有一份逐字拷贝)
|
||
*/
|
||
private toMetonaResponseFromOpenAI(
|
||
data: Record<string, unknown>,
|
||
requestId: string,
|
||
): MetonaResponse {
|
||
const parsed = parseOpenAICompatibleResponse(data);
|
||
return {
|
||
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: this.mapFinishReasonToMetona(parsed.finishReason),
|
||
};
|
||
}
|
||
|
||
/** parseOpenAIFinishReason 输出 → MetonaFinishReason 枚举(显式映射替代裸 as 断言) */
|
||
private mapFinishReasonToMetona(reason: string): MetonaFinishReason {
|
||
switch (reason) {
|
||
case 'length':
|
||
return MetonaFinishReason.LENGTH;
|
||
case 'tool_calls':
|
||
return MetonaFinishReason.TOOL_CALLS;
|
||
case 'content_filter':
|
||
return MetonaFinishReason.CONTENT_FILTER;
|
||
case 'error':
|
||
return MetonaFinishReason.ERROR;
|
||
default:
|
||
return MetonaFinishReason.STOP;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上下文窗口回退链(v0.6.3 一致化后的统一实现):
|
||
* config.contextWindow(用户显式配置)→ 模型元信息 → Provider 兜底。
|
||
*/
|
||
override getContextWindow(): number {
|
||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||
return this.config.contextWindow;
|
||
}
|
||
const modelInfo = this.modelInfoTable()[this.config.defaultModel];
|
||
return modelInfo?.contextWindow ?? this.defaultContextWindowFallback();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 模型能力限制类错误(v0.6.4 升级:此前 OpenAI 推理模型拒图抛裸 Error,
|
||
* 引擎分类落到 UNKNOWN,UI 无法区分"该模型不支持图"与一般故障)。
|
||
* 携带 status=400 使引擎按"不可重试请求级错误"处理并直接展示原因。
|
||
*/
|
||
export class ModelCapabilityError extends Error {
|
||
readonly status = 400;
|
||
constructor(model: string, capability: string) {
|
||
super(`Model "${model}" does not support ${capability}`);
|
||
this.name = 'ModelCapabilityError';
|
||
}
|
||
}
|