- 打通 Worker → WorkerManager → Provider 的 tools 传递链路,ProviderManager/adapter 返回结构化 tool_calls 给 WorkerRuntime - OpenAI-compatible/Anthropic adapter 发送工具 schema,并解析 provider 返回的 tool_calls/tool_use;OpenAI 工具名使用 fs.write ↔ fs__write 双向映射 - 修复独立复审发现的 OpenAI 协议隐患:assistant tool_use blocks 必须转换为 assistant.tool_calls,后续 role=tool 消息的 tool_call_id 必须匹配前一轮 tool_calls[].id;不再把 tool_use JSON 字符串化为普通文本 - ExecutorRole 优先消费原生 tool_calls,回灌 canonical tool_result block;移除 fs.write(...)/shell.run(...) 函数调用正则解析,只保留严格 JSON tool_call fallback 与 filename code block 兼容 - DONE 前执行 verification-before-completion:任务要求 build/compile/run/test/编译/ 运行/测试时必须实际 shell.run 验证,失败不 checkpoint、不返回 completed - fs.write 覆盖已有文件也强制 read-before-write,补齐 Claude Code 文件状态纪律 - 新增 packages/workers/test/executor-role.test.ts 行为测试:原生 tool_calls 执行、 verification 失败不得 completed 真实验收: - TSC=0 - bun test packages/workers/test/executor-role.test.ts: 2 pass / 0 fail - OpenAI converter 探针确认 assistant.tool_calls 与 role=tool 的 tool_call_id 匹配 - 真实 GLM Worker C++ 编译运行任务通过,worker verification 记录实际命令: c++ hello.cpp -o /tmp/aircoding-verify && /tmp/aircoding-verify Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
9.6 KiB
TypeScript
Executable File
260 lines
9.6 KiB
TypeScript
Executable File
/**
|
|
* AnthropicAdapter - Provider adapter for Anthropic API
|
|
*
|
|
* Implements ProviderAdapter contract (contracts §15); DD §12.2.
|
|
*
|
|
* @module packages/llm/src/adapters/AnthropicAdapter
|
|
*/
|
|
|
|
import type {
|
|
ModelID,
|
|
ProviderAdapter,
|
|
ProviderCapabilityMatrix,
|
|
ProviderCompletionInput,
|
|
ProviderID,
|
|
ProviderStreamEvent,
|
|
} from '@aircoding/contracts'
|
|
|
|
import { AnthropicCanonicalConverter } from '../canonical/AnthropicCanonical.js'
|
|
|
|
export interface AnthropicConfig {
|
|
api_key?: string
|
|
base_url?: string
|
|
max_retries?: number
|
|
timeout?: number
|
|
}
|
|
|
|
interface AnthropicApiResponse {
|
|
id: string
|
|
type: string
|
|
role: string
|
|
content: Array<{ type: string; text?: string; thinking?: string; id?: string; name?: string; input?: unknown }>
|
|
stop_reason?: string
|
|
usage?: { input_tokens: number; output_tokens: number }
|
|
}
|
|
|
|
interface AnthropicApiRequest {
|
|
model: string
|
|
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>
|
|
max_tokens: number
|
|
temperature?: number
|
|
top_p?: number
|
|
system?: string
|
|
stream?: boolean
|
|
tools?: Array<{ name: string; description: string; input_schema: Record<string, unknown> }>
|
|
}
|
|
|
|
export class AnthropicAdapter implements ProviderAdapter {
|
|
readonly provider_id: ProviderID = 'anthropic'
|
|
private api_key: string
|
|
private base_url: string
|
|
private max_retries: number
|
|
private timeout: number
|
|
private converter: AnthropicCanonicalConverter
|
|
|
|
constructor(config: AnthropicConfig = {}) {
|
|
this.api_key = config.api_key || process.env.ANTHROPIC_API_KEY || ''
|
|
this.base_url = config.base_url || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'
|
|
this.max_retries = config.max_retries || 3
|
|
this.timeout = config.timeout || 60000
|
|
this.converter = new AnthropicCanonicalConverter()
|
|
}
|
|
|
|
/**
|
|
* Known Anthropic models.
|
|
*/
|
|
private static readonly KNOWN_MODELS: Array<{
|
|
model_id: string
|
|
display_name: string
|
|
family: 'claude-opus' | 'claude-sonnet' | 'claude-haiku'
|
|
}> = [
|
|
{ model_id: 'claude-opus-4-7-20251119', display_name: 'Claude Opus 4.7', family: 'claude-opus' },
|
|
{ model_id: 'claude-sonnet-4-6-20250501', display_name: 'Claude Sonnet 4.6', family: 'claude-sonnet' },
|
|
{ model_id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5', family: 'claude-haiku' },
|
|
]
|
|
|
|
async list_models(): Promise<ProviderCapabilityMatrix[]> {
|
|
return AnthropicAdapter.KNOWN_MODELS.map(m => this.capability_matrix(m.model_id, m.display_name, m.family))
|
|
}
|
|
|
|
async validate_model(model_id: ModelID): Promise<ProviderCapabilityMatrix> {
|
|
const known = AnthropicAdapter.KNOWN_MODELS.find(m => m.model_id === model_id)
|
|
if (known) {
|
|
return this.capability_matrix(known.model_id, known.display_name, known.family)
|
|
}
|
|
// Allow any model that looks like a Claude model (flexible acceptance)
|
|
if (String(model_id).startsWith('claude-')) {
|
|
return this.capability_matrix(String(model_id), `Custom Claude ${model_id}`, 'claude-sonnet')
|
|
}
|
|
throw new Error(`Unknown Anthropic model: ${model_id}`)
|
|
}
|
|
|
|
/**
|
|
* Execute a completion request (implements ProviderAdapter.complete).
|
|
* Returns an AsyncIterable of ProviderStreamEvent ({type, payload}).
|
|
*/
|
|
async *complete(input: ProviderCompletionInput): AsyncIterable<ProviderStreamEvent> {
|
|
const messages = this.convert_to_anthropic_messages(input.messages as { role: string; content: unknown }[])
|
|
const response = await this.make_request({
|
|
model: String(input.model_id),
|
|
messages,
|
|
max_tokens: input.max_output_tokens ?? 4096,
|
|
temperature: input.temperature,
|
|
system: input.system as string | undefined,
|
|
stream: false,
|
|
tools: this.convert_tools(input.tools),
|
|
})
|
|
|
|
// Yield each content block as an event
|
|
yield { type: 'message_start', payload: { id: response.id, role: response.role } }
|
|
for (const block of response.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } }
|
|
} else if (block.type === 'thinking' && block.thinking) {
|
|
yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } }
|
|
} else if (block.type === 'tool_use' && block.name) {
|
|
yield { type: 'tool_use', payload: { id: block.id, name: block.name, input: (block.input as Record<string, unknown>) || {} } }
|
|
}
|
|
}
|
|
if (response.usage) {
|
|
yield {
|
|
type: 'message_stop',
|
|
payload: { stop_reason: response.stop_reason || 'end_turn', usage: { output_tokens: response.usage.output_tokens } }
|
|
}
|
|
} else {
|
|
yield { type: 'message_stop', payload: { stop_reason: 'end_turn' } }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backward-compat: single-shot complete that returns string content.
|
|
* Used by MainAgent.classify_via_llm.
|
|
*/
|
|
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
|
|
const response = await this.make_request({
|
|
model: options.model || 'claude-haiku-4-5-20251001',
|
|
messages: this.convert_raw_messages(messages),
|
|
max_tokens: options.max_tokens || 1024,
|
|
tools: this.convert_tools(options.tools),
|
|
stream: false,
|
|
})
|
|
const tool_calls = response.content
|
|
.filter(b => b.type === 'tool_use' && b.name)
|
|
.map(b => ({ id: b.id, name: b.name!, arguments: (b.input as Record<string, unknown>) || {} }))
|
|
return {
|
|
content: this.extract_content(response),
|
|
usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined,
|
|
tool_calls: tool_calls.length ? tool_calls : undefined,
|
|
}
|
|
}
|
|
|
|
private convert_tools(tools?: unknown[]): Array<{ name: string; description: string; input_schema: Record<string, unknown> }> | undefined {
|
|
if (!tools?.length) return undefined
|
|
return tools.map(t => {
|
|
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description || tool.name,
|
|
input_schema: tool.input_schema || { type: 'object', properties: {} },
|
|
}
|
|
})
|
|
}
|
|
|
|
private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array<Record<string, unknown>> }> {
|
|
return messages.map(m => {
|
|
const blocks: Array<Record<string, unknown>> = []
|
|
if (typeof m.content === 'string') {
|
|
blocks.push({ type: 'text', text: m.content })
|
|
} else if (Array.isArray(m.content)) {
|
|
for (const c of m.content) {
|
|
if (typeof c === 'string') blocks.push({ type: 'text', text: c })
|
|
else blocks.push(c as Record<string, unknown>)
|
|
}
|
|
}
|
|
return { role: m.role, content: blocks }
|
|
})
|
|
}
|
|
|
|
private convert_raw_messages(messages: unknown[]): Array<{ role: string; content: Array<Record<string, unknown>> }> {
|
|
return messages.map(m => {
|
|
const obj = m as { role: string; content: unknown }
|
|
if (typeof obj.content === 'string') {
|
|
return { role: obj.role, content: [{ type: 'text', text: obj.content }] }
|
|
}
|
|
if (Array.isArray(obj.content)) {
|
|
return { role: obj.role, content: obj.content as Array<Record<string, unknown>> }
|
|
}
|
|
return { role: obj.role, content: [{ type: 'text', text: String(obj.content) }] }
|
|
})
|
|
}
|
|
|
|
private extract_content(response: AnthropicApiResponse): string {
|
|
return response.content
|
|
.filter(b => b.type === 'text')
|
|
.map(b => b.text || '')
|
|
.join('')
|
|
}
|
|
|
|
private capability_matrix(model_id: string, display_name: string, family: string): ProviderCapabilityMatrix {
|
|
return {
|
|
provider_id: this.provider_id,
|
|
model_id: model_id as ModelID,
|
|
display_name,
|
|
|
|
max_output_tokens: 200000,
|
|
provider_kind: 'anthropic',
|
|
enabled: true,
|
|
quality_tier: 'frontier',
|
|
cost_tier: 'high',
|
|
conversion: { from_anthropic_canonical: 'lossless' as const, tool_schema: 'native' as const, image_input: 'native' as const, thinking: 'native' as const, cache_control: 'native' as const },
|
|
supports: {
|
|
text_input: true,
|
|
text_output: true,
|
|
streaming: true,
|
|
tool_use: true,
|
|
parallel_tool_use: true,
|
|
structured_output: true,
|
|
json_mode: true,
|
|
thinking: family === 'claude-opus' || family === 'claude-sonnet',
|
|
prompt_cache: true,
|
|
system_prompt: true,
|
|
image_input: true,
|
|
image_output: false,
|
|
audio_input: false,
|
|
audio_output: false,
|
|
file_input: true,
|
|
computer_use: false,
|
|
long_context: true,
|
|
batch: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
private async make_request(body: AnthropicApiRequest): Promise<AnthropicApiResponse> {
|
|
const response = await fetch(`${this.base_url}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': this.api_key,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text()
|
|
throw new Error(`Anthropic API error: ${response.status} - ${error}`)
|
|
}
|
|
|
|
return response.json() as Promise<AnthropicApiResponse>
|
|
}
|
|
}
|
|
|
|
export function createAnthropicAdapter(config?: AnthropicConfig): AnthropicAdapter {
|
|
return new AnthropicAdapter(config)
|
|
}
|
|
|
|
// Backward-compat export
|
|
export type AnthropicStreamEvent = ProviderStreamEvent
|
|
export type { CanonicalMessage } from '../canonical/AnthropicCanonical.js'
|