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')}`
|
||||
|
||||
35
memento-note/lib/billing/stripe-prices.ts
Normal file
35
memento-note/lib/billing/stripe-prices.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
||||
|
||||
export type BillingTier = 'PRO' | 'BUSINESS';
|
||||
export type BillingInterval = 'month' | 'year';
|
||||
|
||||
export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string {
|
||||
const map: Record<BillingTier, Record<BillingInterval, string>> = {
|
||||
PRO: {
|
||||
month: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
||||
year: process.env.STRIPE_PRICE_PRO_ANNUAL!,
|
||||
},
|
||||
BUSINESS: {
|
||||
month: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!,
|
||||
year: process.env.STRIPE_PRICE_BUSINESS_ANNUAL!,
|
||||
},
|
||||
};
|
||||
const priceId = map[tier][interval];
|
||||
if (!priceId) {
|
||||
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
|
||||
}
|
||||
return priceId;
|
||||
}
|
||||
|
||||
export function priceIdToTier(priceId: string): SubscriptionTier | null {
|
||||
const entries: Array<[string | undefined, SubscriptionTier]> = [
|
||||
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
|
||||
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],
|
||||
[process.env.STRIPE_PRICE_BUSINESS_MONTHLY, 'BUSINESS'],
|
||||
[process.env.STRIPE_PRICE_BUSINESS_ANNUAL, 'BUSINESS'],
|
||||
];
|
||||
for (const [envPriceId, tier] of entries) {
|
||||
if (envPriceId && envPriceId === priceId) return tier;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
113
memento-note/lib/billing/sync-subscription-from-stripe.ts
Normal file
113
memento-note/lib/billing/sync-subscription-from-stripe.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type Stripe from 'stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { priceIdToTier } from '@/lib/billing/stripe-prices';
|
||||
import { SubscriptionStatus, SubscriptionTier } from '@prisma/client';
|
||||
|
||||
function mapStripeStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus {
|
||||
switch (stripeStatus) {
|
||||
case 'active':
|
||||
return SubscriptionStatus.ACTIVE;
|
||||
case 'trialing':
|
||||
return SubscriptionStatus.TRIALING;
|
||||
case 'past_due':
|
||||
return SubscriptionStatus.PAST_DUE;
|
||||
case 'canceled':
|
||||
case 'unpaid':
|
||||
return SubscriptionStatus.CANCELED;
|
||||
case 'incomplete':
|
||||
case 'incomplete_expired':
|
||||
return SubscriptionStatus.INACTIVE;
|
||||
default:
|
||||
return SubscriptionStatus.INACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSubscriptionFromStripe(
|
||||
subscription: Stripe.Subscription,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const priceId = subscription.items.data[0]?.price?.id ?? null;
|
||||
const resolvedTier = priceId ? priceIdToTier(priceId) : null;
|
||||
|
||||
const tierFromMetadata =
|
||||
(subscription.metadata?.tier as string | undefined) ??
|
||||
(subscription.metadata?.tier as string | undefined);
|
||||
|
||||
let tier: SubscriptionTier;
|
||||
if (resolvedTier) {
|
||||
tier = resolvedTier as SubscriptionTier;
|
||||
} else if (tierFromMetadata === 'PRO' || tierFromMetadata === 'BUSINESS' || tierFromMetadata === 'ENTERPRISE') {
|
||||
tier = tierFromMetadata as SubscriptionTier;
|
||||
} else {
|
||||
tier = SubscriptionTier.BASIC;
|
||||
}
|
||||
|
||||
const status = mapStripeStatus(subscription.status);
|
||||
|
||||
const currentPeriodStart = new Date(((subscription as any).current_period_start as number) * 1000);
|
||||
const currentPeriodEnd = new Date(((subscription as any).current_period_end as number) * 1000);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
update: {
|
||||
tier,
|
||||
status,
|
||||
stripeCustomerId: typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
|
||||
trialEndsAt: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tier,
|
||||
status,
|
||||
stripeCustomerId: typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
|
||||
trialEndsAt: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||
const existing = await prisma.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
data: {
|
||||
status: SubscriptionStatus.CANCELED,
|
||||
cancelAtPeriodEnd: false,
|
||||
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveUserIdFromStripeEvent(
|
||||
subscription: Stripe.Subscription,
|
||||
): Promise<string | null> {
|
||||
const metaUserId = subscription.metadata?.userId as string | undefined;
|
||||
if (metaUserId) return metaUserId;
|
||||
|
||||
const customerId = typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return existing?.userId ?? null;
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export type BillingOwnerContext = {
|
||||
billingOwnerId: string
|
||||
isGuestActor: boolean
|
||||
}
|
||||
|
||||
/** Resolve quota billing owner from an already-loaded session row (no extra query). */
|
||||
export function billingOwnerFromSession(
|
||||
sessionUserId: string,
|
||||
requestingUserId: string,
|
||||
): BillingOwnerContext {
|
||||
if (!sessionUserId) {
|
||||
throw new Error('Session has no owner — cannot resolve billing owner')
|
||||
}
|
||||
return {
|
||||
billingOwnerId: sessionUserId,
|
||||
isGuestActor: sessionUserId !== requestingUserId,
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-pays: all collaborative AI usage is billed to BrainstormSession.userId (Story 3.4). */
|
||||
export async function getBillingOwner(
|
||||
sessionId: string,
|
||||
requestingUserId: string,
|
||||
): Promise<BillingOwnerContext> {
|
||||
const session = await prisma.brainstormSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { userId: true },
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
|
||||
return billingOwnerFromSession(session.userId, requestingUserId)
|
||||
}
|
||||
|
||||
export async function verifyParticipant(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
|
||||
35
memento-note/lib/brainstorm-quota-client.ts
Normal file
35
memento-note/lib/brainstorm-quota-client.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Client-side parsing for brainstorm host-pays quota responses (Story 3.4). */
|
||||
|
||||
export type BrainstormQuotaPayload = {
|
||||
error: string
|
||||
feature?: string
|
||||
upgradeTier?: string
|
||||
byokConfigured?: boolean
|
||||
billingOwnerId?: string
|
||||
triggeredByUserId?: string
|
||||
isGuestActor?: boolean
|
||||
}
|
||||
|
||||
export class BrainstormQuotaError extends Error {
|
||||
readonly code = 'QUOTA_EXCEEDED'
|
||||
readonly isGuestActor: boolean
|
||||
readonly payload: BrainstormQuotaPayload
|
||||
|
||||
constructor(payload: BrainstormQuotaPayload, message: string) {
|
||||
super(message)
|
||||
this.name = 'BrainstormQuotaError'
|
||||
this.isGuestActor = Boolean(payload.isGuestActor)
|
||||
this.payload = payload
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfBrainstormQuota(
|
||||
res: Response,
|
||||
data: BrainstormQuotaPayload & { success?: boolean; error?: string },
|
||||
guestMessage: string,
|
||||
hostMessage: string,
|
||||
): void {
|
||||
if (res.status !== 402 || data?.error !== 'QUOTA_EXCEEDED') return
|
||||
const message = data.isGuestActor ? guestMessage : hostMessage
|
||||
throw new BrainstormQuotaError(data, message)
|
||||
}
|
||||
137
memento-note/lib/byok.ts
Normal file
137
memento-note/lib/byok.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { decryptApiKey, encryptApiKey, hashApiKey } from '@/lib/crypto';
|
||||
import {
|
||||
VALID_PROVIDERS,
|
||||
type AiGatewayProvider,
|
||||
} from '@/lib/ai/router';
|
||||
import { getProviderConfigKeys } from '@/lib/ai/factory';
|
||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
||||
|
||||
const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
'openai',
|
||||
'anthropic',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'minimax',
|
||||
'zai',
|
||||
];
|
||||
|
||||
const BUSINESS_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
...VALID_PROVIDERS,
|
||||
].filter((p) => p !== 'ollama' && p !== 'lmstudio') as AiGatewayProvider[];
|
||||
|
||||
export function getAllowedByokProviders(
|
||||
tier: SubscriptionTier,
|
||||
): readonly AiGatewayProvider[] {
|
||||
if (tier === 'BASIC') return [];
|
||||
if (tier === 'PRO') return PRO_BYOK_PROVIDERS;
|
||||
return BUSINESS_BYOK_PROVIDERS;
|
||||
}
|
||||
|
||||
export function isByokProviderAllowed(
|
||||
tier: SubscriptionTier,
|
||||
provider: string,
|
||||
): boolean {
|
||||
return getAllowedByokProviders(tier).includes(provider as AiGatewayProvider);
|
||||
}
|
||||
|
||||
export async function hasAnyActiveByok(userId: string): Promise<boolean> {
|
||||
const count = await prisma.userAPIKey.count({
|
||||
where: { userId, isActive: true },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export async function getActiveByokKey(userId: string, provider: string) {
|
||||
return prisma.userAPIKey.findFirst({
|
||||
where: { userId, provider, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveByokApiKey(
|
||||
userId: string,
|
||||
providerType: string,
|
||||
): Promise<{ plaintext: string; provider: string } | null> {
|
||||
const row = await getActiveByokKey(userId, providerType);
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = await decryptApiKey(row.encryptedKey);
|
||||
return { plaintext, provider: row.provider };
|
||||
} catch (err) {
|
||||
console.error('[byok] Failed to decrypt key for provider', providerType, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyByokToConfig(
|
||||
billingUserId: string,
|
||||
providerType: string,
|
||||
config: Record<string, string>,
|
||||
): Promise<{ config: Record<string, string>; usedByok: boolean }> {
|
||||
const byok = await resolveByokApiKey(billingUserId, providerType);
|
||||
if (!byok) return { config, usedByok: false };
|
||||
|
||||
const { apiKeyConfigKey } = getProviderConfigKeys(providerType);
|
||||
if (!apiKeyConfigKey) return { config, usedByok: false };
|
||||
|
||||
return {
|
||||
config: { ...config, [apiKeyConfigKey]: byok.plaintext },
|
||||
usedByok: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertUserApiKey(params: {
|
||||
userId: string;
|
||||
provider: AiGatewayProvider;
|
||||
plaintext: string;
|
||||
alias?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
const encryptedKey = await encryptApiKey(params.plaintext);
|
||||
const keyHash = hashApiKey(params.plaintext);
|
||||
|
||||
return prisma.userAPIKey.upsert({
|
||||
where: {
|
||||
userId_provider: {
|
||||
userId: params.userId,
|
||||
provider: params.provider,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: params.userId,
|
||||
provider: params.provider,
|
||||
alias: params.alias ?? '',
|
||||
encryptedKey,
|
||||
keyHash,
|
||||
model: params.model ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
alias: params.alias ?? '',
|
||||
encryptedKey,
|
||||
keyHash,
|
||||
model: params.model ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toPublicApiKey(row: {
|
||||
provider: string;
|
||||
alias: string;
|
||||
model: string | null;
|
||||
isActive: boolean;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
provider: row.provider,
|
||||
alias: row.alias,
|
||||
model: row.model,
|
||||
isActive: row.isActive,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
106
memento-note/lib/byok/validate-key.ts
Normal file
106
memento-note/lib/byok/validate-key.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
const OPENAI_COMPAT = new Set<string>([
|
||||
'openai',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'mistral',
|
||||
'zai',
|
||||
'minimax',
|
||||
'glm',
|
||||
'custom',
|
||||
'lmstudio',
|
||||
]);
|
||||
|
||||
async function validateOpenAiCompatible(
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Provider rejected the API key (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAnthropic(apiKey: string): Promise<void> {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error('Provider rejected the API key');
|
||||
}
|
||||
if (res.status >= 500) {
|
||||
throw new Error('Provider temporarily unavailable; try again');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGoogle(apiKey: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Provider rejected the API key (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URLS: Partial<Record<AiGatewayProvider, string>> = {
|
||||
deepseek: 'https://api.deepseek.com/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
mistral: 'https://api.mistral.ai/v1',
|
||||
zai: 'https://api.zukijourney.com/v1',
|
||||
minimax: 'https://api.minimax.io/v1',
|
||||
glm: 'https://open.bigmodel.ai/api/paas/v4',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
export async function validateProviderApiKey(
|
||||
provider: AiGatewayProvider,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<void> {
|
||||
if (!apiKey.trim()) {
|
||||
throw new Error('API key is required');
|
||||
}
|
||||
|
||||
if (provider === 'anthropic' || provider === 'anthropic_custom') {
|
||||
await validateAnthropic(apiKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'google') {
|
||||
await validateGoogle(apiKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'ollama' || provider === 'lmstudio') {
|
||||
throw new Error('Local providers are not supported for BYOK');
|
||||
}
|
||||
|
||||
if (OPENAI_COMPAT.has(provider)) {
|
||||
const url = provider === 'custom'
|
||||
? baseUrl
|
||||
: BASE_URLS[provider as AiGatewayProvider];
|
||||
if (!url) {
|
||||
throw new Error('Base URL is required for this provider');
|
||||
}
|
||||
await validateOpenAiCompatible(apiKey, url);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported provider: ${provider}`);
|
||||
}
|
||||
@@ -9,6 +9,12 @@ const ENV_FALLBACKS: Record<string, string> = {
|
||||
AI_MODEL_EMBEDDING: process.env.AI_MODEL_EMBEDDING || '',
|
||||
AI_PROVIDER_CHAT: process.env.AI_PROVIDER_CHAT || '',
|
||||
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
|
||||
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
|
||||
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
|
||||
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
|
||||
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
|
||||
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',
|
||||
AI_MODEL_EMBEDDING_FALLBACK: process.env.AI_MODEL_EMBEDDING_FALLBACK || '',
|
||||
// Ollama
|
||||
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '',
|
||||
// OpenAI
|
||||
|
||||
65
memento-note/lib/crypto.ts
Normal file
65
memento-note/lib/crypto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
} from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 16;
|
||||
const SALT_LEN = 16;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function getMasterPassphrase(): string {
|
||||
const master = process.env.MASTER_ENCRYPTION_KEY;
|
||||
if (!master || master.length < 32) {
|
||||
throw new Error('MASTER_ENCRYPTION_KEY must be set (minimum 32 characters)');
|
||||
}
|
||||
return master;
|
||||
}
|
||||
|
||||
async function deriveKey(salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(getMasterPassphrase(), salt, KEY_LEN, (err, key) => {
|
||||
if (err) reject(err);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function encryptApiKey(plaintext: string): Promise<string> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const key = await deriveKey(salt);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
export async function decryptApiKey(payload: string): Promise<string> {
|
||||
const buf = Buffer.from(payload, 'base64');
|
||||
if (buf.length < SALT_LEN + IV_LEN + TAG_LEN + 1) {
|
||||
throw new Error('Invalid encrypted key payload');
|
||||
}
|
||||
const salt = buf.subarray(0, SALT_LEN);
|
||||
const iv = buf.subarray(SALT_LEN, SALT_LEN + IV_LEN);
|
||||
const tag = buf.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
|
||||
const ciphertext = buf.subarray(SALT_LEN + IV_LEN + TAG_LEN);
|
||||
const key = await deriveKey(salt);
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
export function hashApiKey(plaintext: string): string {
|
||||
return createHash('sha256').update(plaintext, 'utf8').digest('hex');
|
||||
}
|
||||
375
memento-note/lib/entitlements.ts
Normal file
375
memento-note/lib/entitlements.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { redis } from './redis';
|
||||
import { prisma } from './prisma';
|
||||
import { hasAnyActiveByok } from './byok';
|
||||
import {
|
||||
getCurrentPeriodKey,
|
||||
getRedisKey,
|
||||
parseRedisInt,
|
||||
isValidFeature,
|
||||
} from './quota-utils';
|
||||
|
||||
type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE';
|
||||
|
||||
export interface EntitlementResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
tier: SubscriptionTier;
|
||||
reason?: 'QUOTA_EXCEEDED' | 'TIER_LIMITED' | 'FEATURE_NOT_AVAILABLE';
|
||||
message?: string;
|
||||
upgradeTier?: 'PRO' | 'BUSINESS';
|
||||
byokConfigured?: boolean;
|
||||
}
|
||||
|
||||
export class QuotaExceededError extends Error {
|
||||
code = 'QUOTA_EXCEEDED';
|
||||
upgradeTier: 'PRO' | 'BUSINESS';
|
||||
feature: string;
|
||||
currentQuota: number;
|
||||
usedQuota: number;
|
||||
byokConfigured: boolean;
|
||||
billingOwnerId?: string;
|
||||
triggeredByUserId?: string;
|
||||
isGuestActor?: boolean;
|
||||
|
||||
constructor(
|
||||
upgradeTier: 'PRO' | 'BUSINESS',
|
||||
feature: string,
|
||||
currentQuota: number,
|
||||
usedQuota: number,
|
||||
byokConfigured: boolean = false,
|
||||
sessionMeta?: {
|
||||
billingOwnerId?: string;
|
||||
triggeredByUserId?: string;
|
||||
isGuestActor?: boolean;
|
||||
},
|
||||
) {
|
||||
super(`Quota exceeded for ${feature}`);
|
||||
this.upgradeTier = upgradeTier;
|
||||
this.feature = feature;
|
||||
this.currentQuota = currentQuota;
|
||||
this.usedQuota = usedQuota;
|
||||
this.byokConfigured = byokConfigured;
|
||||
this.billingOwnerId = sessionMeta?.billingOwnerId;
|
||||
this.triggeredByUserId = sessionMeta?.triggeredByUserId;
|
||||
this.isGuestActor = sessionMeta?.isGuestActor;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.code,
|
||||
feature: this.feature,
|
||||
upgradeTier: this.upgradeTier,
|
||||
byokConfigured: this.byokConfigured,
|
||||
isGuestActor: this.isGuestActor ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>> = {
|
||||
BASIC: {
|
||||
semantic_search: 30,
|
||||
auto_tag: 20,
|
||||
auto_title: 10,
|
||||
brainstorm_create: 1,
|
||||
brainstorm_expand: 10,
|
||||
brainstorm_enrich: 20,
|
||||
},
|
||||
PRO: {
|
||||
semantic_search: 100,
|
||||
auto_tag: 200,
|
||||
auto_title: 200,
|
||||
reformulate: 50,
|
||||
chat: 100,
|
||||
brainstorm_create: 30,
|
||||
brainstorm_expand: 100,
|
||||
brainstorm_enrich: 200,
|
||||
},
|
||||
BUSINESS: {
|
||||
semantic_search: 1000,
|
||||
auto_tag: 1000,
|
||||
auto_title: 1000,
|
||||
reformulate: 500,
|
||||
chat: 1000,
|
||||
brainstorm_create: 200,
|
||||
brainstorm_expand: 500,
|
||||
brainstorm_enrich: 1000,
|
||||
},
|
||||
ENTERPRISE: {
|
||||
semantic_search: 'unlimited',
|
||||
auto_tag: 'unlimited',
|
||||
auto_title: 'unlimited',
|
||||
reformulate: 'unlimited',
|
||||
chat: 'unlimited',
|
||||
brainstorm_create: 'unlimited',
|
||||
brainstorm_expand: 'unlimited',
|
||||
brainstorm_enrich: 'unlimited',
|
||||
},
|
||||
};
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
const INCREMENT_LUA = `
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local ttl = tonumber(ARGV[1])
|
||||
redis.call('INCRBY', KEYS[1], 1)
|
||||
local ttlResult = redis.call('TTL', KEYS[1])
|
||||
if ttlResult == -1 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
end
|
||||
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
||||
return newCount
|
||||
`;
|
||||
|
||||
const RESERVE_LUA = `
|
||||
local limit = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if current >= limit then
|
||||
return -1
|
||||
end
|
||||
redis.call('INCRBY', KEYS[1], 1)
|
||||
local ttlResult = redis.call('TTL', KEYS[1])
|
||||
if ttlResult == -1 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
end
|
||||
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
||||
return newCount
|
||||
`;
|
||||
|
||||
function getLimit(tier: SubscriptionTier, feature: string): number | undefined {
|
||||
const tierLimits = TIER_LIMITS[tier];
|
||||
const limit = tierLimits?.[feature];
|
||||
if (limit === 'unlimited') return Infinity;
|
||||
if (limit === undefined) return undefined;
|
||||
return limit;
|
||||
}
|
||||
|
||||
export async function getUserInfo(
|
||||
userId: string,
|
||||
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (!subscription) return { tier: 'BASIC', status: 'INACTIVE' };
|
||||
return {
|
||||
tier: subscription.tier as SubscriptionTier,
|
||||
status: subscription.status,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEffectiveTier(
|
||||
userId: string,
|
||||
): Promise<SubscriptionTier> {
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
|
||||
if (status === 'ACTIVE' || status === 'TRIALING') return tier;
|
||||
|
||||
if ((status === 'PAST_DUE' || status === 'CANCELED') && currentPeriodEnd) {
|
||||
if (new Date() < new Date(currentPeriodEnd)) return tier;
|
||||
}
|
||||
|
||||
return 'BASIC';
|
||||
}
|
||||
|
||||
export async function canUseFeature(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<EntitlementResult> {
|
||||
if (!isValidFeature(feature)) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 0,
|
||||
tier: 'BASIC',
|
||||
reason: 'TIER_LIMITED',
|
||||
message: `Unknown feature: ${feature}`,
|
||||
};
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const limit = getLimit(tier, feature);
|
||||
|
||||
if (limit === undefined) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 0,
|
||||
tier,
|
||||
reason: 'FEATURE_NOT_AVAILABLE',
|
||||
message: `Feature "${feature}" is not available on the ${tier} plan. Upgrade to unlock it.`,
|
||||
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
};
|
||||
}
|
||||
|
||||
if (limit === Infinity) {
|
||||
return { allowed: true, remaining: Infinity, limit: Infinity, tier };
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const currentStr = await redis.get(key);
|
||||
const current = parseRedisInt(currentStr);
|
||||
const allowed = current < limit;
|
||||
|
||||
if (!allowed) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit,
|
||||
tier,
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
byokConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.max(0, limit - current),
|
||||
limit,
|
||||
tier,
|
||||
byokConfigured: await hasAnyActiveByok(userId),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err);
|
||||
return { allowed: true, remaining: limit, limit, tier };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkEntitlementOrThrow(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
const result = await canUseFeature(userId, feature);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new QuotaExceededError(
|
||||
result.upgradeTier ?? (result.tier === 'BASIC' ? 'PRO' : 'BUSINESS'),
|
||||
feature,
|
||||
result.limit,
|
||||
result.limit > 0 ? result.limit - result.remaining : 0,
|
||||
result.byokConfigured ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reserveUsageOrThrow(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
if (!isValidFeature(feature)) {
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false);
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const limit = getLimit(tier, feature);
|
||||
|
||||
if (limit === undefined) {
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (limit === Infinity) return;
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const newCount = await redis.eval(
|
||||
RESERVE_LUA,
|
||||
1,
|
||||
key,
|
||||
String(limit),
|
||||
String(TTL_SECONDS),
|
||||
) as number;
|
||||
|
||||
if (newCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
limit,
|
||||
limit,
|
||||
byokConfigured,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) throw err;
|
||||
console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
|
||||
export async function checkSessionEntitlementOrThrow(
|
||||
billingOwnerId: string,
|
||||
triggeredByUserId: string,
|
||||
isGuestActor: boolean,
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await reserveUsageOrThrow(billingOwnerId, feature);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
err.billingOwnerId = billingOwnerId;
|
||||
err.triggeredByUserId = triggeredByUserId;
|
||||
err.isGuestActor = isGuestActor;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementUsageAsync(userId: string, feature: string): Promise<void> {
|
||||
if (!isValidFeature(feature)) return Promise.resolve();
|
||||
|
||||
const key = getRedisKey(userId, feature);
|
||||
return redis.eval(INCREMENT_LUA, 1, key, String(TTL_SECONDS)).then(() => {}).catch((err) => {
|
||||
console.error('[entitlements] Async increment failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserQuotas(
|
||||
userId: string,
|
||||
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const period = getCurrentPeriodKey();
|
||||
const features = Object.keys(TIER_LIMITS[tier]);
|
||||
|
||||
if (features.length === 0) return {};
|
||||
|
||||
const keys = features.map((f) => `usage:${userId}:${f}:${period}`);
|
||||
|
||||
try {
|
||||
const values = await redis.mget(...keys);
|
||||
|
||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const feature = features[i];
|
||||
const limit = getLimit(tier, feature) ?? 0;
|
||||
const current = parseRedisInt(values[i]);
|
||||
result[feature] = {
|
||||
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
||||
limit,
|
||||
used: current,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[entitlements] getUserQuotas Redis error:', err);
|
||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||
for (const feature of features) {
|
||||
const limit = getLimit(tier, feature) ?? 0;
|
||||
result[feature] = { remaining: limit, limit, used: 0 };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export { TIER_LIMITS, getLimit };
|
||||
export type { SubscriptionTier };
|
||||
@@ -919,6 +919,53 @@ export interface Translations {
|
||||
expand: string
|
||||
collapse: string
|
||||
}
|
||||
billing: {
|
||||
title: string
|
||||
currentPlan: string
|
||||
upgradePlan: string
|
||||
manageBilling: string
|
||||
manageDescription: string
|
||||
openPortal: string
|
||||
renewsOn: string
|
||||
expiresOn: string
|
||||
canceledAt: string
|
||||
freePlan: string
|
||||
proPlan: string
|
||||
businessPlan: string
|
||||
enterprisePlan: string
|
||||
perMonth: string
|
||||
perYear: string
|
||||
monthly: string
|
||||
annual: string
|
||||
save: string
|
||||
upgradeTitle: string
|
||||
proPrice: string
|
||||
businessPrice: string
|
||||
proAnnualPrice: string
|
||||
businessAnnualPrice: string
|
||||
proFeature1: string
|
||||
proFeature2: string
|
||||
proFeature3: string
|
||||
proFeature4: string
|
||||
businessFeature1: string
|
||||
businessFeature2: string
|
||||
businessFeature3: string
|
||||
businessFeature4: string
|
||||
enterpriseTitle: string
|
||||
enterpriseDescription: string
|
||||
contactSales: string
|
||||
startCheckout: string
|
||||
checkoutLoading: string
|
||||
checkoutSuccess: string
|
||||
checkoutCanceled: string
|
||||
active: string
|
||||
trialing: string
|
||||
pastDue: string
|
||||
canceled: string
|
||||
inactive: string
|
||||
billingEnabled: string
|
||||
billingDisabled: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
|
||||
31
memento-note/lib/quota-utils.ts
Normal file
31
memento-note/lib/quota-utils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const VALID_FEATURES = [
|
||||
'semantic_search',
|
||||
'auto_tag',
|
||||
'auto_title',
|
||||
'reformulate',
|
||||
'chat',
|
||||
'brainstorm_create',
|
||||
'brainstorm_expand',
|
||||
'brainstorm_enrich',
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof VALID_FEATURES)[number];
|
||||
|
||||
export function getCurrentPeriodKey(): string {
|
||||
return new Date().toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
export function getRedisKey(userId: string, feature: string): string {
|
||||
const period = getCurrentPeriodKey();
|
||||
return `usage:${userId}:${feature}:${period}`;
|
||||
}
|
||||
|
||||
export function parseRedisInt(value: string | number | null | undefined): number {
|
||||
if (value == null) return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? Math.round(n) : 1;
|
||||
}
|
||||
|
||||
export function isValidFeature(feature: string): feature is FeatureName {
|
||||
return (VALID_FEATURES as readonly string[]).includes(feature);
|
||||
}
|
||||
32
memento-note/lib/redis.ts
Normal file
32
memento-note/lib/redis.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis };
|
||||
|
||||
const redis =
|
||||
globalForRedis.redis ??
|
||||
new Redis({
|
||||
host: process.env.REDIS_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379'),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
lazyConnect: true,
|
||||
retryStrategy(times) {
|
||||
if (times > 10) {
|
||||
console.error('[redis] Max retries exceeded, will retry in 30s');
|
||||
return 30000;
|
||||
}
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('[redis] Connection error:', err.message);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
console.log('[redis] Connected');
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis;
|
||||
|
||||
export { redis };
|
||||
18
memento-note/lib/stripe.ts
Normal file
18
memento-note/lib/stripe.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('STRIPE_SECRET_KEY is required in production');
|
||||
}
|
||||
}
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? 'sk_test_placeholder', {
|
||||
apiVersion: '2026-04-22.dahlia',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
export function getPublishableKey(): string {
|
||||
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!key) throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
|
||||
return key;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export function getThemeScript(serverTheme: string = 'light') {
|
||||
root.setAttribute('data-theme', theme);
|
||||
if (theme === 'midnight') root.classList.add('dark');
|
||||
}
|
||||
var accentStored = localStorage.getItem('accent-color');
|
||||
if (accentStored) root.style.setProperty('--color-brand-accent', accentStored);
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
|
||||
45
memento-note/lib/usage-tracker.ts
Normal file
45
memento-note/lib/usage-tracker.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { redis } from './redis';
|
||||
import { getRedisKey, parseRedisInt } from './quota-utils';
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
export function trackFeatureUsage(
|
||||
userId: string,
|
||||
feature: string,
|
||||
tokensUsed: number,
|
||||
): void {
|
||||
if (tokensUsed < 0) {
|
||||
console.warn('[usage-tracker] Negative tokensUsed ignored:', tokensUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokensUsed === 0) return;
|
||||
|
||||
const key = getRedisKey(userId, feature);
|
||||
|
||||
redis
|
||||
.pipeline()
|
||||
.incrbyfloat(`${key}:tokens`, tokensUsed)
|
||||
.expire(`${key}:tokens`, TTL_SECONDS)
|
||||
.exec()
|
||||
.catch((err) => {
|
||||
console.error('[usage-tracker] Failed to track token usage:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUsageCounter(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<{ requests: number; tokens: number }> {
|
||||
const key = getRedisKey(userId, feature);
|
||||
|
||||
const [requests, tokens] = await Promise.all([
|
||||
redis.get(key),
|
||||
redis.get(`${key}:tokens`),
|
||||
]);
|
||||
|
||||
return {
|
||||
requests: parseRedisInt(requests),
|
||||
tokens: parseRedisInt(tokens),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user