Files
metona-ai-desktop/electron/harness/adapters/openai.adapter.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

247 lines
8.2 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 AdapterP3
*
* OpenAI Chat Completions API/v1/chat/completions),支持 Tool Calling、
* 流式输出、多模态图片、o 系列推理模型的 reasoning_effort 参数。
*
* 与 DeepSeek 适配器的关键差异:
* - o 系列 / gpt-5 系列模型使用 max_completion_tokens(非 max_tokens
* - Thinking 模式通过顶层 reasoning_effort 参数(o 系列模型)
* - 模型列表从 /v1/models 动态获取
*
* @see apis 官方文档 https://platform.openai.com/docs/api-reference/chat
*/
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 OpenAIAdapter extends BaseAdapter {
override readonly providerId: string = 'openai';
readonly supportedModels = ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'gpt-4o': {
id: 'gpt-4o',
name: 'GPT-4o',
contextWindow: 128_000,
maxOutputTokens: 16_384,
supportsToolCalling: true,
supportsThinking: false,
description: 'OpenAI 旗舰多模态模型,128K 上下文',
},
'gpt-4o-mini': {
id: 'gpt-4o-mini',
name: 'GPT-4o mini',
contextWindow: 128_000,
maxOutputTokens: 16_384,
supportsToolCalling: true,
supportsThinking: false,
description: 'OpenAI 高性价比模型,128K 上下文',
},
'gpt-4.1': {
id: 'gpt-4.1',
name: 'GPT-4.1',
contextWindow: 1_000_000,
maxOutputTokens: 32_768,
supportsToolCalling: true,
supportsThinking: false,
description: 'OpenAI 长上下文模型,1M 上下文',
},
'o3-mini': {
id: 'o3-mini',
name: 'o3-mini',
contextWindow: 200_000,
maxOutputTokens: 100_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'OpenAI 推理模型,支持 reasoning_effort',
},
};
// ===== POST /v1/chat/completions(非流式) =====
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
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, 'OpenAI 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 /v1/chat/completions(流式) =====
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
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, 'OpenAI stream error');
}
yield* parseSSEStream(
// 非空断言:上方 if 已确保 response.body 不为 null
response.body!,
request.meta.requestId,
request.meta.sessionId,
request.meta.iteration,
);
}
// ===== GET /v1/models =====
override 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) {
return data.data.map((m) => OpenAIAdapter.MODEL_INFO[m.id] ?? { id: m.id });
}
}
} catch {
// API 不可用时降级
}
return this.supportedModels.map((id) => OpenAIAdapter.MODEL_INFO[id] ?? { id });
}
override getContextWindow(): number {
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow;
}
const modelInfo = OpenAIAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 128_000;
}
// ========== 私有方法 ==========
/**
* 构建 OpenAI 原生请求体
*
* OpenAI 特有处理:
* - 多模态图片:user 消息 images[] → content 数组
* - o 系列(o1/o3/o4)与 gpt-5 系列使用 max_completion_tokens + reasoning_effort
* - 思考模式下 temperature 被部分推理模型拒绝,不传
*/
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
const messages = buildOpenAICompatibleMessages(request);
const tools = buildOpenAICompatibleTools(request.tools);
// 推理模型检测(o 系列使用新参数名)
const model = this.config.defaultModel;
const isReasoningModel = /^(o\d|gpt-5)/.test(model);
// === 多模态:将 images 转为 OpenAI content 数组(与 Agnes/MiMo 一致) ===
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
let imageCount = 0;
for (let i = 0; i < messages.length; i++) {
if (i === 0) continue; // messages[0] 是 system
const origMsg = nonSystemMsgs[i - 1];
if (!origMsg?.images?.length) continue;
imageCount += origMsg.images.length;
const contentParts: Array<Record<string, unknown>> = [];
if (origMsg.content) {
contentParts.push({ type: 'text', text: origMsg.content });
}
for (const img of origMsg.images) {
contentParts.push({ type: 'image_url', image_url: { url: img.url } });
}
messages[i].content = contentParts;
}
if (imageCount > 0) {
// 推理模型当前不支持图片输入
if (isReasoningModel) {
throw new Error(`Model "${model}" does not support image inputs`);
}
}
const body: Record<string, unknown> = {
model,
messages,
stream,
};
// Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens
if (request.params.maxTokens) {
if (isReasoningModel) {
body.max_completion_tokens = request.params.maxTokens;
} else {
body.max_tokens = request.params.maxTokens;
}
} else if (isReasoningModel) {
// 推理模型未配置时使用兜底值(thinking 占用 token 配额,默认值过小会被截断)
body.max_completion_tokens = 32_768;
}
if (stream) {
body.stream_options = { include_usage: true };
}
if (tools) {
body.tools = tools;
}
// Thinking 模式:推理模型映射 reasoning_effort;非推理模型忽略
if (request.params.thinkingEnabled && isReasoningModel) {
const effortMap: Record<string, string> = { low: 'low', medium: 'medium', high: 'high', max: 'high' };
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
} else if (!isReasoningModel) {
// 非推理模型使用温度控制
body.temperature = request.params.temperature;
}
// 停止序列
if (request.params.stopSequences?.length) {
body.stop = request.params.stopSequences;
}
return body;
}
}