diff --git a/__tests__/unit/lib/ai-assist-any-form.test.ts b/__tests__/unit/lib/ai-assist-any-form.test.ts index 481d8b22a..31e4a7378 100644 --- a/__tests__/unit/lib/ai-assist-any-form.test.ts +++ b/__tests__/unit/lib/ai-assist-any-form.test.ts @@ -165,6 +165,65 @@ describe('generateFormPrefill — service floor follows the intent, like the rou }); }); +describe('generateFormPrefill — retries a transient provider failure', () => { + const target = resolveAiAssistTarget('service'); + const originalFetch = global.fetch; + const originalGroqKey = process.env.GROQ_API_KEY; + + beforeEach(() => { + process.env.GROQ_API_KEY = 'test-key'; + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalGroqKey === undefined) { + delete process.env.GROQ_API_KEY; + } else { + process.env.GROQ_API_KEY = originalGroqKey; + } + }); + + it('recovers from a single 503 without surfacing an error to the caller', async () => { + const okBody = { + choices: [ + { + message: { content: JSON.stringify({ data: { title: 'Retried fine' }, confidence: {} }) }, + }, + ], + }; + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ ok: false, status: 503, text: async () => 'upstream hiccup' }) + .mockResolvedValueOnce({ ok: true, json: async () => okBody }); + global.fetch = fetchMock as unknown as typeof fetch; + + const result = await generateFormPrefill({ + target: target!, + description: 'A cleaning service for offices in Zurich', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + expect(result.data.title).toBe('Retried fine'); + }); + + it('gives up after repeated failures with the same user-facing message', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue({ ok: false, status: 503, text: async () => 'still down' }); + global.fetch = fetchMock as unknown as typeof fetch; + + const result = await generateFormPrefill({ + target: target!, + description: 'A cleaning service for offices in Zurich', + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(result.success).toBe(false); + expect(result.error).toBe('AI service temporarily unavailable. Please try again.'); + }); +}); + describe('getExampleDescriptions — standalone forms get starters too', () => { it('returns the declared examples for task and proposal', () => { expect(getExampleDescriptions('task')).toEqual(AI_ASSIST_FORMS.task.examples); diff --git a/src/lib/ai/form-prefill-service.ts b/src/lib/ai/form-prefill-service.ts index 9ae033acf..9601c26f4 100644 --- a/src/lib/ai/form-prefill-service.ts +++ b/src/lib/ai/form-prefill-service.ts @@ -15,6 +15,7 @@ import { type AiAssistIntent, } from '@/config/ai-form-assist'; import { logger } from '@/utils/logger'; +import { withApiRetry } from '@/utils/retry'; import { extractFieldDescriptions, formatFieldsForPrompt } from './schema-to-prompt'; import { getSystemPrompt, getUserPrompt, parseAIResponse } from './prompts/form-prefill'; import { sanitizeAiFields } from './sanitize-ai-fields'; @@ -184,26 +185,44 @@ export async function generateFormPrefill({ headers['X-Title'] = 'OrangeCat Form Prefill'; } - const response = await fetch(baseUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - model: useGroq ? model : model.replace('openrouter/', ''), - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - temperature: config?.temperature ?? 0.3, - // Headroom for genuinely written descriptions (and bilingual ones) — - // 1000 truncated the JSON mid-string on multi-field forms. - max_tokens: config?.maxTokens ?? 2000, - ...(useGroq ? {} : { response_format: { type: 'json_object' } }), - }), + const requestBody = JSON.stringify({ + model: useGroq ? model : model.replace('openrouter/', ''), + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: config?.temperature ?? 0.3, + // Headroom for genuinely written descriptions (and bilingual ones) — + // 1000 truncated the JSON mid-string on multi-field forms. + max_tokens: config?.maxTokens ?? 2000, + ...(useGroq ? {} : { response_format: { type: 'json_object' } }), }); - if (!response.ok) { - const errorText = await response.text(); - logger.error('AI API error', { errorText }, 'AI'); + // The upstream provider (Groq/OpenRouter) occasionally answers with a + // transient 429/5xx that clears on its own — visitors were seeing that + // as a hard failure and had to manually re-click "Fill with AI" to get + // the same request to succeed. Retry it here instead, same as every + // other outbound call in the codebase (see withApiRetry usages). + let response: Response; + try { + response = await withApiRetry( + async () => { + const res = await fetch(baseUrl, { method: 'POST', headers, body: requestBody }); + if (!res.ok) { + const errorText = await res.text(); + const error = new Error(`AI provider responded ${res.status}`) as Error & { + status: number; + }; + error.status = res.status; + logger.warn('AI API error, may retry', { status: res.status, errorText }, 'AI'); + throw error; + } + return res; + }, + { maxAttempts: 3, baseDelay: 400, maxDelay: 2000 } + ); + } catch (fetchError) { + logger.error('AI API error after retries', fetchError, 'AI'); return { success: false, data: {},