feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
106
apps/server/src/modules/ai/providers/anthropic.provider.spec.ts
Normal file
106
apps/server/src/modules/ai/providers/anthropic.provider.spec.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
88
apps/server/src/modules/ai/providers/anthropic.provider.ts
Normal file
88
apps/server/src/modules/ai/providers/anthropic.provider.ts
Normal 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(';');
|
||||
}
|
||||
}
|
||||
14
apps/server/src/modules/ai/providers/factory.ts
Normal file
14
apps/server/src/modules/ai/providers/factory.ts
Normal 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}`);
|
||||
}
|
||||
79
apps/server/src/modules/ai/providers/openai.provider.ts
Normal file
79
apps/server/src/modules/ai/providers/openai.provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
apps/server/src/modules/ai/providers/provider.interface.ts
Normal file
49
apps/server/src/modules/ai/providers/provider.interface.ts
Normal 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;
|
||||
/** 必须强制走 tool(Anthropic: 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user