Files
metona-ai-desktop/electron/harness/adapters/openai.adapter.ts
T
thzxx a7090214b1
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m20s
CI / 产物编译验证 (push) Successful in 10m5s
fix: v0.6.2 修复工具调用不稳定与会话停止 — 纯 tool_calls 轮丢失 assistant 消息导致 API 400
【根因(main.log 实证)】
19:04 / 19:05 / 19:06 三次会话终止均为同一报错:
  DeepSeek 400 "Messages with role 'tool' must be a response to a preceding
  message with 'tool_calls'"

缺陷链:engine 主循环仅在 step.thought 存在(该轮有文本或思考内容)时才
将 assistant 消息加入请求历史。当模型发起纯工具调用(零文本零思考 —
DeepSeek 高频行为)时:
  - assistant(tool_calls) 消息不进 messages
  - 但 tool 结果消息照常 push
  → 下一轮请求出现孤立 tool 消息 → 协议 400(不可重试)→ 会话 ERROR 终止
"不稳定" = 模型每轮是否附带文本是概率性行为:带文本正常,纯调用必崩。
DB 持久化侧同源缺陷(if (!step.thought) continue)导致这些步骤的
assistant 与 tool 结果全部不落库 — 重启后工具上下文丢失,模型重复调用。

【修复】
- engine.ts: 有 toolCalls 的轮次必 push assistant(content=null,C-6 规范)
- agent.ts: 持久化条件同步修复(无 thought 但有 toolCalls 的步骤落库)
- 回归测试: 纯 tool_calls 轮后第二次请求中 tool 消息前必须是带
  tool_calls 的 assistant(请求契约断言,engine-toolchain.test.ts)

【纵深防御 — 孤立 tool 消息过滤】
- openai-format.ts(DeepSeek/Agnes/MiMo/OpenAI 四家共享): 构建请求时
  按 tool_call_id 配对过滤孤立 tool 消息(任何来源的历史污染不再 400 死锁)
- anthropic.adapter.ts: tool_use/tool_result 同策略配对过滤
- 单测 ×6: 正常配对保留 / 孤立丢弃 / id 不匹配丢弃 / 多轮配对 /
  includeImages 原位转换 / 非 vision 静默丢弃

【多模态索引对齐收敛】
4 家 adapter 的 images 处理循环原按未过滤的 nonSystemMsgs[i-1] 对齐索引,
孤立 tool 过滤引入后会错位 — 统一收进 buildOpenAICompatibleMessages
(includeImages 参数,基于 sanitized 序列原位转换),4 家 adapter 删除
各自的索引对齐循环(DeepSeek vision 判断 / OpenAI 推理模型拒绝保留在 adapter)。

【终止原因可见化】
MAX_ITERATIONS / TIMEOUT 终止此前无任何提示(用户感知"会话直接停止")—
前端 DONE 事件非 completed 终止原因显示为 system 消息。

【v0.6.1 回归缓解】
web_fetch timeoutMs 120s → 240s:浏览器回退串行化后并发 3 个排队最坏
~127.5s,旧值让排队末位抓取被工具超时杀掉(表现为抓取不稳定)。

【验证】
lint 0/0;typecheck 双工程 0 错误;test:electron 259/259(+7);
electron-vite build 成功
2026-08-22 19:34:16 +08:00

248 lines
8.0 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> {
// 推理模型检测(o 系列使用新参数名)
const model = this.config.defaultModel;
const isReasoningModel = /^(o\d|gpt-5)/.test(model);
// 推理模型不支持图片输入 — 前置校验(转换在共享层,此处仅拦截)
if (isReasoningModel) {
const hasImages = request.messages.some((m) => m.images?.length);
if (hasImages) {
throw new Error(`Model "${model}" does not support 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.5.3: 按模型上限钳制(gpt-4o 16384 / gpt-4.1 32768 / o3-mini 100000)—
// 引擎默认 63488 超过 gpt-4o/gpt-4.1 上限时 API 直接 400
const oaMaxOutput = OpenAIAdapter.MODEL_INFO[model]?.maxOutputTokens ?? 128_000;
const oaMaxTokens = Math.min(
request.params.maxTokens ?? (isReasoningModel ? 32_768 : oaMaxOutput),
oaMaxOutput,
);
if (oaMaxTokens) {
if (isReasoningModel) {
body.max_completion_tokens = oaMaxTokens;
} else {
body.max_tokens = oaMaxTokens;
}
}
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;
}
}