| 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'; |
|
|
| 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' } : {}), ...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(); |
| }); |
|
|
| 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 () => { |
| |
| |
| const { status, body } = await request(app, 'POST', '/v1/chat/completions', { |
| messages: [{ role: 'user', content: 'hello' }], |
| }, authHeaders()); |
| expect(status).not.toBe(400); |
| if (status === 400) { |
| |
| 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')) { |
| |
| 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: [], |
| }, authHeaders()); |
| expect(status).toBe(400); |
| }); |
|
|
| it('rejects an assistant message with neither content nor tool_calls', async () => { |
| const { status } = await request(app, 'POST', '/v1/chat/completions', { |
| messages: [{ role: 'assistant', content: [] }], |
| }, authHeaders()); |
| expect(status).toBe(400); |
| }); |
| }); |
|
|