Files
metona-ai-desktop/electron/harness/adapters/base-adapter.ts
T

230 lines
8.2 KiB
TypeScript
Raw 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.
/**
* 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,
};
}
}