diff --git a/lib/agents/CompactAgent/createCompactAgent.ts b/lib/agents/CompactAgent/createCompactAgent.ts index 144a60284..bd5b309ff 100644 --- a/lib/agents/CompactAgent/createCompactAgent.ts +++ b/lib/agents/CompactAgent/createCompactAgent.ts @@ -1,5 +1,6 @@ import { ToolLoopAgent, stepCountIs } from "ai"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; +import { createModel } from "@/lib/ai/createModel"; const DEFAULT_INSTRUCTIONS = `You are a conversation summarizer. Create a concise summary of the conversation that: - Preserves key information, decisions, and action items @@ -17,7 +18,7 @@ Respond with only the summary text, no additional commentary.`; */ export function createCompactAgent(customInstructions?: string) { return new ToolLoopAgent({ - model: LIGHTWEIGHT_MODEL, + model: createModel(LIGHTWEIGHT_MODEL), instructions: customInstructions || DEFAULT_INSTRUCTIONS, stopWhen: stepCountIs(1), }); diff --git a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts index 29452b7d3..91991d277 100644 --- a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts +++ b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts @@ -1,6 +1,7 @@ import { Output, ToolLoopAgent, stepCountIs } from "ai"; import { z } from "zod"; import { LIGHTWEIGHT_MODEL, INBOUND_EMAIL_DOMAIN } from "@/lib/const"; +import { createModel } from "@/lib/ai/createModel"; const replyDecisionSchema = z.object({ shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"), @@ -22,7 +23,7 @@ Rules (check in this order): */ export function createEmailReplyAgent() { return new ToolLoopAgent({ - model: LIGHTWEIGHT_MODEL, + model: createModel(LIGHTWEIGHT_MODEL), instructions, output: Output.object({ schema: replyDecisionSchema }), stopWhen: stepCountIs(1), diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index 93dc5d10e..3ae1e394f 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -1,6 +1,7 @@ import { Output, ToolLoopAgent, stepCountIs } from "ai"; import { z } from "zod"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; +import { createModel } from "@/lib/ai/createModel"; import { CONTENT_TEMPLATES, DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; import { CAPTION_LENGTHS } from "@/lib/content/captionLengths"; import { songsSchema } from "@/lib/content/songsSchema"; @@ -68,7 +69,7 @@ Defaults: lipsync=${DEFAULT_CONTENT_PROMPT_FLAGS.lipsync}, batch=${DEFAULT_CONTE */ export function createContentPromptAgent() { return new ToolLoopAgent({ - model: LIGHTWEIGHT_MODEL, + model: createModel(LIGHTWEIGHT_MODEL), instructions, output: Output.object({ schema: contentPromptFlagsSchema }), stopWhen: stepCountIs(1), diff --git a/lib/agents/generalAgent/getGeneralAgent.ts b/lib/agents/generalAgent/getGeneralAgent.ts index 7c2c9407b..3e271da40 100644 --- a/lib/agents/generalAgent/getGeneralAgent.ts +++ b/lib/agents/generalAgent/getGeneralAgent.ts @@ -3,6 +3,7 @@ import { AnthropicProviderOptions } from "@ai-sdk/anthropic"; import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; import { DEFAULT_MODEL } from "@/lib/const"; +import { createModel } from "@/lib/ai/createModel"; import { RoutingDecision } from "@/lib/chat/types"; import { extractImageUrlsFromMessages } from "@/lib/messages/extractImageUrlsFromMessages"; import { buildSystemPromptWithImages } from "@/lib/chat/buildSystemPromptWithImages"; @@ -51,7 +52,7 @@ export default async function getGeneralAgent(body: ChatRequestBody): Promise OpenRouter > direct OpenAI. + */ +export function createModel(modelId: string): LanguageModel { + // Vercel AI Gateway — existing production behavior + if (process.env.VERCEL_AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN) { + return gateway(modelId); + } + + // OpenRouter — supports "provider/model" format natively + if (process.env.OPENROUTER_API_KEY) { + const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); + return openrouter(modelId); + } + + // Direct OpenAI — only supports openai/* models + if (process.env.OPENAI_API_KEY) { + if (!modelId.startsWith("openai/") && modelId.includes("/")) { + throw new Error( + `Model "${modelId}" is not an OpenAI model. Direct OpenAI mode only supports openai/* models. ` + + `Use OPENROUTER_API_KEY or VERCEL_AI_GATEWAY_API_KEY for multi-provider support.`, + ); + } + const bareModel = modelId.startsWith("openai/") ? modelId.slice(7) : modelId; + return openai(bareModel); + } + + throw new Error( + "No LLM provider configured. Set VERCEL_AI_GATEWAY_API_KEY, OPENROUTER_API_KEY, or OPENAI_API_KEY.", + ); +} diff --git a/lib/ai/generateArray.ts b/lib/ai/generateArray.ts index c33c459fe..79006132b 100644 --- a/lib/ai/generateArray.ts +++ b/lib/ai/generateArray.ts @@ -1,5 +1,6 @@ import { generateObject } from "ai"; import { DEFAULT_MODEL } from "@/lib/const"; +import { createModel } from "./createModel"; import { z } from "zod"; export interface GenerateArrayResult { @@ -15,7 +16,7 @@ const generateArray = async ({ prompt: string; }): Promise => { const result = await generateObject({ - model: DEFAULT_MODEL, + model: createModel(DEFAULT_MODEL), system, prompt, output: "array", diff --git a/lib/ai/generateText.ts b/lib/ai/generateText.ts index 0d509f899..ac9bce921 100644 --- a/lib/ai/generateText.ts +++ b/lib/ai/generateText.ts @@ -1,5 +1,6 @@ import { generateText as generate } from "ai"; import { DEFAULT_MODEL } from "@/lib/const"; +import { createModel } from "./createModel"; const generateText = async ({ system, @@ -12,7 +13,7 @@ const generateText = async ({ }) => { const result = await generate({ system, - model: model || DEFAULT_MODEL, + model: createModel(model || DEFAULT_MODEL), prompt, }); diff --git a/lib/ai/getAvailableModels.ts b/lib/ai/getAvailableModels.ts index a46fd79ee..4e4941094 100644 --- a/lib/ai/getAvailableModels.ts +++ b/lib/ai/getAvailableModels.ts @@ -1,16 +1,45 @@ import { gateway, GatewayLanguageModelEntry } from "@ai-sdk/gateway"; import isEmbedModel from "./isEmbedModel"; +import { DEFAULT_MODEL, LIGHTWEIGHT_MODEL } from "@/lib/const"; /** - * Returns the list of available LLMs from the Vercel AI Gateway. - * Filters out embed models that are not suitable for chat. + * Default model list for non-gateway providers (OpenRouter, direct OpenAI). + * Returned only when the Vercel AI Gateway is not configured. + */ +const DEFAULT_MODELS: GatewayLanguageModelEntry[] = [ + { + id: DEFAULT_MODEL, + name: "GPT-5 Mini", + description: "Default model for chat and generation", + pricing: { input: "0.0001", output: "0.0004" }, + specification: { specificationVersion: "v2", provider: "openai", modelId: DEFAULT_MODEL }, + }, + { + id: LIGHTWEIGHT_MODEL, + name: "GPT-4o Mini", + description: "Lightweight model for simple tasks", + pricing: { input: "0.00015", output: "0.0006" }, + specification: { specificationVersion: "v2", provider: "openai", modelId: LIGHTWEIGHT_MODEL }, + }, +]; + +/** + * Returns the list of available LLMs. + * Uses Vercel AI Gateway when configured, otherwise returns a default list. */ export const getAvailableModels = async (): Promise => { - try { - const apiResponse = await gateway.getAvailableModels(); - const gatewayModels = apiResponse.models.filter(m => !isEmbedModel(m)); - return gatewayModels; - } catch { - return []; + // Use Vercel AI Gateway when configured + if (process.env.VERCEL_AI_GATEWAY_API_KEY) { + try { + const apiResponse = await gateway.getAvailableModels(); + const gatewayModels = apiResponse.models.filter(m => !isEmbedModel(m)); + return gatewayModels; + } catch (error) { + console.error("[getAvailableModels] Gateway fetch failed:", error); + return []; + } } + + // Fallback for OpenRouter or direct OpenAI + return DEFAULT_MODELS; }; diff --git a/lib/catalog/analyzeCatalogBatch.ts b/lib/catalog/analyzeCatalogBatch.ts index 15a4a7aac..4cc716544 100644 --- a/lib/catalog/analyzeCatalogBatch.ts +++ b/lib/catalog/analyzeCatalogBatch.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { generateObject } from "ai"; import type { CatalogSongWithArtists } from "@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists"; import { DEFAULT_MODEL } from "@/lib/const"; +import { createModel } from "@/lib/ai/createModel"; /** * Analyzes a single batch of catalog songs using AI to filter by criteria @@ -17,7 +18,7 @@ export async function analyzeCatalogBatch( ): Promise { // Use AI to select relevant songs from this batch const { object } = await generateObject({ - model: DEFAULT_MODEL, + model: createModel(DEFAULT_MODEL), schema: z.object({ selected_song_isrcs: z .array(z.string()) diff --git a/lib/chat/toolChains/toolChains.ts b/lib/chat/toolChains/toolChains.ts index 98f2c6f78..28419ee4d 100644 --- a/lib/chat/toolChains/toolChains.ts +++ b/lib/chat/toolChains/toolChains.ts @@ -1,6 +1,7 @@ import { LanguageModel, ModelMessage } from "ai"; import { createReleaseReportToolChain } from "./create_release_report/createReleaseReportToolChain"; import { createNewArtistToolChain } from "./createNewArtistToolChain"; +import { createModel } from "@/lib/ai/createModel"; export type ToolChainItem = { toolName: string; @@ -18,22 +19,22 @@ export type PrepareStepResult = { // Forced toolChoice is incompatible with Anthropic extended thinking. // Every tool used in a chain must have a model here to avoid the conflict. export const TOOL_MODEL_MAP: Record = { - update_account_info: "gemini-2.5-pro", - get_spotify_search: "openai/gpt-5.4-mini", - update_artist_socials: "openai/gpt-5.4-mini", - artist_deep_research: "openai/gpt-5.4-mini", - spotify_deep_research: "openai/gpt-5.4-mini", - get_artist_socials: "openai/gpt-5.4-mini", - get_spotify_artist_top_tracks: "openai/gpt-5.4-mini", - get_spotify_artist_albums: "openai/gpt-5.4-mini", - get_spotify_album: "openai/gpt-5.4-mini", - search_web: "openai/gpt-5.4-mini", - generate_txt_file: "openai/gpt-5.4-mini", - create_segments: "openai/gpt-5.4-mini", - youtube_login: "openai/gpt-5.4-mini", - web_deep_research: "openai/gpt-5.4-mini", - create_knowledge_base: "openai/gpt-5.4-mini", - send_email: "openai/gpt-5.4-mini", + update_account_info: createModel("google/gemini-2.5-pro"), + get_spotify_search: createModel("openai/gpt-5.4-mini"), + update_artist_socials: createModel("openai/gpt-5.4-mini"), + artist_deep_research: createModel("openai/gpt-5.4-mini"), + spotify_deep_research: createModel("openai/gpt-5.4-mini"), + get_artist_socials: createModel("openai/gpt-5.4-mini"), + get_spotify_artist_top_tracks: createModel("openai/gpt-5.4-mini"), + get_spotify_artist_albums: createModel("openai/gpt-5.4-mini"), + get_spotify_album: createModel("openai/gpt-5.4-mini"), + search_web: createModel("openai/gpt-5.4-mini"), + generate_txt_file: createModel("openai/gpt-5.4-mini"), + create_segments: createModel("openai/gpt-5.4-mini"), + youtube_login: createModel("openai/gpt-5.4-mini"), + web_deep_research: createModel("openai/gpt-5.4-mini"), + create_knowledge_base: createModel("openai/gpt-5.4-mini"), + send_email: createModel("openai/gpt-5.4-mini"), }; // Map trigger tool -> sequence AFTER trigger diff --git a/lib/evals/scorers/CatalogAvailability.ts b/lib/evals/scorers/CatalogAvailability.ts index f4829ea41..47f68779a 100644 --- a/lib/evals/scorers/CatalogAvailability.ts +++ b/lib/evals/scorers/CatalogAvailability.ts @@ -1,5 +1,6 @@ import { DEFAULT_MODEL } from "@/lib/consts"; import { generateObject } from "ai"; +import { createModel } from "@/lib/ai/createModel"; import { getCatalogDataAsCSV } from "@/lib/catalog/getCatalogDataAsCSV"; import { z } from "zod"; @@ -18,7 +19,7 @@ export const CatalogAvailability = async ({ const catalog = await getCatalogDataAsCSV(catalogId); const result = await generateObject({ - model: DEFAULT_MODEL, + model: createModel(DEFAULT_MODEL), system: `You are a music catalog analyst. Your job is to analyze song recommendations and determine which ones are available in the provided music catalog. Instructions: diff --git a/lib/evals/scorers/QuestionAnswered.ts b/lib/evals/scorers/QuestionAnswered.ts index abe0222cb..71bb1a360 100644 --- a/lib/evals/scorers/QuestionAnswered.ts +++ b/lib/evals/scorers/QuestionAnswered.ts @@ -1,5 +1,6 @@ import { DEFAULT_MODEL } from "@/lib/consts"; import { generateObject } from "ai"; +import { createModel } from "@/lib/ai/createModel"; import { z } from "zod"; /** @@ -17,7 +18,7 @@ export const QuestionAnswered = async ({ }) => { try { const result = await generateObject({ - model: DEFAULT_MODEL, + model: createModel(DEFAULT_MODEL), system: `You are an AI evaluation expert. Your job is to determine if an AI assistant actually answered the customer's question with a specific answer, or if it deflected, explained why it couldn't answer, or gave generic suggestions without providing the requested information. Instructions: diff --git a/package.json b/package.json index 5d12b8b07..f2ecfb716 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@composio/vercel": "^0.3.4", "@fal-ai/client": "^1.9.5", "@modelcontextprotocol/sdk": "^1.24.3", + "@openrouter/ai-sdk-provider": "^2.6.0", "@privy-io/node": "^0.6.2", "@supabase/supabase-js": "^2.86.0", "@trigger.dev/sdk": "^4.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72b683bc4..8958a87ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.24.3 version: 1.24.3(zod@4.1.13) + '@openrouter/ai-sdk-provider': + specifier: ^2.6.0 + version: 2.6.0(ai@6.0.0-beta.122(zod@4.1.13))(zod@4.1.13) '@privy-io/node': specifier: ^0.6.2 version: 0.6.2(viem@2.40.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) @@ -1298,6 +1301,13 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@openrouter/ai-sdk-provider@2.6.0': + resolution: {integrity: sha512-6rQw/ORDjV9Q+S+uxJwpDyZtWANUr7cDDxtuS4cQ/8UhS/hNNjKcTJVfx56hwypvd0DlRM+KgWHwxFYb90km3w==} + engines: {node: '>=18'} + peerDependencies: + ai: ^6.0.0 + zod: ^3.25.0 || ^4.0.0 + '@opentelemetry/api-logs@0.203.0': resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} engines: {node: '>=8.0.0'} @@ -7800,6 +7810,11 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@openrouter/ai-sdk-provider@2.6.0(ai@6.0.0-beta.122(zod@4.1.13))(zod@4.1.13)': + dependencies: + ai: 6.0.0-beta.122(zod@4.1.13) + zod: 4.1.13 + '@opentelemetry/api-logs@0.203.0': dependencies: '@opentelemetry/api': 1.9.0