fix: stream interactive model tool rounds
This commit is contained in:
@@ -119,6 +119,19 @@ function createResponsesEventStream(
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSseEventStream(
|
||||||
|
events: ReadonlyArray<Record<string, unknown>>
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
...events.flatMap((event) => [
|
||||||
|
`event: ${event.type as string}`,
|
||||||
|
`data: ${JSON.stringify(event)}`,
|
||||||
|
''
|
||||||
|
]),
|
||||||
|
''
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
function createToolProvider(
|
function createToolProvider(
|
||||||
overrides: Partial<ModelToolProviderLike> = {}
|
overrides: Partial<ModelToolProviderLike> = {}
|
||||||
): ModelToolProviderLike {
|
): ModelToolProviderLike {
|
||||||
@@ -1808,6 +1821,227 @@ describe('ModelAgentRuntime', () => {
|
|||||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('streams OpenAI Responses text and reasoning through tool rounds', async () => {
|
||||||
|
const streams = [
|
||||||
|
createSseEventStream([
|
||||||
|
{
|
||||||
|
type: 'response.reasoning_summary_text.delta',
|
||||||
|
delta: '先分析。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
delta: '准备读取。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp-stream-tool-1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'reasoning-stream-1',
|
||||||
|
type: 'reasoning',
|
||||||
|
summary: [
|
||||||
|
{ type: 'summary_text', text: '先分析。' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message-stream-1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{ type: 'output_text', text: '准备读取。' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'function-stream-1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call-stream-1',
|
||||||
|
name: 'workspace_read_text',
|
||||||
|
arguments: '{"path":"README.md"}'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 12, output_tokens: 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
createSseEventStream([
|
||||||
|
{
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
delta: '读取完成。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp-stream-tool-2',
|
||||||
|
model: 'gpt-5',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'message-stream-2',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{ type: 'output_text', text: '读取完成。' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 20, output_tokens: 5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(streams.shift(), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const toolProvider = createToolProvider()
|
||||||
|
const runtime = new ModelAgentRuntime({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
protocol: 'openai-responses',
|
||||||
|
authentication: 'api-key',
|
||||||
|
fetcher,
|
||||||
|
toolProvider
|
||||||
|
})
|
||||||
|
const events = []
|
||||||
|
|
||||||
|
for await (const event of runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'a431666e-5ec8-45e6-beb4-654132eed151',
|
||||||
|
conversationId: 'conversation-responses-streaming-tools',
|
||||||
|
prompt: '读取 README',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'once'
|
||||||
|
)) {
|
||||||
|
events.push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, init] of fetcher.mock.calls) {
|
||||||
|
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.filter((event) => event.type === 'reasoning')
|
||||||
|
.map((event) => event.delta)
|
||||||
|
).toEqual(['先分析。'])
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.filter((event) => event.type === 'text')
|
||||||
|
.map((event) => event.delta)
|
||||||
|
).toEqual(['准备读取。', '读取完成。'])
|
||||||
|
expect(
|
||||||
|
events.findIndex((event) => event.type === 'text')
|
||||||
|
).toBeLessThan(
|
||||||
|
events.findIndex(
|
||||||
|
(event) =>
|
||||||
|
event.type === 'tool' && event.state === 'running'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const secondBody = JSON.parse(
|
||||||
|
fetcher.mock.calls[1]?.[1]?.body as string
|
||||||
|
) as { input: Array<Record<string, unknown>> }
|
||||||
|
expect(secondBody.input).toEqual([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: '读取 README'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reasoning-stream-1',
|
||||||
|
type: 'reasoning',
|
||||||
|
summary: [{ type: 'summary_text', text: '先分析。' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message-stream-1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{ type: 'output_text', text: '准备读取。' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'function-stream-1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call-stream-1',
|
||||||
|
name: 'workspace_read_text',
|
||||||
|
arguments: '{"path":"README.md"}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call-stream-1',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'input_text',
|
||||||
|
text: 'tool result'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(toolProvider.callTool).toHaveBeenCalledOnce()
|
||||||
|
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an incomplete OpenAI Responses tool stream', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(
|
||||||
|
[
|
||||||
|
'event: response.output_text.delta',
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
delta: 'partial'
|
||||||
|
})}`,
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
].join('\n'),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const toolProvider = createToolProvider()
|
||||||
|
const runtime = new ModelAgentRuntime({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
protocol: 'openai-responses',
|
||||||
|
authentication: 'api-key',
|
||||||
|
fetcher,
|
||||||
|
toolProvider
|
||||||
|
})
|
||||||
|
const consume = async (): Promise<void> => {
|
||||||
|
for await (const _event of runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'a431666e-5ec8-45e6-beb4-654132eed153',
|
||||||
|
conversationId: 'conversation-responses-incomplete-tools',
|
||||||
|
prompt: '读取 README',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'once'
|
||||||
|
)) {
|
||||||
|
void _event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(consume()).rejects.toThrow('流式响应意外中断')
|
||||||
|
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('continues OpenAI Responses with function_call_output', async () => {
|
it('continues OpenAI Responses with function_call_output', async () => {
|
||||||
const responses = [
|
const responses = [
|
||||||
{
|
{
|
||||||
@@ -1902,7 +2136,7 @@ describe('ModelAgentRuntime', () => {
|
|||||||
) as Record<string, unknown>
|
) as Record<string, unknown>
|
||||||
expect(firstBody).toMatchObject({
|
expect(firstBody).toMatchObject({
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
stream: false,
|
stream: true,
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@@ -2197,7 +2431,7 @@ describe('ModelAgentRuntime', () => {
|
|||||||
fetcher.mock.calls[0]?.[1]?.body as string
|
fetcher.mock.calls[0]?.[1]?.body as string
|
||||||
) as Record<string, unknown>
|
) as Record<string, unknown>
|
||||||
expect(firstBody).toMatchObject({
|
expect(firstBody).toMatchObject({
|
||||||
stream: false,
|
stream: true,
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
name: 'workspace_read_text',
|
name: 'workspace_read_text',
|
||||||
@@ -2233,6 +2467,372 @@ describe('ModelAgentRuntime', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('streams Anthropic text and thinking through tool rounds', async () => {
|
||||||
|
const streams = [
|
||||||
|
createSseEventStream([
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'message-stream-tool-1',
|
||||||
|
model: 'claude',
|
||||||
|
usage: { input_tokens: 10 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: 'thinking', thinking: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: { type: 'thinking_delta', thinking: '先分析。' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: { type: 'signature_delta', signature: 'signed' }
|
||||||
|
},
|
||||||
|
{ type: 'content_block_stop', index: 0 },
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 1,
|
||||||
|
content_block: { type: 'text', text: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 1,
|
||||||
|
delta: { type: 'text_delta', text: '准备读取。' }
|
||||||
|
},
|
||||||
|
{ type: 'content_block_stop', index: 1 },
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 2,
|
||||||
|
content_block: {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu-stream-1',
|
||||||
|
name: 'workspace_read_text',
|
||||||
|
input: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 2,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: '{"path":'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 2,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: '"notes.md"}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'content_block_stop', index: 2 },
|
||||||
|
{
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: { stop_reason: 'tool_use' },
|
||||||
|
usage: { output_tokens: 4 }
|
||||||
|
},
|
||||||
|
{ type: 'message_stop' }
|
||||||
|
]),
|
||||||
|
createSseEventStream([
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'message-stream-tool-2',
|
||||||
|
model: 'claude',
|
||||||
|
usage: { input_tokens: 18 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: 'text', text: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: { type: 'text_delta', text: '读取完成。' }
|
||||||
|
},
|
||||||
|
{ type: 'content_block_stop', index: 0 },
|
||||||
|
{
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: { stop_reason: 'end_turn' },
|
||||||
|
usage: { output_tokens: 5 }
|
||||||
|
},
|
||||||
|
{ type: 'message_stop' }
|
||||||
|
])
|
||||||
|
]
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(streams.shift(), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const toolProvider = createToolProvider()
|
||||||
|
const runtime = new ModelAgentRuntime({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude',
|
||||||
|
protocol: 'anthropic-messages',
|
||||||
|
authentication: 'api-key',
|
||||||
|
fetcher,
|
||||||
|
toolProvider
|
||||||
|
})
|
||||||
|
const events = []
|
||||||
|
|
||||||
|
for await (const event of runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'a431666e-5ec8-45e6-beb4-654132eed152',
|
||||||
|
conversationId: 'conversation-anthropic-streaming-tools',
|
||||||
|
prompt: '读取 notes',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'once'
|
||||||
|
)) {
|
||||||
|
events.push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, init] of fetcher.mock.calls) {
|
||||||
|
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.filter((event) => event.type === 'reasoning')
|
||||||
|
.map((event) => event.delta)
|
||||||
|
).toEqual(['先分析。'])
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.filter((event) => event.type === 'text')
|
||||||
|
.map((event) => event.delta)
|
||||||
|
).toEqual(['准备读取。', '读取完成。'])
|
||||||
|
expect(
|
||||||
|
events.findIndex((event) => event.type === 'text')
|
||||||
|
).toBeLessThan(
|
||||||
|
events.findIndex(
|
||||||
|
(event) =>
|
||||||
|
event.type === 'tool' && event.state === 'running'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const secondBody = JSON.parse(
|
||||||
|
fetcher.mock.calls[1]?.[1]?.body as string
|
||||||
|
) as { messages: Array<Record<string, unknown>> }
|
||||||
|
expect(secondBody.messages.at(-2)).toEqual({
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '先分析。',
|
||||||
|
signature: 'signed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '准备读取。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu-stream-1',
|
||||||
|
name: 'workspace_read_text',
|
||||||
|
input: { path: 'notes.md' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(secondBody.messages.at(-1)).toEqual({
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'toolu-stream-1',
|
||||||
|
content: [{ type: 'text', text: 'tool result' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(toolProvider.callTool).toHaveBeenCalledOnce()
|
||||||
|
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed streamed Anthropic tool arguments', async () => {
|
||||||
|
const stream = createSseEventStream([
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'message-invalid-tool-1',
|
||||||
|
model: 'claude',
|
||||||
|
usage: { input_tokens: 8 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu-invalid-1',
|
||||||
|
name: 'workspace_read_text',
|
||||||
|
input: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: '{"path":'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'content_block_stop', index: 0 },
|
||||||
|
{ type: 'message_stop' }
|
||||||
|
])
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const toolProvider = createToolProvider()
|
||||||
|
const runtime = new ModelAgentRuntime({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude',
|
||||||
|
protocol: 'anthropic-messages',
|
||||||
|
authentication: 'api-key',
|
||||||
|
fetcher,
|
||||||
|
toolProvider
|
||||||
|
})
|
||||||
|
const consume = async (): Promise<void> => {
|
||||||
|
for await (const _event of runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'a431666e-5ec8-45e6-beb4-654132eed154',
|
||||||
|
conversationId: 'conversation-anthropic-invalid-tools',
|
||||||
|
prompt: '读取 notes',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'once'
|
||||||
|
)) {
|
||||||
|
void _event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(consume()).rejects.toThrow(
|
||||||
|
'模型返回了无效的工具参数 JSON'
|
||||||
|
)
|
||||||
|
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a truncated streamed Anthropic tool round', async () => {
|
||||||
|
const stream = createSseEventStream([
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'message-truncated-stream-1',
|
||||||
|
model: 'claude',
|
||||||
|
usage: { input_tokens: 8 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: 'text', text: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: { type: 'text_delta', text: 'partial' }
|
||||||
|
},
|
||||||
|
{ type: 'content_block_stop', index: 0 },
|
||||||
|
{
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: { stop_reason: 'max_tokens' },
|
||||||
|
usage: { output_tokens: 4 }
|
||||||
|
},
|
||||||
|
{ type: 'message_stop' }
|
||||||
|
])
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const toolProvider = createToolProvider()
|
||||||
|
const runtime = new ModelAgentRuntime({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude',
|
||||||
|
protocol: 'anthropic-messages',
|
||||||
|
authentication: 'api-key',
|
||||||
|
fetcher,
|
||||||
|
toolProvider
|
||||||
|
})
|
||||||
|
const consume = async (): Promise<void> => {
|
||||||
|
for await (const _event of runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'a431666e-5ec8-45e6-beb4-654132eed155',
|
||||||
|
conversationId: 'conversation-anthropic-truncated-stream',
|
||||||
|
prompt: '读取 notes',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'once'
|
||||||
|
)) {
|
||||||
|
void _event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(consume()).rejects.toThrow(
|
||||||
|
'Anthropic 返回未完成结果:max_tokens'
|
||||||
|
)
|
||||||
|
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a truncated Anthropic JSON fallback', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
Response.json({
|
||||||
|
id: 'message-truncated-json-1',
|
||||||
|
model: 'claude',
|
||||||
|
content: [{ type: 'text', text: 'partial' }],
|
||||||
|
stop_reason: 'model_context_window_exceeded',
|
||||||
|
usage: { input_tokens: 8, output_tokens: 4 }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const toolProvider = createToolProvider()
|
||||||
|
const runtime = new ModelAgentRuntime({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude',
|
||||||
|
protocol: 'anthropic-messages',
|
||||||
|
authentication: 'api-key',
|
||||||
|
fetcher,
|
||||||
|
toolProvider
|
||||||
|
})
|
||||||
|
const consume = async (): Promise<void> => {
|
||||||
|
for await (const _event of runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'a431666e-5ec8-45e6-beb4-654132eed156',
|
||||||
|
conversationId: 'conversation-anthropic-truncated-json',
|
||||||
|
prompt: '读取 notes',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'once'
|
||||||
|
)) {
|
||||||
|
void _event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(consume()).rejects.toThrow(
|
||||||
|
'Anthropic 返回未完成结果:model_context_window_exceeded'
|
||||||
|
)
|
||||||
|
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('synthesizes and pairs a missing Anthropic tool_use id', async () => {
|
it('synthesizes and pairs a missing Anthropic tool_use id', async () => {
|
||||||
const responses = [
|
const responses = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -799,6 +799,20 @@ function parseModelToolResponse(
|
|||||||
throw new Error('模型接口返回格式无效')
|
throw new Error('模型接口返回格式无效')
|
||||||
}
|
}
|
||||||
if (protocol === 'anthropic') {
|
if (protocol === 'anthropic') {
|
||||||
|
const stopReason = payload.stop_reason
|
||||||
|
if (
|
||||||
|
stopReason !== undefined &&
|
||||||
|
stopReason !== null &&
|
||||||
|
typeof stopReason !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 模型接口返回了无效停止原因')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stopReason === 'max_tokens' ||
|
||||||
|
stopReason === 'model_context_window_exceeded'
|
||||||
|
) {
|
||||||
|
throw new Error(`Anthropic 返回未完成结果:${stopReason}`)
|
||||||
|
}
|
||||||
if (!Array.isArray(payload.content)) {
|
if (!Array.isArray(payload.content)) {
|
||||||
throw new Error('Anthropic 模型接口未返回 content')
|
throw new Error('Anthropic 模型接口未返回 content')
|
||||||
}
|
}
|
||||||
@@ -1124,6 +1138,349 @@ async function* readBoundedSseBlocks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function* readOpenAIResponsesToolStream(
|
||||||
|
response: Response,
|
||||||
|
requestId: string
|
||||||
|
): AsyncGenerator<RuntimeEvent, ModelToolResponse, void> {
|
||||||
|
let answer = ''
|
||||||
|
let reasoning = ''
|
||||||
|
const usage: ModelUsageAccumulator = {
|
||||||
|
reported: false
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const block of readBoundedSseBlocks(response)) {
|
||||||
|
const parsed = parseSseData(block)
|
||||||
|
if (parsed.stopped) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (parsed.event === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const providerError = getErrorMessage(parsed.event)
|
||||||
|
if (providerError) {
|
||||||
|
throw new Error(providerError)
|
||||||
|
}
|
||||||
|
const event = getRecord(parsed.event)
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('OpenAI Responses 返回了无效流式事件')
|
||||||
|
}
|
||||||
|
if (event.type === 'response.failed') {
|
||||||
|
const failedResponse = getRecord(event.response)
|
||||||
|
throw new Error(
|
||||||
|
getErrorMessage(failedResponse) ?? 'OpenAI Responses 请求失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (event.type === 'response.incomplete') {
|
||||||
|
const incompleteResponse = getRecord(event.response)
|
||||||
|
const details = getRecord(incompleteResponse?.incomplete_details)
|
||||||
|
const reason =
|
||||||
|
typeof details?.reason === 'string'
|
||||||
|
? `:${details.reason.slice(0, 200)}`
|
||||||
|
: ''
|
||||||
|
throw new Error(`OpenAI Responses 返回未完成结果${reason}`)
|
||||||
|
}
|
||||||
|
applyUsageUpdate(usage, getUsageUpdate(event, 'openai'))
|
||||||
|
|
||||||
|
const reasoningDelta = getOpenAIResponsesReasoningDelta(event)
|
||||||
|
if (reasoningDelta) {
|
||||||
|
reasoning += reasoningDelta
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'reasoning',
|
||||||
|
delta: reasoningDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const textDelta = getOpenAIResponsesTextDelta(event)
|
||||||
|
if (textDelta) {
|
||||||
|
answer += textDelta
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: textDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type !== 'response.completed') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedResponse = getRecord(event.response)
|
||||||
|
const result = parseModelToolResponse(
|
||||||
|
completedResponse,
|
||||||
|
'openai-responses'
|
||||||
|
)
|
||||||
|
applyUsageUpdate(usage, result.usage)
|
||||||
|
if (result.reasoning !== reasoning) {
|
||||||
|
if (!result.reasoning.startsWith(reasoning)) {
|
||||||
|
throw new Error(
|
||||||
|
'OpenAI Responses 流式推理与完成结果不一致'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const remainingReasoning = result.reasoning.slice(reasoning.length)
|
||||||
|
if (remainingReasoning) {
|
||||||
|
reasoning += remainingReasoning
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'reasoning',
|
||||||
|
delta: remainingReasoning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.text !== answer) {
|
||||||
|
if (!result.text.startsWith(answer)) {
|
||||||
|
throw new Error('OpenAI Responses 流式文本与完成结果不一致')
|
||||||
|
}
|
||||||
|
const remainingText = result.text.slice(answer.length)
|
||||||
|
if (remainingText) {
|
||||||
|
answer += remainingText
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: remainingText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
text: answer,
|
||||||
|
reasoning,
|
||||||
|
usage,
|
||||||
|
streamed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('模型接口流式响应意外中断')
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnthropicStreamBlock = {
|
||||||
|
content: Record<string, unknown>
|
||||||
|
initialInput?: unknown
|
||||||
|
kind: 'other' | 'text' | 'thinking' | 'tool_use'
|
||||||
|
open: boolean
|
||||||
|
partialJson: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnthropicStreamIndex(
|
||||||
|
event: Record<string, unknown>
|
||||||
|
): number {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(event.index) ||
|
||||||
|
(event.index as number) < 0
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式内容块序号')
|
||||||
|
}
|
||||||
|
return event.index as number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* readAnthropicToolStream(
|
||||||
|
response: Response,
|
||||||
|
requestId: string
|
||||||
|
): AsyncGenerator<RuntimeEvent, ModelToolResponse, void> {
|
||||||
|
const blocks = new Map<number, AnthropicStreamBlock>()
|
||||||
|
const usage: ModelUsageAccumulator = {
|
||||||
|
reported: false
|
||||||
|
}
|
||||||
|
let answer = ''
|
||||||
|
let reasoning = ''
|
||||||
|
let stopReason: unknown
|
||||||
|
let toolCallCount = 0
|
||||||
|
|
||||||
|
for await (const block of readBoundedSseBlocks(response)) {
|
||||||
|
const parsed = parseSseData(block)
|
||||||
|
if (parsed.stopped) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (parsed.event === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const providerError = getErrorMessage(parsed.event)
|
||||||
|
if (providerError) {
|
||||||
|
throw new Error(providerError)
|
||||||
|
}
|
||||||
|
const event = getRecord(parsed.event)
|
||||||
|
if (!event || typeof event.type !== 'string') {
|
||||||
|
throw new Error('Anthropic 返回了无效流式事件')
|
||||||
|
}
|
||||||
|
applyUsageUpdate(usage, getUsageUpdate(event, 'anthropic'))
|
||||||
|
|
||||||
|
if (event.type === 'message_delta') {
|
||||||
|
const delta = getRecord(event.delta)
|
||||||
|
if (delta?.stop_reason !== undefined) {
|
||||||
|
stopReason = delta.stop_reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'content_block_start') {
|
||||||
|
const index = getAnthropicStreamIndex(event)
|
||||||
|
const content = getRecord(event.content_block)
|
||||||
|
if (!content || blocks.has(index)) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式内容块')
|
||||||
|
}
|
||||||
|
const next: AnthropicStreamBlock = {
|
||||||
|
content: { ...content },
|
||||||
|
kind: 'other',
|
||||||
|
open: true,
|
||||||
|
partialJson: ''
|
||||||
|
}
|
||||||
|
if (content.type === 'text') {
|
||||||
|
if (
|
||||||
|
content.text !== undefined &&
|
||||||
|
typeof content.text !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式文本块')
|
||||||
|
}
|
||||||
|
const text = typeof content.text === 'string' ? content.text : ''
|
||||||
|
next.kind = 'text'
|
||||||
|
next.content.text = text
|
||||||
|
if (text) {
|
||||||
|
answer += text
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (content.type === 'thinking') {
|
||||||
|
if (
|
||||||
|
content.thinking !== undefined &&
|
||||||
|
typeof content.thinking !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式推理块')
|
||||||
|
}
|
||||||
|
const thinking =
|
||||||
|
typeof content.thinking === 'string' ? content.thinking : ''
|
||||||
|
next.kind = 'thinking'
|
||||||
|
next.content.thinking = thinking
|
||||||
|
if (thinking) {
|
||||||
|
reasoning += thinking
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'reasoning',
|
||||||
|
delta: thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (content.type === 'tool_use') {
|
||||||
|
toolCallCount += 1
|
||||||
|
if (toolCallCount > maxToolCallsPerRun) {
|
||||||
|
throw new Error('模型单轮工具调用超过安全限制')
|
||||||
|
}
|
||||||
|
const identity = parseToolCallIdentity(content.id, content.name)
|
||||||
|
next.kind = 'tool_use'
|
||||||
|
next.initialInput = content.input
|
||||||
|
next.content.id = identity.id
|
||||||
|
next.content.name = identity.name
|
||||||
|
next.content.input = {}
|
||||||
|
}
|
||||||
|
blocks.set(index, next)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'content_block_delta') {
|
||||||
|
const index = getAnthropicStreamIndex(event)
|
||||||
|
const current = blocks.get(index)
|
||||||
|
const delta = getRecord(event.delta)
|
||||||
|
if (!current?.open || !delta || typeof delta.type !== 'string') {
|
||||||
|
throw new Error('Anthropic 返回了无效流式内容增量')
|
||||||
|
}
|
||||||
|
if (delta.type === 'text_delta') {
|
||||||
|
if (current.kind !== 'text' || typeof delta.text !== 'string') {
|
||||||
|
throw new Error('Anthropic 返回了无效流式文本增量')
|
||||||
|
}
|
||||||
|
current.content.text =
|
||||||
|
`${current.content.text as string}${delta.text}`
|
||||||
|
if (delta.text) {
|
||||||
|
answer += delta.text
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: delta.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (delta.type === 'thinking_delta') {
|
||||||
|
if (
|
||||||
|
current.kind !== 'thinking' ||
|
||||||
|
typeof delta.thinking !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式推理增量')
|
||||||
|
}
|
||||||
|
current.content.thinking =
|
||||||
|
`${current.content.thinking as string}${delta.thinking}`
|
||||||
|
if (delta.thinking) {
|
||||||
|
reasoning += delta.thinking
|
||||||
|
yield {
|
||||||
|
requestId,
|
||||||
|
type: 'reasoning',
|
||||||
|
delta: delta.thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (delta.type === 'signature_delta') {
|
||||||
|
if (
|
||||||
|
current.kind !== 'thinking' ||
|
||||||
|
typeof delta.signature !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式签名增量')
|
||||||
|
}
|
||||||
|
current.content.signature =
|
||||||
|
`${typeof current.content.signature === 'string'
|
||||||
|
? current.content.signature
|
||||||
|
: ''}${delta.signature}`
|
||||||
|
} else if (delta.type === 'input_json_delta') {
|
||||||
|
if (
|
||||||
|
current.kind !== 'tool_use' ||
|
||||||
|
typeof delta.partial_json !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式工具参数增量')
|
||||||
|
}
|
||||||
|
current.partialJson += delta.partial_json
|
||||||
|
if (
|
||||||
|
Buffer.byteLength(current.partialJson) >
|
||||||
|
maxToolArgumentBytes
|
||||||
|
) {
|
||||||
|
throw new Error('模型工具参数超过 128KB 安全限制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'content_block_stop') {
|
||||||
|
const index = getAnthropicStreamIndex(event)
|
||||||
|
const current = blocks.get(index)
|
||||||
|
if (!current?.open) {
|
||||||
|
throw new Error('Anthropic 返回了无效流式内容结束事件')
|
||||||
|
}
|
||||||
|
current.open = false
|
||||||
|
if (current.kind === 'tool_use') {
|
||||||
|
current.content.input = parseToolArguments(
|
||||||
|
current.partialJson || current.initialInput || {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== 'message_stop') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ([...blocks.values()].some((item) => item.open)) {
|
||||||
|
throw new Error('Anthropic 流式响应包含未结束的内容块')
|
||||||
|
}
|
||||||
|
const content = [...blocks.entries()]
|
||||||
|
.sort(([left], [right]) => left - right)
|
||||||
|
.map(([, item]) => item.content)
|
||||||
|
const result = parseModelToolResponse(
|
||||||
|
{ content, stop_reason: stopReason },
|
||||||
|
'anthropic'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
text: answer,
|
||||||
|
reasoning,
|
||||||
|
usage,
|
||||||
|
streamed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('模型接口流式响应意外中断')
|
||||||
|
}
|
||||||
|
|
||||||
export class ModelAgentRuntime implements AgentRuntime {
|
export class ModelAgentRuntime implements AgentRuntime {
|
||||||
readonly runtimeId = 'model'
|
readonly runtimeId = 'model'
|
||||||
readonly requiresToolApproval = false
|
readonly requiresToolApproval = false
|
||||||
@@ -1784,7 +2141,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
? {
|
? {
|
||||||
model: this.options.model,
|
model: this.options.model,
|
||||||
max_output_tokens: this.maxOutputTokens,
|
max_output_tokens: this.maxOutputTokens,
|
||||||
stream: false,
|
stream: true,
|
||||||
instructions: system,
|
instructions: system,
|
||||||
input: messages,
|
input: messages,
|
||||||
tools: providerTools
|
tools: providerTools
|
||||||
@@ -1793,7 +2150,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
? {
|
? {
|
||||||
model: this.options.model,
|
model: this.options.model,
|
||||||
max_tokens: this.maxOutputTokens,
|
max_tokens: this.maxOutputTokens,
|
||||||
stream: false,
|
stream: true,
|
||||||
system,
|
system,
|
||||||
messages,
|
messages,
|
||||||
tools: providerTools
|
tools: providerTools
|
||||||
@@ -1843,12 +2200,22 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
detail ?? `模型接口请求失败(HTTP ${response.status})`
|
detail ?? `模型接口请求失败(HTTP ${response.status})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
const isEventStream = response.headers
|
||||||
streamOpenAIChat &&
|
|
||||||
response.headers
|
|
||||||
.get('content-type')
|
.get('content-type')
|
||||||
?.toLocaleLowerCase()
|
?.toLocaleLowerCase()
|
||||||
.includes('text/event-stream')
|
.includes('text/event-stream') === true
|
||||||
|
if (responses && isEventStream) {
|
||||||
|
return yield* readOpenAIResponsesToolStream(
|
||||||
|
response,
|
||||||
|
requestId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (anthropic && isEventStream) {
|
||||||
|
return yield* readAnthropicToolStream(response, requestId)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
streamOpenAIChat &&
|
||||||
|
isEventStream
|
||||||
) {
|
) {
|
||||||
const streamedToolCalls = new Map<
|
const streamedToolCalls = new Map<
|
||||||
number,
|
number,
|
||||||
|
|||||||
Reference in New Issue
Block a user