feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复: - API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容) - 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级) - error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计) - abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止) - run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化) - .env 真实生效(dotenv 回退加载,应用内配置优先) P1 工程基础: - ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线) - 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层) - test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行) - SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end) - Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知) - MCP 真就绪(等待全部连接完成再广播 tools:ready) - SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态) - CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移) P2 架构升级: - handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播) - AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号) - TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent - 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染) - 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI) - Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入 P3 能力扩展: - OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens) - Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机) - 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
@@ -63,7 +63,7 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Anthropic Provider Adapter(P3)
|
||||
*
|
||||
* Anthropic Messages API(/v1/messages)原生协议,支持 Tool Calling、流式输出、
|
||||
* 扩展思考(thinking + budget_tokens)、多模态图片(base64)。
|
||||
*
|
||||
* 与 OpenAI 兼容 API 的关键差异:
|
||||
* - 认证头:x-api-key + anthropic-version(非 Authorization Bearer)
|
||||
* - 消息结构:content 为块数组(text / tool_use / tool_result / image),
|
||||
* 且要求 user/assistant 严格交替(连续同角色需合并)
|
||||
* - 工具定义:input_schema(非 parameters);工具结果以 user 角色 tool_result 块回传
|
||||
* - SSE 事件:message_start / content_block_start / content_block_delta /
|
||||
* content_block_stop / message_delta / message_stop(非 OpenAI chunk 格式)
|
||||
* - 图片:仅支持 base64 source(URL 需下载后转换)
|
||||
*
|
||||
* @see https://docs.anthropic.com/en/api/messages
|
||||
*/
|
||||
|
||||
import { BaseAdapter } from './base-adapter';
|
||||
import log from 'electron-log';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||||
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
|
||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||
|
||||
export class AnthropicAdapter extends BaseAdapter {
|
||||
override readonly providerId: string = 'anthropic';
|
||||
readonly supportedModels = ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5'];
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'claude-sonnet-4-5': {
|
||||
id: 'claude-sonnet-4-5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 64_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用',
|
||||
},
|
||||
'claude-opus-4-1': {
|
||||
id: 'claude-opus-4-1',
|
||||
name: 'Claude Opus 4.1',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 32_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Anthropic 深度推理模型',
|
||||
},
|
||||
'claude-haiku-4-5': {
|
||||
id: 'claude-haiku-4-5',
|
||||
name: 'Claude Haiku 4.5',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 32_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Anthropic 低延迟模型',
|
||||
},
|
||||
};
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.config.apiKey ?? '',
|
||||
'anthropic-version': '2023-06-01',
|
||||
...this.config.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== POST /v1/messages(非流式) =====
|
||||
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const body = await this.toNativeRequest(request, false);
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 120_000);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'Anthropic API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
return this.toMetonaResponse(data, request.meta.requestId);
|
||||
}
|
||||
|
||||
// ===== POST /v1/messages(流式) =====
|
||||
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const body = await this.toNativeRequest(request, true);
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'Anthropic stream error');
|
||||
}
|
||||
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let seq = 0;
|
||||
let buffer = '';
|
||||
let eventName = '';
|
||||
let streamEndedNormally = false;
|
||||
|
||||
// 工具调用缓冲:content block index → { id, name, argsBuffer }
|
||||
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
|
||||
|
||||
const base = () => ({
|
||||
requestId: request.meta.requestId,
|
||||
sessionId: request.meta.sessionId,
|
||||
iteration: request.meta.iteration,
|
||||
seq: seq++,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
|
||||
const events: MetonaStreamEvent[] = [];
|
||||
switch (name) {
|
||||
case 'content_block_start': {
|
||||
const block = data.content_block as Record<string, unknown> | undefined;
|
||||
const index = (data.index as number) ?? 0;
|
||||
if (block?.type === 'tool_use') {
|
||||
toolBlocks.set(index, {
|
||||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||||
name: (block.name as string) ?? '',
|
||||
argsBuffer: '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
const delta = data.delta as Record<string, unknown> | undefined;
|
||||
const index = (data.index as number) ?? 0;
|
||||
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
||||
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
|
||||
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
||||
events.push({ type: MetonaStreamEventType.REASONING_DELTA, ...base(), delta: delta.thinking });
|
||||
} else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
||||
const block = toolBlocks.get(index);
|
||||
if (block) {
|
||||
block.argsBuffer += delta.partial_json;
|
||||
events.push({
|
||||
type: MetonaStreamEventType.TOOL_CALL_DELTA,
|
||||
...base(),
|
||||
toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json },
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
const index = (data.index as number) ?? 0;
|
||||
const block = toolBlocks.get(index);
|
||||
if (block) {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
events.push({
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
...base(),
|
||||
toolCall: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
args,
|
||||
iteration: request.meta.iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
toolBlocks.delete(index);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_delta': {
|
||||
// 结束时的 usage 统计(output_tokens 增量在此事件携带)
|
||||
const usage = data.usage as Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
events.push({
|
||||
type: MetonaStreamEventType.USAGE,
|
||||
...base(),
|
||||
usage: {
|
||||
inputTokens: (this.lastInputTokens as number) ?? 0,
|
||||
outputTokens: (usage.output_tokens as number) ?? 0,
|
||||
totalTokens: ((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_stop': {
|
||||
streamEndedNormally = true;
|
||||
events.push({ type: MetonaStreamEventType.DONE, ...base() });
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const err = data.error as Record<string, unknown> | undefined;
|
||||
events.push({
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
...base(),
|
||||
error: {
|
||||
code: 'unknown' as never,
|
||||
message: (err?.message as string) ?? 'Anthropic stream error',
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
};
|
||||
|
||||
// message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总)
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('event:')) {
|
||||
eventName = trimmed.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const dataStr = trimmed.slice(5).trim();
|
||||
if (dataStr === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataStr) as Record<string, unknown>;
|
||||
// message_start 携带 input_tokens
|
||||
if (eventName === 'message_start') {
|
||||
const msg = data.message as Record<string, unknown> | undefined;
|
||||
const usage = msg?.usage as Record<string, unknown> | undefined;
|
||||
this.lastInputTokens = (usage?.input_tokens as number) ?? 0;
|
||||
continue;
|
||||
}
|
||||
for (const ev of processEvent(eventName, data)) {
|
||||
yield ev;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
log.warn(`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流中断(连接断开等)补发 DONE,防止 Agent Loop 挂起(与 Ollama 行为一致)
|
||||
if (!streamEndedNormally) {
|
||||
yield { type: MetonaStreamEventType.DONE, ...base() };
|
||||
}
|
||||
}
|
||||
|
||||
/** message_start 捕获的 input_tokens(供 message_delta 汇总 usage) */
|
||||
private lastInputTokens = 0;
|
||||
|
||||
// ===== 模型与上下文窗口 =====
|
||||
|
||||
override async listModels(): Promise<MetonaModelInfo[]> {
|
||||
// Anthropic 无公开 /models 列表端点,返回本地元数据
|
||||
return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id });
|
||||
}
|
||||
|
||||
override getContextWindow(): number {
|
||||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||
return this.config.contextWindow;
|
||||
}
|
||||
const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
return modelInfo?.contextWindow ?? 200_000;
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
/**
|
||||
* 构建 Anthropic 原生请求体
|
||||
*
|
||||
* 转换要点:
|
||||
* 1. MetonaMessage → Anthropic 消息(content 块数组)
|
||||
* 2. tool 消息 → user 角色 tool_result 块
|
||||
* 3. assistant 工具调用 → tool_use 块
|
||||
* 4. 连续同角色消息合并(API 要求严格交替)
|
||||
* 5. 首条消息必须为 user(历史以 assistant 开头时补占位)
|
||||
*/
|
||||
private async toNativeRequest(request: MetonaRequest, stream: boolean): Promise<Record<string, unknown>> {
|
||||
// System Prompt 拼接(Anthropic 使用顶层 system 字段)
|
||||
const system = [
|
||||
request.systemPrompt.roleDefinition,
|
||||
request.systemPrompt.outputConstraints,
|
||||
request.systemPrompt.safetyGuidelines,
|
||||
request.systemPrompt.dynamicReminders,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
// 转换消息(非 system)
|
||||
const converted: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = [];
|
||||
for (const m of request.messages) {
|
||||
if (m.role === 'system') continue;
|
||||
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
// 工具结果 → user 角色 tool_result 块
|
||||
const contentStr = m.toolResult.error
|
||||
? m.toolResult.error
|
||||
: typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
converted.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === 'assistant') {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
if (m.content) content.push({ type: 'text', text: m.content });
|
||||
for (const tc of m.toolCalls ?? []) {
|
||||
content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
|
||||
}
|
||||
if (content.length > 0) {
|
||||
converted.push({ role: 'assistant', content });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// user 消息(含多模态图片)
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
if (m.content) content.push({ type: 'text', text: m.content });
|
||||
for (const img of m.images ?? []) {
|
||||
const block = await this.toImageBlock(img.url);
|
||||
if (block) content.push(block);
|
||||
}
|
||||
if (content.length === 0) content.push({ type: 'text', text: '' });
|
||||
converted.push({ role: 'user', content });
|
||||
}
|
||||
|
||||
// 合并连续同角色消息(Anthropic 要求 user/assistant 交替)
|
||||
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = [];
|
||||
for (const msg of converted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.role === msg.role) {
|
||||
last.content.push(...msg.content);
|
||||
} else {
|
||||
merged.push({ ...msg });
|
||||
}
|
||||
}
|
||||
|
||||
// 首条消息必须为 user
|
||||
if (merged.length === 0 || merged[0].role !== 'user') {
|
||||
merged.unshift({ role: 'user', content: [{ type: 'text', text: '[Conversation history follows]' }] });
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
max_tokens: request.params.maxTokens ?? 8192,
|
||||
system,
|
||||
messages: merged,
|
||||
stream,
|
||||
};
|
||||
|
||||
// 工具定义(input_schema 命名)
|
||||
if (request.tools?.length) {
|
||||
body.tools = request.tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
// Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半)
|
||||
if (request.params.thinkingEnabled) {
|
||||
const budgetMap: Record<string, number> = { low: 1024, medium: 4096, high: 16384, max: 32768 };
|
||||
const budget = Math.min(
|
||||
budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384,
|
||||
Math.floor((body.max_tokens as number) / 2),
|
||||
);
|
||||
body.thinking = { type: 'enabled', budget_tokens: budget };
|
||||
} else {
|
||||
body.temperature = request.params.temperature;
|
||||
}
|
||||
|
||||
// 停止序列
|
||||
if (request.params.stopSequences?.length) {
|
||||
body.stop_sequences = request.params.stopSequences;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片 URL → Anthropic image 块
|
||||
* data URI 直接解析;http(s) URL 下载后转 base64(Anthropic 不支持 URL 引用)
|
||||
*/
|
||||
private async toImageBlock(url: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
if (url.startsWith('data:')) {
|
||||
// data:image/png;base64,xxx → { media_type, data }
|
||||
const match = url.match(/^data:([^;]+);base64,(.*)$/s);
|
||||
if (!match) return null;
|
||||
return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } };
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const res = await this.fetchWithTimeout(url, {}, 30_000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const contentType = res.headers.get('content-type') ?? 'image/png';
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: contentType, data: buf.toString('base64') },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
log.warn(`[Anthropic] Failed to load image: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 非流式响应 → MetonaResponse */
|
||||
private toMetonaResponse(data: Record<string, unknown>, requestId: string): MetonaResponse {
|
||||
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
|
||||
let text = '';
|
||||
let reasoningContent: string | undefined;
|
||||
const toolCalls: MetonaResponse['toolCalls'] = [];
|
||||
|
||||
for (const block of contentBlocks) {
|
||||
if (block.type === 'text') text += (block.text as string) ?? '';
|
||||
else if (block.type === 'thinking') reasoningContent = (block.thinking as string) ?? undefined;
|
||||
else if (block.type === 'tool_use') {
|
||||
let args: Record<string, unknown> = {};
|
||||
const rawInput = block.input;
|
||||
if (rawInput && typeof rawInput === 'object') args = rawInput as Record<string, unknown>;
|
||||
toolCalls?.push({
|
||||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||||
name: (block.name as string) ?? '',
|
||||
args,
|
||||
iteration: 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const usage = (data.usage as Record<string, number>) ?? {};
|
||||
const stopReason = (data.stop_reason as string) ?? 'end_turn';
|
||||
const finishReason: MetonaFinishReason =
|
||||
stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS
|
||||
: stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH
|
||||
: MetonaFinishReason.STOP;
|
||||
|
||||
return {
|
||||
meta: {
|
||||
requestId,
|
||||
provider: this.providerId,
|
||||
model: (data.model as string) ?? this.config.defaultModel,
|
||||
latencyMs: 0,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
content: text,
|
||||
reasoningContent,
|
||||
toolCalls,
|
||||
usage: {
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
|
||||
},
|
||||
finishReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { BaseAdapter } from './base-adapter';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||||
import { MetonaFinishReason, MetonaErrorCode } from '../types';
|
||||
import { MetonaFinishReason } from '../types';
|
||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
|
||||
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
|
||||
@@ -68,7 +68,7 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Provider Adapter 导出
|
||||
*
|
||||
* 四种 Provider 各自独立继承 BaseAdapter,无耦合关系:
|
||||
* 六种 Provider 各自独立继承 BaseAdapter,无耦合关系:
|
||||
* - DeepSeekAdapter — OpenAI 兼容 + DeepSeek 特有参数
|
||||
* - AgnesAdapter — OpenAI 兼容 + Agnes 特有参数
|
||||
* - MimoAdapter — OpenAI 兼容 + MiMo 特有参数
|
||||
* - OllamaAdapter — Ollama 原生 API
|
||||
* - OpenAIAdapter — OpenAI 原生(P3,o 系列推理模型支持)
|
||||
* - AnthropicAdapter — Anthropic Messages API 原生(P3,扩展思考支持)
|
||||
*
|
||||
* 共享工具(仅供 OpenAI 兼容 Adapter 使用):
|
||||
* - shared/openai-format — 消息/工具格式构建
|
||||
@@ -17,3 +19,5 @@ export { DeepSeekAdapter } from './deepseek.adapter';
|
||||
export { AgnesAdapter } from './agnes-ai.adapter';
|
||||
export { MimoAdapter } from './mimo.adapter';
|
||||
export { OllamaAdapter } from './ollama.adapter';
|
||||
export { OpenAIAdapter } from './openai.adapter';
|
||||
export { AnthropicAdapter } from './anthropic.adapter';
|
||||
|
||||
@@ -74,7 +74,7 @@ export class MimoAdapter extends BaseAdapter {
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
@@ -168,15 +168,12 @@ export class MimoAdapter extends BaseAdapter {
|
||||
// buildOpenAICompatibleMessages 不处理图片(各 Provider 自行处理)
|
||||
// MiMo 是 OpenAI 兼容 API,多模态格式与 Agnes AI 一致
|
||||
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||
let imageCount = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
// messages[0] 是 system,非 system 消息从 messages[1] 开始
|
||||
if (i === 0) continue;
|
||||
const origMsg = nonSystemMsgs[i - 1];
|
||||
if (!origMsg?.images?.length) continue;
|
||||
|
||||
imageCount += origMsg.images.length;
|
||||
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (origMsg.content) {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
|
||||
@@ -551,7 +551,7 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
},
|
||||
content: (message?.content as string) ?? '',
|
||||
reasoningContent: message?.thinking as string | undefined,
|
||||
toolCalls: toolCalls?.map((tc, i) => {
|
||||
toolCalls: toolCalls?.map((tc) => {
|
||||
const fn = tc.function as Record<string, unknown>;
|
||||
const rawArgs = fn?.arguments;
|
||||
let args: Record<string, unknown> = {};
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* OpenAI Provider Adapter(P3)
|
||||
*
|
||||
* OpenAI Chat Completions API(/v1/chat/completions),支持 Tool Calling、
|
||||
* 流式输出、多模态图片、o 系列推理模型的 reasoning_effort 参数。
|
||||
*
|
||||
* 与 DeepSeek 适配器的关键差异:
|
||||
* - o 系列 / gpt-5 系列模型使用 max_completion_tokens(非 max_tokens)
|
||||
* - Thinking 模式通过顶层 reasoning_effort 参数(o 系列模型)
|
||||
* - 模型列表从 /v1/models 动态获取
|
||||
*
|
||||
* @see apis 官方文档 https://platform.openai.com/docs/api-reference/chat
|
||||
*/
|
||||
|
||||
import { BaseAdapter } from './base-adapter';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||||
import { MetonaFinishReason } from '../types';
|
||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
|
||||
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
|
||||
|
||||
export class OpenAIAdapter extends BaseAdapter {
|
||||
override readonly providerId: string = 'openai';
|
||||
readonly supportedModels = ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'];
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'gpt-4o': {
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 16_384,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'OpenAI 旗舰多模态模型,128K 上下文',
|
||||
},
|
||||
'gpt-4o-mini': {
|
||||
id: 'gpt-4o-mini',
|
||||
name: 'GPT-4o mini',
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 16_384,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'OpenAI 高性价比模型,128K 上下文',
|
||||
},
|
||||
'gpt-4.1': {
|
||||
id: 'gpt-4.1',
|
||||
name: 'GPT-4.1',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 32_768,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'OpenAI 长上下文模型,1M 上下文',
|
||||
},
|
||||
'o3-mini': {
|
||||
id: 'o3-mini',
|
||||
name: 'o3-mini',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 100_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'OpenAI 推理模型,支持 reasoning_effort',
|
||||
},
|
||||
};
|
||||
|
||||
// ===== POST /v1/chat/completions(非流式) =====
|
||||
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const body = this.toNativeRequest(request, false);
|
||||
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 120_000);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'OpenAI API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
requestId: request.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: parsed.finishReason as MetonaFinishReason,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== POST /v1/chat/completions(流式) =====
|
||||
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const body = this.toNativeRequest(request, true);
|
||||
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'OpenAI stream error');
|
||||
}
|
||||
|
||||
yield* parseSSEStream(
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
response.body!,
|
||||
request.meta.requestId,
|
||||
request.meta.sessionId,
|
||||
request.meta.iteration,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== GET /v1/models =====
|
||||
|
||||
override async listModels(): Promise<MetonaModelInfo[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseURL}/models`, {
|
||||
headers: { Authorization: `Bearer ${this.config.apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data?: Array<{ id: string }> };
|
||||
if (data.data?.length) {
|
||||
return data.data.map((m) => OpenAIAdapter.MODEL_INFO[m.id] ?? { id: m.id });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API 不可用时降级
|
||||
}
|
||||
return this.supportedModels.map((id) => OpenAIAdapter.MODEL_INFO[id] ?? { id });
|
||||
}
|
||||
|
||||
override getContextWindow(): number {
|
||||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||
return this.config.contextWindow;
|
||||
}
|
||||
const modelInfo = OpenAIAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
return modelInfo?.contextWindow ?? 128_000;
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
/**
|
||||
* 构建 OpenAI 原生请求体
|
||||
*
|
||||
* OpenAI 特有处理:
|
||||
* - 多模态图片:user 消息 images[] → content 数组
|
||||
* - o 系列(o1/o3/o4)与 gpt-5 系列使用 max_completion_tokens + reasoning_effort
|
||||
* - 思考模式下 temperature 被部分推理模型拒绝,不传
|
||||
*/
|
||||
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
|
||||
const messages = buildOpenAICompatibleMessages(request);
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// 推理模型检测(o 系列使用新参数名)
|
||||
const model = this.config.defaultModel;
|
||||
const isReasoningModel = /^(o\d|gpt-5)/.test(model);
|
||||
|
||||
// === 多模态:将 images 转为 OpenAI content 数组(与 Agnes/MiMo 一致) ===
|
||||
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||
let imageCount = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (i === 0) continue; // messages[0] 是 system
|
||||
const origMsg = nonSystemMsgs[i - 1];
|
||||
if (!origMsg?.images?.length) continue;
|
||||
|
||||
imageCount += origMsg.images.length;
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (origMsg.content) {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
}
|
||||
for (const img of origMsg.images) {
|
||||
contentParts.push({ type: 'image_url', image_url: { url: img.url } });
|
||||
}
|
||||
messages[i].content = contentParts;
|
||||
}
|
||||
if (imageCount > 0) {
|
||||
// 推理模型当前不支持图片输入
|
||||
if (isReasoningModel) {
|
||||
throw new Error(`Model "${model}" does not support image inputs`);
|
||||
}
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
stream,
|
||||
};
|
||||
|
||||
// Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens
|
||||
if (request.params.maxTokens) {
|
||||
if (isReasoningModel) {
|
||||
body.max_completion_tokens = request.params.maxTokens;
|
||||
} else {
|
||||
body.max_tokens = request.params.maxTokens;
|
||||
}
|
||||
} else if (isReasoningModel) {
|
||||
// 推理模型未配置时使用兜底值(thinking 占用 token 配额,默认值过小会被截断)
|
||||
body.max_completion_tokens = 32_768;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
body.stream_options = { include_usage: true };
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
body.tools = tools;
|
||||
}
|
||||
|
||||
// Thinking 模式:推理模型映射 reasoning_effort;非推理模型忽略
|
||||
if (request.params.thinkingEnabled && isReasoningModel) {
|
||||
const effortMap: Record<string, string> = { low: 'low', medium: 'medium', high: 'high', max: 'high' };
|
||||
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
|
||||
} else if (!isReasoningModel) {
|
||||
// 非推理模型使用温度控制
|
||||
body.temperature = request.params.temperature;
|
||||
}
|
||||
|
||||
// 停止序列
|
||||
if (request.params.stopSequences?.length) {
|
||||
body.stop = request.params.stopSequences;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,6 @@ export async function* parseSSEStream(
|
||||
*/
|
||||
export function parseOpenAICompatibleResponse(
|
||||
data: Record<string, unknown>,
|
||||
requestId: string,
|
||||
provider: string,
|
||||
defaultModel: string,
|
||||
): {
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
|
||||
Reference in New Issue
Block a user