Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 117 additions & 9 deletions lib/ai-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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())
Expand All @@ -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).
*/
Expand All @@ -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}`)
}
Expand All @@ -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
Expand All @@ -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<string> {
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 }>
Expand All @@ -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 {
Expand Down
Loading