fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
背景:v0.5.2 工具调用失效修复后,对模型全能力矩阵(工具/思考/多模态/流式/ 压缩/摘要/记忆/故障转移/余额)做契约级核查,发现并修复两处同类时序/边界缺陷。 P1 MCP 工具运行中增删不同步引擎: - 症状:运行中添加/启用 MCP server 后,已打开会话拿不到新工具;断开 server 后已有引擎仍持有失效工具定义(模型发起调用才报 Unknown tool) - 根因:setToolsAll 仅在启动期与工具开关时调用,MCP 连接/断开路径缺失 (与 v0.5.2 修复的懒创建陷阱同类——"变更点 × 同步路径"未全覆盖) - 修复:MCPManager 新增 setOnToolsChanged 回调,connectServer 注册完成 / disconnectServer 注销完成后触发;main.ts 注入回调同步全部已存在引擎。 懒创建引擎由 createEngine 实时拉取(v0.5.2),三条路径(启动/懒创建/ 运行中变更)全覆盖。README"无需重启动态发现"的宣称至此真实成立。 P1 maxTokens 超模型上限直接 400: - 症状:引擎默认 maxTokens=63488,OpenAI gpt-4o(16384)/gpt-4.1(32768)、 Anthropic opus/haiku(32000)、MiMo standard(32768) 每次请求 400,等于不可用 - 修复:五个 adapter(DeepSeek/Agnes/MiMo/OpenAI/Anthropic)统一按 MODEL_INFO.maxOutputTokens 钳制;MiMo 保留 thinking 兜底 32768 语义; Anthropic thinking budget 在钳制后的 max_tokens 内二分,自动跟随 测试(224 → 236 用例): - 新增 maxTokens 钳制契约测试 ×9(max-tokens-clamp.test.ts):mock fetch 记录真实请求体断言——超限钳制(MiMo standard/OpenAI gpt-4o/Anthropic opus)/ 未超限原样传递(DeepSeek/Agnes/MiMo pro/o3-mini/sonnet)/ 推理模型字段名 / 未配置默认值安全性 - 新增 MCP 动态同步端到端测试 ×3(mcp-tools-sync.test.ts):mock MCP SDK + 真实 MCPManager/ToolRegistry/AgentEngineManager——先建引擎再连 server,断言同一会话请求的 tools 动态更新 / 断开后移除失效定义 / 回调异常不阻断 MCP 主流程 能力矩阵核查结论(无回归确认): 工具调用主链路 ✓(v0.5.2)/ SubAgent 工具 ✓(delegate 实时 resolveTools)/ thinking 热更新 ✓(baseConfig 合并 路径无懒创建陷阱)/ 多模态当轮 ✓ / 压缩与孤立 tool 消息配对 ✓ / 摘要分层 ✓ / 记忆注入 ✓ / 故障转移 ✓ / 余额 ✓(v0.5.2)。已知设计限制:历史轮图片不 回传(attachments 仅存缩略图,图片只在发送当轮注入上下文)。 验证: lint 0 / typecheck 双工程 0 / test:electron 236 全过 / build 成功
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Provider maxTokens 上限钳制契约测试(v0.5.3)
|
||||
*
|
||||
* 背景:引擎默认 maxTokens=63488(engine.ts DEFAULT_CONFIG),超过部分模型
|
||||
* 上限时 API 直接 400 —— OpenAI gpt-4o(16384)/gpt-4.1(32768)、Anthropic
|
||||
* opus/haiku(32000)、MiMo standard(32768) 曾不可用。v0.5.3 各 adapter 按
|
||||
* MODEL_INFO.maxOutputTokens 钳制。
|
||||
*
|
||||
* 测试策略(v0.5.2 教训):mock fetch 记录真实请求体并断言契约 ——
|
||||
* 不 mock adapter 内部方法,验证"发出的 HTTP 请求体"这个最终事实。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { DeepSeekAdapter } from '../deepseek.adapter';
|
||||
import { AgnesAdapter } from '../agnes-ai.adapter';
|
||||
import { MimoAdapter } from '../mimo.adapter';
|
||||
import { OpenAIAdapter } from '../openai.adapter';
|
||||
import { AnthropicAdapter } from '../anthropic.adapter';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
/** 引擎默认形态的请求(maxTokens=63488,与 engine DEFAULT_CONFIG 一致) */
|
||||
function makeRequest(overrides: Partial<MetonaRequest> = {}): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
sessionId: 's',
|
||||
iteration: 1,
|
||||
requestId: 'r',
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1',
|
||||
},
|
||||
systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
|
||||
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||||
params: {
|
||||
maxTokens: 63_488,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAI 兼容系成功响应 */
|
||||
function openAIResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: 'ok' } }], usage: {} }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** Anthropic 成功响应 */
|
||||
function anthropicResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ content: [{ type: 'text', text: 'ok' }], usage: {} }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** 取 mock fetch 收到的请求体 */
|
||||
function requestBody(): Record<string, unknown> {
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
it('DeepSeek v4 pro(上限 384K):63488 未超限,原样传递', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'sk',
|
||||
defaultModel: 'deepseek-v4-pro',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('Agnes flash(上限 65536):63488 未超限,原样传递', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new AgnesAdapter({
|
||||
provider: 'agnes',
|
||||
baseURL: 'https://api.agnes.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'agnes-2.0-flash',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('MiMo standard(上限 32768):钳制到 32768(原为 400 错误场景)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
|
||||
it('MiMo pro(上限 131072):63488 未超限,原样传递', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5-pro',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('OpenAI gpt-4o(上限 16384):钳制到 16384(原为 400 错误场景)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'gpt-4o',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(16_384);
|
||||
});
|
||||
|
||||
it('OpenAI o3-mini(上限 100K):63488 未超限,推理模型字段名正确', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'o3-mini',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(63_488);
|
||||
expect(requestBody().max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Anthropic opus(上限 32000):钳制到 32000(原为 400 错误场景)', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-opus-4-1',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(32_000);
|
||||
});
|
||||
|
||||
it('Anthropic sonnet(上限 64000):63488 未超限', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('未配置 maxTokens 时各 adapter 使用安全默认值(不超过模型上限)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const request = makeRequest({
|
||||
params: { temperature: 0, stream: false } as MetonaRequest['params'],
|
||||
});
|
||||
|
||||
// MiMo standard + thinking 默认 → 兜底 32768(= 上限,安全)
|
||||
const mimo = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5',
|
||||
});
|
||||
await mimo.send(request);
|
||||
expect(requestBody().max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
});
|
||||
@@ -48,21 +48,25 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
const body = this.toNativeRequest(request, false);
|
||||
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
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,
|
||||
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),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'Agnes AI API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
@@ -88,15 +92,19 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
const body = this.toNativeRequest(request, true);
|
||||
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
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,
|
||||
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),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'Agnes AI stream error');
|
||||
@@ -170,15 +178,22 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
}
|
||||
|
||||
if (imageCount > 0) {
|
||||
const firstUrl = request.messages.find(m => m.images?.length)?.images?.[0]?.url ?? '';
|
||||
log.info(`[Agnes] Processing ${imageCount} image(s), first URL prefix: ${firstUrl.slice(0, 50)}`);
|
||||
const firstUrl = request.messages.find((m) => m.images?.length)?.images?.[0]?.url ?? '';
|
||||
log.info(
|
||||
`[Agnes] Processing ${imageCount} image(s), first URL prefix: ${firstUrl.slice(0, 50)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制(agnes-2.0-flash 上限 65536)
|
||||
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
const maxOutput = modelInfo?.maxOutputTokens ?? 65_536;
|
||||
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
temperature: request.params.temperature,
|
||||
max_tokens: request.params.maxTokens ?? 65536,
|
||||
max_tokens: maxTokens,
|
||||
stream,
|
||||
};
|
||||
|
||||
|
||||
@@ -72,17 +72,21 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
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);
|
||||
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>;
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return this.toMetonaResponse(data, request.meta.requestId);
|
||||
}
|
||||
|
||||
@@ -90,11 +94,15 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
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);
|
||||
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');
|
||||
@@ -140,7 +148,11 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
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 });
|
||||
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) {
|
||||
@@ -189,7 +201,8 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
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),
|
||||
totalTokens:
|
||||
((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -250,7 +263,10 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
yield ev;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
log.warn(`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
|
||||
log.warn(
|
||||
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
|
||||
trimmed.slice(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,7 +307,10 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
* 4. 连续同角色消息合并(API 要求严格交替)
|
||||
* 5. 首条消息必须为 user(历史以 assistant 开头时补占位)
|
||||
*/
|
||||
private async toNativeRequest(request: MetonaRequest, stream: boolean): Promise<Record<string, unknown>> {
|
||||
private async toNativeRequest(
|
||||
request: MetonaRequest,
|
||||
stream: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// System Prompt 拼接(Anthropic 使用顶层 system 字段)
|
||||
const system = [
|
||||
request.systemPrompt.roleDefinition,
|
||||
@@ -303,7 +322,10 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
.join('\n\n');
|
||||
|
||||
// 转换消息(非 system)
|
||||
const converted: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = [];
|
||||
const converted: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: Array<Record<string, unknown>>;
|
||||
}> = [];
|
||||
for (const m of request.messages) {
|
||||
if (m.role === 'system') continue;
|
||||
|
||||
@@ -316,7 +338,9 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
converted.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }],
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -345,7 +369,8 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
}
|
||||
|
||||
// 合并连续同角色消息(Anthropic 要求 user/assistant 交替)
|
||||
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = [];
|
||||
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) {
|
||||
@@ -357,12 +382,20 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
// 首条消息必须为 user
|
||||
if (merged.length === 0 || merged[0].role !== 'user') {
|
||||
merged.unshift({ role: 'user', content: [{ type: 'text', text: '[Conversation history follows]' }] });
|
||||
merged.unshift({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '[Conversation history follows]' }],
|
||||
});
|
||||
}
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)—
|
||||
// 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400;thinking budget 已在此值内二分
|
||||
const anthropicMaxOutput =
|
||||
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
max_tokens: request.params.maxTokens ?? 8192,
|
||||
max_tokens: Math.min(request.params.maxTokens ?? 8192, anthropicMaxOutput),
|
||||
system,
|
||||
messages: merged,
|
||||
stream,
|
||||
@@ -379,7 +412,12 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
// Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半)
|
||||
if (request.params.thinkingEnabled) {
|
||||
const budgetMap: Record<string, number> = { low: 1024, medium: 4096, high: 16384, max: 32768 };
|
||||
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),
|
||||
@@ -435,7 +473,8 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
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 === 'thinking')
|
||||
reasoningContent = (block.thinking as string) ?? undefined;
|
||||
else if (block.type === 'tool_use') {
|
||||
let args: Record<string, unknown> = {};
|
||||
const rawInput = block.input;
|
||||
@@ -453,9 +492,11 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
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;
|
||||
stopReason === 'tool_use'
|
||||
? MetonaFinishReason.TOOL_CALLS
|
||||
: stopReason === 'max_tokens'
|
||||
? MetonaFinishReason.LENGTH
|
||||
: MetonaFinishReason.STOP;
|
||||
|
||||
return {
|
||||
meta: {
|
||||
|
||||
@@ -239,11 +239,16 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
const messages = buildOpenAICompatibleMessages(request);
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400
|
||||
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
const maxOutput = modelInfo?.maxOutputTokens ?? 384_000;
|
||||
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
temperature: request.params.temperature,
|
||||
max_tokens: request.params.maxTokens,
|
||||
max_tokens: maxTokens,
|
||||
stream,
|
||||
};
|
||||
|
||||
|
||||
@@ -59,21 +59,25 @@ export class MimoAdapter extends BaseAdapter {
|
||||
const body = this.toNativeRequest(request, false);
|
||||
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
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,
|
||||
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),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 120_000);
|
||||
this.config.timeoutMs ?? 120_000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'MiMo API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
@@ -98,15 +102,19 @@ export class MimoAdapter extends BaseAdapter {
|
||||
const body = this.toNativeRequest(request, true);
|
||||
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
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,
|
||||
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),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'MiMo stream error');
|
||||
@@ -187,16 +195,18 @@ export class MimoAdapter extends BaseAdapter {
|
||||
messages[i].content = contentParts;
|
||||
}
|
||||
|
||||
// MiMo 使用 max_completion_tokens(非 max_tokens)
|
||||
// #41 修复: thinking 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出)
|
||||
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)—
|
||||
// 引擎默认 63488 超过 standard 上限时 API 直接 400
|
||||
const mimoMaxOutput =
|
||||
MimoAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 131_072;
|
||||
const mimoDefault = request.params.thinkingEnabled !== false ? 32_768 : mimoMaxOutput;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
// MiMo 使用 max_completion_tokens(非 max_tokens)
|
||||
// #41 修复: thinking 模式下 max_completion_tokens 未配置时使用兜底默认值(32768)
|
||||
// thinking 占用 token 配额,未配置时 API 默认值可能过小导致输出被截断
|
||||
// thinkingEnabled !== false 包含 true 和 undefined(MiMo 默认 enabled)两种情况
|
||||
// 审查修复: 兜底值从 63488 改为 32768,避免超出标准版上限导致 API 400
|
||||
max_completion_tokens: request.params.maxTokens
|
||||
?? (request.params.thinkingEnabled !== false ? 32768 : undefined),
|
||||
max_completion_tokens: Math.min(request.params.maxTokens ?? mimoDefault, mimoMaxOutput),
|
||||
stream,
|
||||
};
|
||||
|
||||
|
||||
@@ -69,21 +69,25 @@ export class OpenAIAdapter extends BaseAdapter {
|
||||
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,
|
||||
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),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 120_000);
|
||||
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 data = (await response.json()) as Record<string, unknown>;
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
|
||||
return {
|
||||
@@ -107,15 +111,19 @@ export class OpenAIAdapter extends BaseAdapter {
|
||||
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,
|
||||
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),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'OpenAI stream error');
|
||||
@@ -139,7 +147,7 @@ export class OpenAIAdapter extends BaseAdapter {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data?: Array<{ id: string }> };
|
||||
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 });
|
||||
}
|
||||
@@ -208,15 +216,19 @@ export class OpenAIAdapter extends BaseAdapter {
|
||||
};
|
||||
|
||||
// Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens
|
||||
if (request.params.maxTokens) {
|
||||
// v0.5.3: 按模型上限钳制(gpt-4o 16384 / gpt-4.1 32768 / o3-mini 100000)—
|
||||
// 引擎默认 63488 超过 gpt-4o/gpt-4.1 上限时 API 直接 400
|
||||
const oaMaxOutput = OpenAIAdapter.MODEL_INFO[model]?.maxOutputTokens ?? 128_000;
|
||||
const oaMaxTokens = Math.min(
|
||||
request.params.maxTokens ?? (isReasoningModel ? 32_768 : oaMaxOutput),
|
||||
oaMaxOutput,
|
||||
);
|
||||
if (oaMaxTokens) {
|
||||
if (isReasoningModel) {
|
||||
body.max_completion_tokens = request.params.maxTokens;
|
||||
body.max_completion_tokens = oaMaxTokens;
|
||||
} else {
|
||||
body.max_tokens = request.params.maxTokens;
|
||||
body.max_tokens = oaMaxTokens;
|
||||
}
|
||||
} else if (isReasoningModel) {
|
||||
// 推理模型未配置时使用兜底值(thinking 占用 token 配额,默认值过小会被截断)
|
||||
body.max_completion_tokens = 32_768;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
@@ -229,7 +241,12 @@ export class OpenAIAdapter extends BaseAdapter {
|
||||
|
||||
// Thinking 模式:推理模型映射 reasoning_effort;非推理模型忽略
|
||||
if (request.params.thinkingEnabled && isReasoningModel) {
|
||||
const effortMap: Record<string, string> = { low: 'low', medium: 'medium', high: 'high', max: 'high' };
|
||||
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) {
|
||||
// 非推理模型使用温度控制
|
||||
|
||||
Reference in New Issue
Block a user