feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

View File

@@ -0,0 +1,106 @@
import { AnthropicProvider } from './anthropic.provider';
const mockCreate = jest.fn();
jest.mock('@anthropic-ai/sdk', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
messages: {
create: mockCreate,
},
})),
}));
describe('AnthropicProvider', () => {
beforeEach(() => {
mockCreate.mockReset();
});
it('reports stop reason and text preview when no tool_use block is returned', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'end_turn',
model: 'claude-test',
content: [
{
type: 'text',
text: '我理解你的意思,但这里没有可识别的 QY 编号,所以我先用自然语言解释。',
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await expect(
provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
}),
).rejects.toThrow(
/Anthropic 未通过 tool_use 返回结果.*stop_reason=end_turn.*content=text\("我理解你的意思/,
);
});
it('disables parallel tool use when a specific tool is forced', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'tool_use',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'toolu_test',
name: 'submit_decompose',
input: { ok: true },
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
});
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tool_choice: {
type: 'tool',
name: 'submit_decompose',
disable_parallel_tool_use: true,
},
}),
);
});
});

View File

@@ -0,0 +1,88 @@
import Anthropic from '@anthropic-ai/sdk';
import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface';
/**
* Anthropic 格式提供商(官方 API + 兼容 Anthropic 格式的中转站)
*/
export class AnthropicProvider implements AiProvider {
readonly format = 'anthropic' as const;
private client: Anthropic;
constructor(apiKey: string, baseURL?: string) {
this.client = new Anthropic({
apiKey,
...(baseURL && baseURL !== 'https://api.anthropic.com' ? { baseURL } : {}),
});
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const toolChoice = req.forceTool
? ({
type: 'tool',
name: req.tool.name,
disable_parallel_tool_use: true,
} as any)
: { type: 'auto' as const };
const response = await this.client.messages.create({
model: req.model,
max_tokens: req.maxTokens,
system: req.systemPrompt,
tools: [
{
name: req.tool.name,
description: req.tool.description,
input_schema: req.tool.inputSchema,
},
],
tool_choice: toolChoice,
messages: [{ role: 'user', content: req.userPrompt }],
});
const toolUse = response.content.find(
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
);
if (!toolUse) {
throw new Error(this.describeMissingToolUse(response));
}
return {
toolName: toolUse.name,
toolInput: toolUse.input as Record<string, any>,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
const resp = await this.client.messages.create({
model: req.model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }],
});
return {
ok: true,
inputTokens: resp.usage.input_tokens,
outputTokens: resp.usage.output_tokens,
};
}
private describeMissingToolUse(response: Anthropic.Message): string {
const contentSummary = response.content
.map((block) => {
if (block.type === 'text') {
const text = block.text.replace(/\s+/g, ' ').slice(0, 160);
return `text("${text}")`;
}
return block.type;
})
.join(', ');
return [
'Anthropic 未通过 tool_use 返回结果',
`stop_reason=${response.stop_reason || 'unknown'}`,
`content=${contentSummary || 'empty'}`,
].join('');
}
}

View File

@@ -0,0 +1,14 @@
import type { AiProvider } from './provider.interface';
import { AnthropicProvider } from './anthropic.provider';
import { OpenAIProvider } from './openai.provider';
import type { AiProviderConfig } from '@ftb/shared';
export function createProvider(config: AiProviderConfig): AiProvider {
if (config.format === 'anthropic') {
return new AnthropicProvider(config.apiKey, config.baseURL);
}
if (config.format === 'openai') {
return new OpenAIProvider(config.apiKey, config.baseURL);
}
throw new Error(`未支持的 provider format: ${(config as any).format}`);
}

View File

@@ -0,0 +1,79 @@
import OpenAI from 'openai';
import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface';
/**
* OpenAI 格式提供商(兼容 OpenAI Chat Completions 风格的中转站)
*/
export class OpenAIProvider implements AiProvider {
readonly format = 'openai' as const;
private client: OpenAI;
constructor(apiKey: string, baseURL: string) {
this.client = new OpenAI({
apiKey,
baseURL,
});
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const response = await this.client.chat.completions.create({
model: req.model,
max_tokens: req.maxTokens,
messages: [
{ role: 'system', content: req.systemPrompt },
{ role: 'user', content: req.userPrompt },
],
tools: [
{
type: 'function',
function: {
name: req.tool.name,
description: req.tool.description,
parameters: req.tool.inputSchema,
},
},
],
tool_choice: req.forceTool
? { type: 'function', function: { name: req.tool.name } }
: 'auto',
});
const choice = response.choices[0];
if (!choice?.message?.tool_calls?.length) {
throw new Error('OpenAI 未通过 tool_calls 返回结果');
}
const tc = choice.message.tool_calls[0];
if (tc.type !== 'function') {
throw new Error('OpenAI 返回的 tool_call 类型非 function');
}
let parsed: Record<string, any>;
try {
parsed = JSON.parse(tc.function.arguments || '{}');
} catch (e) {
throw new Error(`OpenAI tool_call arguments 不是合法 JSON: ${(e as Error).message}`);
}
return {
toolName: tc.function.name,
toolInput: parsed,
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
const resp = await this.client.chat.completions.create({
model: req.model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }],
});
return {
ok: true,
inputTokens: resp.usage?.prompt_tokens ?? 0,
outputTokens: resp.usage?.completion_tokens ?? 0,
};
}
}

View File

@@ -0,0 +1,49 @@
/**
* AI Provider 抽象接口
* 屏蔽 Anthropic vs OpenAI 格式差异,对上层提供统一调用
*/
export interface ToolDefinition {
name: string;
description: string;
inputSchema: any; // JSON Schema for tool input
}
export interface ToolCallRequest {
systemPrompt: string;
userPrompt: string;
tool: ToolDefinition;
/** 必须强制走 toolAnthropic: tool_choice / OpenAI: tool_choice */
forceTool: boolean;
maxTokens: number;
model: string;
}
export interface ToolCallResponse {
toolName: string;
toolInput: Record<string, any>;
inputTokens: number;
outputTokens: number;
rawModel: string; // 实际服务返回的 model 字段
}
export interface PingRequest {
model: string;
}
export interface PingResponse {
ok: true;
inputTokens: number;
outputTokens: number;
}
/**
* 所有 Provider 实现都满足此接口
*/
export interface AiProvider {
format: 'anthropic' | 'openai';
/** 调一次 tool 工具,返回归一化结果 */
callTool(req: ToolCallRequest): Promise<ToolCallResponse>;
/** 极简调用,验证 Key + 端点可用 */
ping(req: PingRequest): Promise<PingResponse>;
}