硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
/**
|
||
* 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<string, unknown> | Promise<Record<string, unknown>>;
|
||
|
||
/** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120s;Agnes 历史 300s,保留其值。 */
|
||
protected abstract sendTimeoutMs(): number;
|
||
|
||
/** 模型元信息表(子类持有;仅承载展示与能力声明,不含任何窗口/输出上限数值) */
|
||
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
|
||
|
||
// ===== 认证头 =====
|
||
|
||
protected buildHeaders(): Record<string, string> {
|
||
return {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${this.config.apiKey}`,
|
||
...this.config.headers,
|
||
};
|
||
}
|
||
|
||
// ===== POST {chatCompletionsUrl} (非流式) =====
|
||
|
||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||
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<string, unknown>;
|
||
return this.toMetonaResponseFromOpenAI(data, request.meta.requestId);
|
||
}
|
||
|
||
// ===== POST {chatCompletionsUrl} (流式) =====
|
||
|
||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||
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,
|
||
);
|
||
}
|
||
|
||
// ===== 共享装配 =====
|
||
|
||
/** Provider 展示名(错误上下文用):默认取 providerId,子类可覆盖 */
|
||
protected providerLabel(): string {
|
||
return this.providerId;
|
||
}
|
||
|
||
/**
|
||
* 非流式响应组装 —— OpenAI 原生结构到 MetonaResponse 的唯一映射点
|
||
* (此前在四个子类各有一份逐字拷贝)
|
||
*/
|
||
private toMetonaResponseFromOpenAI(
|
||
data: Record<string, unknown>,
|
||
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';
|
||
}
|
||
}
|