import { z } from "zod"; /** * Zod schemas for DataForSEO AI Optimization endpoints. * * The DataForSEO SDK does not yet ship typed bindings for `/ai_optimization/*`, * so we POST raw JSON and validate the responses ourselves. All schemas use * `.passthrough()` to tolerate fields the API may add in future versions. */ // --------------------------------------------------------------------------- // LLM Mentions — shared bits // --------------------------------------------------------------------------- const monthlyVolumeSchema = z .object({ year: z.number().int(), month: z.number().int().min(1).max(12), search_volume: z.number().nullable().optional(), }) .passthrough(); const mentionSourceSchema = z .object({ url: z.string().nullable().optional(), title: z.string().nullable().optional(), domain: z.string().nullable().optional(), }) .passthrough(); const brandEntitySchema = z .object({ title: z.string().nullable().optional(), }) .passthrough(); // --------------------------------------------------------------------------- // LLM Mentions Search — `/v3/ai_optimization/llm_mentions/search/live` // Returns one row per LLM answer that matched the target. // --------------------------------------------------------------------------- export const llmMentionItemSchema = z .object({ question: z.string().nullable().optional(), sources: z.array(mentionSourceSchema).nullable().optional(), ai_search_volume: z.number().nullable().optional(), monthly_searches: z.array(monthlyVolumeSchema).nullable().optional(), first_response_at: z.string().nullable().optional(), last_response_at: z.string().nullable().optional(), brand_entities: z.array(brandEntitySchema).nullable().optional(), }) .passthrough(); export type LlmMentionItem = z.infer; // --------------------------------------------------------------------------- // LLM Mentions Aggregated Metrics — `/v3/ai_optimization/llm_mentions/aggregated_metrics/live` // Each metric category contains an array of group elements with mention counts. // --------------------------------------------------------------------------- const groupElementSchema = z .object({ type: z.string().nullable().optional(), key: z.string().nullable().optional(), mentions: z.number().nullable().optional(), ai_search_volume: z.number().nullable().optional(), impressions: z.number().nullable().optional(), }) .passthrough(); export const llmAggregatedTotalSchema = z .object({ platform: z.array(groupElementSchema).nullable().optional(), }) .passthrough(); export type LlmAggregatedTotal = z.infer; // --------------------------------------------------------------------------- // LLM Mentions Top Pages — `/v3/ai_optimization/llm_mentions/top_pages/live` // Each item has `key` = page URL plus the same group-element arrays. // --------------------------------------------------------------------------- export const llmTopPagesItemSchema = z .object({ key: z.string().nullable().optional(), platform: z.array(groupElementSchema).nullable().optional(), }) .passthrough(); export type LlmTopPagesItem = z.infer; // --------------------------------------------------------------------------- // LLM Mentions Cross-Aggregated Metrics — `/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live` // One item per requested aggregation group (target + competitors). // `.passthrough()` because the real item also carries location, language, // sources_domain, and brand_entities arrays we intentionally ignore. // --------------------------------------------------------------------------- export const llmCrossAggregatedItemSchema = z .object({ // The shared SDK type AiOptimizationLlmMentionssLiveItem documents `key` as // the URL of a found page, but for cross_aggregated `key` is the request // aggregation_key (the brand label). key: z.string().nullable().optional(), platform: z.array(groupElementSchema).nullable().optional(), }) .passthrough(); export type LlmCrossAggregatedItem = z.infer< typeof llmCrossAggregatedItemSchema >; // --------------------------------------------------------------------------- // LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity // All four model endpoints return the same envelope shape. // --------------------------------------------------------------------------- const responseAnnotationSchema = z .object({ type: z.string().nullable().optional(), title: z.string().nullable().optional(), url: z.string().nullable().optional(), }) .passthrough(); const responseSectionSchema = z .object({ type: z.string().nullable().optional(), text: z.string().nullable().optional(), annotations: z.array(responseAnnotationSchema).nullable().optional(), }) .passthrough(); const responseItemSchema = z .object({ type: z.string().nullable().optional(), sections: z.array(responseSectionSchema).nullable().optional(), }) .passthrough(); export const llmResponseResultSchema = z .object({ model_name: z.string().nullable().optional(), output_tokens: z.number().nullable().optional(), web_search: z.boolean().nullable().optional(), items: z.array(responseItemSchema).nullable().optional(), fan_out_queries: z.array(z.string()).nullable().optional(), }) .passthrough(); export type LlmResponseResult = z.infer;