Files
metona-ai-desktop/electron/harness/adapters/shared/openai-compatible-base.ts
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
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 桥契约
2026-08-27 17:06:58 +08:00

190 lines
6.6 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.
/**
* 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=120sAgnes 历史 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';
}
}