Files
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

184 lines
6.7 KiB
TypeScript
Raw Permalink 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;
/** 模型元信息表(子类持有;仅承载展示与能力声明,不含任何窗口/输出上限数值) */
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
// ===== 认证头 =====
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,
// v0.8.2 P3-3: 流式消费阶段的中断贯通
this.getExternalAbortSignal(),
);
}
// ===== 共享装配 =====
/** 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.8.1 硬性契约单一化):
* 唯一合法来源是设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow)。
* 删除了旧的 config → 模型元信息 → 兜底 三级回退链 —— 模型元信息不再承载窗口数值,
* 未配置时返回 0(引擎据此跳过压缩预算,行为与"用户未声明窗口"语义一致)。
*/
override getContextWindow(): number {
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow;
}
return 0;
}
}
/**
* 模型能力限制类错误(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';
}
}