Spaces:
Sleeping
Sleeping
| import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; | |
| import type { Express } from 'express'; | |
| import { createApp } from '../../app.js'; | |
| import { initDb, getDb, getUnifiedApiKey } from '../../db/index.js'; | |
| import { mintDashboardToken, isGatedApiPath } from '../helpers/auth.js'; | |
| let dashToken = ''; | |
| async function request(app: Express, method: string, path: string, body?: any, headers: Record<string, string> = {}) { | |
| const server = app.listen(0); | |
| const addr = server.address() as any; | |
| const url = `http://127.0.0.1:${addr.port}${path}`; | |
| const res = await fetch(url, { | |
| method, | |
| headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(isGatedApiPath(path) && !('Authorization' in headers) ? { Authorization: `Bearer ${dashToken}` } : {}), ...headers }, | |
| body: body ? JSON.stringify(body) : undefined, | |
| }); | |
| const data = await res.text(); | |
| server.close(); | |
| let json: any = null; | |
| try { json = JSON.parse(data); } catch {} | |
| return { status: res.status, body: json }; | |
| } | |
| function authHeaders() { | |
| return { Authorization: `Bearer ${getUnifiedApiKey()}` }; | |
| } | |
| describe('OpenAI multimodal array content', () => { | |
| let app: Express; | |
| beforeAll(() => { | |
| process.env.ENCRYPTION_KEY = '0'.repeat(64); | |
| initDb(':memory:'); | |
| app = createApp(); | |
| dashToken = mintDashboardToken(); | |
| }); | |
| beforeEach(async () => { | |
| const db = getDb(); | |
| db.prepare('DELETE FROM api_keys').run(); | |
| db.prepare('DELETE FROM requests').run(); | |
| const addKey = await request(app, 'POST', '/api/keys', { | |
| platform: 'groq', | |
| key: 'gsk_array_content_test', | |
| label: 'array-content', | |
| }); | |
| expect(addKey.status).toBe(201); | |
| }); | |
| afterEach(() => { | |
| vi.restoreAllMocks(); | |
| }); | |
| it('accepts content as a string (the legacy shape)', async () => { | |
| // Provider call will fail (no real key), but schema validation must pass — | |
| // we assert it isn't rejected with a 400 zod error. | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [{ role: 'user', content: 'hello' }], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) { | |
| // Diagnostic if regression: show the validation error. | |
| throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| } | |
| }); | |
| it('accepts content as a text-only multimodal array', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [{ | |
| role: 'user', | |
| content: [{ type: 'text', text: 'hello from opencode-style client' }], | |
| }], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) { | |
| throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| } | |
| }); | |
| it('accepts mixed text + image_url blocks (image blocks are silently dropped)', async () => { | |
| const { status } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [{ | |
| role: 'user', | |
| content: [ | |
| { type: 'text', text: 'describe' }, | |
| { type: 'image_url', image_url: { url: 'https://example.com/x.png' } }, | |
| ], | |
| }], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| }); | |
| it('successfully routes an array-content request and gets a 200 (mocked groq)', async () => { | |
| const origFetch = global.fetch; | |
| vi.spyOn(global, 'fetch').mockImplementation(async (url, init) => { | |
| const urlStr = typeof url === 'string' ? url : url.toString(); | |
| if (urlStr.includes('api.groq.com/openai/v1/chat/completions')) { | |
| // Sanity check that the array shape made it through to the upstream call. | |
| const body = JSON.parse(String((init as RequestInit).body)); | |
| expect(Array.isArray(body.messages[0].content)).toBe(true); | |
| return { | |
| ok: true, | |
| json: () => Promise.resolve({ | |
| id: 'chatcmpl-array', object: 'chat.completion', created: 1, model: 'openai/gpt-oss-120b', | |
| choices: [{ | |
| index: 0, | |
| message: { role: 'assistant', content: 'got it' }, | |
| finish_reason: 'stop', | |
| }], | |
| usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, | |
| }), | |
| } as any; | |
| } | |
| return origFetch(url, init); | |
| }); | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| model: 'auto', | |
| messages: [{ | |
| role: 'user', | |
| content: [{ type: 'text', text: 'hi' }], | |
| }], | |
| }, authHeaders()); | |
| expect(status).toBe(200); | |
| expect(body.choices[0].message.content).toBe('got it'); | |
| }); | |
| it('rejects an empty array as missing content', async () => { | |
| const { status } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [], // top-level empty messages | |
| }, authHeaders()); | |
| expect(status).toBe(400); | |
| }); | |
| it('accepts an assistant message with empty content and no tool_calls (#165)', async () => { | |
| // OpenAI accepts empty/null assistant turns in history; we coerce to "" and | |
| // forward rather than 400-ing a payload OpenAI would take. The request then | |
| // routes (and fails downstream on the fake key) — the point is it is NOT a | |
| // 400 schema rejection. | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'user', content: 'hi' }, | |
| { role: 'assistant', content: [] }, | |
| { role: 'user', content: 'continue' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| // #200: code agents (AionUI, OpenCode, Qwen Code) fail on the SECOND request | |
| // of a session because their follow-up history carries shapes the strict | |
| // schema rejected. Each case below is a real second-turn payload pattern. | |
| describe('agent second-turn shapes (#200)', () => { | |
| it('accepts Gemini-part-style content blocks without a type field', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'user', content: [{ text: 'hi' }] }, | |
| { role: 'assistant', content: [{ text: 'hello!' }] }, | |
| { role: 'user', content: [{ text: 'ok' }] }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts echoed tool_calls without type and with object arguments', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'user', content: 'list files' }, | |
| { | |
| role: 'assistant', | |
| content: null, | |
| // type dropped + arguments already parsed to an object — the way | |
| // Gemini-lineage agents replay our tool_calls back at us. | |
| tool_calls: [{ id: 'call_1', function: { name: 'ls', arguments: { path: '.' } } }], | |
| }, | |
| { role: 'tool', tool_call_id: 'call_1', content: 'file1.ts' }, | |
| { role: 'user', content: 'thanks' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts the developer role (newer OpenAI SDK system prompt)', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'developer', content: 'You are a coding agent.' }, | |
| { role: 'user', content: 'hi' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('reports the failing path in 400 validation errors', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [{ role: 'user', content: 42 }], | |
| }, authHeaders()); | |
| expect(status).toBe(400); | |
| // Path-qualified detail ("messages.0...") instead of a bare "Invalid input" | |
| expect(body.error.message).toMatch(/messages\.0/); | |
| }); | |
| it('accepts tool_calls with missing or empty ids and pairs tool results by order', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'user', content: 'list files' }, | |
| { | |
| role: 'assistant', | |
| content: null, | |
| // Gemini-lineage: no id at all on the first, empty id on the second. | |
| tool_calls: [ | |
| { function: { name: 'ls', arguments: '{}' } }, | |
| { id: '', function: { name: 'pwd', arguments: '{}' } }, | |
| ], | |
| }, | |
| { role: 'tool', content: 'file1.ts' }, | |
| { role: 'tool', tool_call_id: '', content: '/home' }, | |
| { role: 'user', content: 'thanks' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts a tool message with null content (tool returned nothing)', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'user', content: 'do it' }, | |
| { role: 'assistant', content: null, tool_calls: [{ id: 'c1', function: { name: 'noop', arguments: '{}' } }] }, | |
| { role: 'tool', tool_call_id: 'c1', content: null }, | |
| { role: 'user', content: 'ok' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts the legacy function role in history', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| messages: [ | |
| { role: 'user', content: 'weather?' }, | |
| { role: 'function', name: 'get_weather', content: '{"temp": 20}' }, | |
| { role: 'user', content: 'thanks' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts max_tokens <= 0 (treated as no limit)', async () => { | |
| for (const value of [-1, 0]) { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| max_tokens: value, | |
| messages: [{ role: 'user', content: 'hi' }], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400 for max_tokens ${value}: ${JSON.stringify(body)}`); | |
| } | |
| }); | |
| it('accepts tool_choice "any" and tools without a type field', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| tool_choice: 'any', | |
| tools: [{ function: { name: 'ls', parameters: { type: 'object', properties: {} } } }], | |
| messages: [{ role: 'user', content: 'hi' }], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts an assistant echo with tool_calls: null (aionrs/AionUI session replay)', async () => { | |
| // The exact second-turn shape captured from aionrs v0.1.28 (AionUI's | |
| // engine): resumed sessions replay the assistant turn with an explicit | |
| // tool_calls: null plus a reasoning_content side field. | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| max_tokens: 8192, | |
| messages: [ | |
| { role: 'system', content: 'You are an AI assistant.' }, | |
| { role: 'user', content: 'ciao' }, | |
| { | |
| role: 'assistant', | |
| content: 'Ciao! Come posso aiutarti oggi?', | |
| reasoning_content: 'The user is greeting me in Italian.', | |
| tool_calls: null, | |
| }, | |
| { role: 'user', content: 'Che modello sei' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('accepts null top-level tool knobs (tools, tool_choice, parallel_tool_calls)', async () => { | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| tools: null, | |
| tool_choice: null, | |
| parallel_tool_calls: null, | |
| messages: [{ role: 'user', content: 'hi' }], | |
| }, authHeaders()); | |
| expect(status).not.toBe(400); | |
| if (status === 400) throw new Error(`unexpected 400: ${JSON.stringify(body)}`); | |
| }); | |
| it('drops null/empty tool_calls instead of forwarding them upstream', async () => { | |
| const origFetch = global.fetch; | |
| vi.spyOn(global, 'fetch').mockImplementation(async (url, init) => { | |
| const urlStr = typeof url === 'string' ? url : url.toString(); | |
| if (urlStr.includes('api.groq.com/openai/v1/chat/completions')) { | |
| const body = JSON.parse(String((init as RequestInit).body)); | |
| // Strict upstreams reject tool_calls: null AND tool_calls: [] — | |
| // neither may survive normalization. | |
| expect(body.messages[1]).not.toHaveProperty('tool_calls'); | |
| expect(body.messages[2]).not.toHaveProperty('tool_calls'); | |
| return { | |
| ok: true, | |
| json: () => Promise.resolve({ | |
| id: 'chatcmpl-nulltc', object: 'chat.completion', created: 1, model: 'openai/gpt-oss-120b', | |
| choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], | |
| usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, | |
| }), | |
| } as any; | |
| } | |
| return origFetch(url, init); | |
| }); | |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { | |
| model: 'auto', | |
| messages: [ | |
| { role: 'user', content: 'ciao' }, | |
| { role: 'assistant', content: 'Ciao!', tool_calls: null }, | |
| { role: 'assistant', content: 'ancora', tool_calls: [] }, | |
| { role: 'user', content: 'Che modello sei' }, | |
| ], | |
| }, authHeaders()); | |
| expect(status).toBe(200); | |
| expect(body.choices[0].message.content).toBe('ok'); | |
| }); | |
| }); | |
| }); | |