feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* BaseAdapter 单元测试(v0.4.1 测试补齐)
|
||||
* 覆盖:错误映射(mapError)、HTTP 错误识别(throwHttpError)、
|
||||
* ContentFilterError、上下文窗口读取、fetchWithTimeout 超时与清理
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { BaseAdapter, ContentFilterError } from '../base-adapter';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../../types';
|
||||
import type {
|
||||
IMetonaProviderAdapter,
|
||||
AdapterConfig,
|
||||
MetonaRequest,
|
||||
MetonaResponse,
|
||||
MetonaStreamEvent,
|
||||
} from '../../types';
|
||||
|
||||
/** 测试用具体 Adapter 实现(暴露 protected 方法供测试) */
|
||||
class TestAdapter extends BaseAdapter {
|
||||
readonly providerId = 'test';
|
||||
readonly supportedModels = ['test-model-a', 'test-model-b'];
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = false;
|
||||
|
||||
async send(_request: MetonaRequest): Promise<MetonaResponse> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
async *sendStream(_request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
// 空实现
|
||||
}
|
||||
|
||||
/** 测试辅助: 暴露 protected mapError */
|
||||
mapErrorPublic(error: unknown) {
|
||||
return this.mapError(error);
|
||||
}
|
||||
|
||||
/** 测试辅助: 暴露 protected throwHttpError */
|
||||
async throwHttpErrorPublic(response: Response, context: string) {
|
||||
return this.throwHttpError(response, context);
|
||||
}
|
||||
|
||||
/** 测试辅助: 暴露 protected fetchWithTimeout */
|
||||
async fetchWithTimeoutPublic(url: string, init: RequestInit, timeoutMs: number) {
|
||||
return this.fetchWithTimeout(url, init, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
function makeAdapter(config: Partial<AdapterConfig> = {}): TestAdapter {
|
||||
return new TestAdapter({
|
||||
provider: 'test',
|
||||
baseURL: 'https://api.test.com',
|
||||
apiKey: 'sk-test',
|
||||
defaultModel: 'test-model-a',
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
describe('BaseAdapter — mapError 错误映射', () => {
|
||||
it('timeout 消息映射为 NETWORK_TIMEOUT 且可重试', () => {
|
||||
const adapter = makeAdapter();
|
||||
const err = adapter.mapErrorPublic(new Error('Request timeout after 30s'));
|
||||
expect(err.code).toBe(MetonaErrorCode.NETWORK_TIMEOUT);
|
||||
expect(err.retryable).toBe(true);
|
||||
expect(err.provider).toBe('test');
|
||||
});
|
||||
|
||||
it('ECONNREFUSED 映射为 NETWORK_ERROR 且可重试', () => {
|
||||
const adapter = makeAdapter();
|
||||
const err = adapter.mapErrorPublic(new Error('fetch failed: ECONNREFUSED 127.0.0.1:11434'));
|
||||
expect(err.code).toBe(MetonaErrorCode.NETWORK_ERROR);
|
||||
expect(err.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('HTTP 401 优先按 status code 映射为 AUTH_INVALID 且不可重试', () => {
|
||||
const adapter = makeAdapter();
|
||||
const e = new Error('API error: 401 Unauthorized');
|
||||
(e as Error & { status: number }).status = 401;
|
||||
const err = adapter.mapErrorPublic(e);
|
||||
expect(err.code).toBe(MetonaErrorCode.AUTH_INVALID);
|
||||
expect(err.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('HTTP 429 映射为 RATE_LIMITED 且可重试', () => {
|
||||
const adapter = makeAdapter();
|
||||
const e = new Error('429 Too Many Requests');
|
||||
(e as Error & { status: number }).status = 429;
|
||||
const err = adapter.mapErrorPublic(e);
|
||||
expect(err.code).toBe(MetonaErrorCode.RATE_LIMITED);
|
||||
expect(err.retryable).toBe(true);
|
||||
expect(err.retryAfterMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('ContentFilterError 优先映射为 CONTENT_FILTERED', () => {
|
||||
const adapter = makeAdapter();
|
||||
const cf = new ContentFilterError('high risk content', 'MiMo');
|
||||
const err = adapter.mapErrorPublic(cf);
|
||||
expect(err.code).toBe(MetonaErrorCode.CONTENT_FILTERED);
|
||||
expect(err.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('普通 Error 映射为 UNKNOWN 且不可重试', () => {
|
||||
const adapter = makeAdapter();
|
||||
const err = adapter.mapErrorPublic(new Error('whatever'));
|
||||
expect(err.code).toBe(MetonaErrorCode.UNKNOWN);
|
||||
expect(err.retryable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseAdapter — throwHttpError', () => {
|
||||
function makeResponse(status: number, body: string): Response {
|
||||
return new Response(body, { status, statusText: 'Status' });
|
||||
}
|
||||
|
||||
it('content_filter 错误体抛出 ContentFilterError(含 status)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const body = JSON.stringify({ error: { code: 'content_filter', message: 'high risk' } });
|
||||
await expect(
|
||||
adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo'),
|
||||
).rejects.toBeInstanceOf(ContentFilterError);
|
||||
try {
|
||||
await adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo');
|
||||
} catch (e) {
|
||||
expect((e as ContentFilterError & { status: number }).status).toBe(400);
|
||||
expect((e as ContentFilterError).message).toContain('安全审核拦截');
|
||||
}
|
||||
});
|
||||
|
||||
it('普通错误体抛出带 status 属性的 Error(供 isRetryableError 判断)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
await expect(
|
||||
adapter.throwHttpErrorPublic(makeResponse(503, 'Service Unavailable'), 'DeepSeek'),
|
||||
).rejects.toThrow('DeepSeek: 503');
|
||||
try {
|
||||
await adapter.throwHttpErrorPublic(makeResponse(503, ''), 'DeepSeek');
|
||||
} catch (e) {
|
||||
expect((e as Error & { status: number }).status).toBe(503);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseAdapter — getContextWindow', () => {
|
||||
it('配置了 contextWindow 时返回配置值', () => {
|
||||
const adapter = makeAdapter({ contextWindow: 128_000 });
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
});
|
||||
|
||||
it('未配置时返回保守默认值 1M', () => {
|
||||
const adapter = makeAdapter();
|
||||
expect(adapter.getContextWindow()).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseAdapter — listModels / healthCheck', () => {
|
||||
it('listModels 将 supportedModels 映射为 MetonaModelInfo', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const models = await adapter.listModels();
|
||||
expect(models.map((m) => m.id)).toEqual(['test-model-a', 'test-model-b']);
|
||||
});
|
||||
|
||||
it('healthCheck 成功返回 true', async () => {
|
||||
const adapter = makeAdapter();
|
||||
expect(await adapter.healthCheck()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseAdapter — fetchWithTimeout', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('正常请求返回 Response 并清理 timer', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const mockResponse = new Response('{"ok":true}');
|
||||
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
|
||||
expect(result).toBe(mockResponse);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('超时后 abort 请求(AbortError)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
|
||||
const expectation = expect(promise).rejects.toThrow('Aborted');
|
||||
vi.advanceTimersByTime(150);
|
||||
await expectation;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('外部 abort 信号触发请求中断', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const controller = new AbortController();
|
||||
adapter.setAbortSignal(controller.signal);
|
||||
|
||||
const fetchMock = vi.fn(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
|
||||
const expectation = expect(promise).rejects.toThrow('Aborted');
|
||||
controller.abort();
|
||||
await expectation;
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseAdapter — 接口契约', () => {
|
||||
it('providerId / 能力声明符合 IMetonaProviderAdapter 契约', () => {
|
||||
const adapter: IMetonaProviderAdapter = makeAdapter();
|
||||
expect(adapter.providerId).toBe('test');
|
||||
expect(typeof adapter.send).toBe('function');
|
||||
expect(typeof adapter.sendStream).toBe('function');
|
||||
expect(typeof adapter.getContextWindow).toBe('function');
|
||||
expect(typeof adapter.setAbortSignal).toBe('function');
|
||||
});
|
||||
|
||||
it('sendStream 是 AsyncGenerator(可迭代)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const events: MetonaStreamEvent[] = [];
|
||||
for await (const ev of adapter.sendStream({} as MetonaRequest)) {
|
||||
events.push(ev);
|
||||
}
|
||||
expect(events).toHaveLength(0);
|
||||
expect(MetonaStreamEventType.TEXT_DELTA).toBe('text_delta');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* SSE 流式解析器单元测试(v0.4.1 测试补齐)
|
||||
* 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE /
|
||||
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream';
|
||||
import { MetonaStreamEventType } from '../../types';
|
||||
|
||||
/** 构造 SSE 测试流 */
|
||||
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const c of chunks) controller.enqueue(encoder.encode(c));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
): Promise<Array<{ type: string; [k: string]: unknown }>> {
|
||||
const events: Array<{ type: string; [k: string]: unknown }> = [];
|
||||
for await (const ev of parseSSEStream(stream, 'req_test', 'sess_test', 1)) {
|
||||
events.push(ev as unknown as { type: string; [k: string]: unknown });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('parseSSEStream — 文本与推理增量', () => {
|
||||
it('TEXT_DELTA 事件按序产出', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":",世界"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||||
expect(deltas).toHaveLength(2);
|
||||
expect(deltas[0].delta).toBe('你好');
|
||||
expect(deltas[1].delta).toBe(',世界');
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('REASONING_DELTA(Thinking 模式)事件产出', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"reasoning_content":"让我想想"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
|
||||
expect(reasoning?.delta).toBe('让我想想');
|
||||
});
|
||||
|
||||
it('同一个 chunk 中 content 和 reasoning_content 同时产出', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"content":"答","reasoning_content":"思考"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSSEStream — 工具调用增量拼接', () => {
|
||||
it('分段 arguments 拼接为完整 JSON 并在 finish_reason=tool_calls 时 flush', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\\"pa"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"a.ts\\"}"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(1);
|
||||
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
|
||||
expect(tc.name).toBe('read_file');
|
||||
expect(tc.args).toEqual({ path: 'a.ts' });
|
||||
});
|
||||
|
||||
it('多个工具调用按 index 分别拼接', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"tool_a","arguments":"{}"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"tool_b","arguments":"{}"}}]}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(2);
|
||||
const names = completes.map((e) => (e.toolCall as { name: string }).name);
|
||||
expect(names).toEqual(['tool_a', 'tool_b']);
|
||||
});
|
||||
|
||||
it('损坏的 args JSON 跳过该工具调用且不中断流', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":"后续文本"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
// 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE)
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
// 流继续处理后续事件
|
||||
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSSEStream — USAGE 与容错', () => {
|
||||
it('USAGE 事件解析 DeepSeek 缓存字段', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
|
||||
const usage = usageEvent?.usage as Record<string, number>;
|
||||
expect(usage.inputTokens).toBe(100);
|
||||
expect(usage.outputTokens).toBe(50);
|
||||
expect(usage.totalTokens).toBe(150);
|
||||
expect(usage.cacheHitTokens).toBe(80);
|
||||
expect(usage.cacheMissTokens).toBe(20);
|
||||
});
|
||||
|
||||
it('坏 JSON 行不中断后续事件(记录警告后继续)', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {broken json\n\n',
|
||||
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('跨 chunk 分割的 SSE 行正确拼接', async () => {
|
||||
// "data: {...}\n\n" 被切到两个网络 chunk 中
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"cont',
|
||||
'ent":"拼接成功"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||||
expect(delta?.delta).toBe('拼接成功');
|
||||
});
|
||||
|
||||
it('空行与非 data 行被忽略', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
': comment line\n\n',
|
||||
'\n',
|
||||
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOpenAICompatibleResponse — 非流式响应', () => {
|
||||
it('解析普通文本响应', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
});
|
||||
expect(result.content).toBe('hello');
|
||||
expect(result.finishReason).toBe('stop');
|
||||
expect(result.usage.inputTokens).toBe(10);
|
||||
expect(result.toolCalls).toBeUndefined();
|
||||
});
|
||||
|
||||
it('解析 reasoning_content(Thinking 模式)', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{ message: { content: 'answer', reasoning_content: 'thinking...' }, finish_reason: 'stop' },
|
||||
],
|
||||
usage: {},
|
||||
});
|
||||
expect(result.reasoningContent).toBe('thinking...');
|
||||
});
|
||||
|
||||
it('解析 tool_calls(字符串 arguments 反序列化)', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: null,
|
||||
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{"cmd":"ls"}' } }],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: {},
|
||||
});
|
||||
expect(result.toolCalls).toHaveLength(1);
|
||||
expect(result.toolCalls![0].name).toBe('run');
|
||||
expect(result.toolCalls![0].args).toEqual({ cmd: 'ls' });
|
||||
});
|
||||
|
||||
it('损坏的 tool_calls arguments 降级为空对象', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: null,
|
||||
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: {},
|
||||
});
|
||||
expect(result.toolCalls![0].args).toEqual({});
|
||||
});
|
||||
|
||||
it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }],
|
||||
usage: {},
|
||||
});
|
||||
expect(result.finishReason).toBe('stop');
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
* @param init fetch init(不含 signal,由本方法内部管理)
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
*/
|
||||
protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
||||
protected async fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@@ -181,7 +185,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
*/
|
||||
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||||
let errorBody = '';
|
||||
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
||||
try {
|
||||
errorBody = await response.text();
|
||||
} catch {
|
||||
/* body 可能已消费或为 null */
|
||||
}
|
||||
|
||||
// v0.3.17: 解析 JSON 错误体,识别 content_filter
|
||||
if (errorBody) {
|
||||
@@ -204,7 +212,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||
const error = new Error(
|
||||
`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`,
|
||||
);
|
||||
(error as Error & { status: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
@@ -226,7 +236,10 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
|
||||
// v0.4.1 修复: msg 已 toLowerCase,网络错误码常量必须用小写比较
|
||||
//(原 'ETIMEDOUT'/'ECONNREFUSED' 等大写常量在小写消息上永不匹配,
|
||||
// 导致网络错误全部落入 UNKNOWN,无法触发引擎的重试逻辑)
|
||||
if (msg.includes('timeout') || msg.includes('etimedout')) {
|
||||
return {
|
||||
code: MetonaErrorCode.NETWORK_TIMEOUT,
|
||||
message: error.message,
|
||||
@@ -236,7 +249,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) {
|
||||
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('econnreset')) {
|
||||
return {
|
||||
code: MetonaErrorCode.NETWORK_ERROR,
|
||||
message: error.message,
|
||||
|
||||
Reference in New Issue
Block a user