Spaces:
Sleeping
Sleeping
| import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; | |
| import type { Express } from 'express'; | |
| // Script the provider per-test: every platform resolves to this fake, so the | |
| // first chain model gets emptyResult/emptyStream and the failover target gets | |
| // a real completion. Mock BEFORE importing anything that pulls in the router. | |
| const chatCompletion = vi.fn(); | |
| const streamChatCompletion = vi.fn(); | |
| const fakeProvider = { name: 'fake', chatCompletion, streamChatCompletion } as any; | |
| vi.mock('../../providers/index.js', async (importOriginal) => { | |
| const actual = await importOriginal() as any; | |
| return { | |
| ...actual, | |
| getProvider: () => fakeProvider, | |
| resolveProvider: () => fakeProvider, | |
| }; | |
| }); | |
| const { createApp } = await import('../../app.js'); | |
| const { initDb, getDb, getUnifiedApiKey } = await import('../../db/index.js'); | |
| const { encrypt } = await import('../../lib/crypto.js'); | |
| const { setRoutingStrategy } = await import('../../services/router.js'); | |
| async function post(app: Express, path: string, body: any, key: string) { | |
| const server = app.listen(0); | |
| const addr = server.address() as any; | |
| const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, | |
| body: JSON.stringify(body), | |
| }); | |
| const raw = await res.text(); | |
| server.close(); | |
| let json: any = null; | |
| try { json = JSON.parse(raw); } catch {} | |
| return { status: res.status, body: json, raw, headers: res.headers }; | |
| } | |
| const EMPTY_RESULT = { | |
| choices: [{ message: { role: 'assistant', content: '' } }], | |
| usage: { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 }, | |
| }; | |
| const GOOD_RESULT = { | |
| choices: [{ message: { role: 'assistant', content: 'a real answer' } }], | |
| usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 }, | |
| }; | |
| async function* emptyStream() { /* zero chunks */ } | |
| async function* goodStream() { | |
| yield { choices: [{ delta: { content: 'streamed ' } }] }; | |
| yield { choices: [{ delta: { content: 'answer' } }] }; | |
| } | |
| describe('Empty-completion failover', () => { | |
| let app: Express; | |
| let key: string; | |
| beforeAll(() => { | |
| process.env.ENCRYPTION_KEY = '0'.repeat(64); | |
| initDb(':memory:'); | |
| app = createApp(); | |
| key = getUnifiedApiKey(); | |
| const db = getDb(); | |
| setRoutingStrategy('priority'); | |
| const { encrypted, iv, authTag } = encrypt('test-key'); | |
| db.prepare(` | |
| INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled) | |
| VALUES ('groq', 'test', ?, ?, ?, 'healthy', 1) | |
| `).run(encrypted, iv, authTag); | |
| }); | |
| beforeEach(() => { | |
| chatCompletion.mockReset(); | |
| streamChatCompletion.mockReset(); | |
| // Clear cooldowns set by previous failovers so each test routes fresh. | |
| getDb().prepare('DELETE FROM rate_limit_cooldowns').run(); | |
| }); | |
| it('/v1/chat/completions (non-stream): empty completion fails over to the next model', async () => { | |
| chatCompletion | |
| .mockResolvedValueOnce(EMPTY_RESULT) | |
| .mockResolvedValueOnce(GOOD_RESULT); | |
| const db = getDb(); | |
| db.prepare('DELETE FROM requests').run(); | |
| const { status, body, headers } = await post(app, '/v1/chat/completions', { | |
| messages: [{ role: 'user', content: 'hi' }], | |
| }, key); | |
| expect(status).toBe(200); | |
| expect(body.choices[0].message.content).toBe('a real answer'); | |
| expect(headers.get('x-fallback-attempts')).toBe('1'); | |
| expect(chatCompletion).toHaveBeenCalledTimes(2); | |
| // First model must differ from the one that answered. | |
| expect(chatCompletion.mock.calls[0][2]).not.toBe(chatCompletion.mock.calls[1][2]); | |
| const rows = db.prepare('SELECT status, error FROM requests ORDER BY id').all() as Array<{ status: string; error: string | null }>; | |
| expect(rows.length).toBe(2); | |
| expect(rows[0].status).toBe('error'); | |
| expect(rows[0].error).toContain('empty completion'); | |
| expect(rows[1].status).toBe('success'); | |
| }); | |
| it('/v1/chat/completions (stream): zero-chunk stream fails over instead of emitting an empty stream', async () => { | |
| streamChatCompletion | |
| .mockReturnValueOnce(emptyStream()) | |
| .mockReturnValueOnce(goodStream()); | |
| const { status, raw } = await post(app, '/v1/chat/completions', { | |
| messages: [{ role: 'user', content: 'hi' }], | |
| stream: true, | |
| }, key); | |
| expect(status).toBe(200); | |
| expect(raw).toContain('streamed '); | |
| expect(raw).toContain('[DONE]'); | |
| expect(streamChatCompletion).toHaveBeenCalledTimes(2); | |
| }); | |
| it('/v1/responses (non-stream): empty completion fails over', async () => { | |
| chatCompletion | |
| .mockResolvedValueOnce(EMPTY_RESULT) | |
| .mockResolvedValueOnce(GOOD_RESULT); | |
| const { status, body } = await post(app, '/v1/responses', { | |
| input: 'hi', | |
| }, key); | |
| expect(status).toBe(200); | |
| expect(body.output_text).toBe('a real answer'); | |
| expect(chatCompletion).toHaveBeenCalledTimes(2); | |
| }); | |
| it('/v1/responses (stream): empty stream fails over on the same SSE connection', async () => { | |
| streamChatCompletion | |
| .mockReturnValueOnce(emptyStream()) | |
| .mockReturnValueOnce(goodStream()); | |
| const { status, raw } = await post(app, '/v1/responses', { | |
| input: 'hi', | |
| stream: true, | |
| }, key); | |
| expect(status).toBe(200); | |
| expect(raw).toContain('response.completed'); | |
| expect(raw).toContain('streamed answer'); | |
| expect(raw).not.toContain('response.failed'); | |
| expect(streamChatCompletion).toHaveBeenCalledTimes(2); | |
| }); | |
| it('/v1/responses (stream): a connect-time provider error fails over instead of dying mid-"stream"', async () => { | |
| // Regression: headers used to be sent BEFORE the provider call, so a 503 | |
| // at stream open was misclassified as mid-stream → returned to the client | |
| // with no failover and no cooldown (observed as 17 consecutive 503s to | |
| // the same model). With lazy headers it must take the retry path. | |
| async function* failsAtOpen(): AsyncGenerator<any> { | |
| throw new Error('OpenRouter API error 503: Provider returned error'); | |
| } | |
| streamChatCompletion | |
| .mockReturnValueOnce(failsAtOpen()) | |
| .mockReturnValueOnce(goodStream()); | |
| const { status, raw } = await post(app, '/v1/responses', { | |
| input: 'hi', | |
| stream: true, | |
| }, key); | |
| expect(status).toBe(200); | |
| expect(raw).toContain('response.completed'); | |
| expect(raw).toContain('streamed answer'); | |
| expect(raw).not.toContain('response.failed'); | |
| expect(streamChatCompletion).toHaveBeenCalledTimes(2); | |
| }); | |
| it('logs the empty attempt as an error and the failover as success', async () => { | |
| chatCompletion | |
| .mockResolvedValueOnce(EMPTY_RESULT) | |
| .mockResolvedValueOnce(GOOD_RESULT); | |
| const { headers } = await post(app, '/v1/chat/completions', { messages: [{ role: 'user', content: 'hi' }] }, key); | |
| expect(headers.get('x-request-id')).toMatch(/\S+/); | |
| }); | |
| it('a tool-calls-only completion (no text) is NOT treated as empty', async () => { | |
| chatCompletion.mockResolvedValueOnce({ | |
| choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] } }], | |
| usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, | |
| }); | |
| const { status, body } = await post(app, '/v1/chat/completions', { | |
| messages: [{ role: 'user', content: 'hi' }], | |
| tools: [{ type: 'function', function: { name: 'f', parameters: { type: 'object', properties: {} } } }], | |
| }, key); | |
| expect(status).toBe(200); | |
| expect(body.choices[0].message.tool_calls).toHaveLength(1); | |
| expect(chatCompletion).toHaveBeenCalledTimes(1); | |
| }); | |
| }); | |