feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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 的最终兜底窗口(未配置且模型未知时使用) */
|
||||
protected defaultContextWindowFallback(): number {
|
||||
return 1_000_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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user