Files
Momento/memento-note/lib/ai/fallback.ts
Antigravity 724474cb49
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
chore: remove dead code — 8 components, 5 libs, 4 API routes, 4 npm packages, 30+ scripts, dead CSS, dead exports
Removed unused components:
- brainstorm-canvas, brainstorm-create-dialog, invite-dialog, manual-idea-dialog
- note-inline-editor, profile-page-header, quota-paywall, label-management-dialog

Removed dead lib files:
- api-auth.ts, color-harmony-recommendation.ts, label-storage.ts, modern-color-options.ts
- hooks/use-card-size-mode.ts

Removed dead API routes:
- ai/test-chat, ai/test-embeddings, ai/test-tags, admin/randomize-labels

Removed unused npm packages:
- cmdk, novel, tippy.js, react-force-graph-2d

Cleaned dead CSS from globals.css:
- acrylic-*, win11-shadow-*, muuri-grid/item, ai-glass, ai-tab-indicator, ai-send-btn, sidebar-view-toggle, memento-sidebar-depth

Removed 29 orphan scripts and 3 root orphan files

Cleaned dead exports from 8 lib files:
- NOTE_TYPE_CONFIG, getPublishableKey, PROVIDER_DEFAULTS, useNotes/useNote/invalidateNote, etc.
2026-05-16 20:34:58 +00:00

243 lines
7.0 KiB
TypeScript

