import { describe, expect, it, vi } from 'vitest' import type { ModelToolDefinition, ModelToolProviderLike } from './model-tool-provider' import { ModelAgentRuntime } from './model-runtime' function createEventStream(text: string): string { return [ 'event: message_start', `data: ${JSON.stringify({ type: 'message_start', message: { id: 'message-1', model: 'claude-sonnet-provider', usage: { input_tokens: 23, cache_creation_input_tokens: 5, cache_read_input_tokens: 7 } } })}`, '', 'event: content_block_delta', `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}`, '', 'event: message_delta', `data: ${JSON.stringify({ type: 'message_delta', usage: { output_tokens: 11 } })}`, '', 'event: message_stop', 'data: {"type":"message_stop"}', '', '' ].join('\n') } function createResponsesEventStream(text: string): string { return [ 'event: response.output_text.delta', `data: ${JSON.stringify({ type: 'response.output_text.delta', delta: text })}`, '', 'event: response.completed', `data: ${JSON.stringify({ type: 'response.completed', response: { id: 'resp-provider-1', model: 'gpt-5-provider', usage: { input_tokens: 29, output_tokens: 8, total_tokens: 37, input_tokens_details: { cached_tokens: 11 } } } })}`, '', '' ].join('\n') } function createToolProvider( overrides: Partial = {} ): ModelToolProviderLike { const tool: ModelToolDefinition = { name: 'workspace_read_text', displayName: '读取工作区文本', description: 'Read text', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, source: 'builtin' } return { listTools: vi.fn(async () => [tool]), getApproval: vi.fn((_definition, _arguments, summary) => ({ scopeKey: 'model:builtin:workspace_read_text', title: '允许读取工作区文本?', description: '读取文件', toolName: '读取工作区文本', argumentSummary: summary })), callTool: vi.fn(async () => 'tool result'), dispose: vi.fn(async () => {}), ...overrides } } describe('ModelAgentRuntime', () => { it('performs a real minimal request when testing the connection', async () => { const fetcher = vi.fn(async () => Response.json({ content: [{ type: 'text', text: 'OK' }] }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', fetcher }) await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, id: 'model' }) const body = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as { max_tokens: number; stream: boolean } expect(body).toMatchObject({ max_tokens: 1, stream: false }) }) it('uses the Anthropic messages endpoint and streams text deltas', async () => { const fetcher = vi.fn(async () => { return new Response(createEventStream('真实模型回答'), { status: 200, headers: { 'content-type': 'text/event-stream' } }) }) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', skillInstructions: '# 文档写作', fetcher }) const events: Array<{ type: string; state?: string }> = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed125', conversationId: 'conversation-1', prompt: '你好' }, new AbortController().signal )) { events.push(event) } expect(fetcher).toHaveBeenCalledOnce() const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe('https://bigtoken.ai/v1/messages') expect(init?.method).toBe('POST') const body = JSON.parse(init?.body as string) as { model: string stream: boolean system: string } expect(body).toMatchObject({ model: 'sonnet-5', stream: true }) expect(body.system).toContain('# 文档写作') expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '真实模型回答' }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed125', type: 'model-usage', callId: 'message-1', runtime: 'model', provider: 'anthropic', model: 'claude-sonnet-provider', inputTokens: 23, outputTokens: 11, cacheReadTokens: 7, cacheWriteTokens: 5 } ]) expect(events.at(-2)).toMatchObject({ type: 'model-usage' }) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('rejects a stream that ends without message_stop', async () => { const fetcher = vi.fn(async () => { return new Response( 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}', { status: 200, headers: { 'content-type': 'text/event-stream' } } ) }) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', fetcher }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed126', conversationId: 'conversation-2', prompt: '你好' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow('意外中断') }) it('redacts credentials from provider error messages', async () => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'claude-sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', fetcher: vi.fn(async () => Response.json( { error: { message: 'upstream failed Authorization: Bearer secret-token' } }, { status: 502 } ) ) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: 'test' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow( 'upstream failed Authorization: [REDACTED]' ) }) it('uses OpenAI Chat Completions SSE and omits auth for Ollama', async () => { const stream = [ `data: ${JSON.stringify({ choices: [{ delta: { content: '本机回答' } }] })}`, '', `data: ${JSON.stringify({ id: 'chatcmpl-provider-1', model: 'qwen3-provider', choices: [], usage: { prompt_tokens: 31, completion_tokens: 9, total_tokens: 40, prompt_tokens_details: { cached_tokens: 13 }, cache_write_tokens: 4 } })}`, '', 'data: [DONE]', '', '' ].join('\n') const fetcher = vi.fn(async () => new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }) ) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed127', conversationId: 'conversation-3', prompt: '你好' }, new AbortController().signal )) { events.push(event) } const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe( 'http://127.0.0.1:11434/v1/chat/completions' ) expect(init?.headers).toEqual({ 'content-type': 'application/json' }) expect(JSON.parse(init?.body as string)).toMatchObject({ model: 'qwen3', stream: true, stream_options: { include_usage: true }, messages: [ expect.objectContaining({ role: 'system' }), expect.objectContaining({ role: 'user', content: '你好' }) ] }) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '本机回答' }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed127', type: 'model-usage', callId: 'chatcmpl-provider-1', runtime: 'model', provider: 'openai', model: 'qwen3-provider', inputTokens: 31, outputTokens: 9, cacheReadTokens: 13, cacheWriteTokens: 4, reportedTotalTokens: 40 } ]) expect(events.at(-2)).toMatchObject({ type: 'model-usage' }) expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(toolProvider.listTools).not.toHaveBeenCalled() }) it('uses the OpenAI Responses endpoint and streams output text', async () => { const fetcher = vi.fn(async () => new Response(createResponsesEventStream('Responses 回答'), { status: 200, headers: { 'content-type': 'text/event-stream' } }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-5', protocol: 'openai-responses', authentication: 'api-key', fetcher }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed133', conversationId: 'conversation-responses', prompt: '你好' }, new AbortController().signal )) { events.push(event) } const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe('https://api.openai.com/v1/responses') expect(init?.headers).toEqual({ authorization: 'Bearer test-key', 'content-type': 'application/json' }) expect(JSON.parse(init?.body as string)).toMatchObject({ model: 'gpt-5', max_output_tokens: 4096, stream: true, instructions: expect.stringContaining('GoodBuddy'), input: [ expect.objectContaining({ role: 'user', content: '你好' }) ] }) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: 'Responses 回答' }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed133', type: 'model-usage', callId: 'resp-provider-1', runtime: 'model', provider: 'openai', model: 'gpt-5-provider', inputTokens: 29, outputTokens: 8, cacheReadTokens: 11, cacheWriteTokens: 0, reportedTotalTokens: 37 } ]) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('tests an OpenAI Responses connection with Responses request fields', async () => { const fetcher = vi.fn(async () => Response.json({ id: 'resp-test', output: [] }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1/', model: 'gpt-5', protocol: 'openai-responses', authentication: 'api-key', fetcher }) await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, detail: expect.stringContaining('已验证') }) expect(fetcher.mock.calls[0]?.[0]?.toString()).toBe( 'https://api.openai.com/v1/responses' ) expect( JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string) ).toEqual({ model: 'gpt-5', max_output_tokens: 16, stream: false, input: 'Reply OK.' }) }) it('runs approved direct-model tools and returns their results to OpenAI', async () => { const responses = [ { id: 'chatcmpl-tool-1', model: 'qwen3', choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-1', type: 'function', function: { name: 'workspace_read_text', arguments: '{"path":"README.md"}' } } ] } } ], usage: { prompt_tokens: 10, completion_tokens: 4 } }, { id: 'chatcmpl-tool-2', model: 'qwen3', choices: [ { message: { role: 'assistant', content: '文件内容已读取。' } } ], usage: { prompt_tokens: 18, completion_tokens: 7 } } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const authorize = vi.fn(async () => 'once' as const) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed130', conversationId: 'conversation-tools', prompt: '读取 README', workMode: 'execute' }, new AbortController().signal, authorize )) { events.push(event) } expect(fetcher).toHaveBeenCalledTimes(2) const firstBody = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as Record expect(firstBody).toMatchObject({ stream: false, tools: [ { type: 'function', function: { name: 'workspace_read_text' } } ] }) const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as { messages: Array> } expect(secondBody.messages).toContainEqual({ role: 'tool', tool_call_id: 'call-1', content: 'tool result' }) expect(authorize).toHaveBeenCalledWith( expect.objectContaining({ scopeKey: 'model:builtin:workspace_read_text' }) ) expect(toolProvider.callTool).toHaveBeenCalledWith( 'workspace_read_text', { path: 'README.md' }, expect.any(AbortSignal) ) expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'running', 'completed']) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '文件内容已读取。' }) ) expect(events.at(-1)).toMatchObject({ type: 'done' }) await runtime.dispose() expect(toolProvider.dispose).toHaveBeenCalledOnce() }) it('continues OpenAI Responses with function_call_output', async () => { const responses = [ { id: 'resp-tool-1', model: 'gpt-5', output: [ { type: 'function_call', call_id: 'call-responses-1', name: 'workspace_read_text', arguments: '{"path":"README.md"}' } ], usage: { input_tokens: 14, output_tokens: 3 } }, { id: 'resp-tool-2', model: 'gpt-5', output: [ { type: 'message', role: 'assistant', content: [ { type: 'output_text', text: 'Responses 工具调用完成。' } ] } ], usage: { input_tokens: 21, output_tokens: 6 } } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-5', protocol: 'openai-responses', authentication: 'api-key', fetcher, toolProvider: createToolProvider() }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed134', conversationId: 'conversation-responses-tools', prompt: '读取 README', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { events.push(event) } const firstBody = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as Record expect(firstBody).toMatchObject({ model: 'gpt-5', stream: false, tools: [ { type: 'function', name: 'workspace_read_text', strict: false } ] }) const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as Record expect(secondBody).toMatchObject({ previous_response_id: 'resp-tool-1', input: [ { type: 'function_call_output', call_id: 'call-responses-1', output: 'tool result' } ] }) expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'running', 'completed']) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: 'Responses 工具调用完成。' }) ) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('fails closed when a direct-model tool is denied', async () => { const fetcher = vi.fn(async () => Response.json({ choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-denied', type: 'function', function: { name: 'workspace_read_text', arguments: '{"path":"secret.txt"}' } } ] } } ] }) ) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const events: Array<{ type: string; state?: string }> = [] const consume = async (): Promise => { for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed131', conversationId: 'conversation-denied', prompt: '读取 secret', workMode: 'execute' }, new AbortController().signal, async () => 'deny' )) { events.push(event) } } await expect(consume()).rejects.toThrow('用户拒绝') expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'failed']) expect(toolProvider.callTool).not.toHaveBeenCalled() }) it('uses Anthropic tool_use and tool_result messages in Execute mode', async () => { const responses = [ { id: 'message-tool-1', model: 'claude', content: [ { type: 'tool_use', id: 'toolu-1', name: 'workspace_read_text', input: { path: 'notes.md' } } ], usage: { input_tokens: 12, output_tokens: 3 } }, { id: 'message-tool-2', model: 'claude', content: [{ type: 'text', text: '读取完成。' }], usage: { input_tokens: 20, output_tokens: 5 } } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'claude', protocol: 'anthropic-messages', authentication: 'api-key', fetcher, toolProvider: createToolProvider() }) for await (const _event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed132', conversationId: 'conversation-anthropic-tools', prompt: '读取 notes', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { void _event } const firstBody = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as Record expect(firstBody).toMatchObject({ stream: false, tools: [ { name: 'workspace_read_text', input_schema: expect.objectContaining({ type: 'object' }) } ] }) const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as { messages: Array> } expect(secondBody.messages.at(-1)).toEqual({ role: 'user', content: [ { type: 'tool_result', tool_use_id: 'toolu-1', content: 'tool result' } ] }) }) it('generates a bounded image through the BigToken-compatible endpoint', async () => { const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00 ]).toString('base64') const fetcher = vi.fn(async () => Response.json({ id: 'image-provider-1', model: 'gpt-image-provider', usage: { input_tokens: 17, output_tokens: 29, total_tokens: 46 }, data: [{ b64_json: png }] }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed128', conversationId: 'conversation-image', prompt: '一只在窗边睡觉的猫' }, new AbortController().signal )) { events.push(event) } const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe( 'https://bigtoken.ai/v1/images/generations' ) expect(init?.headers).toEqual({ authorization: 'Bearer test-key', 'content-type': 'application/json' }) expect(JSON.parse(init?.body as string)).toEqual({ model: 'gpt-image-2', prompt: '一只在窗边睡觉的猫', n: 1, response_format: 'b64_json' }) expect(events).toContainEqual( expect.objectContaining({ type: 'generated-image', mimeType: 'image/png', data: png }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed128', type: 'model-usage', callId: 'image-provider-1', runtime: 'model', provider: 'openai', model: 'gpt-image-provider', inputTokens: 17, outputTokens: 29, cacheReadTokens: 0, cacheWriteTokens: 0, reportedTotalTokens: 46 } ]) expect(events.findIndex((event) => event.type === 'model-usage')).toBeLessThan( events.findIndex((event) => event.type === 'generated-image') ) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('rejects remote image URLs instead of fetching provider output', async () => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher: vi.fn(async () => Response.json({ data: [{ url: 'https://untrusted.example/image.png' }] }) ) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed129', conversationId: 'conversation-image-url', prompt: '测试图片' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow('未返回 base64 图片') }) it('accepts a bounded inline image data URL from compatible gateways', async () => { const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00 ]).toString('base64') const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher: vi.fn(async () => Response.json({ data: [{ url: `data:image/png;base64,${png}` }] }) ) }) const events = [] for await (const event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: '测试内联图片' }, new AbortController().signal )) { events.push(event) } expect(events).toContainEqual( expect.objectContaining({ type: 'generated-image', mimeType: 'image/png', data: png }) ) }) it.each([ { body: JSON.stringify({ error: { message: 'upstream unavailable Authorization: Bearer secret-token' } }), headers: { 'content-type': 'application/json', 'x-request-id': 'image-request-502' }, expected: 'upstream unavailable Authorization: [REDACTED](HTTP 502,请求 ID image-request-502)' }, { body: 'Bad Gateway', headers: { 'content-type': 'text/html' }, expected: '图像生成请求失败(HTTP 502)' }, { body: JSON.stringify({ error: '模型接口请求失败(HTTP 502)' }), headers: { 'content-type': 'application/json' }, expected: '上游图像服务暂时不可用,请稍后重试或联系服务商(HTTP 502)' } ])( 'retains HTTP status for image gateway failures', async ({ body, headers, expected }) => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher: vi.fn(async () => new Response(body, { status: 502, headers }) ) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: '测试网关错误' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow(expected) } ) it.runIf( process.env.GOODBUDDY_BIGTOKEN_IMAGE_INTEGRATION === '1' )( 'generates a real synthetic image with BigToken gpt-image-2', async () => { const apiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY if (!apiKey) { throw new Error('GOODBUDDY_BIGTOKEN_API_KEY is required') } const runtime = new ModelAgentRuntime({ apiKey, baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key' }) const events = [] for await (const event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: 'A simple solid blue circle centered on a plain white background.' }, new AbortController().signal )) { events.push(event) } expect(events).toContainEqual( expect.objectContaining({ type: 'generated-image', mimeType: expect.stringMatching(/^image\//u) }) ) await runtime.dispose() }, 180_000 ) })