/** * Provider Adapter — 基类 * * 所有 Provider 适配器共享的基类逻辑: * - 请求超时处理(含显式的网络超时错误分类) * - 内容审核错误类型 * * v0.6.4 P3-2 错误分类单轨化: * 此前本类存在两套互相漂移的错误分类器 —— `mapError`(protected,生产路径 * 无任何调用方,仅测试引用)与 engine.isRetryableError(真正生效)。运行时 * 行为由后者单独决定,导致 v0.4.1/#23 的映射修复只体现在测试里。 * 现已删除 mapError 与废弃的 getFetchSignal:错误分类的唯一事实来源是 * engine.isRetryableError(读取 error.status / error.code / message), * 本层负责保证抛出的错误携带可判定的结构化字段: * - HTTP 非 2xx → throwHttpError 挂 status * - 本方法超时 → code='ETIMEDOUT' + 'timed out' message * - content_filter → ContentFilterError 实例 */ import type { IMetonaProviderAdapter, AdapterConfig, MetonaRequest, MetonaResponse, MetonaStreamEvent, } from '../types'; import type { MetonaModelInfo } from '../types/metona-adapter'; /** * 内容审核错误 — Provider 的安全过滤策略触发的错误 * * 当 Provider(如 MiMo)的 API 返回 content_filter 错误码时抛出。 * Engine 识别此错误后映射为 MetonaErrorCode.CONTENT_FILTERED, * 前端显示友好提示而非原始 JSON 错误体。 */ export class ContentFilterError extends Error { /** Provider 返回的原始错误消息(如 "The request was rejected because it was considered high risk") */ readonly providerMessage: string; constructor(providerMessage: string, context: string) { super(`${context}: 内容被 Provider 安全审核拦截 - ${providerMessage}`); this.name = 'ContentFilterError'; this.providerMessage = providerMessage; } } export abstract class BaseAdapter implements IMetonaProviderAdapter { // H-2 修复: provider → providerId(规范要求) abstract readonly providerId: string; abstract readonly supportedModels: string[]; abstract readonly supportsToolCalling: boolean; abstract readonly supportsThinking: boolean; /** * C-2 修复: 外部注入的 AbortSignal(来自 Engine 的 abortController) * * 用户点击中断时,Engine 调用 abortController.abort(),此信号触发后, * 正在进行的 fetch 会被立即中断,避免资源泄漏。 */ private externalAbortSignal: AbortSignal | undefined; constructor(protected config: AdapterConfig) {} // H-2 修复: chat → send(规范要求) abstract send(request: MetonaRequest): Promise; // H-2 修复: chatStream → sendStream(规范要求) abstract sendStream(request: MetonaRequest): AsyncIterable; /** * H-2 修复: 获取上下文窗口大小(规范要求) * * 默认实现从 config 读取 contextWindow,子类可覆盖以支持动态查询。 * Engine 用此值估算上下文使用率,决定是否触发压缩。 * * @returns 上下文窗口大小(token 数) */ getContextWindow(): number { // 优先使用 AdapterConfig.contextWindow(如果存在) const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow; if (typeof ctx === 'number' && ctx > 0) return ctx; // v0.7.4 P4-5: 兜底从 1M 降至 128K —— 旧默认值 1M 在 config 与模型元信息均缺失时 // (如 DeepSeek 未知模型),压缩阈值按 1M 算,实际 64K/128K 模型会先 413 再压缩。 // 128K 是当前最保守的主流窗口,未知模型按最小值预算更安全。 return 128_000; } /** * C-2 修复: 注入外部 AbortSignal * Engine 在调用 send/sendStream 前调用此方法,关联 abortController */ setAbortSignal(signal: AbortSignal | undefined): void { this.externalAbortSignal = signal; } /** * #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏 * * v0.6.4 P3-2(错误分类单轨化): 本方法自身触发的超时不再以裸 DOMException * (消息不含 timeout 字样、被引擎误归 UNKNOWN 后仅因含 "aborted" 碰巧可重试) * 冒泡 —— 显式转译为带 ETIMEDOUT code 的 Error,使其进入 engine.isRetryableError * 的网络超时判定分支,与其他网络错误同轨。用户主动中断(外部信号)则原样抛出 * AbortError —— 引擎 chatStreamWithRetry 入口由 this.aborted 拦截,不会误触发重试。 * * 已知边界(设计取舍,注明而非隐藏):响应头返回后 clearTimeout,后续 SSE 流体 * 不再受本超时约束;长挂流由引擎 totalTimeoutMs 兜底。中止时通过 removeEventListener * 解除外部信号监听 —— 流式消费阶段外部 abort 不再打断底层连接(消费方停止拉取即终结)。 * * @param url 请求 URL * @param init fetch init(不含 signal,由本方法内部管理) * @param timeoutMs 超时时间(毫秒) */ protected async fetchWithTimeout( url: string, init: RequestInit, timeoutMs: number, ): Promise { const controller = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); // 审查修复 M20: 保存 listener 引用,finally 中 removeEventListener 清理,避免 listener 泄漏 const onExternalAbort = () => controller.abort(); try { // 合并外部 abort signal(来自 Engine 的 abortController) if (this.externalAbortSignal) { if (this.externalAbortSignal.aborted) { controller.abort(); } else { this.externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true }); } } return await fetch(url, { ...init, signal: controller.signal }); } catch (err) { // 区分中止来源: // a) 本方法超时且非外部中断 → 归类为可重试的网络超时(ETIMEDOUT) // b) 外部信号 abort(用户中断)→ 原样抛 AbortError // c) 底层网络错误 → 原样抛出 const externalAborted = this.externalAbortSignal?.aborted === true; if (timedOut && !externalAborted) { const timeoutError = new Error( `Request timed out after ${timeoutMs}ms (url=${String(url).slice(0, 120)})`, ); (timeoutError as Error & { code: string }).code = 'ETIMEDOUT'; throw timeoutError; } throw err; } finally { // #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer clearTimeout(timer); // 审查修复 M20: 清理 externalAbortSignal 上注册的 listener if (this.externalAbortSignal) { this.externalAbortSignal.removeEventListener('abort', onExternalAbort); } } } async healthCheck(): Promise { try { await this.listModels(); return true; } catch { return false; } } /** * H-2 修复: 返回 MetonaModelInfo[](规范要求) * * 默认实现将 supportedModels 映射为 MetonaModelInfo[], * 子类可覆盖以从 API 获取完整元信息。 */ async listModels(): Promise { return this.supportedModels.map((id) => ({ id })); } /** * P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出 * engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx) * * v0.3.17: 解析 Provider 返回的 JSON 错误体,识别 content_filter 错误码, * 抛出 ContentFilterError 让 Engine 映射为 CONTENT_FILTERED 错误码。 */ protected async throwHttpError(response: Response, context: string): Promise { let errorBody = ''; try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ } // v0.3.17: 解析 JSON 错误体,识别 content_filter if (errorBody) { try { const parsed = JSON.parse(errorBody); // 兼容 OpenAI 格式 {error: {code, message}} 和 MiMo/其他格式 const errObj = parsed?.error ?? parsed; const errorCode = errObj?.code ?? errObj?.type ?? ''; const errorMessage = errObj?.message ?? ''; if (typeof errorCode === 'string' && errorCode.toLowerCase().includes('content_filter')) { const providerMsg = errorMessage || '内容触发安全过滤策略'; const cfError = new ContentFilterError(providerMsg, context); (cfError as ContentFilterError & { status: number }).status = response.status; throw cfError; } } catch (parseErr) { // JSON 解析失败(非 JSON 响应体),走原逻辑 if (parseErr instanceof ContentFilterError) throw parseErr; } } // v0.6.4: 巨大 HTML 错误页整体拼进消息会造成日志/事件载荷爆炸 —— 截断到合理长度 const safeBody = errorBody.length > 500 ? `${errorBody.slice(0, 500)}…[truncated ${errorBody.length} chars]` : errorBody; const error = new Error( `${context}: ${response.status} ${response.statusText}${safeBody ? ` - ${safeBody}` : ''}`, ); (error as Error & { status: number }).status = response.status; throw error; } }