feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
@@ -4,8 +4,9 @@ import { CustomOpenAIProvider } from './providers/custom-openai';
|
||||
import { AnthropicProvider } from './providers/anthropic';
|
||||
import { GoogleProvider } from './providers/google';
|
||||
import { AIProvider } from './types';
|
||||
import { resolveAiRoute } from './router';
|
||||
|
||||
type ProviderType =
|
||||
export type ProviderType =
|
||||
| 'ollama'
|
||||
| 'openai'
|
||||
| 'google'
|
||||
@@ -195,7 +196,8 @@ function createGLMProvider(config: Record<string, string>, modelName: string, em
|
||||
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
|
||||
}
|
||||
|
||||
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
|
||||
/** Exported for tests and Story 3.3+ orchestration; prefer `get*Provider` in production call sites. */
|
||||
export function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
|
||||
switch (providerType) {
|
||||
case 'ollama':
|
||||
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
@@ -250,61 +252,15 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
|
||||
}
|
||||
|
||||
export function getTagsProvider(config?: Record<string, string>): AIProvider {
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
if (!providerType) {
|
||||
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL;
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
const cfg = config || {};
|
||||
const route = resolveAiRoute('tags', cfg);
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
if (!providerType) {
|
||||
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
|
||||
if (provider === 'anthropic' || provider === 'anthropic_custom') {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
|
||||
);
|
||||
}
|
||||
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
const cfg = config || {};
|
||||
const route = resolveAiRoute('embedding', cfg);
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
export function getAIProvider(config?: Record<string, string>): AIProvider {
|
||||
@@ -312,35 +268,9 @@ export function getAIProvider(config?: Record<string, string>): AIProvider {
|
||||
}
|
||||
|
||||
export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_CHAT ||
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_CHAT ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
if (!providerType) {
|
||||
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
const modelName = (
|
||||
config?.AI_MODEL_CHAT ||
|
||||
process.env.AI_MODEL_CHAT ||
|
||||
'granite4:latest'
|
||||
);
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_CHAT || config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
const cfg = config || {};
|
||||
const route = resolveAiRoute('chat', cfg);
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
// Export for use by admin settings form and deploy scripts
|
||||
|
||||
255
memento-note/lib/ai/fallback.ts
Normal file
255
memento-note/lib/ai/fallback.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldLogAiFallback(): boolean {
|
||||
return process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1'
|
||||
}
|
||||
|
||||
export function formatAiFallbackDebug(meta: {
|
||||
lane: AiFeatureLane
|
||||
primaryProvider: string
|
||||
secondaryProvider: string
|
||||
primaryStatus?: number
|
||||
fallbackMs: number
|
||||
}): string {
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
function logFallbackSuccess(meta: {
|
||||
lane: AiFeatureLane
|
||||
primaryProvider: string
|
||||
secondaryProvider: string
|
||||
primaryStatus?: number
|
||||
fallbackMs: number
|
||||
}): void {
|
||||
if (!shouldLogAiFallback()) return
|
||||
console.debug('[ai-fallback]', formatAiFallbackDebug(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
|
||||
}
|
||||
}
|
||||
}
|
||||
108
memento-note/lib/ai/provider-for-user.ts
Normal file
108
memento-note/lib/ai/provider-for-user.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
getProviderInstance,
|
||||
type ProviderType,
|
||||
} from '@/lib/ai/factory';
|
||||
import { applyByokToConfig } from '@/lib/byok';
|
||||
import {
|
||||
resolveAiRoute,
|
||||
type AiFeatureLane,
|
||||
type ResolvedAiRoute,
|
||||
} from '@/lib/ai/router';
|
||||
import { withAiProviderFallback } from '@/lib/ai/fallback';
|
||||
import type { AIProvider } from '@/lib/ai/types';
|
||||
|
||||
export interface ProviderForUserResult {
|
||||
provider: AIProvider;
|
||||
usedByok: boolean;
|
||||
route: ResolvedAiRoute;
|
||||
}
|
||||
|
||||
async function resolveProviderForLane(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
const cfg = { ...config };
|
||||
const route = resolveAiRoute(lane, cfg);
|
||||
let usedByok = false;
|
||||
|
||||
if (billingUserId) {
|
||||
const overlay = await applyByokToConfig(
|
||||
billingUserId,
|
||||
route.providerType,
|
||||
cfg,
|
||||
);
|
||||
Object.assign(cfg, overlay.config);
|
||||
usedByok = overlay.usedByok;
|
||||
}
|
||||
|
||||
const provider = getProviderInstance(
|
||||
route.providerType as ProviderType,
|
||||
cfg,
|
||||
route.modelName,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl,
|
||||
);
|
||||
|
||||
return { provider, usedByok, route };
|
||||
}
|
||||
|
||||
export async function getChatProviderForBillingUser(
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
return resolveProviderForLane('chat', config, billingUserId);
|
||||
}
|
||||
|
||||
export async function getTagsProviderForBillingUser(
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
return resolveProviderForLane('tags', config, billingUserId);
|
||||
}
|
||||
|
||||
export async function getEmbeddingsProviderForBillingUser(
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
return resolveProviderForLane('embedding', config, billingUserId);
|
||||
}
|
||||
|
||||
/** Run a lane with BYOK overlay; skips system fallback when user key is active. */
|
||||
export async function willUseByokForLane(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<{ providerType: string; usedByok: boolean }> {
|
||||
if (!billingUserId) {
|
||||
const route = resolveAiRoute(lane, config)
|
||||
return { providerType: route.providerType, usedByok: false }
|
||||
}
|
||||
const route = resolveAiRoute(lane, config)
|
||||
const overlay = await applyByokToConfig(billingUserId, route.providerType, config)
|
||||
return { providerType: route.providerType, usedByok: overlay.usedByok }
|
||||
}
|
||||
|
||||
export async function runLaneWithBillingUser<T>(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
billingUserId: string | undefined,
|
||||
run: (provider: AIProvider) => Promise<T>,
|
||||
): Promise<{ result: T; usedByok: boolean }> {
|
||||
if (billingUserId) {
|
||||
const resolved =
|
||||
lane === 'chat'
|
||||
? await getChatProviderForBillingUser(config, billingUserId)
|
||||
: lane === 'tags'
|
||||
? await getTagsProviderForBillingUser(config, billingUserId)
|
||||
: await getEmbeddingsProviderForBillingUser(config, billingUserId);
|
||||
|
||||
if (resolved.usedByok) {
|
||||
const result = await run(resolved.provider);
|
||||
return { result, usedByok: true };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await withAiProviderFallback(lane, config, run);
|
||||
return { result, usedByok: false };
|
||||
}
|
||||
172
memento-note/lib/ai/router.ts
Normal file
172
memento-note/lib/ai/router.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Central synchronous AI gateway routing (Story 3.2 — FR17 / NFR-P3).
|
||||
*
|
||||
* Future (Story 3.5 BYOK): plug user-scoped API keys into resolveAiRoute output / factory instantiation.
|
||||
*
|
||||
* Non-goals here (by design):
|
||||
* - Multi-provider HTTP fallback on 429/500 → Story 3.3
|
||||
* - BYOK / UserAPIKey decryption → Story 3.5 (extension seam: same resolve output + key source later)
|
||||
*/
|
||||
|
||||
export type AiFeatureLane = 'chat' | 'tags' | 'embedding'
|
||||
|
||||
export type AiGatewayProvider =
|
||||
| 'ollama'
|
||||
| 'openai'
|
||||
| 'google'
|
||||
| 'minimax'
|
||||
| 'glm'
|
||||
| 'custom'
|
||||
| 'deepseek'
|
||||
| 'openrouter'
|
||||
| 'mistral'
|
||||
| 'zai'
|
||||
| 'lmstudio'
|
||||
| 'anthropic'
|
||||
| 'anthropic_custom'
|
||||
|
||||
export interface ResolvedAiRoute {
|
||||
lane: AiFeatureLane
|
||||
providerType: AiGatewayProvider
|
||||
modelName: string
|
||||
embeddingModelName: string
|
||||
ollamaBaseUrl?: string
|
||||
meta: {
|
||||
resolveMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const VALID_PROVIDERS = new Set<string>([
|
||||
'ollama', 'openai', 'google', 'minimax', 'glm', 'custom',
|
||||
'deepseek', 'openrouter', 'mistral', 'zai', 'lmstudio',
|
||||
'anthropic', 'anthropic_custom',
|
||||
])
|
||||
|
||||
const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: string }> = {
|
||||
ollama: { model: 'granite4:latest', embeddingModel: 'embeddinggemma:latest' },
|
||||
openai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
|
||||
anthropic: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
|
||||
anthropic_custom: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
|
||||
deepseek: { model: 'deepseek-chat', embeddingModel: '' },
|
||||
openrouter: { model: 'openai/gpt-4o-mini', embeddingModel: 'openai/text-embedding-3-small' },
|
||||
google: { model: 'gemini-1.5-flash', embeddingModel: 'text-embedding-004' },
|
||||
mistral: { model: 'mistral-small-latest', embeddingModel: 'mistral-embed' },
|
||||
zai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
|
||||
minimax: { model: 'abab6.5-chat', embeddingModel: '' },
|
||||
glm: { model: 'glm-4', embeddingModel: 'embedding-2' },
|
||||
lmstudio: { model: '', embeddingModel: '' },
|
||||
custom: { model: '', embeddingModel: '' },
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const VALID_PROVIDER_LIST = [...VALID_PROVIDERS].join(', ')
|
||||
|
||||
export function resolveAiRoute(lane: AiFeatureLane, config: Record<string, string>): ResolvedAiRoute {
|
||||
let providerRaw: string | undefined
|
||||
let modelKey: string
|
||||
let ollamaBaseUrl: string | undefined
|
||||
|
||||
if (lane === 'tags') {
|
||||
providerRaw =
|
||||
pick(config, 'AI_PROVIDER_TAGS') ||
|
||||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
|
||||
pick(config, 'AI_PROVIDER')
|
||||
modelKey = 'AI_MODEL_TAGS'
|
||||
ollamaBaseUrl = cfgOnly(config, 'OLLAMA_BASE_URL_TAGS') || cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
if (!providerRaw) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ' + VALID_PROVIDER_LIST
|
||||
)
|
||||
}
|
||||
} else if (lane === 'embedding') {
|
||||
providerRaw =
|
||||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
|
||||
pick(config, 'AI_PROVIDER_TAGS') ||
|
||||
pick(config, 'AI_PROVIDER')
|
||||
modelKey = 'AI_MODEL_EMBEDDING'
|
||||
ollamaBaseUrl = cfgOnly(config, 'OLLAMA_BASE_URL_EMBEDDING') || cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
if (!providerRaw) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ' + VALID_PROVIDER_LIST
|
||||
)
|
||||
}
|
||||
} else {
|
||||
providerRaw =
|
||||
pick(config, 'AI_PROVIDER_CHAT') ||
|
||||
pick(config, 'AI_PROVIDER_TAGS') ||
|
||||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
|
||||
pick(config, 'AI_PROVIDER')
|
||||
modelKey = 'AI_MODEL_CHAT'
|
||||
ollamaBaseUrl =
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL_CHAT') ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL_TAGS') ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL_EMBEDDING') ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
if (!providerRaw) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ' + VALID_PROVIDER_LIST
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const providerType = providerRaw.toLowerCase()
|
||||
|
||||
if (!VALID_PROVIDERS.has(providerType)) {
|
||||
throw new Error(
|
||||
`Unknown AI provider '${providerRaw}'. Valid options: ${VALID_PROVIDER_LIST}`
|
||||
)
|
||||
}
|
||||
|
||||
if (lane === 'embedding' && (providerType === 'anthropic' || providerType === 'anthropic_custom')) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
|
||||
)
|
||||
}
|
||||
|
||||
const defaults = PROVIDER_MODEL_DEFAULTS[providerType] || { model: '', embeddingModel: '' }
|
||||
const modelName = pick(config, modelKey) || defaults.model
|
||||
const embeddingModelName = pick(config, 'AI_MODEL_EMBEDDING') || defaults.embeddingModel
|
||||
|
||||
return {
|
||||
lane,
|
||||
providerType: providerType as AiGatewayProvider,
|
||||
modelName,
|
||||
embeddingModelName,
|
||||
ollamaBaseUrl,
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiRouteWithTiming(lane: AiFeatureLane, config: Record<string, string>): ResolvedAiRoute {
|
||||
const t0 = performance.now()
|
||||
const route = resolveAiRoute(lane, config)
|
||||
const resolveMs = performance.now() - t0
|
||||
return {
|
||||
...route,
|
||||
meta: { ...route.meta, resolveMs },
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAiRouteDebug(route: ResolvedAiRoute): string {
|
||||
return JSON.stringify({
|
||||
lane: route.lane,
|
||||
providerType: route.providerType,
|
||||
modelId: route.modelName,
|
||||
embeddingModelId: route.embeddingModelName,
|
||||
resolveMs: route.meta.resolveMs,
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Stores embeddings as native pgvector in PostgreSQL.
|
||||
*/
|
||||
|
||||
import { getAIProvider } from '../factory'
|
||||
import { withAiProviderFallback } from '../fallback'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface EmbeddingResult {
|
||||
@@ -23,8 +23,9 @@ export class EmbeddingService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const embedding = await provider.getEmbeddings(text)
|
||||
const embedding = await withAiProviderFallback('embedding', config, (provider) =>
|
||||
provider.getEmbeddings(text)
|
||||
)
|
||||
|
||||
return {
|
||||
embedding,
|
||||
@@ -45,9 +46,8 @@ export class EmbeddingService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const embeddings = await Promise.all(
|
||||
validTexts.map(text => provider.getEmbeddings(text))
|
||||
const embeddings = await withAiProviderFallback('embedding', config, (provider) =>
|
||||
Promise.all(validTexts.map((text) => provider.getEmbeddings(text)))
|
||||
)
|
||||
|
||||
return embeddings.map(embedding => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { withAiProviderFallback } from '@/lib/ai/fallback'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
toolRegistry.register({
|
||||
@@ -44,7 +44,6 @@ toolRegistry.register({
|
||||
const lang = locale === 'fr' ? 'français' : locale === 'es' ? 'espagnol' : locale === 'de' ? 'allemand' : locale === 'it' ? 'italien' : locale === 'pt' ? 'portugais' : locale === 'nl' ? 'néerlandais' : locale === 'ru' ? 'russe' : locale === 'zh' ? 'chinois' : locale === 'ja' ? 'japonais' : locale === 'ar' ? 'arabe' : locale === 'fa' ? 'persan' : locale === 'hi' ? 'hindi' : 'English'
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = `You are a task extraction specialist. Analyze the following notes and extract ALL action items, tasks, and TODOs.
|
||||
|
||||
@@ -72,7 +71,9 @@ Format each task as:
|
||||
- **Deadline**: ...
|
||||
- **Status**: ...`
|
||||
|
||||
const result = await provider.generateText(prompt)
|
||||
const result = await withAiProviderFallback('tags', config, (provider) =>
|
||||
provider.generateText(prompt)
|
||||
)
|
||||
|
||||
const summaryTitle = locale === 'fr'
|
||||
? `Action Items — ${new Date().toLocaleDateString('fr-FR')}`
|
||||
|
||||
Reference in New Issue
Block a user