/**
* Provider failover on retriable upstream errors (Story 3.3 — FR18 / NFR-R1).
*
* Story 3.5 BYOK: when user BYOK is active, call with skipSystemFallback: true.
*/
import { APICallError } from 'ai'
import { QuotaExceededError } from '@/lib/entitlements'
import { getProviderInstance, type ProviderType } from './factory'
import {
resolveAiRoute,
VALID_PROVIDERS,
type AiFeatureLane,
type AiGatewayProvider,
type ResolvedAiRoute,
} from './router'
import type { AIProvider } from './types'
export const FALLBACK_BUDGET_MS = 1500
const VALID_PROVIDER_LIST = [...VALID_PROVIDERS].join(', ')
const LANE_FALLBACK_KEYS: Record<
AiFeatureLane,
{ provider: string; model: string; ollamaUrl?: string }
> = {
chat: {
provider: 'AI_PROVIDER_CHAT_FALLBACK',
model: 'AI_MODEL_CHAT_FALLBACK',
ollamaUrl: 'OLLAMA_BASE_URL_CHAT',
},
tags: {
provider: 'AI_PROVIDER_TAGS_FALLBACK',
model: 'AI_MODEL_TAGS_FALLBACK',
ollamaUrl: 'OLLAMA_BASE_URL_TAGS',
},
embedding: {
provider: 'AI_PROVIDER_EMBEDDING_FALLBACK',
model: 'AI_MODEL_EMBEDDING_FALLBACK',
ollamaUrl: 'OLLAMA_BASE_URL_EMBEDDING',
},
}
function pick(config: Record<string, string>, key: string): string | undefined {
const v = config[key]
if (v != null && v !== '') return v
const e = process.env[key]
return e != null && e !== '' ? e : undefined
}
function cfgOnly(config: Record<string, string>, key: string): string | undefined {
const v = config[key]
return v != null && v !== '' ? v : undefined
}
function extractProviderErrorStatusDepth(err: unknown, depth: number): number | undefined {
if (depth > 5) return undefined
if (err instanceof QuotaExceededError) return 402
if (APICallError.isInstance(err)) {
return err.statusCode ?? undefined
}
if (err && typeof err === 'object') {
const o = err as Record<string, unknown>
if (typeof o.statusCode === 'number') return o.statusCode
if (typeof o.status === 'number') return o.status
if (o.cause) return extractProviderErrorStatusDepth(o.cause, depth + 1)
}
return undefined
}
export function extractProviderErrorStatus(err: unknown): number | undefined {
return extractProviderErrorStatusDepth(err, 0)
}
/** True for HTTP 429 and 5xx provider failures; false for quota and other 4xx. */
export function isRetriableProviderError(err: unknown): boolean {
if (err instanceof QuotaExceededError) return false
if (err && typeof err === 'object') {
const code = (err as { code?: string }).code
if (code === 'QUOTA_EXCEEDED') return false
}
const status = extractProviderErrorStatus(err)
if (status === undefined) return false
if (status === 429) return true
if (status >= 500 && status < 600) return true
return false
}
/**
* Resolve secondary route from *_FALLBACK keys only (primary keys untouched).
*/
export function resolveAiFallbackRoute(
lane: AiFeatureLane,
config: Record<string, string>
): ResolvedAiRoute | null {
const keys = LANE_FALLBACK_KEYS[lane]
const providerRaw = pick(config, keys.provider)?.trim()
if (!providerRaw) return null
const providerType = providerRaw.toLowerCase()
if (!VALID_PROVIDERS.has(providerType)) {
throw new Error(
`Unknown fallback provider '${providerRaw}'. Valid options: ${VALID_PROVIDER_LIST}`
)
}
if (lane === 'embedding' && (providerType === 'anthropic' || providerType === 'anthropic_custom')) {
throw new Error(
'AI_PROVIDER_EMBEDDING_FALLBACK cannot use "anthropic" or "anthropic_custom": no embeddings on this gateway.'
)
}
const primary = resolveAiRoute(lane, config)
if (providerType === primary.providerType) return null
const modelName =
pick(config, keys.model) ??
(lane === 'chat'
? pick(config, 'AI_MODEL_CHAT')
: lane === 'tags'
? pick(config, 'AI_MODEL_TAGS')
: pick(config, 'AI_MODEL_EMBEDDING')) ??
primary.modelName
const embeddingModelName =
pick(config, 'AI_MODEL_EMBEDDING_FALLBACK') ??
pick(config, 'AI_MODEL_EMBEDDING') ??
primary.embeddingModelName
const ollamaBaseUrl =
cfgOnly(config, keys.ollamaUrl!) ||
cfgOnly(config, 'OLLAMA_BASE_URL')
return {
lane,
providerType: providerType as AiGatewayProvider,
modelName,
embeddingModelName,
ollamaBaseUrl,
meta: {},
}
}
function getProviderForRoute(config: Record<string, string>, route: ResolvedAiRoute): AIProvider {
return getProviderInstance(
route.providerType as ProviderType,
config,
route.modelName,
route.embeddingModelName,
route.ollamaBaseUrl
)
}
function getPrimaryProvider(lane: AiFeatureLane, config: Record<string, string>): {
provider: AIProvider
route: ResolvedAiRoute
} {
const route = resolveAiRoute(lane, config)
return { route, provider: getProviderForRoute(config, route) }
}
function getSecondaryProvider(lane: AiFeatureLane, config: Record<string, string>): {
provider: AIProvider
route: ResolvedAiRoute
} | null {
try {
const fbRoute = resolveAiFallbackRoute(lane, config)
if (!fbRoute) return null
return { route: fbRoute, provider: getProviderForRoute(config, fbRoute) }
} catch {
return null
}
}
function logFallbackSuccess(meta: {
lane: AiFeatureLane
primaryProvider: string
secondaryProvider: string
primaryStatus?: number
fallbackMs: number
}): void {
if (process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1') {
console.debug('[ai-fallback]', JSON.stringify(meta))
if (meta.fallbackMs > FALLBACK_BUDGET_MS) {
console.warn(
`[ai-fallback] NFR-R1 budget exceeded: ${meta.fallbackMs.toFixed(1)}ms > ${FALLBACK_BUDGET_MS}ms`
)
}
}
}
export interface WithAiProviderFallbackOptions {
/** Story 3.5: skip system secondary when user BYOK is active */
skipSystemFallback?: boolean
}
/**
* Run an AI operation on the primary provider; on retriable failure, try secondary once.
*/
export async function withAiProviderFallback<T>(
lane: AiFeatureLane,
config: Record<string, string>,
run: (provider: AIProvider) => Promise<T>,
options?: WithAiProviderFallbackOptions
): Promise<T> {
if (options?.skipSystemFallback) {
const primary = getPrimaryProvider(lane, config)
return run(primary.provider)
}
const primary = getPrimaryProvider(lane, config)
try {
return await run(primary.provider)
} catch (err) {
if (!isRetriableProviderError(err)) throw err
const fallbackStart = performance.now()
const secondary = getSecondaryProvider(lane, config)
if (!secondary) throw err
const primaryStatus = extractProviderErrorStatus(err)
try {
const result = await run(secondary.provider)
logFallbackSuccess({
lane,
primaryProvider: primary.route.providerType,
secondaryProvider: secondary.route.providerType,
primaryStatus,
fallbackMs: performance.now() - fallbackStart,
})
return result
} catch (secondaryErr) {
console.error(
`[ai-fallback] secondary also failed for lane '${lane}':`,
secondaryErr instanceof Error ? secondaryErr.message : secondaryErr
)
throw secondaryErr
}
}
}