230 lines
8.2 KiB
TypeScript
230 lines
8.2 KiB
TypeScript
/**
|
||
* Provider Adapter — 基类
|
||
*
|
||
* 所有 Provider 适配器共享的基类逻辑:
|
||
* - 请求超时处理
|
||
* - 错误映射到 MetonaError
|
||
* - 流式事件标准化
|
||
*/
|
||
|
||
import type {
|
||
IMetonaProviderAdapter,
|
||
AdapterConfig,
|
||
MetonaRequest,
|
||
MetonaResponse,
|
||
MetonaStreamEvent,
|
||
MetonaError,
|
||
} from '../types';
|
||
import { MetonaErrorCode } from '../types';
|
||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||
|
||
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 修复: 获取上下文窗口大小(规范要求)
|
||
*
|
||
* 默认实现从 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;
|
||
// 默认 1M(保守值,子类应覆盖)
|
||
return 1_000_000;
|
||
}
|
||
|
||
/**
|
||
* C-2 修复: 注入外部 AbortSignal
|
||
* Engine 在调用 send/sendStream 前调用此方法,关联 abortController
|
||
*/
|
||
setAbortSignal(signal: AbortSignal | undefined): void {
|
||
this.externalAbortSignal = signal;
|
||
}
|
||
|
||
/**
|
||
* C-2 修复: 合并外部 abort signal 和 timeout signal
|
||
*
|
||
* 使用 AbortSignal.any() 合并两个信号,任一触发都会中断 fetch:
|
||
* - timeout signal:防止请求挂起
|
||
* - external abort signal:用户主动中断
|
||
*
|
||
* @param timeoutMs 超时时间(毫秒)
|
||
* @returns 合并后的 AbortSignal
|
||
* @deprecated 审查修复 M20: 使用 fetchWithTimeout 替代。
|
||
* getFetchSignal 内部 AbortSignal.timeout() 创建的 timer 在请求成功完成后仍会存活到超时,
|
||
* 高频调用下 timer 句柄累积;fetchWithTimeout 用 setTimeout + clearTimeout 已解决此问题。
|
||
*/
|
||
protected getFetchSignal(timeoutMs: number): AbortSignal {
|
||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||
|
||
// 如果没有外部信号,直接使用 timeout signal
|
||
if (!this.externalAbortSignal) {
|
||
return timeoutSignal;
|
||
}
|
||
|
||
// 如果外部信号已经 abort,直接返回它
|
||
if (this.externalAbortSignal.aborted) {
|
||
return this.externalAbortSignal;
|
||
}
|
||
|
||
// 合并两个信号 — 任一触发都会 abort
|
||
// Node.js 20+ / Electron 35+ 支持 AbortSignal.any()
|
||
return AbortSignal.any([timeoutSignal, this.externalAbortSignal]);
|
||
}
|
||
|
||
/**
|
||
* #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏
|
||
*
|
||
* getFetchSignal 使用 AbortSignal.timeout() 内部创建的 timer 在请求成功完成后
|
||
* 仍会存活到超时,高频调用下 timer 句柄累积。本方法使用 setTimeout + clearTimeout
|
||
* 确保 fetch 完成(无论成功/失败/abort)后立即清理 timer。
|
||
*
|
||
* @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();
|
||
const timer = setTimeout(() => 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 });
|
||
} finally {
|
||
// #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer
|
||
clearTimeout(timer);
|
||
// 审查修复 M20: 清理 externalAbortSignal 上注册的 listener
|
||
// (即使 { once: true },请求正常完成时 listener 仍挂在 signal 上直到 abort 或 GC,需显式移除)
|
||
if (this.externalAbortSignal) {
|
||
this.externalAbortSignal.removeEventListener('abort', onExternalAbort);
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
*/
|
||
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||
let errorBody = '';
|
||
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
||
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||
(error as Error & { status: number }).status = response.status;
|
||
throw error;
|
||
}
|
||
|
||
/**
|
||
* 将原生错误映射为 MetonaError
|
||
*/
|
||
protected mapError(error: unknown): MetonaError {
|
||
if (error instanceof Error) {
|
||
const msg = error.message.toLowerCase();
|
||
|
||
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
|
||
return {
|
||
code: MetonaErrorCode.NETWORK_TIMEOUT,
|
||
message: error.message,
|
||
provider: this.providerId,
|
||
retryable: true,
|
||
retryAfterMs: 3000,
|
||
};
|
||
}
|
||
|
||
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) {
|
||
return {
|
||
code: MetonaErrorCode.NETWORK_ERROR,
|
||
message: error.message,
|
||
provider: this.providerId,
|
||
retryable: true,
|
||
retryAfterMs: 3000,
|
||
};
|
||
}
|
||
|
||
// #23 修复: 优先基于 HTTP status code 判断 401/429,避免字符串 includes 误匹配 URL 端口等数字
|
||
// throwHttpError 已将 response.status 挂到 error.status,优先读取此字段
|
||
const httpStatus = (error as Error & { status?: number }).status;
|
||
|
||
// #23 修复: 401 认证失败 — 优先用 status code,'unauthorized' 是单词不会误匹配
|
||
if (httpStatus === 401 || msg.includes('unauthorized')) {
|
||
return {
|
||
code: MetonaErrorCode.AUTH_INVALID,
|
||
message: 'API key 无效或已过期',
|
||
provider: this.providerId,
|
||
retryable: false,
|
||
};
|
||
}
|
||
|
||
// #23 修复: 429 限流 — 优先用 status code,'rate limit' 是单词不会误匹配
|
||
if (httpStatus === 429 || msg.includes('rate limit')) {
|
||
return {
|
||
code: MetonaErrorCode.RATE_LIMITED,
|
||
message: '请求过于频繁,请稍后重试',
|
||
provider: this.providerId,
|
||
retryable: true,
|
||
retryAfterMs: 5000,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
code: MetonaErrorCode.UNKNOWN,
|
||
message: error instanceof Error ? error.message : 'Unknown error',
|
||
provider: this.providerId,
|
||
retryable: false,
|
||
};
|
||
}
|
||
}
|