/** * 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 | Promise>; /** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120s;Agnes 历史 300s,保留其值。 */ protected abstract sendTimeoutMs(): number; /** 模型元信息表(子类持有;仅承载展示与能力声明,不含任何窗口/输出上限数值) */ protected abstract modelInfoTable(): Record; // ===== 认证头 ===== protected buildHeaders(): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}`, ...this.config.headers, }; } // ===== POST {chatCompletionsUrl} (非流式) ===== async send(request: MetonaRequest): Promise { 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; return this.toMetonaResponseFromOpenAI(data, request.meta.requestId); } // ===== POST {chatCompletionsUrl} (流式) ===== async *sendStream(request: MetonaRequest): AsyncIterable { 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, 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'; } }