diff --git a/src/server/features/ai-search/services/promptExplorer.ts b/src/server/features/ai-search/services/promptExplorer.ts index 1aaf5dd..39404e2 100644 --- a/src/server/features/ai-search/services/promptExplorer.ts +++ b/src/server/features/ai-search/services/promptExplorer.ts @@ -123,10 +123,12 @@ async function runModel( return reapplyHighlightBrand(shaped, args.highlightBrand); } -// DataForSEO's Claude catalog caps at the 4.0 family (no Sonnet 4.5+ yet). +// Each value must be a member of ACCEPTED_LLM_MODEL_NAMES in dataforseo/ai.ts, +// which mirrors DataForSEO's llm_responses/models catalog. DataForSEO dropped +// the Claude Sonnet 4.0 family, so we target the 4.5 alias (latest dated 4.5). const MODEL_NAMES: Record = { chat_gpt: "gpt-5", - claude: "claude-sonnet-4-0", + claude: "claude-sonnet-4-5", gemini: "gemini-2.5-pro", perplexity: "sonar-reasoning-pro", }; diff --git a/src/server/lib/dataforseo/ai.ts b/src/server/lib/dataforseo/ai.ts index 104f052..0401daa 100644 --- a/src/server/lib/dataforseo/ai.ts +++ b/src/server/lib/dataforseo/ai.ts @@ -310,6 +310,24 @@ export async function fetchLlmCrossAggregatedMetrics( type LlmResponseModelSlug = "chat_gpt" | "claude" | "gemini" | "perplexity"; +/** + * Accepted `model_name` values per slug, mirroring DataForSEO's + * `/ai_optimization/{model}/llm_responses/models` catalog (verified 2026-06-30). + * We validate against this before dispatching because DataForSEO BILLS a task + * that fails with `Invalid Field: 'model_name'` — a stale or mistyped model name + * would otherwise pay for a guaranteed-rejected call. DataForSEO resolves a + * basic alias (e.g. `claude-sonnet-4-5`) to its latest dated version. + */ +const ACCEPTED_LLM_MODEL_NAMES: Record< + LlmResponseModelSlug, + ReadonlySet +> = { + chat_gpt: new Set(["gpt-5"]), + claude: new Set(["claude-sonnet-4-5", "claude-sonnet-4-6"]), + gemini: new Set(["gemini-2.5-pro"]), + perplexity: new Set(["sonar-reasoning-pro", "sonar-pro", "sonar"]), +}; + type LlmResponsesInput = { userPrompt: string; modelSlug: LlmResponseModelSlug; @@ -348,6 +366,15 @@ function buildPerplexityLlmResponseRequest( export async function fetchLlmResponse( input: LlmResponsesInput, ): Promise> { + // Fail fast on an unknown model_name: DataForSEO charges for tasks that fail + // with `Invalid Field: 'model_name'`, so we must never dispatch one. + if (!ACCEPTED_LLM_MODEL_NAMES[input.modelSlug].has(input.modelName)) { + throw new AppError( + "VALIDATION_ERROR", + `Unsupported DataForSEO model_name "${input.modelName}" for ${input.modelSlug}`, + ); + } + // DataForSEO's Gemini endpoint rejects `web_search_country_iso_code` with a // 40501 "Invalid Field" error. The other three models accept it. const supportsCountry = input.modelSlug !== "gemini"; diff --git a/src/server/lib/dataforseo/endpoints.test.ts b/src/server/lib/dataforseo/endpoints.test.ts index 9233d2e..47b4974 100644 --- a/src/server/lib/dataforseo/endpoints.test.ts +++ b/src/server/lib/dataforseo/endpoints.test.ts @@ -356,3 +356,25 @@ describe("DataForSEO SDK-backed endpoints", () => { ]); }); }); + +describe("fetchLlmResponse model_name validation", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects an unknown model_name before dispatching a paid LLM task", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchLlmResponse({ + userPrompt: "What is OpenSEO?", + modelSlug: "claude", + // DataForSEO dropped this from its catalog; it must never be dispatched. + modelName: "claude-sonnet-4-0", + }), + ).rejects.toThrow(/Unsupported DataForSEO model_name/); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +});