253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/**
|
||
* 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';
|
||
import { fetchImageAsBase64 } from './shared/ssrf-image-fetch';
|
||
|
||
/**
|
||
* 内容审核错误 — 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<MetonaResponse>;
|
||
// H-2 修复: chatStream → sendStream(规范要求)
|
||
abstract sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
|
||
|
||
/**
|
||
* H-2 修复: 获取上下文窗口大小(规范要求)
|
||
*
|
||
* v0.8.1 硬性契约:上下文窗口的唯一合法来源是设置面板 LLM 配置的「上下文长度」
|
||
* (llm.contextWindow,经 main.ts 注入 AdapterConfig.contextWindow)。
|
||
* 本基类与所有子类禁止携带任何写死的默认窗口值 —— 未配置时返回 0,
|
||
* 引擎据此跳过压缩预算计算(syncContextWindow 仅在 >0 时采纳)。
|
||
*/
|
||
getContextWindow(): number {
|
||
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
|
||
if (typeof ctx === 'number' && ctx > 0) return ctx;
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* C-2 修复: 注入外部 AbortSignal
|
||
* Engine 在调用 send/sendStream 前调用此方法,关联 abortController
|
||
*/
|
||
setAbortSignal(signal: AbortSignal | undefined): void {
|
||
this.externalAbortSignal = signal;
|
||
}
|
||
|
||
/**
|
||
* v0.8.2 P3-3: 读取外部中断信号(流式消费阶段的中断贯通)。
|
||
* 子类的 sendStream 把它传入流读取辅助 —— fetch 头阶段的 abort 由
|
||
* fetchWithTimeout 处理,流体消费阶段的 abort 由 readStreamChunkWithIdleTimeout
|
||
* 竞速处理(此前 reader.read() 对用户中断无感,挂起流无法被"中断"按钮终止)。
|
||
*/
|
||
protected getExternalAbortSignal(): AbortSignal | undefined {
|
||
return this.externalAbortSignal;
|
||
}
|
||
|
||
/**
|
||
* #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<Response> {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v0.8.2 P0-1: SSRF 安全的图片下载通道(Anthropic / Ollama 图片 URL 共用)
|
||
*
|
||
* 此前子类直接 fetchWithTimeout 下载消息里的 http(s) 图片 URL —— 无 SSRF 校验、
|
||
* 无字节上限,且下载结果以 base64 进入模型上下文(**数据可回读**的外泄通道)。
|
||
* 现统一走 fetchImageAsBase64:resolvePublicAddresses + DNS pinning 校验与连接
|
||
* 同源、逐跳重定向复检、10MB 字节上限、png/jpeg/gif/webp 类型白名单;
|
||
* 外部 abort 信号(用户中断)照常透传。失败时调用方按"跳过该图"降级。
|
||
*/
|
||
protected async fetchImageAsBase64(
|
||
url: string,
|
||
timeoutMs = 30_000,
|
||
): Promise<{ base64: string; mediaType: string }> {
|
||
return fetchImageAsBase64(url, { timeoutMs, signal: this.externalAbortSignal });
|
||
}
|
||
|
||
async healthCheck(): Promise<boolean> {
|
||
try {
|
||
await this.listModels();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
|
||
*
|
||
* 默认实现将 supportedModels 映射为 MetonaModelInfo[],
|
||
* 子类可覆盖以从 API 获取完整元信息。
|
||
*/
|
||
async listModels(): Promise<MetonaModelInfo[]> {
|
||
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<never> {
|
||
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;
|
||
}
|
||
}
|