Files
metona-ai-desktop/electron/harness/adapters/openai.adapter.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

189 lines
7.1 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 参数。
*
* v0.6.4 P3-1: 继承 OpenAICompatibleAdapter —— 传输/组装/回退链收敛到共享基类,
* 本文件只保留 OpenAI 差异点:o 系列/gpt-5 的字段名路由与 reasoning_effort、
* 推理模型拒图的前置拦截(升级为 ModelCapabilityError)、动态 /models 列表、
* 非推理模型 temperature 控制。
*
* @see https://platform.openai.com/docs/api-reference/chat
*/
import log from 'electron-log';
import type { MetonaRequest } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { ModelCapabilityError, OpenAICompatibleAdapter } from './shared/openai-compatible-base';
export class OpenAIAdapter extends OpenAICompatibleAdapter {
override readonly providerId: string = 'openai';
readonly supportedModels = ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'gpt-4o': {
id: 'gpt-4o',
name: 'GPT-4o',
supportsToolCalling: true,
supportsThinking: false,
description: 'OpenAI 旗舰多模态模型',
},
'gpt-4o-mini': {
id: 'gpt-4o-mini',
name: 'GPT-4o mini',
supportsToolCalling: true,
supportsThinking: false,
description: 'OpenAI 高性价比模型',
},
'gpt-4.1': {
id: 'gpt-4.1',
name: 'GPT-4.1',
supportsToolCalling: true,
supportsThinking: false,
description: 'OpenAI 长上下文模型',
},
'o3-mini': {
id: 'o3-mini',
name: 'o3-mini',
supportsToolCalling: true,
supportsThinking: true,
description: 'OpenAI 推理模型,支持 reasoning_effort',
},
};
// ===== 共享基类差异声明 =====
protected override chatCompletionsUrl(): string {
return `${this.config.baseURL}/chat/completions`;
}
protected override sendTimeoutMs(): number {
return 120_000;
}
protected override modelInfoTable(): Record<string, MetonaModelInfo> {
return OpenAIAdapter.MODEL_INFO;
}
protected override providerLabel(): string {
return 'OpenAI';
}
// ===== 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 });
}
// ========== 协议参数映射(OpenAI 差异点) ==========
protected override toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Record<string, unknown> {
// 推理模型检测(o 系列使用新参数名)
// v0.8.2 P2-6: 正则补边界 — 旧 /^(o\d|gpt-5)/ 对命名变体存在两处漏判/误判:
// ① `chatgpt-5-*` 系列(gpt-5 非前缀)漏判;② 未来 `o10` 等多位数编号无影响
// 但 `o\d` 会把 `openai/...` 路径形态误判(当前配置层不透传路径,防御性加边界)。
// 现改为带词边界的枚举前缀匹配(o<digits> / gpt-5 / chatgpt-5)。
const model = this.config.defaultModel;
const isReasoningModel = /^(o\d+(?:[-.][\w.]+)?|gpt-5[\w.-]*|chatgpt-5[\w.-]*)/.test(model);
// 推理模型不支持图片输入 — 前置校验
// v0.6.4 升级: 原实现抛裸 Error 落入 UNKNOWN 错误码;现在抛 ModelCapabilityError
// (携带 status=400),引擎按"不可重试请求级错误"处理,UI 可区分能力限制与一般故障。
if (isReasoningModel) {
const hasImages = request.messages.some((m) => m.images?.length);
if (hasImages) {
throw new ModelCapabilityError(model, 'image inputs');
}
}
// v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位)
const messages = buildOpenAICompatibleMessages(request, true);
const tools = buildOpenAICompatibleTools(request.tools);
const body: Record<string, unknown> = {
model,
messages,
stream,
};
// Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens
// v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了
// 旧的按模型元信息钳制逻辑与未配置时的 32_768 兜底 —— 未配置时不下发该字段。
if (request.params.maxTokens != null) {
if (isReasoningModel) {
body.max_completion_tokens = request.params.maxTokens;
} else {
body.max_tokens = request.params.maxTokens;
}
}
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';
// v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 用户配置的预算过小时告警
if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) {
log.warn(
`[OpenAI] reasoning enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
);
}
} else if (!isReasoningModel) {
// 非推理模型使用温度控制
body.temperature = request.params.temperature;
}
// 停止序列
// v0.8.2 P2-6 根治: 推理模型不支持 stop 参数 —— 旧实现"仍透传,400 属上游约束"
// 的注释性放弃改为前置拦截:推理模型请求丢弃 stop 并告警(与拒图的
// ModelCapabilityError 同思路,但 stop 是可选增强参数,静默丢弃 + 告警优于
// 让整个请求 400 失败)。
if (request.params.stopSequences?.length) {
if (isReasoningModel) {
log.warn(
`[OpenAI] stop sequences are not supported by reasoning model "${model}" — dropping ${request.params.stopSequences.length} stop sequence(s) for this request`,
);
} else {
body.stop = request.params.stopSequences;
}
}
return body;
}
}