Files
goodbuddy/src/main/agent/model-runtime.test.ts
T

1050 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}
): 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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<void> => {
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<typeof fetch>(async () =>
Response.json(
{
error: {
message:
'upstream failed Authorization: Bearer secret-token'
}
},
{ status: 502 }
)
)
})
const consume = async (): Promise<void> => {
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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<string, unknown>
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<Record<string, unknown>> }
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<typeof fetch>(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<string, unknown>
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<string, unknown>
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<typeof fetch>(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<void> => {
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<typeof fetch>(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<string, unknown>
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<Record<string, unknown>> }
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<typeof fetch>(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<typeof fetch>(async () =>
Response.json({
data: [{ url: 'https://untrusted.example/image.png' }]
})
)
})
const consume = async (): Promise<void> => {
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<typeof fetch>(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: '<html>Bad Gateway</html>',
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<typeof fetch>(async () =>
new Response(body, { status: 502, headers })
)
})
const consume = async (): Promise<void> => {
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
)
})