diff --git a/.env.example b/.env.example index bcdd756..ae9658c 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,12 @@ OPENAI_API_KEY=sk-... AI_GATEWAY_API_KEY= # Direct Anthropic fallback when gateway auth is not configured ANTHROPIC_API_KEY=sk-ant-... -# Optional override for analysis + scaffold (default: Claude Sonnet 4.5 snapshot) -# ANTHROPIC_ANALYSIS_MODEL=claude-sonnet-4-5-20250929 +# Optional gateway model override for analysis + scaffold/build (default: anthropic/claude-sonnet-4.6) +# ANTHROPIC_ANALYSIS_MODEL=anthropic/claude-sonnet-4.6 +# Optional comma-separated fallback models tried by Vercel AI Gateway if the primary model fails +# AI_GATEWAY_FALLBACK_MODELS=openai/gpt-5.4,anthropic/claude-haiku-4.5,google/gemini-3-flash +# Optional direct Anthropic model used when falling back from blocked gateway credits +# ANTHROPIC_DIRECT_MODEL=claude-sonnet-4-5-20250929 # Stripe Billing (set STRIPE_MODE=live for production keys) STRIPE_MODE=live diff --git a/lib/ai-gateway.ts b/lib/ai-gateway.ts index 629874a..6e58953 100644 --- a/lib/ai-gateway.ts +++ b/lib/ai-gateway.ts @@ -3,6 +3,12 @@ import { generateText, type ModelMessage } from 'ai' /** Default gateway model (provider/model). See https://ai-gateway.vercel.sh/v1/models */ export const DEFAULT_GATEWAY_MODEL = 'anthropic/claude-sonnet-4.6' +export const DEFAULT_GATEWAY_FALLBACK_MODELS = [ + 'openai/gpt-5.4', + 'anthropic/claude-haiku-4.5', + 'google/gemini-3-flash', +] +export const DEFAULT_DIRECT_ANTHROPIC_MODEL = 'claude-sonnet-4-5-20250929' export type AiGatewayFeature = | 'analysis-run' @@ -23,6 +29,17 @@ function directAnthropicKey(): string | undefined { return process.env[ANTHROPIC_KEY_ENV]?.trim() } +function parseModelList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((model) => model.trim()) + .filter(Boolean) +} + +function uniqueModels(models: string[]): string[] { + return Array.from(new Set(models)) +} + /** True when AI Gateway or a direct Anthropic key is available. */ export function isAiConfigured(): boolean { return usesGatewayAuth() || Boolean(directAnthropicKey()) @@ -38,6 +55,18 @@ export function getGatewayModel(): string { return `anthropic/${configured}` } +export function getGatewayFallbackModels(): string[] { + const configured = parseModelList(process.env.AI_GATEWAY_FALLBACK_MODELS) + const primaryModel = getGatewayModel() + const fallbackModels = configured.length > 0 ? configured : DEFAULT_GATEWAY_FALLBACK_MODELS + + return uniqueModels(fallbackModels).filter((model) => model !== primaryModel) +} + +function getDirectAnthropicModel(): string { + return process.env.ANTHROPIC_DIRECT_MODEL?.trim() || DEFAULT_DIRECT_ANTHROPIC_MODEL +} + /** * Model id for Anthropic Messages API (gateway-compatible endpoint or direct Anthropic). */ @@ -54,6 +83,7 @@ export function getAnthropicMessagesModel(): string { export function gatewayProviderOptions(userId?: string, feature?: AiGatewayFeature) { const tags = feature ? [`feature:${feature}`] : [] + const fallbackModels = getGatewayFallbackModels() if (process.env.VERCEL_ENV) { tags.push(`env:${process.env.VERCEL_ENV}`) } @@ -63,6 +93,7 @@ export function gatewayProviderOptions(userId?: string, feature?: AiGatewayFeatu gateway: { ...(userId ? { user: userId } : {}), ...(tags.length > 0 ? { tags } : {}), + ...(usesGatewayAuth() && fallbackModels.length > 0 ? { models: fallbackModels } : {}), }, }, } as const @@ -87,6 +118,55 @@ export function getAnthropicClient(): Anthropic { return new Anthropic({ apiKey: directKey }) } +function isGatewayAccessError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + const lower = message.toLowerCase() + + return ( + lower.includes('free tier users do not have access') || + lower.includes('upgrade to paid credits') || + lower.includes('top-up') || + lower.includes('payment required') || + lower.includes('insufficient credits') || + lower.includes('gateway credits') || + lower.includes('status code: 402') || + lower.includes('status code: 403') + ) +} + +async function generateWithDirectAnthropic(params: { + system?: string + messages: Array<{ role: 'user' | 'assistant'; content: string }> + maxOutputTokens?: number + temperature?: number +}): Promise { + const directKey = directAnthropicKey() + if (!directKey) { + throw new Error('Direct Anthropic fallback is not configured.') + } + + const client = new Anthropic({ apiKey: directKey }) + const response = await client.messages.create({ + model: getDirectAnthropicModel(), + max_tokens: params.maxOutputTokens ?? 4096, + ...(params.temperature !== undefined ? { temperature: params.temperature } : {}), + ...(params.system ? { system: params.system } : {}), + messages: params.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + }) + + return response.content + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() +} + +function gatewayAccessErrorMessage(): string { + return 'AI generation is temporarily unavailable because the configured Vercel AI Gateway account is blocked by model credits. Your Pro app access is active; please contact support or try again shortly.' +} + export async function generateWithGateway(params: { system?: string messages: Array<{ role: 'user' | 'assistant'; content: string }> @@ -100,16 +180,44 @@ export async function generateWithGateway(params: { content: message.content, })) - const result = await generateText({ - model: getGatewayModel(), - system: params.system, - messages: modelMessages, - maxOutputTokens: params.maxOutputTokens ?? 4096, - temperature: params.temperature, - ...gatewayProviderOptions(params.userId, params.feature), - }) + if (!usesGatewayAuth() && directAnthropicKey()) { + return generateWithDirectAnthropic({ + system: params.system, + messages: params.messages, + maxOutputTokens: params.maxOutputTokens, + temperature: params.temperature, + }) + } + + try { + const result = await generateText({ + model: getGatewayModel(), + system: params.system, + messages: modelMessages, + maxOutputTokens: params.maxOutputTokens ?? 4096, + temperature: params.temperature, + ...gatewayProviderOptions(params.userId, params.feature), + }) + + return result.text + } catch (error) { + if (usesGatewayAuth() && isGatewayAccessError(error)) { + const directKey = directAnthropicKey() + if (directKey) { + console.warn('[ai] Vercel AI Gateway access failed; retrying with direct Anthropic fallback.') + return generateWithDirectAnthropic({ + system: params.system, + messages: params.messages, + maxOutputTokens: params.maxOutputTokens, + temperature: params.temperature, + }) + } + + throw new Error(gatewayAccessErrorMessage()) + } - return result.text + throw error + } } export function aiConfigErrorMessage(): string {