style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
+22 -22
View File
@@ -14,38 +14,38 @@ function createTestDeps() {
}
return {
db: { query: { user: { findFirst: vi.fn() } } } as never,
billingService: {} as never,
characterService: {} as never,
chatService: {} as never,
providerService: {} as never,
fluxService: {} as never,
fluxTransactionService: {} as never,
stripeService: {} as never,
billingService: {} as never,
ttsMeter: {} as never,
requestLogService: {} as never,
voicePackService: {} as never,
providerCatalogService: {} as never,
productEventService: {
track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
} as never,
configKV: { getOrThrow: vi.fn() } as never,
redis: redis as never,
db: { query: { user: { findFirst: vi.fn() } } } as never,
env: {
API_SERVER_URL: 'https://api.airi.build',
AUTH_SERVER_URL: 'https://api.airi.build',
} as never,
otel: null,
userDeletionService: { register: vi.fn(), softDeleteAll: vi.fn() },
llmRouter: {
route: vi.fn(async () => new Response('{}', { status: 200 })),
invalidateConfig: vi.fn(),
} as never,
envelopeCrypto: {
encryptKey: vi.fn(),
decryptKey: vi.fn(),
encryptKey: vi.fn(),
} as never,
fluxService: {} as never,
fluxTransactionService: {} as never,
llmRouter: {
invalidateConfig: vi.fn(),
route: vi.fn(async () => new Response('{}', { status: 200 })),
} as never,
otel: null,
productEventService: {
track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
} as never,
providerCatalogService: {} as never,
providerService: {} as never,
redis: redis as never,
requestLogService: {} as never,
stripeService: {} as never,
ttsMeter: {} as never,
userDeletionService: { register: vi.fn(), softDeleteAll: vi.fn() },
voicePackService: {} as never,
}
}
+122 -122
View File
@@ -81,30 +81,32 @@ import { nanoid } from './utils/id'
import { getTrustedOrigin } from './utils/origin'
interface AppDeps {
db: Database
billingService: BillingService
characterService: CharacterService
chatService: ChatService
providerService: ProviderService
configKV: ConfigKVService
db: Database
env: Env
envelopeCrypto: EnvelopeCrypto
fluxService: FluxService
fluxTransactionService: FluxTransactionService
stripeService: StripeService
billingService: BillingService
ttsMeter: FluxMeter
requestLogService: RequestLogService
voicePackService: VoicePackService
productEventService: ProductEventService
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
redis: Redis
env: Env
otel: OtelInstance | null
userDeletionService: UserDeletionService
llmRouter: LlmRouterService
otel: null | OtelInstance
productEventService: ProductEventService
providerCatalogService: ProviderCatalogService
providerService: ProviderService
redis: Redis
requestLogService: RequestLogService
stripeService: StripeService
ttsMeter: FluxMeter
userDeletionService: UserDeletionService
voicePackService: VoicePackService
}
const MAX_UNAUTHENTICATED_CHAT_WS_FRAME_BYTES = 8192
export type AppType = Awaited<ReturnType<typeof buildApp>>['app']
export async function buildApp(deps: AppDeps) {
const logger = useLogger('app').useGlobalConfig()
@@ -121,8 +123,8 @@ export async function buildApp(deps: AppDeps) {
.use(
'/api/*',
cors({
origin: origin => getTrustedOrigin(origin, deps.env.ADDITIONAL_TRUSTED_ORIGINS),
credentials: true,
origin: origin => getTrustedOrigin(origin, deps.env.ADDITIONAL_TRUSTED_ORIGINS),
}),
)
.use(honoLogger())
@@ -213,8 +215,8 @@ export async function buildApp(deps: AppDeps) {
configKV: deps.configKV,
envelopeCrypto: deps.envelopeCrypto,
fluxService: deps.fluxService,
ttsMeter: deps.ttsMeter,
requestLogService: deps.requestLogService,
ttsMeter: deps.ttsMeter,
})
app.get('/api/v1/audio/speech/ws', upgradeWebSocket(async (c) => {
const token = c.req.query('token')
@@ -230,8 +232,8 @@ export async function buildApp(deps: AppDeps) {
return createUnauthorizedWsEvents()
return audioSpeechWsSetup(session.user.id, {
trigger: c.req.query('tts_trigger') === 'auto' ? 'auto' : 'manual',
source: parseTtsSource(c.req.query('tts_source'), 'audio.speech.ws'),
trigger: c.req.query('tts_trigger') === 'auto' ? 'auto' : 'manual',
voiceType: parseTtsVoiceType(c.req.query('tts_voice_type')),
})
}))
@@ -240,9 +242,9 @@ export async function buildApp(deps: AppDeps) {
// the request body is a live microphone PCM stream rather than a bounded JSON
// payload. Auth is resolved manually here for the same reason.
app.post('/api/v1/audio/transcriptions/stream', createAudioTranscriptionStreamHandler({
configKV: deps.configKV,
db: deps.db,
env: deps.env,
configKV: deps.configKV,
envelopeCrypto: deps.envelopeCrypto,
providerCatalogService: deps.providerCatalogService,
}))
@@ -250,30 +252,30 @@ export async function buildApp(deps: AppDeps) {
// Cross-instance config invalidation. The subscriber owns its own
// connection + lifecycle metrics; see services/llm-router/config-sync-subscriber.ts.
createConfigSyncSubscriber({
redis: deps.redis,
configKV: deps.configKV,
llmRouter: deps.llmRouter,
gatewayMetrics: deps.otel?.gateway ?? null,
instanceId: deps.env.OTEL_SERVICE_NAME,
llmRouter: deps.llmRouter,
logger: useLogger('config-sync').useGlobalConfig(),
redis: deps.redis,
})
// Built once so the OpenAI-compat and audio routers share the same closure
// (helpers like recordMetrics / recordRequestLog cross both surfaces) but
// mount at different prefixes — see the `.route` calls below.
const v1Routes = createV1Routes({
fluxService: deps.fluxService,
billingService: deps.billingService,
configKV: deps.configKV,
requestLogService: deps.requestLogService,
productEventService: deps.productEventService,
ttsMeter: deps.ttsMeter,
llmRouter: deps.llmRouter,
providerCatalogService: deps.providerCatalogService,
voicePackService: deps.voicePackService,
fluxService: deps.fluxService,
genAi: deps.otel?.genAi,
revenue: deps.otel?.revenue,
llmRouter: deps.llmRouter,
productEventService: deps.productEventService,
providerCatalogService: deps.providerCatalogService,
rateLimitMetrics: deps.otel?.rateLimit,
requestLogService: deps.requestLogService,
revenue: deps.otel?.revenue,
ttsMeter: deps.ttsMeter,
voicePackService: deps.voicePackService,
})
const builtApp = app
@@ -285,7 +287,7 @@ export async function buildApp(deps: AppDeps) {
// upstream body content (carried by `cause`) out of the client
// response body; the logger / OTel pipeline is the right channel
// for operators to see the real upstream message.
const logFields = { details: err.details, cause: (err as { cause?: unknown }).cause }
const logFields = { cause: (err as { cause?: unknown }).cause, details: err.details }
if (err.statusCode >= 500) {
logger.withError(err).withFields(logFields).error('API error occurred')
@@ -295,9 +297,9 @@ export async function buildApp(deps: AppDeps) {
}
return c.json({
details: err.details,
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
@@ -336,8 +338,8 @@ export async function buildApp(deps: AppDeps) {
return c.json(
{
status: ready ? 'ready' : 'not_ready',
checks: { db: dbReady ? 'ok' : 'fail', redis: redisReady ? 'ok' : 'fail' },
status: ready ? 'ready' : 'not_ready',
},
ready ? 200 : 503,
)
@@ -349,15 +351,15 @@ export async function buildApp(deps: AppDeps) {
* the actual product UI instead of the framework's default "404 Not Found".
*/
.on('GET', '/', c => c.json({
service: 'airi-api',
message: 'This is the Project AIRI API server. Visit https://airi.moeru.ai to use the product, or see the docs at https://airi.moeru.ai/docs.',
docs: 'https://airi.moeru.ai/docs',
message: 'This is the Project AIRI API server. Visit https://airi.moeru.ai to use the product, or see the docs at https://airi.moeru.ai/docs.',
service: 'airi-api',
ui: 'https://airi.moeru.ai',
}))
.route('/internal/auth', createInternalAuthRoutes({
userDeletionService: deps.userDeletionService,
productEventService: deps.productEventService,
userDeletionService: deps.userDeletionService,
}))
/**
@@ -413,39 +415,6 @@ export async function buildApp(deps: AppDeps) {
return { app: builtApp, injectWebSocket }
}
function parseTtsSource(
value: string | undefined,
fallback: 'audio.speech.ws',
): 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' {
switch (value) {
case 'chat_auto_tts':
case 'manual_preview':
case 'settings_test':
return value
default:
return fallback
}
}
/**
* Normalizes the client-provided streaming TTS voice bucket for request telemetry.
*/
function parseTtsVoiceType(
value: string | undefined,
): StreamingTtsVoiceType {
switch (value) {
case 'official_default':
case 'official_selected':
case 'custom_configured':
case 'voice_pack':
return value
default:
return 'unknown'
}
}
export type AppType = Awaited<ReturnType<typeof buildApp>>['app']
export async function createApp() {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
injeca.setLogger(createLoggLogger(useLogger('injeca').useGlobalConfig()))
@@ -453,7 +422,7 @@ export async function createApp() {
// Forward logg output to OpenTelemetry log exporter
setGlobalHookPostLog((log) => {
emitOtelLog(log.level, log.context, log.message, log.fields as Record<string, string | number | boolean>)
emitOtelLog(log.level, log.context, log.message, log.fields as Record<string, boolean | number | string>)
})
// NOTICE: OTel SDK lifecycle (start/shutdown) is owned entirely by
@@ -462,12 +431,11 @@ export async function createApp() {
// counters. No `lifecycle.onStop(shutdown)` here — preload registers SIGTERM
// / SIGINT to flush exporters on its own.
const otel = injeca.provide('libs:otel', {
dependsOn: { env: parsedEnv },
build: ({ dependsOn }) => initOtel(dependsOn.env),
dependsOn: { env: parsedEnv },
})
const db = injeca.provide('datastore:db', {
dependsOn: { env: parsedEnv, lifecycle, otel },
build: async ({ dependsOn }) => {
const { db: dbInstance, pool } = await initializeExternalDependency(
'Database',
@@ -494,10 +462,10 @@ export async function createApp() {
dependsOn.lifecycle.appHooks.onStop(() => pool.end())
return dbInstance
},
dependsOn: { env: parsedEnv, lifecycle, otel },
})
const redis = injeca.provide('datastore:redis', {
dependsOn: { env: parsedEnv, lifecycle },
build: async ({ dependsOn }) => {
const redisInstance = await initializeExternalDependency(
'Redis',
@@ -522,15 +490,15 @@ export async function createApp() {
})
return redisInstance
},
dependsOn: { env: parsedEnv, lifecycle },
})
const configKV = injeca.provide('datastore:configKV', {
dependsOn: { db, redis },
build: ({ dependsOn }) => createConfigKVService(createConfigKVStore(dependsOn.db, dependsOn.redis)),
dependsOn: { db, redis },
})
const posthogSink = injeca.provide('services:posthogSink', {
dependsOn: { env: parsedEnv, lifecycle },
// POSTHOG_PROJECT_KEY defaults to the shared project key, so the falsy
// branch is only reachable via the documented off-switch: setting the
// env var to an empty string (valibot defaults don't apply to '').
@@ -539,36 +507,36 @@ export async function createApp() {
return null
const sink = createPosthogSink({
projectKey: dependsOn.env.POSTHOG_PROJECT_KEY,
host: dependsOn.env.POSTHOG_API_HOST,
projectKey: dependsOn.env.POSTHOG_PROJECT_KEY,
})
dependsOn.lifecycle.appHooks.onStop(() => sink.shutdown())
return sink
},
dependsOn: { env: parsedEnv, lifecycle },
})
const productEventService = injeca.provide('services:productEvents', {
dependsOn: { posthogSink },
build: ({ dependsOn }) => createProductEventService(dependsOn.posthogSink),
dependsOn: { posthogSink },
})
const characterService = injeca.provide('services:characters', {
dependsOn: { db, otel },
build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement),
dependsOn: { db, otel },
})
const providerService = injeca.provide('services:providers', {
dependsOn: { db },
build: ({ dependsOn }) => createProviderService(dependsOn.db),
dependsOn: { db },
})
const chatService = injeca.provide('services:chats', {
dependsOn: { db, otel },
build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement),
dependsOn: { db, otel },
})
const stripeService = injeca.provide('services:stripe', {
dependsOn: { db, env: parsedEnv },
build: ({ dependsOn }) => {
// Stripe SDK is optional — when STRIPE_SECRET_KEY is unset (dev/CI)
// billing routes degrade gracefully and the user-deletion pipeline
@@ -576,16 +544,17 @@ export async function createApp() {
const stripe = dependsOn.env.STRIPE_SECRET_KEY ? new Stripe(dependsOn.env.STRIPE_SECRET_KEY) : null
return createStripeService(dependsOn.db, stripe)
},
dependsOn: { db, env: parsedEnv },
})
const fluxTransactionService = injeca.provide('services:fluxTransaction', {
dependsOn: { db },
build: ({ dependsOn }) => createFluxTransactionService(dependsOn.db),
dependsOn: { db },
})
const fluxService = injeca.provide('services:flux', {
dependsOn: { db, redis, configKV },
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV),
dependsOn: { configKV, db, redis },
})
// NOTICE:
@@ -596,7 +565,6 @@ export async function createApp() {
// Domain knowledge stays inside each service instead of being copied into
// a parallel handler file. See `server/apps/api/docs/ai-context/account-deletion.md`.
const userDeletionService = injeca.provide('services:userDeletion', {
dependsOn: { stripeService, fluxService, providerService, characterService, chatService },
build: ({ dependsOn }) => {
const service = createUserDeletionService()
// priority: 10 = external side-effects (Stripe API cancel — unrollable),
@@ -609,30 +577,30 @@ export async function createApp() {
service.register({ name: 'chats', priority: 30, softDelete: ({ userId }) => dependsOn.chatService.deleteAllForUser(userId) })
return service
},
dependsOn: { characterService, chatService, fluxService, providerService, stripeService },
})
const requestLogService = injeca.provide('services:requestLog', {
dependsOn: { db },
build: ({ dependsOn }) => createRequestLogService(dependsOn.db),
dependsOn: { db },
})
const voicePackService = injeca.provide('services:voicePack', {
dependsOn: { db },
build: ({ dependsOn }) => createVoicePackService(dependsOn.db),
dependsOn: { db },
})
const providerCatalogService = injeca.provide('services:providerCatalog', {
dependsOn: { db },
build: ({ dependsOn }) => createProviderCatalogService(dependsOn.db),
dependsOn: { db },
})
const billingService = injeca.provide('services:billing', {
dependsOn: { db, redis, configKV, otel },
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.otel?.revenue),
dependsOn: { configKV, db, otel, redis },
})
const ttsMeter = injeca.provide('services:ttsMeter', {
dependsOn: { redis, billingService, configKV, otel },
build: ({ dependsOn }) => createFluxMeter(dependsOn.redis, dependsOn.billingService, {
name: 'tts',
// Lazy config read: missing FLUX_PER_1K_CHARS_TTS surfaces as a
@@ -642,22 +610,23 @@ export async function createApp() {
const fluxPer1kChars = await dependsOn.configKV.getOrThrow('FLUX_PER_1K_CHARS_TTS')
const ttl = await dependsOn.configKV.get('TTS_DEBT_TTL_SECONDS')
return {
unitsPerFlux: Math.max(1, Math.floor(1000 / fluxPer1kChars)),
debtTtlSeconds: ttl,
unitsPerFlux: Math.max(1, Math.floor(1000 / fluxPer1kChars)),
}
},
}, dependsOn.otel?.revenue),
dependsOn: { billingService, configKV, otel, redis },
})
// Envelope crypto for at-rest upstream key decryption. Shared by the LLM
// router (HTTP chat / TTS) and the audio-speech-ws proxy (streaming TTS)
// so a single master-key change rotates every surface at once.
const envelopeCrypto = injeca.provide('libs:envelopeCrypto', {
dependsOn: { env: parsedEnv },
build: ({ dependsOn }) => createEnvelopeCrypto({
masterKey: dependsOn.env.LLM_ROUTER_MASTER_KEY,
previousMasterKey: dependsOn.env.LLM_ROUTER_MASTER_KEY_PREVIOUS,
}),
dependsOn: { env: parsedEnv },
})
// LLM router (KTD-5 in-process replacement for the knoway sidecar).
@@ -666,44 +635,44 @@ export async function createApp() {
// Shared by the TTS router (acquires slots) and the pool watermark gauge
// (reads the snapshot). Cluster-wide Redis state — the server is multi-instance.
const ttsConcurrencyLedger = injeca.provide('services:ttsConcurrencyLedger', {
dependsOn: { redis },
build: ({ dependsOn }) => createConcurrencyLedger(dependsOn.redis),
dependsOn: { redis },
})
const llmRouter = injeca.provide('services:llmRouter', {
dependsOn: { configKV, envelopeCrypto, otel, redis, ttsConcurrencyLedger },
build: ({ dependsOn }) => createLlmRouterService({
concurrencyLedger: dependsOn.ttsConcurrencyLedger,
configKV: dependsOn.configKV,
envelopeCrypto: dependsOn.envelopeCrypto,
gatewayMetrics: dependsOn.otel?.gateway ?? null,
redis: dependsOn.redis,
concurrencyLedger: dependsOn.ttsConcurrencyLedger,
}),
dependsOn: { configKV, envelopeCrypto, otel, redis, ttsConcurrencyLedger },
})
await injeca.start()
const resolved = await injeca.resolve({
db,
billingService,
characterService,
chatService,
providerService,
configKV,
db,
env: parsedEnv,
envelopeCrypto,
fluxService,
fluxTransactionService,
requestLogService,
voicePackService,
productEventService,
stripeService,
billingService,
ttsMeter,
configKV,
envelopeCrypto,
redis,
env: parsedEnv,
otel,
userDeletionService,
llmRouter,
otel,
productEventService,
providerCatalogService,
providerService,
redis,
requestLogService,
stripeService,
ttsConcurrencyLedger,
ttsMeter,
userDeletionService,
voicePackService,
})
if (resolved.otel) {
registerTtsPoolGauge(resolved.otel.gateway.poolInflight, resolved.ttsConcurrencyLedger, resolved.otel.observability.metricReadErrors)
@@ -711,36 +680,67 @@ export async function createApp() {
}
const appDeps = {
db: resolved.db,
billingService: resolved.billingService,
characterService: resolved.characterService,
chatService: resolved.chatService,
providerService: resolved.providerService,
configKV: resolved.configKV,
db: resolved.db,
env: resolved.env,
envelopeCrypto: resolved.envelopeCrypto,
fluxService: resolved.fluxService,
fluxTransactionService: resolved.fluxTransactionService,
stripeService: resolved.stripeService,
voicePackService: resolved.voicePackService,
billingService: resolved.billingService,
ttsMeter: resolved.ttsMeter,
requestLogService: resolved.requestLogService,
productEventService: resolved.productEventService,
configKV: resolved.configKV,
envelopeCrypto: resolved.envelopeCrypto,
redis: resolved.redis,
env: resolved.env,
otel: resolved.otel,
userDeletionService: resolved.userDeletionService,
llmRouter: resolved.llmRouter,
otel: resolved.otel,
productEventService: resolved.productEventService,
providerCatalogService: resolved.providerCatalogService,
providerService: resolved.providerService,
redis: resolved.redis,
requestLogService: resolved.requestLogService,
stripeService: resolved.stripeService,
ttsMeter: resolved.ttsMeter,
userDeletionService: resolved.userDeletionService,
voicePackService: resolved.voicePackService,
}
const { app, injectWebSocket } = await buildApp(appDeps)
logger.withFields({ role: 'api', hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started')
logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT, role: 'api' }).log('Server started')
return {
app,
hostname: resolved.env.HOST,
injectWebSocket,
port: resolved.env.PORT,
hostname: resolved.env.HOST,
}
}
function parseTtsSource(
value: string | undefined,
fallback: 'audio.speech.ws',
): 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' {
switch (value) {
case 'chat_auto_tts':
case 'manual_preview':
case 'settings_test':
return value
default:
return fallback
}
}
/**
* Normalizes the client-provided streaming TTS voice bucket for request telemetry.
*/
function parseTtsVoiceType(
value: string | undefined,
): StreamingTtsVoiceType {
switch (value) {
case 'custom_configured':
case 'official_default':
case 'official_selected':
case 'voice_pack':
return value
default:
return 'unknown'
}
}
+3 -3
View File
@@ -15,7 +15,7 @@ const migrationsFolder = fileURLToPath(new URL('../../drizzle', import.meta.url)
export type Database = ReturnType<typeof createDrizzle>['db']
type DrizzleEnv = Pick<Env, 'DATABASE_URL' | 'DB_POOL_MAX' | 'DB_POOL_IDLE_TIMEOUT_MS' | 'DB_POOL_CONNECTION_TIMEOUT_MS' | 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS'>
type DrizzleEnv = Pick<Env, 'DATABASE_URL' | 'DB_POOL_CONNECTION_TIMEOUT_MS' | 'DB_POOL_IDLE_TIMEOUT_MS' | 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS' | 'DB_POOL_MAX'>
// NOTICE: pg is imported statically here. The OTEL instrumentation hooks are
// registered via --import ./instrumentation.ts (preload) which runs before
@@ -23,11 +23,11 @@ type DrizzleEnv = Pick<Env, 'DATABASE_URL' | 'DB_POOL_MAX' | 'DB_POOL_IDLE_TIMEO
export function createDrizzle(env: DrizzleEnv) {
const pool = new pg.Pool({
connectionString: env.DATABASE_URL,
max: env.DB_POOL_MAX,
idleTimeoutMillis: env.DB_POOL_IDLE_TIMEOUT_MS,
connectionTimeoutMillis: env.DB_POOL_CONNECTION_TIMEOUT_MS,
idleTimeoutMillis: env.DB_POOL_IDLE_TIMEOUT_MS,
keepAlive: true,
keepAliveInitialDelayMillis: env.DB_POOL_KEEPALIVE_INITIAL_DELAY_MS,
max: env.DB_POOL_MAX,
})
pool.on('error', (err) => {
@@ -19,13 +19,13 @@ export async function initializeExternalDependency<T>(
return await initialize(attempt)
},
{
onError: (error) => {
logger.withError(error).warn(`${dependencyName} initialization failed on attempt ${attempt}/${EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS}`)
},
retry: EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1,
retryDelay: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS,
retryDelayFactor: 2,
retryDelayMax: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS * 2 ** (EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1),
onError: (error) => {
logger.withError(error).warn(`${dependencyName} initialization failed on attempt ${attempt}/${EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS}`)
},
},
)()
}
+61 -61
View File
@@ -11,23 +11,23 @@ import { createRemoteJWKSet, errors, jwtVerify } from 'jose'
import * as authSchema from '@proj-airi/auth-shared'
export type RequestAuthSession = AuthSession
interface RequestAuthEnv {
AUTH_SERVER_URL: string
AUTH_SERVER_INTERNAL_URL?: string
AUTH_SERVER_URL: string
TEST_AUTH_TOKEN: string
TEST_AUTH_USER_ID: string
TEST_AUTH_USER_EMAIL: string
TEST_AUTH_USER_ID: string
TEST_AUTH_USER_NAME: string
}
interface TokenIssuerEnv {
AUTH_SERVER_URL: string
AUTH_SERVER_INTERNAL_URL?: string
AUTH_SERVER_URL: string
}
export type RequestAuthSession = AuthSession
function readBearerToken(headers: Headers): string | null {
function readBearerToken(headers: Headers): null | string {
const authorization = headers.get('authorization')
if (!authorization?.startsWith('Bearer '))
return null
@@ -36,12 +36,6 @@ function readBearerToken(headers: Headers): string | null {
return token.length > 0 ? token : null
}
function timingSafeStringEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
}
function resolveTestAuthToken(env: RequestAuthEnv, accessToken: string): AuthSession | null {
if (!env.TEST_AUTH_TOKEN || !timingSafeStringEqual(accessToken, env.TEST_AUTH_TOKEN))
return null
@@ -49,34 +43,66 @@ function resolveTestAuthToken(env: RequestAuthEnv, accessToken: string): AuthSes
const now = new Date()
const expiresAt = new Date(now.getTime() + 60 * 60 * 1000)
return {
session: {
createdAt: now,
expiresAt,
id: `test-auth:${env.TEST_AUTH_USER_ID}`,
ipAddress: null,
token: accessToken,
updatedAt: now,
userAgent: null,
userId: env.TEST_AUTH_USER_ID,
} as AuthSession['session'],
user: {
id: env.TEST_AUTH_USER_ID,
email: env.TEST_AUTH_USER_EMAIL.toLowerCase(),
name: env.TEST_AUTH_USER_NAME,
emailVerified: true,
image: null,
banExpires: null,
banned: false,
banReason: null,
banExpires: null,
lastSeenAt: now,
createdAt: now,
email: env.TEST_AUTH_USER_EMAIL.toLowerCase(),
emailVerified: true,
id: env.TEST_AUTH_USER_ID,
image: null,
lastSeenAt: now,
name: env.TEST_AUTH_USER_NAME,
updatedAt: now,
} as AuthSession['user'],
session: {
id: `test-auth:${env.TEST_AUTH_USER_ID}`,
token: accessToken,
userId: env.TEST_AUTH_USER_ID,
createdAt: now,
updatedAt: now,
expiresAt,
ipAddress: null,
userAgent: null,
} as AuthSession['session'],
}
}
function timingSafeStringEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
}
const cachedJWKS = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
export async function resolveRequestAuth(
db: Database,
env: RequestAuthEnv,
headers: Headers,
): Promise<AuthSession | null> {
const accessToken = readBearerToken(headers)
if (!accessToken)
return null
const testSession = resolveTestAuthToken(env, accessToken)
const resolved = testSession ?? await resolveJWTAccessToken(db, env, accessToken)
if (!resolved)
return null
// Reject banned principals on every request. OIDC JWT access tokens are
// stateless — verified by signature, not by a session row — so the admin
// plugin's session.create.before hook (which only fires on login) cannot
// invalidate a token mid-TTL. Re-checking `user.banned` here (free: the user
// row is already loaded) is what makes a ban take effect immediately across
// the HTTP, WebSocket, and OIDC token paths that funnel through this function.
if (isUserBannedNow(resolved.user))
return null
return resolved
}
function getJWKS(env: TokenIssuerEnv): ReturnType<typeof createRemoteJWKSet> {
const jwksUrl = new URL('/api/auth/jwks', env.AUTH_SERVER_INTERNAL_URL ?? env.AUTH_SERVER_URL).toString()
const cached = cachedJWKS.get(jwksUrl)
@@ -105,8 +131,8 @@ async function resolveJWTAccessToken(
// including the path prefix (e.g. "http://localhost:3000/api/auth"),
// not just the server origin.
const verified = await jwtVerify(accessToken, jwks, {
issuer: `${env.AUTH_SERVER_URL}/api/auth`,
audience: env.AUTH_SERVER_URL,
issuer: `${env.AUTH_SERVER_URL}/api/auth`,
})
payload = verified.payload
}
@@ -130,42 +156,16 @@ async function resolveJWTAccessToken(
return null
return {
user,
session: {
id: payload.jti ?? payload.sub,
token: accessToken,
userId: payload.sub,
createdAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
updatedAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
expiresAt: payload.exp ? new Date(payload.exp * 1000) : new Date(),
id: payload.jti ?? payload.sub,
ipAddress: null,
token: accessToken,
updatedAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
userAgent: null,
userId: payload.sub,
},
user,
}
}
export async function resolveRequestAuth(
db: Database,
env: RequestAuthEnv,
headers: Headers,
): Promise<AuthSession | null> {
const accessToken = readBearerToken(headers)
if (!accessToken)
return null
const testSession = resolveTestAuthToken(env, accessToken)
const resolved = testSession ?? await resolveJWTAccessToken(db, env, accessToken)
if (!resolved)
return null
// Reject banned principals on every request. OIDC JWT access tokens are
// stateless — verified by signature, not by a session row — so the admin
// plugin's session.create.before hook (which only fires on login) cannot
// invalidate a token mid-TTL. Re-checking `user.banned` here (free: the user
// row is already loaded) is what makes a ban take effect immediately across
// the HTTP, WebSocket, and OIDC token paths that funnel through this function.
if (isUserBannedNow(resolved.user))
return null
return resolved
}
+2 -2
View File
@@ -7,9 +7,9 @@ import { parseEnv } from '../env'
function baseEnv(): Record<string, string> {
return {
DATABASE_URL: 'postgres://example',
REDIS_URL: 'redis://example',
// Required: a deterministic 32-byte base64 value so env parse succeeds.
LLM_ROUTER_MASTER_KEY: Buffer.alloc(32, 0xAA).toString('base64'),
REDIS_URL: 'redis://example',
}
}
@@ -53,8 +53,8 @@ describe('parseEnv', () => {
const env = parseEnv({
...baseEnv(),
TEST_AUTH_TOKEN: 'local-test-token',
TEST_AUTH_USER_ID: 'admin-user',
TEST_AUTH_USER_EMAIL: 'admin@example.com',
TEST_AUTH_USER_ID: 'admin-user',
TEST_AUTH_USER_NAME: 'Admin User',
})
@@ -20,29 +20,12 @@ const mockedJwtVerify = vi.mocked(jwtVerify)
const mockEnv = {
AUTH_SERVER_URL: 'https://api.airi.build',
TEST_AUTH_TOKEN: '',
TEST_AUTH_USER_ID: 'test-user',
TEST_AUTH_USER_EMAIL: 'test@example.com',
TEST_AUTH_USER_ID: 'test-user',
TEST_AUTH_USER_NAME: 'Test User',
} as const
function createUser(overrides: Partial<RequestAuthSession['user']> = {}): RequestAuthSession['user'] {
const now = new Date()
return {
id: 'user-1',
email: 'user@example.com',
name: 'User',
emailVerified: true,
image: null,
banned: false,
banReason: null,
banExpires: null,
createdAt: now,
updatedAt: now,
...overrides,
}
}
function createDb(user: RequestAuthSession['user'] | null, failure?: Error): Database {
function createDb(user: null | RequestAuthSession['user'], failure?: Error): Database {
return {
query: {
user: {
@@ -56,15 +39,32 @@ function createDb(user: RequestAuthSession['user'] | null, failure?: Error): Dat
} as unknown as Database
}
function createUser(overrides: Partial<RequestAuthSession['user']> = {}): RequestAuthSession['user'] {
const now = new Date()
return {
banExpires: null,
banned: false,
banReason: null,
createdAt: now,
email: 'user@example.com',
emailVerified: true,
id: 'user-1',
image: null,
name: 'User',
updatedAt: now,
...overrides,
}
}
function mockValidJwt(subject = 'user-1') {
const iat = Math.floor(Date.now() / 1000)
const exp = iat + 3600
mockedJwtVerify.mockResolvedValue({
payload: { sub: subject, iat, exp, jti: 'jwt-token-id' },
protectedHeader: { alg: 'RS256' },
key: new Uint8Array(),
payload: { exp, iat, jti: 'jwt-token-id', sub: subject },
protectedHeader: { alg: 'RS256' },
})
return { iat, exp }
return { exp, iat }
}
describe('resolveRequestAuth', () => {
@@ -74,7 +74,7 @@ describe('resolveRequestAuth', () => {
})
it('verifies access tokens against the public API issuer and audience', async () => {
const { iat, exp } = mockValidJwt()
const { exp, iat } = mockValidJwt()
const user = createUser()
const result = await resolveRequestAuth(
@@ -85,21 +85,21 @@ describe('resolveRequestAuth', () => {
expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL('https://api.airi.build/api/auth/jwks'))
expect(mockedJwtVerify).toHaveBeenCalledWith('eyJhbGciOiJSUzI1NiJ9.test.sig', 'mock-jwks', {
issuer: 'https://api.airi.build/api/auth',
audience: 'https://api.airi.build',
issuer: 'https://api.airi.build/api/auth',
})
expect(result).toEqual({
user,
session: {
id: 'jwt-token-id',
userId: 'user-1',
token: 'eyJhbGciOiJSUzI1NiJ9.test.sig',
createdAt: new Date(iat * 1000),
updatedAt: new Date(iat * 1000),
expiresAt: new Date(exp * 1000),
id: 'jwt-token-id',
ipAddress: null,
token: 'eyJhbGciOiJSUzI1NiJ9.test.sig',
updatedAt: new Date(iat * 1000),
userAgent: null,
userId: 'user-1',
},
user,
})
})
@@ -117,15 +117,15 @@ describe('resolveRequestAuth', () => {
expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL('http://auth:3000/api/auth/jwks'))
expect(mockedJwtVerify).toHaveBeenCalledWith('jwt', 'mock-jwks', {
issuer: 'https://api.airi.build/api/auth',
audience: 'https://api.airi.build',
issuer: 'https://api.airi.build/api/auth',
})
})
it('rejects a banned principal after signature verification', async () => {
mockValidJwt()
const result = await resolveRequestAuth(
createDb(createUser({ banned: true, banExpires: null })),
createDb(createUser({ banExpires: null, banned: true })),
mockEnv,
new Headers({ Authorization: 'Bearer jwt' }),
)
@@ -134,7 +134,7 @@ describe('resolveRequestAuth', () => {
it('accepts a principal whose temporary ban has expired', async () => {
mockValidJwt()
const user = createUser({ banned: true, banExpires: new Date(Date.now() - 1000) })
const user = createUser({ banExpires: new Date(Date.now() - 1000), banned: true })
const result = await resolveRequestAuth(
createDb(user),
mockEnv,
@@ -149,8 +149,8 @@ describe('resolveRequestAuth', () => {
{
...mockEnv,
TEST_AUTH_TOKEN: 'test-secret',
TEST_AUTH_USER_ID: 'test-user-1',
TEST_AUTH_USER_EMAIL: 'Test@Example.com',
TEST_AUTH_USER_ID: 'test-user-1',
TEST_AUTH_USER_NAME: 'Local Test User',
},
new Headers({ Authorization: 'Bearer test-secret' }),
@@ -172,9 +172,9 @@ describe('resolveRequestAuth', () => {
)).toBeNull()
mockedJwtVerify.mockResolvedValueOnce({
key: new Uint8Array(),
payload: { exp: Math.floor(Date.now() / 1000) + 3600 },
protectedHeader: { alg: 'RS256' },
key: new Uint8Array(),
})
expect(await resolveRequestAuth(
createDb(null),
+19 -19
View File
@@ -9,18 +9,10 @@ import { getConnInfo } from '@hono/node-server/conninfo'
import { rateLimiter as createRateLimiter } from 'hono-rate-limiter'
interface RateLimitOptions {
/** Max requests allowed within the window */
max: number
/** Window size in seconds */
windowSec: number
/** Key generator: extracts a unique identifier from the request */
keyGenerator?: (c: Context<HonoEnv>) => string
/**
* Reverse proxy whose client-address header is safe to use. The caller must
* select this only when the deployment guarantees that the named proxy owns
* and overwrites that header before the request reaches the application.
*/
trustedProxy?: 'railway'
/** Max requests allowed within the window */
max: number
/**
* Optional metrics handle. When provided, blocked requests increment
* `airi_rate_limit_blocked_total{route, key_type, limit}`.
@@ -28,13 +20,21 @@ interface RateLimitOptions {
* or remote IP — important for distinguishing logged-in abuse from
* anonymous scraping.
*/
metrics?: RateLimitMetrics | null
metrics?: null | RateLimitMetrics
/**
* Stable label for the route this limiter guards (e.g. `auth.api`,
* `openai.completions`, `stripe.checkout`). Avoids high-cardinality URL
* paths in metric labels.
*/
routeLabel?: string
/**
* Reverse proxy whose client-address header is safe to use. The caller must
* select this only when the deployment guarantees that the named proxy owns
* and overwrites that header before the request reaches the application.
*/
trustedProxy?: 'railway'
/** Window size in seconds */
windowSec: number
}
/**
@@ -65,24 +65,24 @@ export function rateLimiter(opts: RateLimitOptions) {
})
return createRateLimiter<HonoEnv>({
windowMs: opts.windowSec * 1000,
limit: opts.max,
// NOTICE: keep `draft-6` so the middleware emits the widely supported
// `RateLimit-*` header set. `draft-7`/`draft-8` switch to newer combined
// header formats that are easier to break in existing clients and proxies.
standardHeaders: 'draft-6',
keyGenerator: keyGen,
handler: (c) => {
// Record before producing the 429 response so the time series captures
// every block, even when the response shape later changes.
const keyType = c.get('user')?.id ? 'user' : 'ip'
opts.metrics?.blocked.add(1, {
route: opts.routeLabel ?? 'unknown',
key_type: keyType,
limit: String(opts.max),
route: opts.routeLabel ?? 'unknown',
})
return c.json({ error: 'TOO_MANY_REQUESTS', message: 'Too many requests' }, 429)
},
keyGenerator: keyGen,
limit: opts.max,
// NOTICE: keep `draft-6` so the middleware emits the widely supported
// `RateLimit-*` header set. `draft-7`/`draft-8` switch to newer combined
// header formats that are easier to break in existing clients and proxies.
standardHeaders: 'draft-6',
windowMs: opts.windowSec * 1000,
})
}
@@ -27,9 +27,9 @@ function makeGauge() {
describe('registerDbPoolGauge', () => {
it('reports the configured capacity and the live pool counts', () => {
const pool = {
idleCount: 2,
options: { max: 20 },
totalCount: 7,
idleCount: 2,
waitingCount: 3,
}
const { gauge, observe, run } = makeGauge()
+1 -1
View File
@@ -20,10 +20,10 @@ export function registerDbPoolGauge(
const idle = pool.idleCount
const used = total - idle
const counts = {
idle,
max: pool.options.max ?? 10,
total,
used,
idle,
waiting: pool.waitingCount,
}
@@ -16,7 +16,7 @@ import { registerTtsPoolGauge } from './tts-pool'
* expect(observe).toHaveBeenCalledWith(3, { app_id: 'app-1' })
*/
function makeGauge() {
let cb: ((result: { observe: (v: number, attrs: Record<string, string>) => void }) => void | Promise<void>) | null = null
let cb: ((result: { observe: (v: number, attrs: Record<string, string>) => void }) => Promise<void> | void) | null = null
const observe = vi.fn()
const gauge = {
addCallback: vi.fn((fn: typeof cb) => { cb = fn }),
@@ -32,20 +32,20 @@ function makeGauge() {
}
}
function makeLedger(snapshot: () => Promise<Array<{ poolId: string, inflight: number }>>): ConcurrencyLedger {
function makeLedger(snapshot: () => Promise<Array<{ inflight: number, poolId: string }>>): ConcurrencyLedger {
return {
tryAcquire: vi.fn(),
release: vi.fn(),
markSaturated: vi.fn(),
isSaturated: vi.fn(),
currentInflight: vi.fn(),
isSaturated: vi.fn(),
markSaturated: vi.fn(),
release: vi.fn(),
snapshot: vi.fn(snapshot),
tryAcquire: vi.fn(),
} as unknown as ConcurrencyLedger
}
function makeReadErrors() {
const add = vi.fn()
return { metricReadErrors: { add } as unknown as ObservabilityMetrics['metricReadErrors'], add }
return { add, metricReadErrors: { add } as unknown as ObservabilityMetrics['metricReadErrors'] }
}
describe('registerTtsPoolGauge', () => {
@@ -59,8 +59,8 @@ describe('registerTtsPoolGauge', () => {
it('observes one point per pool with the app_id attribute', async () => {
const ledger = makeLedger(async () => [
{ poolId: 'app-1', inflight: 3 },
{ poolId: 'app-2', inflight: 7 },
{ inflight: 3, poolId: 'app-1' },
{ inflight: 7, poolId: 'app-2' },
])
const { metricReadErrors } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
@@ -79,7 +79,7 @@ describe('registerTtsPoolGauge', () => {
const ledger = makeLedger(async () => {
throw new Error('redis down')
})
const { metricReadErrors, add } = makeReadErrors()
const { add, metricReadErrors } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
registerTtsPoolGauge(gauge, ledger, metricReadErrors)
@@ -90,7 +90,7 @@ describe('registerTtsPoolGauge', () => {
})
it('serves the cached snapshot within the 10s TTL without re-reading Redis', async () => {
const ledger = makeLedger(async () => [{ poolId: 'app-1', inflight: 1 }])
const ledger = makeLedger(async () => [{ inflight: 1, poolId: 'app-1' }])
const { metricReadErrors } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
@@ -104,7 +104,7 @@ describe('registerTtsPoolGauge', () => {
})
it('re-reads Redis after the cache TTL expires', async () => {
const ledger = makeLedger(async () => [{ poolId: 'app-1', inflight: 1 }])
const ledger = makeLedger(async () => [{ inflight: 1, poolId: 'app-1' }])
const { metricReadErrors } = makeReadErrors()
const { gauge, run } = makeGauge()
+4 -4
View File
@@ -41,8 +41,8 @@ export function registerTtsPoolGauge(
const CACHE_TTL_MS = 10_000
let cachedAt = 0
let cachedSnapshot: Array<{ poolId: string, inflight: number }> = []
let refreshInFlight: Promise<boolean> | null = null
let cachedSnapshot: Array<{ inflight: number, poolId: string }> = []
let refreshInFlight: null | Promise<boolean> = null
async function refresh(): Promise<boolean> {
try {
@@ -61,7 +61,7 @@ export function registerTtsPoolGauge(
const now = Date.now()
if (cachedAt !== 0 && now - cachedAt < CACHE_TTL_MS) {
for (const { poolId, inflight } of cachedSnapshot)
for (const { inflight, poolId } of cachedSnapshot)
result.observe(inflight, { app_id: poolId })
return
}
@@ -74,7 +74,7 @@ export function registerTtsPoolGauge(
const ok = await refreshInFlight
if (ok) {
for (const { poolId, inflight } of cachedSnapshot)
for (const { inflight, poolId } of cachedSnapshot)
result.observe(inflight, { app_id: poolId })
}
// else: deliberately do nothing — let Prometheus staleness expose the outage.
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerWsOnlineUsersGauge } from './ws-online-users'
function makeGauge() {
let callback: ((result: { observe: (value: number) => void }) => void | Promise<void>) | null = null
let callback: ((result: { observe: (value: number) => void }) => Promise<void> | void) | null = null
const observe = vi.fn()
const gauge = {
addCallback: vi.fn((registeredCallback: typeof callback) => {
@@ -27,8 +27,8 @@ function makeGauge() {
function makeReadErrors() {
const add = vi.fn()
return {
metricReadErrors: { add } as unknown as ObservabilityMetrics['metricReadErrors'],
add,
metricReadErrors: { add } as unknown as ObservabilityMetrics['metricReadErrors'],
}
}
@@ -75,7 +75,7 @@ describe('registerWsOnlineUsersGauge', () => {
const pubsub = vi.fn(async () => {
throw new Error('Redis unavailable')
})
const { metricReadErrors, add } = makeReadErrors()
const { add, metricReadErrors } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
registerWsOnlineUsersGauge(gauge, { pubsub }, metricReadErrors)
@@ -31,7 +31,7 @@ export function registerWsOnlineUsersGauge(
let cachedAt = 0
let cachedCount = 0
let refreshInFlight: Promise<boolean> | null = null
let refreshInFlight: null | Promise<boolean> = null
async function refresh(): Promise<boolean> {
try {
+216 -216
View File
@@ -68,15 +68,30 @@ const logger = useLogger('otel')
export interface AuthMetrics {
attempts: Counter
failures: Counter
userRegistered: Counter
userLogin: Counter
userRegistered: Counter
}
export interface DatabaseMetrics {
/**
* Per-process pg pool counts. Labels: `pool_state` (`max`, `total`, `used`,
* `idle`, `waiting`). Use `used / max` for capacity and `waiting > 0` for
* saturation. Do not use `used / (used + idle)` as a capacity ratio.
*/
poolConnections: ObservableGauge
}
export interface EmailMetrics {
duration: Histogram
failures: Counter
send: Counter
}
export interface EngagementMetrics {
chatMessages: Counter
characterCreated: Counter
characterDeleted: Counter
characterEngagement: Counter
chatMessages: Counter
/**
* Pull-based gauge for active WebSocket connections.
*
@@ -95,6 +110,8 @@ export interface EngagementMetrics {
* `addCallback`. Multiple callbacks would double-count.
*/
wsConnectionsActive: ObservableGauge
wsMessagesReceived: Counter
wsMessagesSent: Counter
/**
* Cluster-wide distinct users with at least one active chat WebSocket.
*
@@ -105,19 +122,127 @@ export interface EngagementMetrics {
* `sum()`.
*/
wsUsersOnline: ObservableGauge
wsMessagesSent: Counter
wsMessagesReceived: Counter
}
export interface GatewayMetrics {
/**
* Pub/Sub invalidation messages dropped because the HMAC did not verify.
* >0 = forged or replayed message — investigate Redis access boundary.
*/
configInvalidHmac: Counter
/**
* Local in-memory configKV cache reloaded (router config). Labels: `source`
* (`pubsub` | `ttl` | `manual`), `service_instance_id`.
*/
configReload: Counter
/**
* Envelope-crypto decryption auth-tag failures. Any >0 sample indicates
* config corruption or a master-key rotation misstep — investigate.
* Recommended labels: `provider`, `key_entry_id`.
*/
decryptFailures: Counter
/**
* Per-attempt fallback event. Increments once per failing key try when the
* router moves on to the next key/upstream. Recommended labels:
* `provider`, `from_key`, `reason`.
*/
fallbackCount: Counter
/**
* The configured route exhausted every allowed key and upstream in one
* request. The user gets a 5xx. Primary alert source for user-facing
* degradation. Recommended labels: `provider`, `status_code`, `surface`.
*
* Recommended alert:
* Filter to operational status codes before paging on this metric.
*/
keyExhaustedCount: Counter
/**
* Cluster-wide gauge of current in-flight requests per pool, sourced from
* Redis. Label: `app_id`. Every replica reports the same value — dashboards
* MUST aggregate with `avg()`, NOT `sum()` (see observability-conventions.md).
*/
poolInflight: ObservableGauge
/**
* Apool was circuit-broken after exhausting with a 429 (app_id concurrency
* exceeded upstream-side). Labels: `provider`, `app_id`. A pool with a high
* mark rate is being driven past its real upstream limit.
*/
poolSaturationMarked: Counter
/**
* Capacity-aware TTS routing skipped a pool because its app_id was already at
* the concurrency cap (the pre-read said free but the atomic acquire lost the
* race, or every pool was full). Labels: `provider`, `app_id`.
*
* Recommended alert: sustained rate relative to TTS request volume means the
*pool is undersized — add app_ids or raise the cap.
*/
poolSlotRejected: Counter
/**
* All keys in one request failed with the *same* upstream status code.
* Strong signal of account-level (shared-backend) rate limiting that
* per-key fallback cannot recover from — see plan D33 risk-acceptance
* and the adversarial finding ADV-PLAN-006.
* Recommended labels: `provider`, `status_code`.
*
* Recommended alert:
* `rate(airi_gen_ai_gateway_same_status_exhaustion_total[15m]) / rate(...request_count[15m]) > 0.05`
*/
sameStatusExhaustion: Counter
/**
* Pub/Sub subscriber lifecycle transitions (`subscribed` |
* `reconnecting` | `error` | `closed`). Watch for sustained
* `reconnecting` — the TTL self-heal stops being ≤5s once the subscriber
* is dead.
*/
subscriberState: Counter
/**
* Upstream error responses received during fallback iteration. Recommended
* labels: `provider`, `status_code`.
*/
upstreamErrors: Counter
}
export interface GenAiMetrics {
firstTokenDuration: Histogram
fluxConsumed: Counter
operationCount: Counter
operationDuration: Histogram
streamInterrupted: Counter
tokenUsageInput: Counter
tokenUsageOutput: Counter
}
export interface ObservabilityMetrics {
/**
* Counts failures inside metric-pipeline callbacks (for example, a Redis-
* backed ObservableGauge that could not refresh). Use for self-monitoring —
* when this is rising, treat the affected gauge's value as potentially
* stale.
*
* Labels: `metric` (the failing gauge's logical name).
*/
metricReadErrors: Counter
}
export interface OtelInstance {
auth: AuthMetrics
database: DatabaseMetrics
email: EmailMetrics
engagement: EngagementMetrics
gateway: GatewayMetrics
genAi: GenAiMetrics
observability: ObservabilityMetrics
rateLimit: RateLimitMetrics
revenue: RevenueMetrics
}
export interface RateLimitMetrics {
blocked: Counter
}
export interface RevenueMetrics {
stripeCheckoutCreated: Counter
stripeCheckoutCompleted: Counter
stripePaymentFailed: Counter
stripeSubscriptionEvent: Counter
stripeEvents: Counter
stripeRevenue: Counter
fluxInsufficientBalance: Counter
fluxCredited: Counter
fluxInsufficientBalance: Counter
/**
* Flux value that the LLM proxy could not collect from the user. Fires from
* both the streaming and non-streaming completion paths.
@@ -142,141 +267,16 @@ export interface RevenueMetrics {
* pages on-call immediately on any sustained leak.
*/
fluxUnbilled: Counter
stripeCheckoutCompleted: Counter
stripeCheckoutCreated: Counter
stripeEvents: Counter
stripePaymentFailed: Counter
stripeRevenue: Counter
stripeSubscriptionEvent: Counter
ttsChars: Counter
ttsPreflightRejections: Counter
}
export interface GenAiMetrics {
operationDuration: Histogram
operationCount: Counter
tokenUsageInput: Counter
tokenUsageOutput: Counter
fluxConsumed: Counter
firstTokenDuration: Histogram
streamInterrupted: Counter
}
export interface GatewayMetrics {
/**
* Per-attempt fallback event. Increments once per failing key try when the
* router moves on to the next key/upstream. Recommended labels:
* `provider`, `from_key`, `reason`.
*/
fallbackCount: Counter
/**
* Upstream error responses received during fallback iteration. Recommended
* labels: `provider`, `status_code`.
*/
upstreamErrors: Counter
/**
* The configured route exhausted every allowed key and upstream in one
* request. The user gets a 5xx. Primary alert source for user-facing
* degradation. Recommended labels: `provider`, `status_code`, `surface`.
*
* Recommended alert:
* Filter to operational status codes before paging on this metric.
*/
keyExhaustedCount: Counter
/**
* All keys in one request failed with the *same* upstream status code.
* Strong signal of account-level (shared-backend) rate limiting that
* per-key fallback cannot recover from — see plan D33 risk-acceptance
* and the adversarial finding ADV-PLAN-006.
* Recommended labels: `provider`, `status_code`.
*
* Recommended alert:
* `rate(airi_gen_ai_gateway_same_status_exhaustion_total[15m]) / rate(...request_count[15m]) > 0.05`
*/
sameStatusExhaustion: Counter
/**
* Local in-memory configKV cache reloaded (router config). Labels: `source`
* (`pubsub` | `ttl` | `manual`), `service_instance_id`.
*/
configReload: Counter
/**
* Envelope-crypto decryption auth-tag failures. Any >0 sample indicates
* config corruption or a master-key rotation misstep — investigate.
* Recommended labels: `provider`, `key_entry_id`.
*/
decryptFailures: Counter
/**
* Pub/Sub subscriber lifecycle transitions (`subscribed` |
* `reconnecting` | `error` | `closed`). Watch for sustained
* `reconnecting` — the TTL self-heal stops being ≤5s once the subscriber
* is dead.
*/
subscriberState: Counter
/**
* Pub/Sub invalidation messages dropped because the HMAC did not verify.
* >0 = forged or replayed message — investigate Redis access boundary.
*/
configInvalidHmac: Counter
/**
* Capacity-aware TTS routing skipped a pool because its app_id was already at
* the concurrency cap (the pre-read said free but the atomic acquire lost the
* race, or every pool was full). Labels: `provider`, `app_id`.
*
* Recommended alert: sustained rate relative to TTS request volume means the
*pool is undersized — add app_ids or raise the cap.
*/
poolSlotRejected: Counter
/**
* Apool was circuit-broken after exhausting with a 429 (app_id concurrency
* exceeded upstream-side). Labels: `provider`, `app_id`. A pool with a high
* mark rate is being driven past its real upstream limit.
*/
poolSaturationMarked: Counter
/**
* Cluster-wide gauge of current in-flight requests per pool, sourced from
* Redis. Label: `app_id`. Every replica reports the same value — dashboards
* MUST aggregate with `avg()`, NOT `sum()` (see observability-conventions.md).
*/
poolInflight: ObservableGauge
}
export interface EmailMetrics {
send: Counter
failures: Counter
duration: Histogram
}
export interface RateLimitMetrics {
blocked: Counter
}
export interface ObservabilityMetrics {
/**
* Counts failures inside metric-pipeline callbacks (for example, a Redis-
* backed ObservableGauge that could not refresh). Use for self-monitoring —
* when this is rising, treat the affected gauge's value as potentially
* stale.
*
* Labels: `metric` (the failing gauge's logical name).
*/
metricReadErrors: Counter
}
export interface DatabaseMetrics {
/**
* Per-process pg pool counts. Labels: `pool_state` (`max`, `total`, `used`,
* `idle`, `waiting`). Use `used / max` for capacity and `waiting > 0` for
* saturation. Do not use `used / (used + idle)` as a capacity ratio.
*/
poolConnections: ObservableGauge
}
export interface OtelInstance {
auth: AuthMetrics
engagement: EngagementMetrics
revenue: RevenueMetrics
genAi: GenAiMetrics
gateway: GatewayMetrics
database: DatabaseMetrics
email: EmailMetrics
rateLimit: RateLimitMetrics
observability: ObservabilityMetrics
}
/**
* Build the structured metric-handle bundle used across the app.
*
@@ -294,7 +294,7 @@ export interface OtelInstance {
* - Metric bundle with primed counters (so low-traffic series show up in
* Prometheus from boot), or `null` when OTel is disabled.
*/
export function initOtel(env: Env): OtelInstance | null {
export function initOtel(env: Env): null | OtelInstance {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
logger.log('OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)')
return null
@@ -310,19 +310,16 @@ export function initOtel(env: Env): OtelInstance | null {
failures: meter.createCounter(METRIC_AUTH_FAILURES, {
description: 'Number of failed authentication attempts',
}),
userRegistered: meter.createCounter(METRIC_USER_REGISTERED, {
description: 'Number of new user registrations',
}),
userLogin: meter.createCounter(METRIC_USER_LOGIN, {
description: 'Number of user sign-ins',
}),
userRegistered: meter.createCounter(METRIC_USER_REGISTERED, {
description: 'Number of new user registrations',
}),
}
// Engagement metrics
const engagement: EngagementMetrics = {
chatMessages: meter.createCounter(METRIC_CHAT_MESSAGES, {
description: 'Number of chat messages written or pulled',
}),
characterCreated: meter.createCounter(METRIC_CHARACTER_CREATED, {
description: 'Number of characters created',
}),
@@ -332,49 +329,52 @@ export function initOtel(env: Env): OtelInstance | null {
characterEngagement: meter.createCounter(METRIC_CHARACTER_ENGAGEMENT, {
description: 'Number of character engagement actions (like/bookmark)',
}),
chatMessages: meter.createCounter(METRIC_CHAT_MESSAGES, {
description: 'Number of chat messages written or pulled',
}),
wsConnectionsActive: meter.createObservableGauge(METRIC_WS_CONNECTIONS_ACTIVE, {
description: 'Active WebSocket connections (live registry size, scraped per export interval)',
}),
wsUsersOnline: meter.createObservableGauge(METRIC_WS_USERS_ONLINE, {
description: 'Cluster-wide distinct users with an active chat WebSocket, sourced from unique Redis Pub/Sub channels',
wsMessagesReceived: meter.createCounter(METRIC_WS_MESSAGES_RECEIVED, {
description: 'Messages received via WebSocket',
}),
wsMessagesSent: meter.createCounter(METRIC_WS_MESSAGES_SENT, {
description: 'Messages sent via WebSocket',
}),
wsMessagesReceived: meter.createCounter(METRIC_WS_MESSAGES_RECEIVED, {
description: 'Messages received via WebSocket',
wsUsersOnline: meter.createObservableGauge(METRIC_WS_USERS_ONLINE, {
description: 'Cluster-wide distinct users with an active chat WebSocket, sourced from unique Redis Pub/Sub channels',
}),
}
// Revenue metrics
const revenue: RevenueMetrics = {
stripeCheckoutCreated: meter.createCounter(METRIC_STRIPE_CHECKOUT_CREATED, {
description: 'Number of Stripe checkout sessions created',
fluxCredited: meter.createCounter(METRIC_AIRI_FLUX_CREDITED, {
description: 'Total flux credited to user balances, by source',
}),
fluxInsufficientBalance: meter.createCounter(METRIC_FLUX_INSUFFICIENT_BALANCE, {
description: 'Number of insufficient flux balance errors',
}),
fluxUnbilled: meter.createCounter(METRIC_AIRI_FLUX_UNBILLED, {
description: 'Flux owed but unbilled (post-stream debit failed). Real revenue leak.',
}),
stripeCheckoutCompleted: meter.createCounter(METRIC_STRIPE_CHECKOUT_COMPLETED, {
description: 'Number of Stripe checkout sessions completed',
}),
stripePaymentFailed: meter.createCounter(METRIC_STRIPE_PAYMENT_FAILED, {
description: 'Number of failed Stripe payments',
}),
stripeSubscriptionEvent: meter.createCounter(METRIC_STRIPE_SUBSCRIPTION_EVENT, {
description: 'Number of Stripe subscription lifecycle events',
stripeCheckoutCreated: meter.createCounter(METRIC_STRIPE_CHECKOUT_CREATED, {
description: 'Number of Stripe checkout sessions created',
}),
stripeEvents: meter.createCounter(METRIC_STRIPE_EVENTS, {
description: 'Number of Stripe webhook events processed',
}),
stripePaymentFailed: meter.createCounter(METRIC_STRIPE_PAYMENT_FAILED, {
description: 'Number of failed Stripe payments',
}),
stripeRevenue: meter.createCounter(METRIC_AIRI_STRIPE_REVENUE, {
description: 'Stripe revenue in smallest currency unit (e.g. cents)',
unit: 'minor_unit',
}),
fluxInsufficientBalance: meter.createCounter(METRIC_FLUX_INSUFFICIENT_BALANCE, {
description: 'Number of insufficient flux balance errors',
}),
fluxCredited: meter.createCounter(METRIC_AIRI_FLUX_CREDITED, {
description: 'Total flux credited to user balances, by source',
}),
fluxUnbilled: meter.createCounter(METRIC_AIRI_FLUX_UNBILLED, {
description: 'Flux owed but unbilled (post-stream debit failed). Real revenue leak.',
stripeSubscriptionEvent: meter.createCounter(METRIC_STRIPE_SUBSCRIPTION_EVENT, {
description: 'Number of Stripe subscription lifecycle events',
}),
ttsChars: meter.createCounter(METRIC_AIRI_TTS_CHARS, {
description: 'TTS input characters processed (billing base unit)',
@@ -386,12 +386,22 @@ export function initOtel(env: Env): OtelInstance | null {
// GenAI metrics (semconv: gen_ai.client.*)
const genAi: GenAiMetrics = {
firstTokenDuration: meter.createHistogram(METRIC_GEN_AI_CLIENT_FIRST_TOKEN_DURATION, {
description: 'Time from request start to first streamed token (TTFB for streaming)',
unit: 's',
}),
fluxConsumed: meter.createCounter(METRIC_FLUX_CONSUMED, {
description: 'Total flux consumed',
}),
operationCount: meter.createCounter(METRIC_GEN_AI_CLIENT_OPERATION_COUNT, {
description: 'Number of GenAI client operations',
}),
operationDuration: meter.createHistogram(METRIC_GEN_AI_CLIENT_OPERATION_DURATION, {
description: 'GenAI client operation duration',
unit: 's',
}),
operationCount: meter.createCounter(METRIC_GEN_AI_CLIENT_OPERATION_COUNT, {
description: 'Number of GenAI client operations',
streamInterrupted: meter.createCounter(METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED, {
description: 'Streaming responses interrupted before completion',
}),
tokenUsageInput: meter.createCounter(METRIC_GEN_AI_CLIENT_TOKEN_USAGE_INPUT, {
description: 'Total input (prompt) tokens consumed',
@@ -399,33 +409,14 @@ export function initOtel(env: Env): OtelInstance | null {
tokenUsageOutput: meter.createCounter(METRIC_GEN_AI_CLIENT_TOKEN_USAGE_OUTPUT, {
description: 'Total output (completion) tokens consumed',
}),
fluxConsumed: meter.createCounter(METRIC_FLUX_CONSUMED, {
description: 'Total flux consumed',
}),
firstTokenDuration: meter.createHistogram(METRIC_GEN_AI_CLIENT_FIRST_TOKEN_DURATION, {
description: 'Time from request start to first streamed token (TTFB for streaming)',
unit: 's',
}),
streamInterrupted: meter.createCounter(METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED, {
description: 'Streaming responses interrupted before completion',
}),
}
// Router gateway metrics (in-process LLM/TTS routing — KTD-3).
// Every counter alerts on a different failure shape; see metric-handle JSDoc
// on GatewayMetrics for the recommended PromQL.
const gateway: GatewayMetrics = {
fallbackCount: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT, {
description: 'Per-attempt fallback events in the in-process LLM/TTS router',
}),
upstreamErrors: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS, {
description: 'Upstream error responses received during fallback iteration',
}),
keyExhaustedCount: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT, {
description: 'All keys (across all upstreams) failed in a single request — primary user-facing alert',
}),
sameStatusExhaustion: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_SAME_STATUS_EXHAUSTION, {
description: 'All keys in one request failed with the same upstream status (account-level rate-limit signal)',
configInvalidHmac: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC, {
description: 'Pub/Sub invalidation messages dropped due to HMAC mismatch (forged or replayed)',
}),
configReload: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_RELOAD, {
description: 'Local in-memory router config cache reloaded (by source: pubsub / ttl / manual)',
@@ -433,33 +424,42 @@ export function initOtel(env: Env): OtelInstance | null {
decryptFailures: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES, {
description: 'Envelope-crypto decryption auth-tag failures (config corruption or rotation misstep)',
}),
subscriberState: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE, {
description: 'Pub/Sub subscriber lifecycle state transitions',
fallbackCount: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT, {
description: 'Per-attempt fallback events in the in-process LLM/TTS router',
}),
configInvalidHmac: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC, {
description: 'Pub/Sub invalidation messages dropped due to HMAC mismatch (forged or replayed)',
}),
poolSlotRejected: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED, {
description: 'Capacity-aware TTS routing skipped a pool already at its app_id concurrency cap',
}),
poolSaturationMarked: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED, {
description: 'TTSpool circuit-broken after exhausting with a 429 (app_id concurrency exceeded)',
keyExhaustedCount: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT, {
description: 'All keys (across all upstreams) failed in a single request — primary user-facing alert',
}),
poolInflight: meter.createObservableGauge(METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT, {
description: 'In-flight TTS requests per pool sourced from Redis (cluster-wide; dashboard must use avg(), not sum())',
}),
poolSaturationMarked: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED, {
description: 'TTSpool circuit-broken after exhausting with a 429 (app_id concurrency exceeded)',
}),
poolSlotRejected: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED, {
description: 'Capacity-aware TTS routing skipped a pool already at its app_id concurrency cap',
}),
sameStatusExhaustion: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_SAME_STATUS_EXHAUSTION, {
description: 'All keys in one request failed with the same upstream status (account-level rate-limit signal)',
}),
subscriberState: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE, {
description: 'Pub/Sub subscriber lifecycle state transitions',
}),
upstreamErrors: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS, {
description: 'Upstream error responses received during fallback iteration',
}),
}
const email: EmailMetrics = {
send: meter.createCounter(METRIC_AIRI_EMAIL_SEND, {
description: 'Transactional emails accepted by Resend',
duration: meter.createHistogram(METRIC_AIRI_EMAIL_DURATION, {
description: 'Email provider call duration',
unit: 's',
}),
failures: meter.createCounter(METRIC_AIRI_EMAIL_FAILURES, {
description: 'Transactional email send failures',
}),
duration: meter.createHistogram(METRIC_AIRI_EMAIL_DURATION, {
description: 'Email provider call duration',
unit: 's',
send: meter.createCounter(METRIC_AIRI_EMAIL_SEND, {
description: 'Transactional emails accepted by Resend',
}),
}
@@ -532,16 +532,16 @@ export function initOtel(env: Env): OtelInstance | null {
]
for (const counter of counters) counter.add(0)
return { auth, engagement, revenue, genAi, gateway, database, email, rateLimit, observability }
return { auth, database, email, engagement, gateway, genAi, observability, rateLimit, revenue }
}
const severityMap: Record<string, SeverityNumber> = {
debug: SeverityNumber.DEBUG,
verbose: SeverityNumber.TRACE,
log: SeverityNumber.INFO,
info: SeverityNumber.INFO,
warn: SeverityNumber.WARN,
error: SeverityNumber.ERROR,
info: SeverityNumber.INFO,
log: SeverityNumber.INFO,
verbose: SeverityNumber.TRACE,
warn: SeverityNumber.WARN,
}
/**
@@ -552,21 +552,21 @@ export function emitOtelLog(
level: string,
context: string,
message: string,
attributes?: Record<string, string | number | boolean>,
attributes?: Record<string, boolean | number | string>,
): void {
const otelLogger = logs.getLogger(context)
const spanContext = trace.getActiveSpan()?.spanContext()
otelLogger.emit({
severityNumber: severityMap[level.toLowerCase()] ?? SeverityNumber.INFO,
severityText: level.toUpperCase(),
body: message,
attributes: {
...attributes,
...(spanContext && {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
trace_id: spanContext.traceId,
}),
},
body: message,
severityNumber: severityMap[level.toLowerCase()] ?? SeverityNumber.INFO,
severityText: level.toUpperCase(),
})
}
@@ -36,23 +36,23 @@ export function createAudioSpeechWsHandlers(opts: AudioSpeechWsHandlersOptions)
const sessionState = createSessionState(userId, opts, analytics)
return {
onOpen(_event, ws) {
sessionState.attachClient(ws)
},
onMessage(message, ws) {
sessionState.handleClientMessage(message, ws)
},
onClose(_event, _ws) {
sessionState.handleClientClose()
},
onError(event, ws) {
log.withFields({ userId, event: String(event) }).warn('client ws error')
log.withFields({ event: String(event), userId }).warn('client ws error')
sessionState.handleClientClose()
try {
ws.close(1011, 'internal_error')
}
catch {}
},
onMessage(message, ws) {
sessionState.handleClientMessage(message, ws)
},
onOpen(_event, ws) {
sessionState.attachClient(ws)
},
}
}
}
@@ -33,6 +33,23 @@ export function bufferToString(data: RawData): string {
return data.toString('utf8')
}
/**
* Reads authoritative TTS usage characters from an upstream control payload.
*
* Before:
* - `{ usage: { text_words: 42 } }`
* - `{}`
*
* After:
* - `42`
* - `null`
*/
export function readUsageChars(payload: Record<string, unknown> | undefined): null | number {
const result = safeParse(UpstreamUsagePayloadSchema, payload)
const textWords = result.success ? result.output.usage?.text_words : undefined
return typeof textWords === 'number' ? Math.floor(textWords) : null
}
/**
* Normalizes websocket binary payload chunks.
*
@@ -52,20 +69,3 @@ export function toBufferLike(data: RawData): ArrayBuffer {
return data
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
}
/**
* Reads authoritative TTS usage characters from an upstream control payload.
*
* Before:
* - `{ usage: { text_words: 42 } }`
* - `{}`
*
* After:
* - `42`
* - `null`
*/
export function readUsageChars(payload: Record<string, unknown> | undefined): number | null {
const result = safeParse(UpstreamUsagePayloadSchema, payload)
const textWords = result.success ? result.output.usage?.text_words : undefined
return typeof textWords === 'number' ? Math.floor(textWords) : null
}
@@ -10,19 +10,129 @@ import { WebSocketServer } from 'ws'
import { createAudioSpeechWsHandlers } from './index'
interface MockClientWs {
closeCode?: number
closed: boolean
closeReason?: string
ctx: WSContext
sent: Array<{ data: ArrayBuffer | Buffer | string, kind: 'binary' | 'text' }>
}
interface MockUpstream {
url: string
close: () => Promise<void>
/** Auth header observed during handshake. */
observedAuth: string | undefined
/** Frames the upstream actually received from the proxy, in arrival order. */
receivedFrames: Array<{ data: Buffer | string, kind: 'binary' | 'text' }>
restBaseURL: string
/** Outgoing JSON frames the server should send after receiving `start`. */
scriptedResponses: Array<
| { bytes: Buffer, kind: 'binary' }
| { kind: 'json', payload: Record<string, unknown> }
| { kind: 'binary', bytes: Buffer }
>
/** Frames the upstream actually received from the proxy, in arrival order. */
receivedFrames: Array<{ kind: 'text' | 'binary', data: string | Buffer }>
/** Auth header observed during handshake. */
observedAuth: string | undefined
close: () => Promise<void>
url: string
}
/** Drives the WSEvents lifecycle as if a real client had connected. */
async function driveClientSession(events: WSEvents, client: MockClientWs, clientFrames: Array<Buffer | string>) {
// onOpen handles the initial dial. The route fires `void dialUpstream()`
// which is async, so we await a microtask tick to let the upstream
// dialing kick off.
events.onOpen?.(new Event('open') as any, client.ctx)
await new Promise(r => setTimeout(r, 50))
for (const frame of clientFrames) {
const isBinary = Buffer.isBuffer(frame)
const data = isBinary ? frame : String(frame)
events.onMessage?.({ data } as any, client.ctx)
await new Promise(r => setTimeout(r, 20))
}
}
function makeFakeDeps(overrides: {
decryptedKey?: string
fluxBalance: number
restBaseURL?: string
streamingModels?: Array<{ description?: string, id: string, name?: string }>
upstreamURL: string
}) {
const ttsMeter = {
accumulate: vi.fn(async () => ({
balanceAfter: overrides.fluxBalance - 1,
debtAfter: 0,
fluxDebited: 1,
unbilledFlux: 0,
})),
assertCanAfford: vi.fn(async (_userId: string, _newUnits: number, currentBalance: number) => {
if (currentBalance <= 0)
throw Object.assign(new Error('Insufficient flux'), { statusCode: 402 })
}),
}
const fluxService = {
getFlux: vi.fn(async () => ({ flux: overrides.fluxBalance })),
}
const requestLogService = {
logRequest: vi.fn(async () => undefined),
}
const configKV = {
getOptional: vi.fn(async (key: string) => {
if (key === 'UNSPEECH_UPSTREAM') {
return {
restBaseURL: overrides.restBaseURL ?? 'http://unspeech.local:5933',
streaming: {
adapterParams: {},
baseURL: overrides.upstreamURL,
keys: [{ ciphertext: 'ENCRYPTED_PLACEHOLDER', id: 'test-key-1' }],
models: overrides.streamingModels ?? [
{ id: 'volcengine/seed-tts-1.0', name: 'Seed-TTS 1.0' },
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0' },
],
},
}
}
return null
}),
}
const envelopeCrypto = {
decryptKey: vi.fn(() => Buffer.from(overrides.decryptedKey ?? 'mock-upstream-token', 'utf8')),
}
return { configKV, envelopeCrypto, fluxService, requestLogService, ttsMeter }
}
function makeMockClientWs(): MockClientWs {
const sent: MockClientWs['sent'] = []
const state = {
closeCode: undefined as number | undefined,
closed: false as boolean,
closeReason: undefined as string | undefined,
}
const ctx = {
binaryType: 'arraybuffer',
close: (code?: number, reason?: string) => {
state.closed = true
state.closeCode = code
state.closeReason = reason
},
protocol: '',
raw: {} as any,
readyState: 1,
send: (data: ArrayBuffer | Buffer | string) => {
sent.push({
data,
kind: typeof data === 'string' ? 'text' : 'binary',
})
},
url: null,
} as unknown as WSContext
return {
get closeCode() { return state.closeCode },
get closed() { return state.closed },
get closeReason() { return state.closeReason },
ctx,
sent,
}
}
async function startMockUpstream(
@@ -50,8 +160,8 @@ async function startMockUpstream(
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
const decoded = isBinary ? buf : buf.toString('utf8')
receivedFrames.push({
kind: isBinary ? 'binary' : 'text',
data: isBinary ? buf : decoded,
kind: isBinary ? 'binary' : 'text',
})
// Hold the scripted replay until we observe the client's `finish`
@@ -102,127 +212,17 @@ async function startMockUpstream(
const { port } = httpServer.address() as AddressInfo
return {
url: `ws://127.0.0.1:${port}`,
restBaseURL: `http://127.0.0.1:${port}`,
scriptedResponses,
receivedFrames,
get observedAuth() {
return observedAuth
},
async close() {
wss.close()
await new Promise<void>(resolve => httpServer.close(() => resolve()))
},
}
}
interface MockClientWs {
ctx: WSContext
sent: Array<{ kind: 'text' | 'binary', data: string | ArrayBuffer | Buffer }>
closed: boolean
closeCode?: number
closeReason?: string
}
function makeMockClientWs(): MockClientWs {
const sent: MockClientWs['sent'] = []
const state = {
closed: false as boolean,
closeCode: undefined as number | undefined,
closeReason: undefined as string | undefined,
}
const ctx = {
send: (data: string | ArrayBuffer | Buffer) => {
sent.push({
kind: typeof data === 'string' ? 'text' : 'binary',
data,
})
get observedAuth() {
return observedAuth
},
close: (code?: number, reason?: string) => {
state.closed = true
state.closeCode = code
state.closeReason = reason
},
readyState: 1,
binaryType: 'arraybuffer',
raw: {} as any,
protocol: '',
url: null,
} as unknown as WSContext
return {
ctx,
sent,
get closed() { return state.closed },
get closeCode() { return state.closeCode },
get closeReason() { return state.closeReason },
}
}
function makeFakeDeps(overrides: {
upstreamURL: string
restBaseURL?: string
fluxBalance: number
decryptedKey?: string
streamingModels?: Array<{ id: string, name?: string, description?: string }>
}) {
const ttsMeter = {
assertCanAfford: vi.fn(async (_userId: string, _newUnits: number, currentBalance: number) => {
if (currentBalance <= 0)
throw Object.assign(new Error('Insufficient flux'), { statusCode: 402 })
}),
accumulate: vi.fn(async () => ({
fluxDebited: 1,
debtAfter: 0,
balanceAfter: overrides.fluxBalance - 1,
unbilledFlux: 0,
})),
}
const fluxService = {
getFlux: vi.fn(async () => ({ flux: overrides.fluxBalance })),
}
const requestLogService = {
logRequest: vi.fn(async () => undefined),
}
const configKV = {
getOptional: vi.fn(async (key: string) => {
if (key === 'UNSPEECH_UPSTREAM') {
return {
restBaseURL: overrides.restBaseURL ?? 'http://unspeech.local:5933',
streaming: {
baseURL: overrides.upstreamURL,
keys: [{ id: 'test-key-1', ciphertext: 'ENCRYPTED_PLACEHOLDER' }],
adapterParams: {},
models: overrides.streamingModels ?? [
{ id: 'volcengine/seed-tts-1.0', name: 'Seed-TTS 1.0' },
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0' },
],
},
}
}
return null
}),
}
const envelopeCrypto = {
decryptKey: vi.fn(() => Buffer.from(overrides.decryptedKey ?? 'mock-upstream-token', 'utf8')),
}
return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService }
}
/** Drives the WSEvents lifecycle as if a real client had connected. */
async function driveClientSession(events: WSEvents, client: MockClientWs, clientFrames: Array<string | Buffer>) {
// onOpen handles the initial dial. The route fires `void dialUpstream()`
// which is async, so we await a microtask tick to let the upstream
// dialing kick off.
events.onOpen?.(new Event('open') as any, client.ctx)
await new Promise(r => setTimeout(r, 50))
for (const frame of clientFrames) {
const isBinary = Buffer.isBuffer(frame)
const data = isBinary ? frame : String(frame)
events.onMessage?.({ data } as any, client.ctx)
await new Promise(r => setTimeout(r, 20))
receivedFrames,
restBaseURL: `http://127.0.0.1:${port}`,
scriptedResponses,
url: `ws://127.0.0.1:${port}`,
}
}
@@ -239,11 +239,11 @@ describe('audio-speech-ws route', () => {
const audioPayload = Buffer.from('FAKE_AUDIO_BYTES_AAAAAAAAAA', 'utf8')
upstream = await startMockUpstream([
{ kind: 'json', payload: { event: 'session.started' } },
{ kind: 'binary', bytes: audioPayload },
{ bytes: audioPayload, kind: 'binary' },
{ kind: 'json', payload: { event: 'session.finished', payload: { usage: { text_words: 42 } } } },
])
const deps = makeFakeDeps({ upstreamURL: upstream.url, restBaseURL: upstream.restBaseURL, fluxBalance: 100 })
const deps = makeFakeDeps({ fluxBalance: 100, restBaseURL: upstream.restBaseURL, upstreamURL: upstream.url })
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-123', { voiceType: 'official_selected' })
const client = makeMockClientWs()
@@ -280,27 +280,27 @@ describe('audio-speech-ws route', () => {
// length of "hello streaming tts" = 19).
expect(deps.ttsMeter.accumulate).toHaveBeenCalledTimes(1)
expect((deps.ttsMeter.accumulate.mock.calls[0] as any[])[0]).toMatchObject({
userId: 'user-123',
units: 42,
metadata: { model: 'volcengine/seed-tts-2.0' },
units: 42,
userId: 'user-123',
})
// Request log gets the model label from the start frame, not the
// hardcoded fallback.
expect(deps.requestLogService.logRequest).toHaveBeenCalledTimes(1)
expect((deps.requestLogService.logRequest.mock.calls[0] as any[])[0]).toMatchObject({
userId: 'user-123',
fluxConsumed: 1,
model: 'volcengine/seed-tts-2.0',
status: 200,
fluxConsumed: 1,
userId: 'user-123',
})
})
it('refuses the session with insufficient_flux when the user is broke', async () => {
upstream = await startMockUpstream([])
const deps = makeFakeDeps({ upstreamURL: upstream.url, restBaseURL: upstream.restBaseURL, fluxBalance: 0 })
const deps = makeFakeDeps({ fluxBalance: 0, restBaseURL: upstream.restBaseURL, upstreamURL: upstream.url })
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-broke', { trigger: 'auto', source: 'chat_auto_tts' })
const events = handlers('user-broke', { source: 'chat_auto_tts', trigger: 'auto' })
const client = makeMockClientWs()
await driveClientSession(events, client, [
@@ -314,15 +314,15 @@ describe('audio-speech-ws route', () => {
const errorFrame = client.sent.find(s => s.kind === 'text')
expect(errorFrame).toBeDefined()
expect(JSON.parse(errorFrame!.data as string)).toMatchObject({
event: 'error',
code: 'insufficient_flux',
event: 'error',
})
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1008)
})
it('refuses with streaming_tts_not_configured when UNSPEECH_UPSTREAM.streaming is empty', async () => {
const deps = makeFakeDeps({ upstreamURL: 'ws://unused', fluxBalance: 100 })
const deps = makeFakeDeps({ fluxBalance: 100, upstreamURL: 'ws://unused' })
deps.configKV.getOptional = vi.fn(async () => null) as any
const handlers = createAudioSpeechWsHandlers(deps as any)
@@ -336,8 +336,8 @@ describe('audio-speech-ws route', () => {
const errorFrame = client.sent.find(s => s.kind === 'text')
expect(errorFrame).toBeDefined()
expect(JSON.parse(errorFrame!.data as string)).toMatchObject({
event: 'error',
code: 'streaming_tts_not_configured',
event: 'error',
})
expect(client.closed).toBe(true)
})
@@ -345,10 +345,10 @@ describe('audio-speech-ws route', () => {
it('refuses an unconfigured streaming model before dialing upstream', async () => {
upstream = await startMockUpstream([])
const deps = makeFakeDeps({
upstreamURL: upstream.url,
restBaseURL: upstream.restBaseURL,
fluxBalance: 100,
restBaseURL: upstream.restBaseURL,
streamingModels: [{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0' }],
upstreamURL: upstream.url,
})
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-disabled-model')
@@ -366,8 +366,8 @@ describe('audio-speech-ws route', () => {
const errorFrame = client.sent.find(s => s.kind === 'text')
expect(errorFrame).toBeDefined()
expect(JSON.parse(errorFrame!.data as string)).toMatchObject({
event: 'error',
code: 'streaming_tts_model_not_enabled',
event: 'error',
})
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1008)
@@ -375,7 +375,7 @@ describe('audio-speech-ws route', () => {
it('refuses an unknown streaming voice before dialing upstream', async () => {
upstream = await startMockUpstream([], [{ id: 'enabled-voice', name: 'Enabled Voice' }])
const deps = makeFakeDeps({ upstreamURL: upstream.url, restBaseURL: upstream.restBaseURL, fluxBalance: 100 })
const deps = makeFakeDeps({ fluxBalance: 100, restBaseURL: upstream.restBaseURL, upstreamURL: upstream.url })
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-disabled-voice')
const client = makeMockClientWs()
@@ -392,8 +392,8 @@ describe('audio-speech-ws route', () => {
const errorFrame = client.sent.find(s => s.kind === 'text')
expect(errorFrame).toBeDefined()
expect(JSON.parse(errorFrame!.data as string)).toMatchObject({
event: 'error',
code: 'streaming_tts_voice_not_enabled',
event: 'error',
})
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1008)
@@ -404,11 +404,11 @@ describe('audio-speech-ws route', () => {
// length of every `text` frame's `text` field instead.
upstream = await startMockUpstream([
{ kind: 'json', payload: { event: 'session.started' } },
{ kind: 'binary', bytes: Buffer.from('audio', 'utf8') },
{ bytes: Buffer.from('audio', 'utf8'), kind: 'binary' },
{ kind: 'json', payload: { event: 'session.finished', payload: {} } },
])
const deps = makeFakeDeps({ upstreamURL: upstream.url, restBaseURL: upstream.restBaseURL, fluxBalance: 100 })
const deps = makeFakeDeps({ fluxBalance: 100, restBaseURL: upstream.restBaseURL, upstreamURL: upstream.url })
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-no-usage')
const client = makeMockClientWs()
@@ -423,8 +423,8 @@ describe('audio-speech-ws route', () => {
expect(deps.ttsMeter.accumulate).toHaveBeenCalledTimes(1)
expect((deps.ttsMeter.accumulate.mock.calls[0] as any[])[0]).toMatchObject({
userId: 'user-no-usage',
units: 10, // "hello" + "world" = 10 chars
userId: 'user-no-usage',
})
})
})
@@ -37,6 +37,12 @@ const STREAM_MODEL_LABEL_FALLBACK = 'streaming-tts'
const tracer = trace.getTracer('audio-speech-ws')
export interface AudioSpeechSessionAnalytics {
source?: StreamingTtsSource
trigger?: StreamingTtsTrigger
voiceType?: StreamingTtsVoiceType
}
/**
* Mutable state for one streaming speech websocket connection.
*/
@@ -45,20 +51,20 @@ export interface AudioSpeechSessionState {
attachClient: (ws: WSContext) => void
/** Reads config, checks balance, decrypts the upstream key, and dials upstream after the start frame is accepted. */
dialUpstream: () => Promise<void>
/** Forwards a client frame or queues it while the upstream connection opens. */
handleClientMessage: (message: { data: unknown }, ws: WSContext) => void
/** Cancels upstream and finalizes the span when the client disconnects. */
handleClientClose: () => void
/** Forwards a client frame or queues it while the upstream connection opens. */
handleClientMessage: (message: { data: unknown }, ws: WSContext) => void
}
export type StreamingTtsTrigger = 'auto' | 'manual'
export type StreamingTtsSource = 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
export type StreamingTtsVoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
export type StreamingTtsTrigger = 'auto' | 'manual'
export interface AudioSpeechSessionAnalytics {
trigger?: StreamingTtsTrigger
source?: StreamingTtsSource
voiceType?: StreamingTtsVoiceType
export type StreamingTtsVoiceType = 'custom_configured' | 'official_default' | 'official_selected' | 'unknown' | 'voice_pack'
interface StreamingTtsStartFrame {
event: 'start'
model: string
voice: string
}
/**
@@ -88,8 +94,8 @@ export function createSessionState(
},
})
let clientWs: WSContext | null = null
let upstreamWs: WebSocket | null = null
let clientWs: null | WSContext = null
let upstreamWs: null | WebSocket = null
let upstreamReady = false
let closed = false
let billed = false
@@ -156,8 +162,8 @@ export function createSessionState(
let keyPlaintext: Buffer
try {
keyPlaintext = opts.envelopeCrypto.decryptKey(entry.ciphertext, {
modelName: STREAM_MODEL_LABEL_FALLBACK,
keyEntryId: entry.id,
modelName: STREAM_MODEL_LABEL_FALLBACK,
})
}
catch (err) {
@@ -205,7 +211,7 @@ export function createSessionState(
})
upstream.on('close', (code, reason) => {
log.withFields({ userId, code, reason: reason?.toString() }).debug('upstream ws closed')
log.withFields({ code, reason: reason?.toString(), userId }).debug('upstream ws closed')
finalize()
})
@@ -215,8 +221,8 @@ export function createSessionState(
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
try {
clientWs?.send(JSON.stringify({
event: 'error',
code: 'upstream_error',
event: 'error',
message: err.message,
}))
}
@@ -344,6 +350,12 @@ export function createSessionState(
function handleUpstreamControlEvent(evt: { event?: string, payload?: Record<string, unknown> }) {
switch (evt.event) {
case 'error': {
const code = typeof evt.payload?.code === 'string' ? evt.payload.code : 'upstream_error'
log.withFields({ code, message: String(evt.payload?.message ?? ''), userId }).warn('upstream sent error event')
span.setStatus({ code: SpanStatusCode.ERROR, message: code })
break
}
case 'session.finished': {
// Pull authoritative usage from upstream when present. Falls back to
// the client-text-frame estimate accumulated in handleClientMessage.
@@ -355,12 +367,6 @@ export function createSessionState(
finalize()
break
}
case 'error': {
const code = typeof evt.payload?.code === 'string' ? evt.payload.code : 'upstream_error'
log.withFields({ userId, code, message: String(evt.payload?.message ?? '') }).warn('upstream sent error event')
span.setStatus({ code: SpanStatusCode.ERROR, message: code })
break
}
// session.started / sentence.* / subtitle — no server-side action, pure
// pass-through to client.
}
@@ -454,11 +460,11 @@ export function createSessionState(
try {
const result = await otelContext.with(trace.setSpan(otelContext.active(), span), () =>
opts.ttsMeter.accumulate({
userId,
units,
currentBalance: flux.flux,
requestId,
metadata: { model: modelLabel },
requestId,
units,
userId,
}))
fluxConsumed = result.fluxDebited
span.setAttribute(AIRI_ATTR_BILLING_FLUX_CONSUMED, fluxConsumed)
@@ -467,7 +473,7 @@ export function createSessionState(
// Billing failure is surfaced but does not retroactively reject the
// already-delivered audio — the user got the audio, the meter retains
// the debt for the next request to settle (per FluxMeter rollback path).
log.withError(err).withFields({ userId, units, reason }).error('billing accumulate failed for streaming tts')
log.withError(err).withFields({ reason, units, userId }).error('billing accumulate failed for streaming tts')
span.recordException(err as Error)
span.setStatus({ code: SpanStatusCode.ERROR, message: 'billing_failed' })
}
@@ -475,11 +481,11 @@ export function createSessionState(
const durationMs = Date.now() - startedAt
try {
await opts.requestLogService.logRequest({
userId,
model: modelLabel,
status: 200,
durationMs,
fluxConsumed,
model: modelLabel,
status: 200,
userId,
})
}
catch (err) {
@@ -510,7 +516,7 @@ export function createSessionState(
span.setStatus({ code: SpanStatusCode.ERROR, message: reason })
if (clientWs) {
try {
clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason }))
clientWs.send(JSON.stringify({ code: reason, event: 'error', message: reason }))
}
catch {}
try {
@@ -527,7 +533,7 @@ export function createSessionState(
return
if (clientWs) {
try {
clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason }))
clientWs.send(JSON.stringify({ code: reason, event: 'error', message: reason }))
}
catch {}
try {
@@ -542,8 +548,8 @@ export function createSessionState(
return {
attachClient,
dialUpstream,
handleClientMessage,
handleClientClose,
handleClientMessage,
}
}
@@ -556,13 +562,7 @@ function isPaymentRequiredError(err: unknown): boolean {
&& (err as { statusCode?: unknown }).statusCode === 402
}
interface StreamingTtsStartFrame {
event: 'start'
model: string
voice: string
}
function parseStartFrame(rawText: string): StreamingTtsStartFrame | null {
function parseStartFrame(rawText: string): null | StreamingTtsStartFrame {
try {
const parsed = JSON.parse(rawText) as Record<string, unknown>
if (parsed.event !== 'start')
@@ -586,7 +586,14 @@ function streamingModelResourceId(model: string): string {
return model.includes('/') ? model.split('/', 2)[1] : model
}
function streamingVoicesURL(restBaseURL: string, resourceId: string): string | null {
function streamingVoiceId(voice: unknown): null | string {
if (typeof voice !== 'object' || voice == null)
return null
const id = (voice as { id?: unknown }).id
return typeof id === 'string' && id.length > 0 ? id : null
}
function streamingVoicesURL(restBaseURL: string, resourceId: string): null | string {
try {
const url = new URL(restBaseURL)
url.pathname = '/api/voices'
@@ -594,17 +601,10 @@ function streamingVoicesURL(restBaseURL: string, resourceId: string): string | n
// Volcengine Unspeech adapter. If another streaming provider is added,
// thread provider identity through the start-frame validation path instead
// of deriving it from the model id here.
url.search = new URLSearchParams({ provider: 'volcengine', model: resourceId }).toString()
url.search = new URLSearchParams({ model: resourceId, provider: 'volcengine' }).toString()
return url.toString()
}
catch {
return null
}
}
function streamingVoiceId(voice: unknown): string | null {
if (typeof voice !== 'object' || voice == null)
return null
const id = (voice as { id?: unknown }).id
return typeof id === 'string' && id.length > 0 ? id : null
}
@@ -14,8 +14,8 @@ export interface AudioSpeechWsHandlersOptions {
envelopeCrypto: EnvelopeCrypto
/** Reads the user's current Flux balance for pre-flight and final billing. */
fluxService: FluxService
/** Applies pre-flight affordability checks and final streaming TTS billing. */
ttsMeter: FluxMeter
/** Persists request accounting after a stream finishes. */
requestLogService: RequestLogService
/** Applies pre-flight affordability checks and final streaming TTS billing. */
ttsMeter: FluxMeter
}
@@ -9,48 +9,48 @@ import { createEnvelopeCrypto } from '../../utils/envelope-crypto'
import { ApiError } from '../../utils/error'
import { resolveOfficialAliyunNlsCredentials, resolveOfficialAliyunNlsCredentialsFromConfig } from './route'
function createRouterConfig(overrides?: Partial<RouterConfig>): RouterConfig {
return {
llm: { models: {} },
tts: { models: {} },
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
...overrides,
}
}
function createProviderCatalogService(routeModelId = 'auto'): ProviderCatalogService {
return {
syncAliasesFromRouterConfig: vi.fn(async () => []),
resolveEnabledAlias: vi.fn(async () => ({
id: 'alias-auto',
surface: 'asr',
aliasId: 'auto',
displayName: 'Auto',
enabled: true,
displayOrder: 0,
fallbackEnabled: true,
loadBalancingEnabled: false,
createdAt: new Date(),
updatedAt: new Date(),
displayName: 'Auto',
displayOrder: 0,
enabled: true,
fallbackEnabled: true,
id: 'alias-auto',
loadBalancingEnabled: false,
routes: [{
id: 'route-1',
aliasId: 'alias-auto',
routerModelId: routeModelId,
pool: 'primary',
enabled: true,
weight: 1,
displayOrder: 0,
createdAt: new Date(),
displayOrder: 0,
enabled: true,
id: 'route-1',
pool: 'primary',
routerModelId: routeModelId,
updatedAt: new Date(),
weight: 1,
}],
surface: 'asr',
updatedAt: new Date(),
})),
syncAliasesFromRouterConfig: vi.fn(async () => []),
} as unknown as ProviderCatalogService
}
function createRouterConfig(overrides?: Partial<RouterConfig>): RouterConfig {
return {
defaults: {
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
fullChainTimeoutMs: 60000,
perAttemptTimeoutMs: 30000,
},
llm: { models: {} },
tts: { models: {} },
...overrides,
}
}
describe('resolveOfficialAliyunNlsCredentials', () => {
/**
* @example
@@ -71,8 +71,8 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
it('decrypts Aliyun NLS credentials from LLM_ROUTER_CONFIG.asr', () => {
const envelope = createEnvelopeCrypto({ masterKey: Buffer.alloc(32, 7) })
const ciphertext = envelope.encryptKey(' secret ', {
modelName: 'auto',
keyEntryId: 'aliyun-nls-asr-prod-1',
modelName: 'auto',
})
const credentials = resolveOfficialAliyunNlsCredentials(createRouterConfig({
@@ -81,12 +81,12 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
auto: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext }],
adapterParams: {
accessKeyId: ' ak ',
appKey: ' app ',
region: '',
},
keys: [{ ciphertext, id: 'aliyun-nls-asr-prod-1' }],
}],
},
},
@@ -104,8 +104,8 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
it('resolves official ASR alias through the catalog before decrypting credentials', async () => {
const envelope = createEnvelopeCrypto({ masterKey: Buffer.alloc(32, 7) })
const ciphertext = envelope.encryptKey(' secret ', {
modelName: 'aliyun/asr-primary',
keyEntryId: 'aliyun-nls-asr-prod-1',
modelName: 'aliyun/asr-primary',
})
const routerConfig = createRouterConfig({
asr: {
@@ -113,11 +113,11 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
'aliyun/asr-primary': {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext }],
adapterParams: {
accessKeyId: 'ak',
appKey: 'app',
},
keys: [{ ciphertext, id: 'aliyun-nls-asr-prod-1' }],
}],
},
},
@@ -152,8 +152,8 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
auto: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext: 'unused' }],
adapterParams: {},
keys: [{ ciphertext: 'unused', id: 'aliyun-nls-asr-prod-1' }],
}],
},
},
@@ -165,8 +165,8 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
envelopeCrypto: envelope,
providerCatalogService,
})).rejects.toMatchObject({
statusCode: 400,
errorCode: 'CAPABILITY_ALIAS_DISABLED',
statusCode: 400,
})
})
})
@@ -12,23 +12,61 @@ import { createKeyRotator } from '../../services/domain/llm-router/key-rotator'
import { createServiceUnavailableError, createUnauthorizedError } from '../../utils/error'
import { createAliyunNlsStreamResponse } from './session'
type AliyunNlsRegion = 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
type AliyunNlsRegion = 'cn-beijing' | 'cn-beijing-internal' | 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
const ALIYUN_NLS_REGION_FALLBACK: AliyunNlsRegion = 'cn-shanghai'
const ALIYUN_NLS_REGIONS = new Set<AliyunNlsRegion>([
'cn-shanghai',
'cn-shanghai-internal',
'cn-beijing',
'cn-beijing-internal',
'cn-shanghai',
'cn-shanghai-internal',
'cn-shenzhen',
'cn-shenzhen-internal',
])
const OFFICIAL_ASR_MODEL_NAME = 'auto'
function stringAdapterParam(params: Record<string, unknown> | undefined, key: string): string {
const value = params?.[key]
return typeof value === 'string' ? value.trim() : ''
/**
* Handles official realtime transcription audio upload streams.
*
* Use when:
* - A browser client POSTs the Hearing PCM stream and expects SSE transcript deltas.
*
* Expects:
* - Authentication has not yet run through normal session middleware because this route is mounted before body limits.
*
* Returns:
* - An SSE response that mirrors `@xsai/stream-transcription` delta events.
*/
export function createAudioTranscriptionStreamHandler(input: {
configKV: ConfigKVService
db: Database
env: Env
envelopeCrypto: EnvelopeCrypto
providerCatalogService: ProviderCatalogService
}) {
return async function handleAudioTranscriptionStream(c: Context) {
const session = await resolveRequestAuth(
input.db,
input.env,
c.req.raw.headers,
)
if (!session?.user)
throw createUnauthorizedError()
const credentials = await resolveOfficialAliyunNlsCredentialsFromConfig(input)
if (!credentials)
throw createServiceUnavailableError('Official ASR transcription is not configured in the ASR capability catalog', 'CONFIG_NOT_SET')
const audioStream = c.req.raw.body
if (!audioStream)
throw createServiceUnavailableError('Streaming transcription request is missing audio body', 'REQUEST_BODY_NOT_STREAMABLE')
return createAliyunNlsStreamResponse({
audioStream: audioStream as ReadableStream<Uint8Array>,
credentials,
})
}
}
/**
@@ -46,7 +84,7 @@ function stringAdapterParam(params: Record<string, unknown> | undefined, key: st
* - Decrypted credentials, or `null` when any required config is missing.
*/
export function resolveOfficialAliyunNlsCredentials(
routerConfig: RouterConfig | null | undefined,
routerConfig: null | RouterConfig | undefined,
envelopeCrypto: EnvelopeCrypto,
modelName: string = OFFICIAL_ASR_MODEL_NAME,
) {
@@ -104,45 +142,7 @@ export async function resolveOfficialAliyunNlsCredentialsFromConfig(input: {
return credentials
}
/**
* Handles official realtime transcription audio upload streams.
*
* Use when:
* - A browser client POSTs the Hearing PCM stream and expects SSE transcript deltas.
*
* Expects:
* - Authentication has not yet run through normal session middleware because this route is mounted before body limits.
*
* Returns:
* - An SSE response that mirrors `@xsai/stream-transcription` delta events.
*/
export function createAudioTranscriptionStreamHandler(input: {
db: Database
env: Env
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
providerCatalogService: ProviderCatalogService
}) {
return async function handleAudioTranscriptionStream(c: Context) {
const session = await resolveRequestAuth(
input.db,
input.env,
c.req.raw.headers,
)
if (!session?.user)
throw createUnauthorizedError()
const credentials = await resolveOfficialAliyunNlsCredentialsFromConfig(input)
if (!credentials)
throw createServiceUnavailableError('Official ASR transcription is not configured in the ASR capability catalog', 'CONFIG_NOT_SET')
const audioStream = c.req.raw.body
if (!audioStream)
throw createServiceUnavailableError('Streaming transcription request is missing audio body', 'REQUEST_BODY_NOT_STREAMABLE')
return createAliyunNlsStreamResponse({
audioStream: audioStream as ReadableStream<Uint8Array>,
credentials,
})
}
function stringAdapterParam(params: Record<string, unknown> | undefined, key: string): string {
const value = params?.[key]
return typeof value === 'string' ? value.trim() : ''
}
@@ -9,10 +9,24 @@ import { WebSocketServer } from 'ws'
import { createAliyunNlsStreamResponse } from './session'
interface MockAliyunUpstream {
url: string
receivedTextFrames: string[]
receivedBinaryFrames: Buffer[]
close: () => Promise<void>
receivedBinaryFrames: Buffer[]
receivedTextFrames: string[]
url: string
}
async function readText(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done)
break
text += decoder.decode(value, { stream: true })
}
text += decoder.decode()
return text
}
async function startMockAliyunUpstream(): Promise<MockAliyunUpstream> {
@@ -56,13 +70,13 @@ async function startMockAliyunUpstream(): Promise<MockAliyunUpstream> {
const { port } = httpServer.address() as AddressInfo
return {
url: `ws://127.0.0.1:${port}`,
receivedTextFrames,
receivedBinaryFrames,
async close() {
wss.close()
await new Promise<void>(resolve => httpServer.close(() => resolve()))
},
receivedBinaryFrames,
receivedTextFrames,
url: `ws://127.0.0.1:${port}`,
}
}
@@ -76,20 +90,6 @@ function streamOf(chunks: Uint8Array[]) {
})
}
async function readText(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done)
break
text += decoder.decode(value, { stream: true })
}
text += decoder.decode()
return text
}
describe('createAliyunNlsStreamResponse', () => {
let upstream: MockAliyunUpstream | undefined
@@ -107,13 +107,13 @@ describe('createAliyunNlsStreamResponse', () => {
const response = createAliyunNlsStreamResponse({
audioStream: streamOf([Buffer.from([1, 2]), Buffer.from([3, 4])]),
createToken: async () => ({ expiresAt: Date.now() + 3600_000, token: 'mock-token' }),
credentials: {
accessKeyId: 'ak',
accessKeySecret: 'secret',
appKey: 'app',
region: 'cn-shanghai',
},
createToken: async () => ({ token: 'mock-token', expiresAt: Date.now() + 3600_000 }),
websocketBaseURL: upstream.url,
})
@@ -128,7 +128,7 @@ describe('createAliyunNlsStreamResponse', () => {
const startFrame = JSON.parse(upstream.receivedTextFrames[0]) as {
header: { appkey: string, name: string }
payload: { format: string, sample_rate: number, enable_intermediate_result: boolean }
payload: { enable_intermediate_result: boolean, format: string, sample_rate: number }
}
expect(startFrame.header.appkey).toBe('app')
expect(startFrame.header.name).toBe('StartTranscription')
@@ -5,8 +5,6 @@ import WebSocket from 'ws'
import { merge } from '@moeru/std'
import { ofetch } from 'ofetch'
type AliyunNlsRegion = 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
interface AliyunNlsCredentials {
accessKeyId: string
accessKeySecret: string
@@ -14,20 +12,7 @@ interface AliyunNlsCredentials {
region: AliyunNlsRegion
}
interface AliyunNlsToken {
token: string
expiresAt: number
}
interface AliyunNlsStartPayload {
format?: 'pcm' | 'wav' | 'opus' | 'speex' | 'amr' | 'mp3' | 'aac'
sample_rate?: 8000 | 16000
enable_intermediate_result?: boolean
enable_punctuation_prediction?: boolean
enable_inverse_text_normalization?: boolean
enable_words?: boolean
max_sentence_silence?: number
}
type AliyunNlsRegion = 'cn-beijing' | 'cn-beijing-internal' | 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
interface AliyunNlsServerEvent {
header?: {
@@ -38,46 +23,112 @@ interface AliyunNlsServerEvent {
}
}
interface AliyunNlsStartPayload {
enable_intermediate_result?: boolean
enable_inverse_text_normalization?: boolean
enable_punctuation_prediction?: boolean
enable_words?: boolean
format?: 'aac' | 'amr' | 'mp3' | 'opus' | 'pcm' | 'speex' | 'wav'
max_sentence_silence?: number
sample_rate?: 8000 | 16000
}
interface AliyunNlsToken {
expiresAt: number
token: string
}
interface CreateAliyunNlsStreamResponseOptions {
audioStream: ReadableStream<Uint8Array>
credentials: AliyunNlsCredentials
createToken?: (credentials: AliyunNlsCredentials) => Promise<AliyunNlsToken>
credentials: AliyunNlsCredentials
sessionOptions?: AliyunNlsStartPayload
websocketBaseURL?: string
}
const encoder = new TextEncoder()
const DEFAULT_SESSION_OPTIONS: AliyunNlsStartPayload = {
format: 'pcm',
sample_rate: 16000,
enable_intermediate_result: true,
enable_punctuation_prediction: true,
enable_words: true,
format: 'pcm',
sample_rate: 16000,
}
function nlsMetaEndpointFromRegion(region: AliyunNlsRegion): URL {
return new URL(`http://nls-meta.${region}.aliyuncs.com`)
/**
* Streams client microphone PCM through Aliyun NLS and returns xsai-compatible SSE transcript deltas.
*
* Use when:
* - AIRI owns the Aliyun NLS credentials server-side.
* - The browser uploads a realtime audio `ReadableStream` and expects transcript deltas.
*
* Expects:
* - `audioStream` contains 16 kHz PCM chunks by default, matching the Hearing worklet output.
*
* Returns:
* - A `text/event-stream` response consumable by the shared `streamTranscription` adapter.
*/
export function createAliyunNlsStreamResponse(options: CreateAliyunNlsStreamResponseOptions): Response {
const body = new ReadableStream<Uint8Array>({
cancel() {
// The upstream websocket is closed by its own completion/error handlers.
},
async start(controller) {
const createToken = options.createToken ?? createAliyunNlsToken
const token = await createToken(options.credentials)
const sessionId = randomUUID().replaceAll('-', '')
const upstreamURL = new URL(options.websocketBaseURL ?? nlsWebSocketEndpointFromRegion(options.credentials.region))
upstreamURL.searchParams.set('token', token.token)
const ws = new WebSocket(upstreamURL)
ws.on('open', () => {
ws.send(createClientEvent(options.credentials, 'StartTranscription', sessionId, merge(DEFAULT_SESSION_OPTIONS, options.sessionOptions)))
})
ws.on('message', (data) => {
const event = JSON.parse(data.toString()) as AliyunNlsServerEvent
switch (event.header?.name) {
case 'SentenceEnd': {
const text = event.payload?.result ? `${event.payload.result}\n` : ''
if (text)
controller.enqueue(sse({ delta: text, type: 'transcript.text.delta' }))
controller.enqueue(sse({ delta: '', type: 'transcript.text.done' }))
break
}
case 'TranscriptionCompleted':
controller.close()
ws.close(1000, 'completed')
break
case 'TranscriptionStarted':
void writeAudioToUpstream(options.audioStream, ws, options.credentials, sessionId)
break
}
})
ws.on('error', (error) => {
controller.error(error)
})
ws.on('close', () => {
try {
controller.close()
}
catch {}
})
},
})
return new Response(body, {
headers: {
'Cache-Control': 'no-cache',
'Content-Type': 'text/event-stream',
},
})
}
function nlsWebSocketEndpointFromRegion(region: AliyunNlsRegion): URL {
const websocketURL = new URL('/ws/v1', 'https://example.com')
switch (region) {
case 'cn-shanghai':
case 'cn-beijing':
case 'cn-shenzhen':
websocketURL.protocol = 'wss:'
websocketURL.hostname = `nls-gateway-${region}.aliyuncs.com`
break
case 'cn-shanghai-internal':
case 'cn-beijing-internal':
case 'cn-shenzhen-internal':
websocketURL.protocol = 'wss:'
websocketURL.hostname = `nls-gateway-${region}-internal.aliyuncs.com:80`
break
}
return websocketURL
function aliyunTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
function canonicalizeQuery(params: Record<string, string>): string {
@@ -87,18 +138,6 @@ function canonicalizeQuery(params: Record<string, string>): string {
.join('&')
}
function createStringToSign(method: string, path: string, canonicalQuery: string): string {
return `${method}&${encodeURIComponent(path)}&${encodeURIComponent(canonicalQuery)}`
}
function signStringToBase64(stringToSign: string, accessKeySecret: string): string {
return createHmac('sha1', `${accessKeySecret}&`).update(stringToSign).digest('base64')
}
function aliyunTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
async function createAliyunNlsToken(credentials: AliyunNlsCredentials): Promise<AliyunNlsToken> {
const params: Record<string, string> = {
AccessKeyId: credentials.accessKeyId,
@@ -115,33 +154,66 @@ async function createAliyunNlsToken(credentials: AliyunNlsCredentials): Promise<
const signature = encodeURIComponent(signStringToBase64(createStringToSign('POST', '/', canonicalQuery), credentials.accessKeySecret))
const endpoint = nlsMetaEndpointFromRegion(credentials.region).toString().replace(/\/$/, '')
const response = await ofetch<{
Token?: { ExpireTime?: number, Id?: string }
Message?: string
Token?: { ExpireTime?: number, Id?: string }
}>(`${endpoint}/?Signature=${signature}&${canonicalQuery}`, { method: 'POST' })
if (typeof response.Token?.Id === 'string' && typeof response.Token?.ExpireTime === 'number')
return { token: response.Token.Id, expiresAt: response.Token.ExpireTime * 1000 }
return { expiresAt: response.Token.ExpireTime * 1000, token: response.Token.Id }
throw new Error(`Failed to create Aliyun NLS token: ${response.Message || 'unknown error'}`)
}
function sse(payload: { delta: string, type: 'transcript.text.delta' | 'transcript.text.done' }): Uint8Array {
return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
}
function createClientEvent(credentials: AliyunNlsCredentials, name: 'StartTranscription' | 'StopTranscription', sessionId: string, payload?: AliyunNlsStartPayload) {
return JSON.stringify({
header: {
appkey: credentials.appKey,
message_id: randomUUID().replaceAll('-', ''),
task_id: sessionId,
namespace: 'SpeechTranscriber',
name,
namespace: 'SpeechTranscriber',
task_id: sessionId,
},
payload,
})
}
function createStringToSign(method: string, path: string, canonicalQuery: string): string {
return `${method}&${encodeURIComponent(path)}&${encodeURIComponent(canonicalQuery)}`
}
function nlsMetaEndpointFromRegion(region: AliyunNlsRegion): URL {
return new URL(`http://nls-meta.${region}.aliyuncs.com`)
}
function nlsWebSocketEndpointFromRegion(region: AliyunNlsRegion): URL {
const websocketURL = new URL('/ws/v1', 'https://example.com')
switch (region) {
case 'cn-beijing':
case 'cn-shanghai':
case 'cn-shenzhen':
websocketURL.protocol = 'wss:'
websocketURL.hostname = `nls-gateway-${region}.aliyuncs.com`
break
case 'cn-beijing-internal':
case 'cn-shanghai-internal':
case 'cn-shenzhen-internal':
websocketURL.protocol = 'wss:'
websocketURL.hostname = `nls-gateway-${region}-internal.aliyuncs.com:80`
break
}
return websocketURL
}
function signStringToBase64(stringToSign: string, accessKeySecret: string): string {
return createHmac('sha1', `${accessKeySecret}&`).update(stringToSign).digest('base64')
}
function sse(payload: { delta: string, type: 'transcript.text.delta' | 'transcript.text.done' }): Uint8Array {
return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
}
async function writeAudioToUpstream(audioStream: ReadableStream<Uint8Array>, ws: WebSocket, credentials: AliyunNlsCredentials, sessionId: string) {
const reader = audioStream.getReader()
try {
@@ -157,75 +229,3 @@ async function writeAudioToUpstream(audioStream: ReadableStream<Uint8Array>, ws:
ws.send(createClientEvent(credentials, 'StopTranscription', sessionId))
}
}
/**
* Streams client microphone PCM through Aliyun NLS and returns xsai-compatible SSE transcript deltas.
*
* Use when:
* - AIRI owns the Aliyun NLS credentials server-side.
* - The browser uploads a realtime audio `ReadableStream` and expects transcript deltas.
*
* Expects:
* - `audioStream` contains 16 kHz PCM chunks by default, matching the Hearing worklet output.
*
* Returns:
* - A `text/event-stream` response consumable by the shared `streamTranscription` adapter.
*/
export function createAliyunNlsStreamResponse(options: CreateAliyunNlsStreamResponseOptions): Response {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const createToken = options.createToken ?? createAliyunNlsToken
const token = await createToken(options.credentials)
const sessionId = randomUUID().replaceAll('-', '')
const upstreamURL = new URL(options.websocketBaseURL ?? nlsWebSocketEndpointFromRegion(options.credentials.region))
upstreamURL.searchParams.set('token', token.token)
const ws = new WebSocket(upstreamURL)
ws.on('open', () => {
ws.send(createClientEvent(options.credentials, 'StartTranscription', sessionId, merge(DEFAULT_SESSION_OPTIONS, options.sessionOptions)))
})
ws.on('message', (data) => {
const event = JSON.parse(data.toString()) as AliyunNlsServerEvent
switch (event.header?.name) {
case 'TranscriptionStarted':
void writeAudioToUpstream(options.audioStream, ws, options.credentials, sessionId)
break
case 'SentenceEnd': {
const text = event.payload?.result ? `${event.payload.result}\n` : ''
if (text)
controller.enqueue(sse({ delta: text, type: 'transcript.text.delta' }))
controller.enqueue(sse({ delta: '', type: 'transcript.text.done' }))
break
}
case 'TranscriptionCompleted':
controller.close()
ws.close(1000, 'completed')
break
}
})
ws.on('error', (error) => {
controller.error(error)
})
ws.on('close', () => {
try {
controller.close()
}
catch {}
})
},
cancel() {
// The upstream websocket is closed by its own completion/error handlers.
},
})
return new Response(body, {
headers: {
'Cache-Control': 'no-cache',
'Content-Type': 'text/event-stream',
},
})
}
@@ -46,8 +46,8 @@ export function createCharacterRoutes(characterService: CharacterService) {
...result.output,
character: {
...result.output.character,
ownerId: user.id,
creatorId: user.id,
ownerId: user.id,
},
})
@@ -23,9 +23,9 @@ describe('characterRoutes', () => {
// Create a test user
const [user] = await db.insert(schema.user).values({
email: 'test@example.com',
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
@@ -35,9 +35,9 @@ describe('characterRoutes', () => {
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
details: err.details,
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
@@ -67,15 +67,15 @@ describe('characterRoutes', () => {
it('post / should create character with cover', async () => {
const payload = {
character: { version: '1', coverUrl: 'url', characterId: 'cid' },
i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }],
cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' },
character: { characterId: 'cid', coverUrl: 'url', version: '1' },
cover: { backgroundUrl: 'bg', foregroundUrl: 'fg' },
i18n: [{ description: 'desc', language: 'en', name: 'Aster', tags: [] }],
}
const res = await app.fetch(new Request('http://localhost/', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}), { user: testUser } as any)
expect(res.status).toBe(201)
@@ -117,9 +117,9 @@ describe('characterRoutes', () => {
const charId = characters[0].id
const res = await app.fetch(new Request(`http://localhost/${charId}`, {
method: 'PATCH',
body: JSON.stringify({ version: '2.0' }),
headers: { 'Content-Type': 'application/json' },
method: 'PATCH',
}), { user: testUser } as any)
expect(res.status).toBe(200)
@@ -130,18 +130,18 @@ describe('characterRoutes', () => {
it('patch /:id should return 403 if not owner', async () => {
// Create another user
const [otherUser] = await db.insert(schema.user).values({
email: 'other@example.com',
id: 'user-2',
name: 'Other User',
email: 'other@example.com',
}).returning()
const characters = await characterService.findAll()
const charId = characters[0].id
const res = await app.fetch(new Request(`http://localhost/${charId}`, {
method: 'PATCH',
body: JSON.stringify({ version: '3.0' }),
headers: { 'Content-Type': 'application/json' },
method: 'PATCH',
}), { user: otherUser } as any)
expect(res.status).toBe(403)
+28 -28
View File
@@ -4,33 +4,33 @@ import { array, literal, number, object, optional, pipe, string, transform, unio
import * as schema from '../../schemas/characters'
export const AvatarModelConfigSchema = object({
vrm: optional(object({
live2d: optional(object({
urls: array(string()),
})),
live2d: optional(object({
vrm: optional(object({
urls: array(string()),
})),
})
export const CharacterCapabilityConfigSchema = object({
apiKey: string(),
apiBaseUrl: string(),
apiKey: string(),
asr: optional(object({
audio: string(),
})),
llm: optional(object({
temperature: number(),
model: string(),
temperature: number(),
})),
tts: optional(object({
pitch: number(),
speed: number(),
ssml: string(),
voiceId: string(),
speed: number(),
pitch: number(),
})),
vlm: optional(object({
image: string(),
})),
asr: optional(object({
audio: string(),
})),
})
const CharacterCapabilityTypeSchema = union([
@@ -69,28 +69,28 @@ const DateSchema = pipe(
)
export const CreateCharacterSchema = object({
avatarModels: optional(array(createInsertSchema(schema.avatarModel, {
characterId: optional(string()),
config: AvatarModelConfigSchema,
type: AvatarModelTypeSchema,
}))),
capabilities: optional(array(createInsertSchema(schema.characterCapabilities, {
characterId: optional(string()),
config: CharacterCapabilityConfigSchema,
type: CharacterCapabilityTypeSchema,
}))),
// TODO: Replace createInsertSchema-derived request bodies with explicit HTTP DTO schemas.
// The current shape still leaks persistence fields such as ownerId/creatorId into the API boundary.
character: createInsertSchema(schema.character, {
creatorId: optional(string()),
ownerId: optional(string()),
avatarUrl: optional(string()),
creatorId: optional(string()),
creatorRole: optional(string()),
ownerId: optional(string()),
priceCredit: optional(string()),
}),
cover: optional(createInsertSchema(schema.characterCovers, {
characterId: optional(string()),
})),
capabilities: optional(array(createInsertSchema(schema.characterCapabilities, {
characterId: optional(string()),
type: CharacterCapabilityTypeSchema,
config: CharacterCapabilityConfigSchema,
}))),
avatarModels: optional(array(createInsertSchema(schema.avatarModel, {
characterId: optional(string()),
type: AvatarModelTypeSchema,
config: AvatarModelConfigSchema,
}))),
i18n: optional(array(createInsertSchema(schema.characterI18n, {
characterId: optional(string()),
tagline: optional(string()),
@@ -103,15 +103,15 @@ export const CreateCharacterSchema = object({
// TODO: Split update request schema from DB insert schema.
// This route should reject server-managed fields like id/ownerId/creatorId/timestamps instead of allowing them here.
export const UpdateCharacterSchema = createInsertSchema(schema.character, {
id: optional(string()),
version: optional(string()),
coverUrl: optional(string()),
avatarUrl: optional(string()),
creatorRole: optional(string()),
priceCredit: optional(string()),
creatorId: optional(string()),
ownerId: optional(string()),
characterId: optional(string()),
coverUrl: optional(string()),
createdAt: optional(DateSchema),
creatorId: optional(string()),
creatorRole: optional(string()),
id: optional(string()),
ownerId: optional(string()),
priceCredit: optional(string()),
updatedAt: optional(DateSchema),
version: optional(string()),
})
@@ -23,12 +23,12 @@ export interface ChatBroadcastCoordinator {
}
export interface ChatBroadcastCoordinatorOptions {
/** Stable per-process id used to skip self-published messages. */
instanceId: string
/** Redis connection used for publish and duplicate subscriber creation. */
redis: Redis
/** Local registry that receives messages from other instances. */
registry: ChatConnectionRegistry
/** Stable per-process id used to skip self-published messages. */
instanceId: string
}
/**
@@ -4,16 +4,16 @@ import type { ChatBroadcastPayload } from '../../utils/chat-broadcast'
* In-process websocket connection registry keyed by authenticated user id.
*/
export interface ChatConnectionRegistry {
/** Adds one version-specific websocket emitter for the user. */
add: (userId: string, connectionId: string, emit: (payload: ChatBroadcastPayload) => void) => void
/** Removes one websocket emitter and deletes the user bucket when empty. */
remove: (userId: string, connectionId: string) => void
/** Returns whether this process still has local connections for the user. */
hasUser: (userId: string) => boolean
/** Counts all local websocket connections across users for metrics export. */
activeCount: () => number
/** Adds one version-specific websocket emitter for the user. */
add: (userId: string, connectionId: string, emit: (payload: ChatBroadcastPayload) => void) => void
/** Emits `chat:new-messages` to all local user devices except an optional sender context. */
emitNewMessages: (userId: string, excludeConnectionId: string | null, payload: ChatBroadcastPayload) => void
emitNewMessages: (userId: string, excludeConnectionId: null | string, payload: ChatBroadcastPayload) => void
/** Returns whether this process still has local connections for the user. */
hasUser: (userId: string) => boolean
/** Removes one websocket emitter and deletes the user bucket when empty. */
remove: (userId: string, connectionId: string) => void
}
/**
@@ -33,6 +33,13 @@ export function createChatConnectionRegistry(): ChatConnectionRegistry {
const userConnections = new Map<string, Map<string, (payload: ChatBroadcastPayload) => void>>()
return {
activeCount() {
let total = 0
for (const conns of userConnections.values())
total += conns.size
return total
},
add(userId, connectionId, emit) {
let conns = userConnections.get(userId)
if (!conns) {
@@ -42,26 +49,6 @@ export function createChatConnectionRegistry(): ChatConnectionRegistry {
conns.set(connectionId, emit)
},
remove(userId, connectionId) {
const conns = userConnections.get(userId)
if (!conns)
return
conns.delete(connectionId)
if (conns.size === 0)
userConnections.delete(userId)
},
hasUser(userId) {
return userConnections.has(userId)
},
activeCount() {
let total = 0
for (const conns of userConnections.values())
total += conns.size
return total
},
emitNewMessages(userId, excludeConnectionId, payload) {
const conns = userConnections.get(userId)
if (!conns)
@@ -71,5 +58,18 @@ export function createChatConnectionRegistry(): ChatConnectionRegistry {
emit(payload)
}
},
hasUser(userId) {
return userConnections.has(userId)
},
remove(userId, connectionId) {
const conns = userConnections.get(userId)
if (!conns)
return
conns.delete(connectionId)
if (conns.size === 0)
userConnections.delete(userId)
},
}
}
+12 -12
View File
@@ -14,16 +14,16 @@ import { registerChatRpcHandlers } from './rpc'
const log = useLogger('chat-ws').useGlobalConfig()
export interface RegisterChatWsPeerOptions {
/** Eventa websocket context for one authenticated peer. */
ctx: HonoWsInvocableEventContext
/** User that owns the authenticated peer. */
userId: string
/** Domain service that persists and reads chat messages. */
chatService: ChatService
/** Shared local registry and Redis broadcast runtime. */
runtime: ChatWsRuntime
/** Eventa websocket context for one authenticated peer. */
ctx: HonoWsInvocableEventContext
/** Optional engagement metrics. */
metrics?: EngagementMetrics | null
/** Shared local registry and Redis broadcast runtime. */
runtime: ChatWsRuntime
/** User that owns the authenticated peer. */
userId: string
}
/**
@@ -33,7 +33,7 @@ export interface RegisterChatWsPeerOptions {
* authentication step. The beta.15 adapter accepts the beta.13 wire envelope.
*/
export function registerChatWsPeer(options: RegisterChatWsPeerOptions): void {
const { ctx, userId, chatService, runtime, metrics } = options
const { chatService, ctx, metrics, runtime, userId } = options
const connectionId = nanoid()
runtime.registry.add(userId, connectionId, (payload) => {
void ctx.emit(newMessages, payload)
@@ -48,12 +48,12 @@ export function registerChatWsPeer(options: RegisterChatWsPeerOptions): void {
})
registerChatRpcHandlers({
ctx,
connectionId,
userId,
chatService,
registry: runtime.registry,
broadcast: runtime.broadcast,
chatService,
connectionId,
ctx,
metrics,
registry: runtime.registry,
userId,
})
}
+14 -14
View File
@@ -12,20 +12,20 @@ import { parsePullMessagesRequest, parseSendMessagesRequest, pullMessages, sendM
const log = useLogger('chat-ws').useGlobalConfig()
export interface RegisterChatRpcHandlersOptions {
/** Eventa websocket context for the connected peer. */
ctx: HonoWsInvocableEventContext
/** Authenticated user that owns this websocket connection. */
userId: string
/** Domain service that persists and reads chat messages. */
chatService: ChatService
/** Local websocket registry for same-instance fanout. */
registry: ChatConnectionRegistry
/** Stable id for this connection in the shared registry. */
connectionId: string
/** Redis coordinator for cross-instance fanout. */
broadcast: ChatBroadcastCoordinator
/** Domain service that persists and reads chat messages. */
chatService: ChatService
/** Stable id for this connection in the shared registry. */
connectionId: string
/** Eventa websocket context for the connected peer. */
ctx: HonoWsInvocableEventContext
/** Optional engagement metrics. */
metrics?: EngagementMetrics | null
/** Local websocket registry for same-instance fanout. */
registry: ChatConnectionRegistry
/** Authenticated user that owns this websocket connection. */
userId: string
}
/**
@@ -35,18 +35,18 @@ export interface RegisterChatRpcHandlersOptions {
* before the handler reads its fields or calls the chat service.
*/
export function registerChatRpcHandlers(options: RegisterChatRpcHandlersOptions): void {
const { ctx, userId, chatService, registry, connectionId, broadcast, metrics } = options
const { broadcast, chatService, connectionId, ctx, metrics, registry, userId } = options
defineInvokeHandler(ctx, sendMessages, async (req) => {
const request = parseSendMessagesRequest(req)
log.withFields({ userId, chatId: request.chatId, count: request.messages.length }).log('sendMessages')
log.withFields({ chatId: request.chatId, count: request.messages.length, userId }).log('sendMessages')
const result = await chatService.pushMessages(userId, request.chatId, request.messages)
const wireMessages = await chatService.pullMessages(userId, request.chatId, result.fromSeq - 1, result.toSeq - result.fromSeq + 1)
const broadcastPayload = {
chatId: request.chatId,
messages: wireMessages.messages,
fromSeq: result.fromSeq,
messages: wireMessages.messages,
toSeq: result.toSeq,
}
@@ -67,7 +67,7 @@ export function registerChatRpcHandlers(options: RegisterChatRpcHandlersOptions)
defineInvokeHandler(ctx, pullMessages, async (req) => {
const request = parsePullMessagesRequest(req)
log.withFields({ userId, chatId: request.chatId, afterSeq: request.afterSeq }).log('pullMessages')
log.withFields({ afterSeq: request.afterSeq, chatId: request.chatId, userId }).log('pullMessages')
return chatService.pullMessages(userId, request.chatId, request.afterSeq, request.limit)
})
}
@@ -8,8 +8,8 @@ import { createChatBroadcastCoordinator } from './broadcast'
import { createChatConnectionRegistry } from './connection-registry'
export interface ChatWsRuntime {
registry: ChatConnectionRegistry
broadcast: ChatBroadcastCoordinator
registry: ChatConnectionRegistry
}
/** Creates the shared fanout runtime used by both chat websocket versions. */
@@ -19,11 +19,11 @@ export function createChatWsRuntime(
metrics?: EngagementMetrics | null,
): ChatWsRuntime {
const registry = createChatConnectionRegistry()
const broadcast = createChatBroadcastCoordinator({ redis, registry, instanceId })
const broadcast = createChatBroadcastCoordinator({ instanceId, redis, registry })
metrics?.wsConnectionsActive.addCallback((result) => {
result.observe(registry.activeCount())
})
return { registry, broadcast }
return { broadcast, registry }
}
@@ -27,7 +27,7 @@ export function createChatWsV1Handlers(
return function setupPeer(userId: string) {
const { hooks } = createPeerHooks({
onContext: (ctx) => {
registerChatWsPeer({ ctx, userId, chatService, runtime: chatRuntime, metrics })
registerChatWsPeer({ chatService, ctx, metrics, runtime: chatRuntime, userId })
},
})
return hooks
@@ -10,11 +10,11 @@ const serverPong = defineOutboundEventa<{ value: string }>('chat-ws:server-pong'
function createPeer(sent: string[]): WSContext {
return new WSContext({
close() {},
readyState: 1,
send(data) {
sent.push(String(data))
},
close() {},
readyState: 1,
})
}
@@ -48,11 +48,11 @@ describe('v1 chat WebSocket protocol', () => {
hooks.onMessage?.(new MessageEvent('message', {
data: JSON.stringify({
id: 'beta13-delivery',
type: 'chat-ws:legacy-ping',
payload: {
id: 'chat-ws:legacy-ping',
body: { value: 'from-beta13' },
id: 'chat-ws:legacy-ping',
},
type: 'chat-ws:legacy-ping',
}),
}), peer)
@@ -68,11 +68,11 @@ describe('v1 chat WebSocket protocol', () => {
expect(sent).toHaveLength(2)
expect(JSON.parse(sent.at(-1)!)).toMatchObject({
deliveryId: expect.any(String),
eventa: { body: { value: 'from-beta15' }, id: 'chat-ws:server-pong' },
hopsRemaining: expect.any(Number),
eventa: { id: 'chat-ws:server-pong', body: { value: 'from-beta15' } },
id: expect.any(String),
payload: { body: { value: 'from-beta15' }, id: 'chat-ws:server-pong' },
type: 'chat-ws:server-pong',
payload: { id: 'chat-ws:server-pong', body: { value: 'from-beta15' } },
})
})
})
@@ -13,12 +13,12 @@ describe('v1 chat WebSocket request contracts', () => {
// The shared protocol now owns the request schemas. The v1 handlers parse
// every invoke body before logging or calling ChatService.
it('rejects malformed send-messages requests', () => {
expect(() => parseSendMessagesRequest({ chatId: 'chat-1', messages: [{ id: 'message-1', content: 'hello' }] }))
expect(() => parseSendMessagesRequest({ chatId: 'chat-1', messages: [{ content: 'hello', id: 'message-1' }] }))
.toThrow()
})
it('rejects malformed pull-messages requests', () => {
expect(() => parsePullMessagesRequest({ chatId: 'chat-1', afterSeq: -1 }))
expect(() => parsePullMessagesRequest({ afterSeq: -1, chatId: 'chat-1' }))
.toThrow()
})
@@ -9,6 +9,17 @@ interface Deferred<T> {
resolve: (value: T) => void
}
function createAuthentication(resolveUserId: (token: string) => Promise<null | string>) {
const close = vi.fn<(code?: number, reason?: string) => void>()
const onAuthenticated = vi.fn()
const authentication = createChatWsV2Authentication({
onAuthenticated,
resolveUserId,
socket: { close },
})
return { authentication, close, onAuthenticated }
}
function createDeferred<T>(): Deferred<T> {
let reject: (error: Error) => void = () => {}
let resolve: (value: T) => void = () => {}
@@ -19,17 +30,6 @@ function createDeferred<T>(): Deferred<T> {
return { promise, reject, resolve }
}
function createAuthentication(resolveUserId: (token: string) => Promise<string | null>) {
const close = vi.fn<(code?: number, reason?: string) => void>()
const onAuthenticated = vi.fn()
const authentication = createChatWsV2Authentication({
socket: { close },
resolveUserId,
onAuthenticated,
})
return { authentication, close, onAuthenticated }
}
describe('v2 chat WebSocket authentication', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -50,7 +50,7 @@ describe('v2 chat WebSocket authentication', () => {
// The authentication session now accepts one attempt, marks the socket
// inactive before it closes, and refuses a late resolver result.
it('does not authenticate after disconnecting during authentication', async () => {
const deferred = createDeferred<string | null>()
const deferred = createDeferred<null | string>()
const resolveUserId = vi.fn(() => deferred.promise)
const { authentication, onAuthenticated } = createAuthentication(resolveUserId)
@@ -63,7 +63,7 @@ describe('v2 chat WebSocket authentication', () => {
})
it('limits each socket to one authentication attempt', async () => {
const deferred = createDeferred<string | null>()
const deferred = createDeferred<null | string>()
const resolveUserId = vi.fn(() => deferred.promise)
const { authentication } = createAuthentication(resolveUserId)
@@ -76,7 +76,7 @@ describe('v2 chat WebSocket authentication', () => {
})
it('uses a retryable code when authentication times out', async () => {
const deferred = createDeferred<string | null>()
const deferred = createDeferred<null | string>()
const { authentication, close, onAuthenticated } = createAuthentication(() => deferred.promise)
const request = authentication.authenticate({ token: 'valid-token' })
@@ -7,13 +7,7 @@ import { WS_CLOSE_INTERNAL_ERROR, WS_CLOSE_TRY_AGAIN_LATER, WS_CLOSE_UNAUTHORIZE
const CHAT_AUTH_TIMEOUT_MS = 15_000
export interface ChatWsAuthResolver {
(token: string): Promise<string | null>
}
interface CreateChatWsV2AuthenticationOptions {
socket?: Pick<WSContext, 'close'>
resolveUserId: ChatWsAuthResolver
onAuthenticated: (userId: string) => void
(token: string): Promise<null | string>
}
export interface ChatWsV2Authentication {
@@ -23,6 +17,12 @@ export interface ChatWsV2Authentication {
disconnect: () => void
}
interface CreateChatWsV2AuthenticationOptions {
onAuthenticated: (userId: string) => void
resolveUserId: ChatWsAuthResolver
socket?: Pick<WSContext, 'close'>
}
/**
* Owns authentication lifetime for one version-two websocket connection.
*
@@ -63,7 +63,7 @@ export function createChatWsV2Authentication(options: CreateChatWsV2Authenticati
throw new Error('WebSocket authentication failed')
}
let userId: string | null
let userId: null | string
try {
userId = await options.resolveUserId(parsedRequest.token)
}
+40 -40
View File
@@ -19,27 +19,6 @@ import { createChatWsUnauthenticatedPeerLimit } from './unauthenticated-peers'
const MAX_UNAUTHENTICATED_CHAT_WS_CONNECTIONS = 100
const MAX_UNAUTHENTICATED_CHAT_WS_FRAME_BYTES = 8192
function isTerminableSocket(raw: unknown): raw is { terminate: () => void } {
return typeof raw === 'object'
&& raw !== null
&& 'terminate' in raw
&& typeof raw.terminate === 'function'
}
function isAuthenticateFrame(data: unknown): boolean {
if (typeof data !== 'string' || data.length > MAX_UNAUTHENTICATED_CHAT_WS_FRAME_BYTES)
return false
try {
const frame = JSON.parse(data) as { eventa?: { id?: unknown }, payload?: { id?: unknown } }
return frame.eventa?.id === 'chat:authenticate-send'
|| frame.payload?.id === 'chat:authenticate-send'
}
catch {
return false
}
}
/**
* Creates version-two WebSocket handlers for chat sync and message fanout.
*
@@ -62,7 +41,7 @@ export function createChatWsV2Handlers(
const unauthenticatedPeers = createChatWsUnauthenticatedPeerLimit(MAX_UNAUTHENTICATED_CHAT_WS_CONNECTIONS)
return function setupPeer() {
let socket: WSContext | undefined
let socket: undefined | WSContext
let ownsUnauthenticatedSlot = false
let authenticated = false
@@ -77,15 +56,15 @@ export function createChatWsV2Handlers(
const { hooks } = createPeerHooks({
onContext: (ctx) => {
const authentication = createChatWsV2Authentication({
socket,
resolveUserId,
onAuthenticated(userId) {
if (socket)
restoreAuthenticatedPayloadLimit?.(socket.raw)
authenticated = true
releaseUnauthenticatedSlot()
registerChatWsPeer({ ctx, userId, chatService, runtime: chatRuntime, metrics })
registerChatWsPeer({ chatService, ctx, metrics, runtime: chatRuntime, userId })
},
resolveUserId,
socket,
})
const unregisterAuthenticate = defineInvokeHandler(ctx, authenticate, authentication.authenticate)
@@ -102,21 +81,6 @@ export function createChatWsV2Handlers(
const originalOnMessage = hooks.onMessage
const v2Hooks: WSEvents = {
...hooks,
onOpen(event, ws) {
if (!unauthenticatedPeers.tryAcquire()) {
// Do not wait for a hostile peer to answer a close frame. The slot is
// full, so terminating releases this connection immediately.
if (isTerminableSocket(ws.raw))
ws.raw.terminate()
else
ws.close(WS_CLOSE_TRY_AGAIN_LATER, 'too many unauthenticated connections')
return
}
ownsUnauthenticatedSlot = true
socket = ws
originalOnOpen?.(event, ws)
},
onClose(event, ws) {
releaseUnauthenticatedSlot()
originalOnClose?.(event, ws)
@@ -135,7 +99,43 @@ export function createChatWsV2Handlers(
originalOnMessage?.(event, ws)
},
onOpen(event, ws) {
if (!unauthenticatedPeers.tryAcquire()) {
// Do not wait for a hostile peer to answer a close frame. The slot is
// full, so terminating releases this connection immediately.
if (isTerminableSocket(ws.raw))
ws.raw.terminate()
else
ws.close(WS_CLOSE_TRY_AGAIN_LATER, 'too many unauthenticated connections')
return
}
ownsUnauthenticatedSlot = true
socket = ws
originalOnOpen?.(event, ws)
},
}
return v2Hooks
}
}
function isAuthenticateFrame(data: unknown): boolean {
if (typeof data !== 'string' || data.length > MAX_UNAUTHENTICATED_CHAT_WS_FRAME_BYTES)
return false
try {
const frame = JSON.parse(data) as { eventa?: { id?: unknown }, payload?: { id?: unknown } }
return frame.eventa?.id === 'chat:authenticate-send'
|| frame.payload?.id === 'chat:authenticate-send'
}
catch {
return false
}
}
function isTerminableSocket(raw: unknown): raw is { terminate: () => void } {
return typeof raw === 'object'
&& raw !== null
&& 'terminate' in raw
&& typeof raw.terminate === 'function'
}
@@ -1,22 +1,12 @@
interface PayloadReceiverSocket {
_receiver: { _maxPayload: number }
}
function hasReceiverPayloadLimit(socket: unknown): socket is PayloadReceiverSocket {
return typeof socket === 'object'
&& socket !== null
&& '_receiver' in socket
&& typeof socket._receiver === 'object'
&& socket._receiver !== null
&& '_maxPayload' in socket._receiver
&& typeof socket._receiver._maxPayload === 'number'
}
export interface ChatWsPayloadLimit {
/** Limits frames while the peer has not authenticated. */
restrict: (socket: unknown) => void
/** Restores the transport limit after the peer authenticates. */
restore: (socket: unknown) => void
/** Limits frames while the peer has not authenticated. */
restrict: (socket: unknown) => void
}
interface PayloadReceiverSocket {
_receiver: { _maxPayload: number }
}
/**
@@ -30,13 +20,6 @@ export function createChatWsPayloadLimit(unauthenticatedMaximum: number): ChatWs
const originalLimits = new WeakMap<object, number>()
return {
restrict(socket) {
if (!hasReceiverPayloadLimit(socket))
return
originalLimits.set(socket, socket._receiver._maxPayload)
socket._receiver._maxPayload = unauthenticatedMaximum
},
restore(socket) {
if (!hasReceiverPayloadLimit(socket))
return
@@ -48,5 +31,22 @@ export function createChatWsPayloadLimit(unauthenticatedMaximum: number): ChatWs
socket._receiver._maxPayload = originalLimit
originalLimits.delete(socket)
},
restrict(socket) {
if (!hasReceiverPayloadLimit(socket))
return
originalLimits.set(socket, socket._receiver._maxPayload)
socket._receiver._maxPayload = unauthenticatedMaximum
},
}
}
function hasReceiverPayloadLimit(socket: unknown): socket is PayloadReceiverSocket {
return typeof socket === 'object'
&& socket !== null
&& '_receiver' in socket
&& typeof socket._receiver === 'object'
&& socket._receiver !== null
&& '_maxPayload' in socket._receiver
&& typeof socket._receiver._maxPayload === 'number'
}
@@ -1,8 +1,8 @@
export interface ChatWsUnauthenticatedPeerLimit {
/** Reserves an unauthenticated connection slot when capacity remains. */
tryAcquire: () => boolean
/** Releases a previously reserved unauthenticated connection slot. */
release: () => void
/** Reserves an unauthenticated connection slot when capacity remains. */
tryAcquire: () => boolean
}
/**
@@ -16,6 +16,10 @@ export function createChatWsUnauthenticatedPeerLimit(maximumConnections: number)
let activeConnections = 0
return {
release() {
if (activeConnections > 0)
activeConnections -= 1
},
tryAcquire() {
if (activeConnections >= maximumConnections)
return false
@@ -23,9 +27,5 @@ export function createChatWsUnauthenticatedPeerLimit(maximumConnections: number)
activeConnections += 1
return true
},
release() {
if (activeConnections > 0)
activeConnections -= 1
},
}
}
+4 -4
View File
@@ -18,13 +18,13 @@ const ChatMemberTypeSchema = union([
// - non-user member types require characterId
export const CreateChatSchema = object({
id: optional(pipe(string(), minLength(1), maxLength(30))),
type: optional(ChatTypeSchema),
title: optional(string()),
members: optional(array(object({
characterId: optional(string()),
type: ChatMemberTypeSchema,
userId: optional(string()),
characterId: optional(string()),
}))),
title: optional(string()),
type: optional(ChatTypeSchema),
})
export const UpdateChatSchema = object({
@@ -33,7 +33,7 @@ export const UpdateChatSchema = object({
// TODO: Promote the same discriminated validation rules to AddMemberSchema so invalid combinations fail as 4xx at the HTTP boundary.
export const AddMemberSchema = object({
characterId: optional(string()),
type: ChatMemberTypeSchema,
userId: optional(string()),
characterId: optional(string()),
})
+9 -9
View File
@@ -31,18 +31,18 @@ export function createFluxRoutes(
offset: c.req.query('offset'),
})
const { records, hasMore } = await fluxTransactionService.getHistory(user.id, limit, offset)
const { hasMore, records } = await fluxTransactionService.getHistory(user.id, limit, offset)
return c.json({
records: records.map(r => ({
id: r.id,
type: r.type,
amount: r.amount,
description: r.description,
metadata: r.metadata,
createdAt: r.createdAt.toISOString(),
})),
hasMore,
records: records.map(r => ({
amount: r.amount,
createdAt: r.createdAt.toISOString(),
description: r.description,
id: r.id,
metadata: r.metadata,
type: r.type,
})),
})
})
}
+15 -15
View File
@@ -10,27 +10,27 @@ import { ApiError } from '../../utils/error'
function createMockFluxService(): FluxService {
return {
getFlux: vi.fn(async (userId: string) => ({ userId, flux: 42 })),
getFlux: vi.fn(async (userId: string) => ({ flux: 42, userId })),
updateStripeCustomerId: vi.fn(),
} as any
}
function createMockFluxTransactionService(): FluxTransactionService {
return {
createEntry: vi.fn(),
createEntries: vi.fn(),
createEntry: vi.fn(),
getHistory: vi.fn(async (_userId: string, limit: number, offset: number) => ({
hasMore: limit === 100 && offset === 0,
records: [
{
id: 'tx-1',
type: 'credit',
amount: 5,
description: 'Top up',
metadata: { source: 'test' },
createdAt: new Date('2026-03-27T10:00:00.000Z'),
description: 'Top up',
id: 'tx-1',
metadata: { source: 'test' },
type: 'credit',
},
],
hasMore: limit === 100 && offset === 0,
})),
} as any
}
@@ -42,9 +42,9 @@ function createTestApp(fluxService: FluxService, fluxTransactionService: FluxTra
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
details: err.details,
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
@@ -63,7 +63,7 @@ function createTestApp(fluxService: FluxService, fluxTransactionService: FluxTra
return app
}
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
const testUser = { email: 'test@example.com', id: 'user-1', name: 'Test User' }
describe('fluxRoutes', () => {
it('get /api/v1/flux should return the current user balance', async () => {
@@ -76,7 +76,7 @@ describe('fluxRoutes', () => {
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ userId: 'user-1', flux: 42 })
expect(await res.json()).toEqual({ flux: 42, userId: 'user-1' })
expect(fluxService.getFlux).toHaveBeenCalledWith('user-1')
})
@@ -92,17 +92,17 @@ describe('fluxRoutes', () => {
expect(res.status).toBe(200)
expect(fluxTransactionService.getHistory).toHaveBeenCalledWith('user-1', 100, 0)
expect(await res.json()).toEqual({
hasMore: true,
records: [
{
id: 'tx-1',
type: 'credit',
amount: 5,
description: 'Top up',
metadata: { source: 'test' },
createdAt: '2026-03-27T10:00:00.000Z',
description: 'Top up',
id: 'tx-1',
metadata: { source: 'test' },
type: 'credit',
},
],
hasMore: true,
})
})
})
@@ -6,9 +6,9 @@ describe('internal auth routes', () => {
it('rejects an invalid deletion contract before calling business services', async () => {
const userDeletionService = { register: vi.fn(), softDeleteAll: vi.fn() }
const productEventService = { track: vi.fn() }
const app = createInternalAuthRoutes({ userDeletionService, productEventService })
const app = createInternalAuthRoutes({ productEventService, userDeletionService })
const response = await app.request('/user-deletion', { method: 'POST', body: '{}' })
const response = await app.request('/user-deletion', { body: '{}', method: 'POST' })
expect(response.status).toBe(400)
expect(userDeletionService.softDeleteAll).not.toHaveBeenCalled()
@@ -17,20 +17,20 @@ describe('internal auth routes', () => {
it('delegates private cleanup to the API-owned deletion workflow', async () => {
const userDeletionService = { register: vi.fn(), softDeleteAll: vi.fn(async () => undefined) }
const productEventService = { track: vi.fn() }
const app = createInternalAuthRoutes({ userDeletionService, productEventService })
const app = createInternalAuthRoutes({ productEventService, userDeletionService })
const response = await app.request('/user-deletion', {
method: 'POST',
body: JSON.stringify({ reason: 'user-requested', userId: 'user-1' }),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId: 'user-1', reason: 'user-requested' }),
method: 'POST',
})
expect(response.status).toBe(200)
expect(userDeletionService.softDeleteAll).toHaveBeenCalledWith({
userId: 'user-1',
reason: 'user-requested',
userId: 'user-1',
})
})
@@ -38,29 +38,29 @@ describe('internal auth routes', () => {
const userDeletionService = { register: vi.fn(), softDeleteAll: vi.fn() }
const productEventService = { track: vi.fn(async () => undefined) }
const app = createInternalAuthRoutes({
userDeletionService,
productEventService,
userDeletionService,
})
const response = await app.request('/events', {
method: 'POST',
body: JSON.stringify({
action: 'user_signed_up',
source: 'better-auth.user.create',
userId: 'user-1',
}),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: 'user-1',
action: 'user_signed_up',
source: 'better-auth.user.create',
}),
method: 'POST',
})
expect(response.status).toBe(200)
expect(productEventService.track).toHaveBeenCalledWith({
userId: 'user-1',
feature: 'auth',
action: 'user_signed_up',
status: 'succeeded',
feature: 'auth',
source: 'better-auth.user.create',
status: 'succeeded',
userId: 'user-1',
})
})
})
+7 -7
View File
@@ -6,14 +6,14 @@ import { Hono } from 'hono'
import { nonEmpty, object, picklist, pipe, safeParse, string, trim } from 'valibot'
const UserDeletionRequestSchema = object({
userId: pipe(string(), trim(), nonEmpty()),
reason: picklist(['user-requested', 'admin', 'compliance']),
userId: pipe(string(), trim(), nonEmpty()),
})
const AuthEventRequestSchema = object({
userId: pipe(string(), trim(), nonEmpty()),
action: picklist(['user_signed_up']),
source: picklist(['better-auth.user.create']),
userId: pipe(string(), trim(), nonEmpty()),
})
/**
@@ -22,8 +22,8 @@ const AuthEventRequestSchema = object({
* and block `/internal/*` at the public edge.
*/
export function createInternalAuthRoutes(input: {
userDeletionService: UserDeletionExecutor
productEventService: Pick<ProductEventService, 'track'>
userDeletionService: UserDeletionExecutor
}) {
return new Hono<HonoEnv>()
.post('/user-deletion', async (c) => {
@@ -33,8 +33,8 @@ export function createInternalAuthRoutes(input: {
const request = parsed.output
await input.userDeletionService.softDeleteAll({
userId: request.userId,
reason: request.reason as UserDeletionReason,
userId: request.userId,
})
return c.json({ success: true })
})
@@ -44,11 +44,11 @@ export function createInternalAuthRoutes(input: {
return c.json({ error: 'BAD_REQUEST', message: 'Invalid auth event' }, 400)
await input.productEventService.track({
userId: parsed.output.userId,
feature: 'auth',
action: parsed.output.action,
status: 'succeeded',
feature: 'auth',
source: parsed.output.source,
status: 'succeeded',
userId: parsed.output.userId,
})
return c.json({ success: true })
})
@@ -4,7 +4,7 @@ export const AIRI_CHAT_SESSION_ID_HEADER = 'x-airi-session-id'
export const AIRI_CHAT_ROUND_ID_HEADER = 'x-airi-round-id'
export const AIRI_CHAT_APP_SURFACE_HEADER = 'x-airi-app-surface'
const CLIENT_CHAT_ANALYTICS_SURFACES = new Set<AiGenerationAppSurface>(['web', 'mobile', 'electron'])
const CLIENT_CHAT_ANALYTICS_SURFACES = new Set<AiGenerationAppSurface>(['electron', 'mobile', 'web'])
/**
* Resolves the product runtime from a trusted client hint.
+67 -67
View File
@@ -16,7 +16,11 @@ export type GatewayMiddleware<Name extends V1GatewayOperationName> = (
next: () => Promise<Response>,
) => Promise<Response>
export type V1HttpSurface = 'audio' | 'openai'
export interface V1GatewayContext<Name extends V1GatewayOperationName> {
deps: V1RouteDeps
hono: Context<HonoEnv>
input: V1GatewayOperationInput[Name]
}
export interface V1GatewayOperationInput {
'chat.completions': ChatCompletionsOperationRequest
@@ -27,27 +31,6 @@ export type V1GatewayOperationName = keyof V1GatewayOperationInput
export type V1GatewayPlugin = (gateway: V1GatewayRuntime) => void
export interface V1GatewayContext<Name extends V1GatewayOperationName> {
deps: V1RouteDeps
hono: Context<HonoEnv>
input: V1GatewayOperationInput[Name]
}
export interface V1GatewayRuntime {
deps: V1RouteDeps
handler: <Name extends V1GatewayOperationName>(
name: Name,
parse: (hono: Context<HonoEnv>) => V1GatewayOperationInput[Name] | Promise<V1GatewayOperationInput[Name]>,
callback: GatewayCallback<Name>,
) => Handler<HonoEnv>
route: (surface: V1HttpSurface) => V1GatewayRoute
use: {
(plugin: V1GatewayPlugin): V1GatewayRuntime
<Name extends V1GatewayOperationName>(name: Name, middleware: GatewayMiddleware<Name>): V1GatewayRuntime
}
useHono: (surface: V1HttpSurface | '*', path: string, middleware: MiddlewareHandler<HonoEnv>) => V1GatewayRuntime
}
export interface V1GatewayRoute {
deps: V1RouteDeps
get: (path: string, handler: Handler<HonoEnv> | V1GatewayRouteHandler) => V1GatewayRoute
@@ -58,6 +41,23 @@ export interface V1GatewayRoute {
useHono: (path: string, middleware: MiddlewareHandler<HonoEnv>) => V1GatewayRoute
}
export interface V1GatewayRuntime {
deps: V1RouteDeps
handler: <Name extends V1GatewayOperationName>(
name: Name,
parse: (hono: Context<HonoEnv>) => Promise<V1GatewayOperationInput[Name]> | V1GatewayOperationInput[Name],
callback: GatewayCallback<Name>,
) => Handler<HonoEnv>
route: (surface: V1HttpSurface) => V1GatewayRoute
use: {
(plugin: V1GatewayPlugin): V1GatewayRuntime
<Name extends V1GatewayOperationName>(name: Name, middleware: GatewayMiddleware<Name>): V1GatewayRuntime
}
useHono: (surface: '*' | V1HttpSurface, path: string, middleware: MiddlewareHandler<HonoEnv>) => V1GatewayRuntime
}
export type V1HttpSurface = 'audio' | 'openai'
const routeHandlerMarker = Symbol('v1-gateway-route-handler')
export interface V1GatewayRouteHandler {
@@ -65,53 +65,14 @@ export interface V1GatewayRouteHandler {
[routeHandlerMarker]: true
}
export function routeHandler(handler: (scope: Pick<V1GatewayRoute, 'deps' | 'handler'>) => Handler<HonoEnv>): V1GatewayRouteHandler {
return Object.assign(handler, { [routeHandlerMarker]: true as const })
type OperationMiddlewares = {
[Name in V1GatewayOperationName]: GatewayMiddleware<Name>[]
}
interface RegisteredHttpMiddleware {
middleware: MiddlewareHandler<HonoEnv>
path: string
surface: V1HttpSurface | '*'
}
type OperationMiddlewares = {
[Name in V1GatewayOperationName]: GatewayMiddleware<Name>[]
}
function cloneOperationMiddlewares(input: OperationMiddlewares): OperationMiddlewares {
return {
'chat.completions': [...input['chat.completions']],
'speech.generate': [...input['speech.generate']],
}
}
/**
* Runs an OpenAI gateway callback through operation-scoped middleware.
*
* Use when:
* - The middleware needs parsed gateway input such as user id, model, body,
* session id, or abort signal.
* - The behavior is not a generic HTTP concern and should not receive Hono
* `Context`.
*
* Expects:
* - `callback` is the concrete gateway business callback.
* - `middlewares` are ordered from outermost to innermost.
*
* Returns:
* - A response produced by the gateway callback chain.
*/
export function runGatewayMiddlewares<Name extends V1GatewayOperationName>(
context: V1GatewayContext<Name>,
callback: GatewayCallback<Name>,
middlewares: GatewayMiddleware<Name>[],
): Promise<Response> {
const runnable = middlewares.reduceRight<GatewayCallback<Name>>(
(next, middleware) => ctx => middleware(ctx, () => next(ctx)),
callback,
)
return runnable(context)
surface: '*' | V1HttpSurface
}
export function createV1Gateway(deps: V1RouteDeps): V1GatewayRuntime {
@@ -126,7 +87,7 @@ export function createV1Gateway(deps: V1RouteDeps): V1GatewayRuntime {
function use(plugin: V1GatewayPlugin): V1GatewayRuntime
function use<Name extends V1GatewayOperationName>(name: Name, middleware: GatewayMiddleware<Name>): V1GatewayRuntime
function use<Name extends V1GatewayOperationName>(
arg1: V1GatewayPlugin | Name,
arg1: Name | V1GatewayPlugin,
arg2?: GatewayMiddleware<Name>,
): V1GatewayRuntime {
if (typeof arg1 === 'function') {
@@ -141,7 +102,7 @@ export function createV1Gateway(deps: V1RouteDeps): V1GatewayRuntime {
function handlerWithMiddlewares<Name extends V1GatewayOperationName>(
middlewares: OperationMiddlewares,
name: Name,
parse: (hono: Context<HonoEnv>) => V1GatewayOperationInput[Name] | Promise<V1GatewayOperationInput[Name]>,
parse: (hono: Context<HonoEnv>) => Promise<V1GatewayOperationInput[Name]> | V1GatewayOperationInput[Name],
callback: GatewayCallback<Name>,
): Handler<HonoEnv> {
return async (hono) => {
@@ -203,7 +164,7 @@ export function createV1Gateway(deps: V1RouteDeps): V1GatewayRuntime {
route: createRoute,
use,
useHono(surface, path, middleware) {
httpMiddlewares.push({ surface, path, middleware })
httpMiddlewares.push({ middleware, path, surface })
return gateway
},
}
@@ -211,6 +172,45 @@ export function createV1Gateway(deps: V1RouteDeps): V1GatewayRuntime {
return gateway
}
export function routeHandler(handler: (scope: Pick<V1GatewayRoute, 'deps' | 'handler'>) => Handler<HonoEnv>): V1GatewayRouteHandler {
return Object.assign(handler, { [routeHandlerMarker]: true as const })
}
/**
* Runs an OpenAI gateway callback through operation-scoped middleware.
*
* Use when:
* - The middleware needs parsed gateway input such as user id, model, body,
* session id, or abort signal.
* - The behavior is not a generic HTTP concern and should not receive Hono
* `Context`.
*
* Expects:
* - `callback` is the concrete gateway business callback.
* - `middlewares` are ordered from outermost to innermost.
*
* Returns:
* - A response produced by the gateway callback chain.
*/
export function runGatewayMiddlewares<Name extends V1GatewayOperationName>(
context: V1GatewayContext<Name>,
callback: GatewayCallback<Name>,
middlewares: GatewayMiddleware<Name>[],
): Promise<Response> {
const runnable = middlewares.reduceRight<GatewayCallback<Name>>(
(next, middleware) => ctx => middleware(ctx, () => next(ctx)),
callback,
)
return runnable(context)
}
function cloneOperationMiddlewares(input: OperationMiddlewares): OperationMiddlewares {
return {
'chat.completions': [...input['chat.completions']],
'speech.generate': [...input['speech.generate']],
}
}
function resolveRouteHandler(scope: V1GatewayRoute, handler: Handler<HonoEnv> | V1GatewayRouteHandler): Handler<HonoEnv> {
if (routeHandlerMarker in handler)
return (handler as V1GatewayRouteHandler)(scope)
@@ -1,8 +1,8 @@
const SAFE_RESPONSE_HEADERS = new Set([
'content-type',
'content-length',
'transfer-encoding',
'cache-control',
'content-length',
'content-type',
'transfer-encoding',
])
export function buildSafeResponseHeaders(response: Response): Headers {
@@ -45,12 +45,12 @@ export function createV1Routes(input: CreateV1RoutesDeps) {
const body = await c.req.json() as Record<string, unknown>
return {
userId: user.id,
body,
sessionId: c.req.header(AIRI_CHAT_SESSION_ID_HEADER),
roundId: c.req.header(AIRI_CHAT_ROUND_ID_HEADER),
appSurface: resolveChatAnalyticsSurface(c.req.header(AIRI_CHAT_APP_SURFACE_HEADER)),
abortSignal: c.req.raw.signal,
appSurface: resolveChatAnalyticsSurface(c.req.header(AIRI_CHAT_APP_SURFACE_HEADER)),
body,
roundId: c.req.header(AIRI_CHAT_ROUND_ID_HEADER),
sessionId: c.req.header(AIRI_CHAT_SESSION_ID_HEADER),
userId: user.id,
}
},
chatCompletions(deps),
@@ -72,10 +72,10 @@ export function createV1Routes(input: CreateV1RoutesDeps) {
const body = await c.req.json() as Record<string, unknown>
return {
userId: user.id,
abortSignal: c.req.raw.signal,
body,
sessionId: c.req.header(AIRI_CHAT_SESSION_ID_HEADER),
abortSignal: c.req.raw.signal,
userId: user.id,
}
},
speechGeneration(deps),
@@ -90,5 +90,5 @@ export function createV1Routes(input: CreateV1RoutesDeps) {
.get('/models/streaming', () => speechCatalog.listStreamingSpeechModels())
.route
return { openaiRoutes, audioRoutes }
return { audioRoutes, openaiRoutes }
}
@@ -9,29 +9,24 @@ import { calculateFluxFromUsage } from '../../../../services/domain/billing/bill
import { createPaymentRequiredError } from '../../../../utils/error'
import { GEN_AI_ATTR_REQUEST_MODEL } from '../../../../utils/observability'
export interface ChatFluxDebitInput extends UsageInfo {
billingService: BillingService
revenue?: RevenueMetrics | null
userId: string
requestId: string
model: string
amount: number
stage: 'streaming' | 'non_streaming'
logger: {
withFields: (fields: Record<string, unknown>) => {
warn: (message: string) => void
}
}
}
export interface ChatBillingPolicy {
fallbackRate: number
fluxPer1kTokens?: number
}
export interface TtsBillingAuthorization {
balance: number
inputChars: number
export interface ChatFluxDebitInput extends UsageInfo {
amount: number
billingService: BillingService
logger: {
withFields: (fields: Record<string, unknown>) => {
warn: (message: string) => void
}
}
model: string
requestId: string
revenue?: null | RevenueMetrics
stage: 'non_streaming' | 'streaming'
userId: string
}
export interface OpenAiRouteBilling {
@@ -41,23 +36,28 @@ export interface OpenAiRouteBilling {
recordChatDebitFailure: (input: {
amount: number
model: string
stage: 'streaming' | 'non_streaming'
stage: 'non_streaming' | 'streaming'
}) => void
settleChat: (input: Omit<ChatFluxDebitInput, 'billingService' | 'revenue'>) => Promise<number>
settleTts: (input: {
userId: string
inputText: string
currentBalance: number
requestId: string
inputText: string
model: string
requestId: string
userId: string
}) => Promise<{ fluxDebited: number }>
}
export interface TtsBillingAuthorization {
balance: number
inputChars: number
}
export function createOpenAiRouteBilling(deps: {
billingService: BillingService
configKV: ConfigKVService
fluxService: FluxService
revenue?: RevenueMetrics | null
revenue?: null | RevenueMetrics
ttsMeter: FluxMeter
}): OpenAiRouteBilling {
// NOTICE: Billing is best-effort — chat flux is debited AFTER the LLM
@@ -100,7 +100,7 @@ export function createOpenAiRouteBilling(deps: {
function recordChatDebitFailure(input: {
amount: number
model: string
stage: 'streaming' | 'non_streaming'
stage: 'non_streaming' | 'streaming'
}): void {
deps.revenue?.fluxUnbilled.add(input.amount, {
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
@@ -123,18 +123,18 @@ export function createOpenAiRouteBilling(deps: {
}
async function settleTts(input: {
userId: string
inputText: string
currentBalance: number
requestId: string
inputText: string
model: string
requestId: string
userId: string
}) {
return deps.ttsMeter.accumulate({
userId: input.userId,
units: input.inputText.length,
currentBalance: input.currentBalance,
requestId: input.requestId,
metadata: { model: input.model },
requestId: input.requestId,
units: input.inputText.length,
userId: input.userId,
})
}
@@ -143,13 +143,13 @@ export function createOpenAiRouteBilling(deps: {
export async function debitChatFlux(input: ChatFluxDebitInput): Promise<number> {
const result = await input.billingService.consumeFluxForLLM({
userId: input.userId,
amount: input.amount,
requestId: input.requestId,
completionTokens: input.completionTokens,
description: 'llm_request',
model: input.model,
promptTokens: input.promptTokens,
completionTokens: input.completionTokens,
requestId: input.requestId,
userId: input.userId,
})
if (result.charged < result.requested) {
@@ -159,11 +159,11 @@ export async function debitChatFlux(input: ChatFluxDebitInput): Promise<number>
stage: input.stage,
})
input.logger.withFields({
userId: input.userId,
requestId: input.requestId,
requested: result.requested,
charged: result.charged,
requested: result.requested,
requestId: input.requestId,
unbilled: result.requested - result.charged,
userId: input.userId,
}).warn(input.stage === 'streaming'
? 'Partial debit after streaming — flux drained to zero'
: 'Partial debit on non-streaming completion — flux drained to zero')
@@ -22,51 +22,20 @@ export const tracer = trace.getTracer('v1-completions')
export type GatewaySpan = ReturnType<typeof tracer.startSpan>
export interface OperationMetricsInput extends UsageInfo {
model: string
status: number
type: string
provider: string
durationMs: number
fluxConsumed: number
model: string
provider: string
status: number
type: string
}
export interface RequestLogInput extends UsageInfo {
userId: string
model: string
status: number
durationMs: number
fluxConsumed: number
}
export function getLlmMetricAttributes(opts: { model: string, type: string, status: number, provider: string }): Record<string, string | number> {
// `provider` is the upstream the router actually used (winning upstream on
// success, last-tried on exhaustion), so per-provider rollups in Grafana
// line up with each vendor's own console. Same label name as the gateway
// error counters (`airi_gen_ai_gateway_upstream_errors{provider}`) so the
// two can be compared/joined.
if (opts.type === 'chat') {
return {
[GEN_AI_ATTR_REQUEST_MODEL]: opts.model,
[GEN_AI_ATTR_OPERATION_NAME]: 'chat',
'http.response.status_code': opts.status,
'provider': opts.provider,
}
}
return {
[GEN_AI_ATTR_REQUEST_MODEL]: opts.model,
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: opts.type,
'http.response.status_code': opts.status,
'provider': opts.provider,
}
}
// Fresh per-request context handed to `llmRouter.route` / `routeTts` so the
// router can report back which upstream it used (for the `provider` metric
// label). Must be created per request — never shared — because the route
// closures live at factory scope across concurrent requests.
export function newRouteContext(): LlmRouteContext {
return { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null }
model: string
status: number
userId: string
}
export function createRouteTelemetry(deps: {
@@ -98,9 +67,9 @@ export function createRouteTelemetry(deps: {
function startChatSpan(input: { model: string, stream: boolean }): GatewaySpan {
return tracer.startSpan('llm.gateway.chat', {
attributes: {
[AIRI_ATTR_GEN_AI_STREAM]: input.stream,
[GEN_AI_ATTR_OPERATION_NAME]: 'chat',
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
[AIRI_ATTR_GEN_AI_STREAM]: input.stream,
},
})
}
@@ -108,8 +77,8 @@ export function createRouteTelemetry(deps: {
function startTtsSpan(input: { model: string }): GatewaySpan {
return tracer.startSpan('llm.gateway.tts', {
attributes: {
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech',
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
},
})
}
@@ -133,9 +102,9 @@ export function createRouteTelemetry(deps: {
function recordUsageOnSpan(span: GatewaySpan, input: UsageInfo & { fluxConsumed: number }): void {
span.setAttributes({
[AIRI_ATTR_BILLING_FLUX_CONSUMED]: input.fluxConsumed,
[GEN_AI_ATTR_USAGE_INPUT_TOKENS]: input.promptTokens ?? 0,
[GEN_AI_ATTR_USAGE_OUTPUT_TOKENS]: input.completionTokens ?? 0,
[AIRI_ATTR_BILLING_FLUX_CONSUMED]: input.fluxConsumed,
})
}
@@ -144,22 +113,22 @@ export function createRouteTelemetry(deps: {
}
function recordFirstToken(input: {
firstChunkAt: number
model: string
provider: string
startedAt: number
firstChunkAt: number
}): void {
deps.genAi?.firstTokenDuration.record((input.firstChunkAt - input.startedAt) / 1000, {
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
[GEN_AI_ATTR_OPERATION_NAME]: 'chat',
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
provider: input.provider,
})
}
function recordStreamInterrupted(input: {
model: string
stage: 'mid_stream' | 'before_first_chunk'
span: GatewaySpan
stage: 'before_first_chunk' | 'mid_stream'
}): void {
input.span.setStatus({ code: SpanStatusCode.ERROR, message: 'Gateway stream interrupted' })
input.span.setAttribute(AIRI_ATTR_GEN_AI_STREAM_INTERRUPTED, true)
@@ -186,3 +155,34 @@ export function createRouteTelemetry(deps: {
startTtsSpan,
}
}
export function getLlmMetricAttributes(opts: { model: string, provider: string, status: number, type: string }): Record<string, number | string> {
// `provider` is the upstream the router actually used (winning upstream on
// success, last-tried on exhaustion), so per-provider rollups in Grafana
// line up with each vendor's own console. Same label name as the gateway
// error counters (`airi_gen_ai_gateway_upstream_errors{provider}`) so the
// two can be compared/joined.
if (opts.type === 'chat') {
return {
[GEN_AI_ATTR_OPERATION_NAME]: 'chat',
[GEN_AI_ATTR_REQUEST_MODEL]: opts.model,
'http.response.status_code': opts.status,
'provider': opts.provider,
}
}
return {
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: opts.type,
[GEN_AI_ATTR_REQUEST_MODEL]: opts.model,
'http.response.status_code': opts.status,
'provider': opts.provider,
}
}
// Fresh per-request context handed to `llmRouter.route` / `routeTts` so the
// router can report back which upstream it used (for the `provider` metric
// label). Must be created per request — never shared — because the route
// closures live at factory scope across concurrent requests.
export function newRouteContext(): LlmRouteContext {
return { lastStatus: null, provider: 'unknown', triedKeys: 0, triedUpstreams: 0 }
}
@@ -1,8 +1,6 @@
import type { RateLimitMetrics } from '../../../../otel'
import type { GatewayMiddleware, V1GatewayContext, V1GatewayOperationName } from '../gateway'
type RateLimitKeyType = 'ip' | 'model' | 'user'
interface GatewayRateLimitClassification {
key: string
keyType: RateLimitKeyType
@@ -12,7 +10,7 @@ interface GatewayRateLimitClassification {
interface GatewayRateLimitOptions<Name extends V1GatewayOperationName> {
classify: (context: V1GatewayContext<Name>) => GatewayRateLimitClassification
max: number
metrics?: RateLimitMetrics | null
metrics?: null | RateLimitMetrics
routeLabel: string
windowSec: number
}
@@ -22,8 +20,10 @@ interface RateLimitBucket {
resetAt: number
}
type RateLimitKeyType = 'ip' | 'model' | 'user'
export function chatCompletionsRateLimit(input: {
metrics?: RateLimitMetrics | null
metrics?: null | RateLimitMetrics
}): GatewayMiddleware<'chat.completions'> {
return createGatewayRateLimiter({
classify: context => ({
@@ -52,21 +52,21 @@ function createGatewayRateLimiter<Name extends V1GatewayOperationName>(opts: Gat
if (bucket.count >= opts.max) {
opts.metrics?.blocked.add(1, {
route: opts.routeLabel,
key_type: classification.keyType,
limit: String(opts.max),
route: opts.routeLabel,
})
const retryAfterSec = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
return Response.json(
{ error: 'TOO_MANY_REQUESTS', message: 'Too many requests' },
{
status: 429,
headers: {
'RateLimit-Limit': String(opts.max),
'RateLimit-Remaining': '0',
'RateLimit-Reset': String(retryAfterSec),
'Retry-After': String(retryAfterSec),
},
status: 429,
},
)
}
@@ -13,33 +13,37 @@ import { buildSafeResponseHeaders } from '../../http/response'
import { createOpenAiRouteBilling } from '../../middlewares/billing'
import { createRouteTelemetry, newRouteContext } from '../../middlewares/telemetry'
export interface ChatCompletionsOperationRequest {
abortSignal?: AbortSignal
appSurface?: AiGenerationAppSurface
body: Record<string, unknown>
roundId?: string
sessionId?: string
userId: string
}
type ChatBilling = ReturnType<typeof createOpenAiRouteBilling>
type ChatBillingPolicy = Awaited<ReturnType<ChatBilling['authorizeChat']>>
type RouteTelemetry = ReturnType<typeof createRouteTelemetry>
export interface ChatCompletionsOperationRequest {
userId: string
body: Record<string, unknown>
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
abortSignal?: AbortSignal
interface ChatModelAliasPlan {
modelIds: string[]
}
interface GenerationCaptureInput {
deps: V1RouteDeps
userId: string
requestId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
generationModel: string
routeCtxProvider: string
usage: UsageInfo
deps: V1RouteDeps
durationMs: number
generationModel: string
requestId: string
roundId?: string
routeCtxProvider: string
sessionId?: string
stream: boolean
usage: UsageInfo
userId: string
}
type RouteTelemetry = ReturnType<typeof createRouteTelemetry>
export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.completions'> {
const logger = useLogger('v1-completions').useGlobalConfig()
const telemetry = createRouteTelemetry({
@@ -65,11 +69,11 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
const stream = !!body.stream
logger.withFields({
requestId,
userId: input.userId,
model: requestModel,
stream,
messageCount: Array.isArray(body.messages) ? body.messages.length : undefined,
model: requestModel,
requestId,
stream,
userId: input.userId,
}).log('chat completion request')
// Server-connection attrs come from the router (which knows the actual
// upstream baseURL it dispatched to) — it enriches the active span with
@@ -93,10 +97,10 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
try {
const routed = await telemetry.runWithSpan(span, () =>
routeChatAliasCandidates({
deps,
body,
modelIds: aliasPlan.modelIds,
abortSignal: clientAbort,
body,
deps,
modelIds: aliasPlan.modelIds,
}))
response = routed.response
routeCtx = routed.routeCtx
@@ -108,11 +112,11 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
input: body.messages,
model: routeCtx.upstreamModel ?? requestModel,
requestId,
sessionId: input.sessionId,
stream,
userId: input.userId,
sessionId: input.sessionId,
}).fail('Router exhausted or unknown model')
telemetry.recordMetrics({ model: requestModel, status: 502, type: 'chat', provider: routeCtx.provider, durationMs: Date.now() - startedAt, fluxConsumed: 0 })
telemetry.recordMetrics({ durationMs: Date.now() - startedAt, fluxConsumed: 0, model: requestModel, provider: routeCtx.provider, status: 502, type: 'chat' })
throw err
}
@@ -129,73 +133,69 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
input: body.messages,
model: langfuseModel,
requestId,
sessionId: input.sessionId,
stream,
userId: input.userId,
sessionId: input.sessionId,
})
if (!response.ok) {
telemetry.failSpan(span, `Gateway ${response.status}`)
generationTrace.fail(`Gateway ${response.status}`)
telemetry.recordMetrics({ model: requestModel, status: response.status, type: 'chat', provider: routeCtx.provider, durationMs, fluxConsumed: 0 })
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
telemetry.recordMetrics({ durationMs, fluxConsumed: 0, model: requestModel, provider: routeCtx.provider, status: response.status, type: 'chat' })
logger.withFields({ durationMs, model: requestModel, requestId, status: response.status, userId: input.userId })
.warn('chat completion delivered with upstream error status')
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
status: response.status,
})
}
if (stream) {
return streamChatCompletion({
deps,
response,
generationTrace,
span,
startedAt,
durationMs,
requestId,
userId: input.userId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
requestModel,
generationModel: langfuseModel,
routeCtxProvider: routeCtx.provider,
billing,
billingPolicy,
telemetry,
deps,
durationMs,
generationModel: langfuseModel,
generationTrace,
logger,
requestId,
requestModel,
response,
roundId: input.roundId,
routeCtxProvider: routeCtx.provider,
sessionId: input.sessionId,
span,
startedAt,
telemetry,
userId: input.userId,
})
}
return completeNonStreamingChat({
deps,
response,
generationTrace,
span,
durationMs,
requestId,
userId: input.userId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
requestModel,
generationModel: langfuseModel,
routeCtxProvider: routeCtx.provider,
billing,
billingPolicy,
telemetry,
deps,
durationMs,
generationModel: langfuseModel,
generationTrace,
logger,
requestId,
requestModel,
response,
roundId: input.roundId,
routeCtxProvider: routeCtx.provider,
sessionId: input.sessionId,
span,
telemetry,
userId: input.userId,
})
}
}
interface ChatModelAliasPlan {
modelIds: string[]
}
function captureGeneration(input: GenerationCaptureInput): void {
const generationId = input.roundId ?? input.requestId
const conversationId = input.sessionId ?? input.requestId
@@ -204,22 +204,22 @@ function captureGeneration(input: GenerationCaptureInput): void {
: undefined
input.deps.productEventService.trackGeneration({
userId: input.userId,
traceId: conversationId,
conversationId,
conversationIdSource: input.sessionId ? 'client_header' : 'server_request',
costUsdSource: 'unavailable',
generationId,
inputTokens: input.usage.promptTokens,
model: input.generationModel,
outputTokens: input.usage.completionTokens,
provider: input.routeCtxProvider || 'unknown',
providerType: 'official',
roundId: generationId,
totalTokens,
traceId: conversationId,
usageSource: input.usage.promptTokens != null || input.usage.completionTokens != null
? 'reported'
: 'unavailable',
inputTokens: input.usage.promptTokens,
outputTokens: input.usage.completionTokens,
totalTokens,
costUsdSource: 'unavailable',
conversationId,
conversationIdSource: input.sessionId ? 'client_header' : 'server_request',
roundId: generationId,
userId: input.userId,
...(input.appSurface && { appSurface: input.appSurface }),
captureSurface: 'server',
latencySeconds: input.durationMs / 1000,
@@ -227,6 +227,104 @@ function captureGeneration(input: GenerationCaptureInput): void {
})
}
async function completeNonStreamingChat(input: {
appSurface?: AiGenerationAppSurface
billing: ChatBilling
billingPolicy: ChatBillingPolicy
deps: V1RouteDeps
durationMs: number
generationModel: string
generationTrace: ReturnType<V1RouteDeps['llmTracing']['startChatGeneration']>
logger: ReturnType<typeof useLogger>
requestId: string
requestModel: string
response: Response
roundId?: string
routeCtxProvider: string
sessionId?: string
span: Parameters<RouteTelemetry['endSpan']>[0]
telemetry: RouteTelemetry
userId: string
}) {
// Non-streaming: parse response, bill, then return.
// Parse failure (malformed upstream JSON) must close both span and the
// Langfuse generation before bubbling up — otherwise the trace leaks.
// Mirrors the error-branch shape used above (router throw / !response.ok).
let responseBody
try {
responseBody = await input.response.json()
}
catch (err) {
input.telemetry.failSpan(input.span, 'Failed to parse upstream response body')
input.generationTrace.fail('Failed to parse upstream response body')
input.telemetry.recordMetrics({ durationMs: input.durationMs, fluxConsumed: 0, model: input.requestModel, provider: input.routeCtxProvider, status: input.response.status, type: 'chat' })
throw err
}
const usage = extractUsageFromBody(responseBody)
const fluxConsumed = input.billing.priceChatUsage(usage, input.billingPolicy)
input.telemetry.recordUsageOnSpan(input.span, { ...usage, fluxConsumed })
input.telemetry.endSpan(input.span)
input.generationTrace.succeed({
completionTokens: usage.completionTokens,
fluxConsumed,
output: responseBody,
promptTokens: usage.promptTokens,
})
input.telemetry.recordMetrics({ durationMs: input.durationMs, fluxConsumed, model: input.requestModel, provider: input.routeCtxProvider, status: input.response.status, type: 'chat', ...usage })
captureGeneration({
appSurface: input.appSurface,
deps: input.deps,
durationMs: input.durationMs,
generationModel: input.generationModel,
requestId: input.requestId,
roundId: input.roundId,
routeCtxProvider: input.routeCtxProvider,
sessionId: input.sessionId,
stream: false,
usage,
userId: input.userId,
})
// Debit flux via DB transaction (source of truth).
// The upstream call has already happened (cost incurred), so partial
// debit + `fluxUnbilled` is the only sane recovery — same shape as the
// streaming path. `balance <= 0` still throws and bubbles up as 402.
const actualCharged = await input.billing.settleChat({
amount: fluxConsumed,
logger: input.logger,
model: input.requestModel,
requestId: input.requestId,
stage: 'non_streaming',
userId: input.userId,
...usage,
})
input.telemetry.recordRequestLog({
completionTokens: usage.completionTokens,
durationMs: input.durationMs,
fluxConsumed: actualCharged,
model: input.requestModel,
promptTokens: usage.promptTokens,
status: input.response.status,
userId: input.userId,
})
input.logger.withFields({
completionTokens: usage.completionTokens,
durationMs: input.durationMs,
fluxConsumed: actualCharged,
model: input.requestModel,
promptTokens: usage.promptTokens,
requestId: input.requestId,
status: input.response.status,
stream: false,
userId: input.userId,
}).log('chat completion delivered')
return Response.json(responseBody)
}
async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Promise<ChatModelAliasPlan> {
const alias = await deps.providerCatalogService.resolveEnabledAlias('llm', aliasId)
const primaryRoutes = alias.routes.filter(route => route.pool === 'primary')
@@ -240,8 +338,8 @@ async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Pr
if (routedModelIds.length === 0) {
throw createBadRequestError('Capability alias has no enabled route', 'CAPABILITY_ALIAS_ROUTE_NOT_FOUND', {
surface: 'llm',
aliasId,
surface: 'llm',
})
}
@@ -249,10 +347,10 @@ async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Pr
}
async function routeChatAliasCandidates(input: {
deps: V1RouteDeps
body: Record<string, unknown>
modelIds: string[]
abortSignal?: AbortSignal
body: Record<string, unknown>
deps: V1RouteDeps
modelIds: string[]
}): Promise<{
modelId: string
response: Response
@@ -263,10 +361,10 @@ async function routeChatAliasCandidates(input: {
const routeCtx = newRouteContext()
try {
const response = await input.deps.llmRouter.route({
modelName: modelId,
abortSignal: input.abortSignal,
body: input.body,
headers: {},
abortSignal: input.abortSignal,
modelName: modelId,
}, routeCtx)
return { modelId, response, routeCtx }
}
@@ -280,52 +378,25 @@ async function routeChatAliasCandidates(input: {
throw lastError
}
function weightedRouteOrder(routes: CapabilityAliasRoute[]): CapabilityAliasRoute[] {
if (routes.length <= 1)
return routes
const totalWeight = routes.reduce((sum, route) => sum + Math.max(route.weight, 0), 0)
if (totalWeight <= 0)
return routes
let cursor = Math.random() * totalWeight
const selectedIndex = routes.findIndex((route) => {
cursor -= Math.max(route.weight, 0)
return cursor < 0
})
if (selectedIndex < 0)
return routes
const selected = routes[selectedIndex]
return [
selected,
...routes.filter((_, index) => index !== selectedIndex),
]
}
function uniqueModelIds(routes: CapabilityAliasRoute[]): string[] {
return Array.from(new Set(routes.map(route => route.routerModelId)))
}
function streamChatCompletion(input: {
deps: V1RouteDeps
response: Response
generationTrace: ReturnType<V1RouteDeps['llmTracing']['startChatGeneration']>
span: Parameters<RouteTelemetry['endSpan']>[0]
startedAt: number
durationMs: number
requestId: string
userId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
requestModel: string
generationModel: string
routeCtxProvider: string
billing: ChatBilling
billingPolicy: ChatBillingPolicy
telemetry: RouteTelemetry
deps: V1RouteDeps
durationMs: number
generationModel: string
generationTrace: ReturnType<V1RouteDeps['llmTracing']['startChatGeneration']>
logger: ReturnType<typeof useLogger>
requestId: string
requestModel: string
response: Response
roundId?: string
routeCtxProvider: string
sessionId?: string
span: Parameters<RouteTelemetry['endSpan']>[0]
startedAt: number
telemetry: RouteTelemetry
userId: string
}) {
// Streaming: return response immediately, bill after stream ends
const { readable, writable } = new TransformStream()
@@ -390,7 +461,7 @@ function streamChatCompletion(input: {
if (streamInterrupted) {
input.telemetry.endSpan(input.span)
input.generationTrace.fail('Gateway stream interrupted')
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 })
input.telemetry.recordMetrics({ durationMs: input.durationMs, fluxConsumed: 0, model: input.requestModel, provider: input.routeCtxProvider, status: input.response.status, type: 'chat' })
}
else if (streamCompleted) {
try {
@@ -418,24 +489,24 @@ function streamChatCompletion(input: {
// Streaming output comes from appendStreamChunk above, so succeed
// omits it and the module uses the assembled assistant text.
input.generationTrace.succeed({
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
fluxConsumed,
promptTokens: usage.promptTokens,
})
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed, ...usage })
input.telemetry.recordMetrics({ durationMs: input.durationMs, fluxConsumed, model: input.requestModel, provider: input.routeCtxProvider, status: input.response.status, type: 'chat', ...usage })
captureGeneration({
deps: input.deps,
userId: input.userId,
requestId: input.requestId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
generationModel: input.generationModel,
routeCtxProvider: input.routeCtxProvider,
usage,
deps: input.deps,
durationMs: input.durationMs,
generationModel: input.generationModel,
requestId: input.requestId,
roundId: input.roundId,
routeCtxProvider: input.routeCtxProvider,
sessionId: input.sessionId,
stream: true,
usage,
userId: input.userId,
})
// Debit flux via DB transaction (source of truth)
@@ -450,12 +521,12 @@ function streamChatCompletion(input: {
let actualCharged = 0
try {
actualCharged = await input.billing.settleChat({
userId: input.userId,
amount: fluxConsumed,
requestId: input.requestId,
model: input.requestModel,
stage: 'streaming',
logger: input.logger,
model: input.requestModel,
requestId: input.requestId,
stage: 'streaming',
userId: input.userId,
...usage,
})
}
@@ -465,134 +536,63 @@ function streamChatCompletion(input: {
// latency spike on the request path. Without a dedicated counter,
// the failure is silent. Page on any sustained `increase()`.
input.billing.recordChatDebitFailure({ amount: fluxConsumed, model: input.requestModel, stage: 'streaming' })
input.logger.withError(err).withFields({ userId: input.userId, fluxConsumed, requestId: input.requestId }).error('Failed to debit flux after streaming — unpaid usage')
input.logger.withError(err).withFields({ fluxConsumed, requestId: input.requestId, userId: input.userId }).error('Failed to debit flux after streaming — unpaid usage')
}
input.telemetry.recordRequestLog({
userId: input.userId,
model: input.requestModel,
status: input.response.status,
completionTokens: usage.completionTokens,
durationMs: input.durationMs,
fluxConsumed: actualCharged,
model: input.requestModel,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
status: input.response.status,
userId: input.userId,
})
input.logger.withFields({
requestId: input.requestId,
userId: input.userId,
model: input.requestModel,
status: input.response.status,
durationMs: input.durationMs,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
durationMs: input.durationMs,
fluxConsumed: actualCharged,
model: input.requestModel,
promptTokens: usage.promptTokens,
requestId: input.requestId,
status: input.response.status,
stream: true,
userId: input.userId,
}).log('chat completion delivered')
}
}
})()
return new Response(readable, {
status: input.response.status,
headers: buildSafeResponseHeaders(input.response),
status: input.response.status,
})
}
async function completeNonStreamingChat(input: {
deps: V1RouteDeps
response: Response
generationTrace: ReturnType<V1RouteDeps['llmTracing']['startChatGeneration']>
span: Parameters<RouteTelemetry['endSpan']>[0]
durationMs: number
requestId: string
userId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
requestModel: string
generationModel: string
routeCtxProvider: string
billing: ChatBilling
billingPolicy: ChatBillingPolicy
telemetry: RouteTelemetry
logger: ReturnType<typeof useLogger>
}) {
// Non-streaming: parse response, bill, then return.
// Parse failure (malformed upstream JSON) must close both span and the
// Langfuse generation before bubbling up — otherwise the trace leaks.
// Mirrors the error-branch shape used above (router throw / !response.ok).
let responseBody
try {
responseBody = await input.response.json()
}
catch (err) {
input.telemetry.failSpan(input.span, 'Failed to parse upstream response body')
input.generationTrace.fail('Failed to parse upstream response body')
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 })
throw err
}
const usage = extractUsageFromBody(responseBody)
const fluxConsumed = input.billing.priceChatUsage(usage, input.billingPolicy)
function uniqueModelIds(routes: CapabilityAliasRoute[]): string[] {
return Array.from(new Set(routes.map(route => route.routerModelId)))
}
input.telemetry.recordUsageOnSpan(input.span, { ...usage, fluxConsumed })
input.telemetry.endSpan(input.span)
input.generationTrace.succeed({
output: responseBody,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
fluxConsumed,
})
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed, ...usage })
captureGeneration({
deps: input.deps,
userId: input.userId,
requestId: input.requestId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
generationModel: input.generationModel,
routeCtxProvider: input.routeCtxProvider,
usage,
durationMs: input.durationMs,
stream: false,
})
function weightedRouteOrder(routes: CapabilityAliasRoute[]): CapabilityAliasRoute[] {
if (routes.length <= 1)
return routes
// Debit flux via DB transaction (source of truth).
// The upstream call has already happened (cost incurred), so partial
// debit + `fluxUnbilled` is the only sane recovery — same shape as the
// streaming path. `balance <= 0` still throws and bubbles up as 402.
const actualCharged = await input.billing.settleChat({
userId: input.userId,
amount: fluxConsumed,
requestId: input.requestId,
model: input.requestModel,
stage: 'non_streaming',
logger: input.logger,
...usage,
})
const totalWeight = routes.reduce((sum, route) => sum + Math.max(route.weight, 0), 0)
if (totalWeight <= 0)
return routes
input.telemetry.recordRequestLog({
userId: input.userId,
model: input.requestModel,
status: input.response.status,
durationMs: input.durationMs,
fluxConsumed: actualCharged,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
let cursor = Math.random() * totalWeight
const selectedIndex = routes.findIndex((route) => {
cursor -= Math.max(route.weight, 0)
return cursor < 0
})
input.logger.withFields({
requestId: input.requestId,
userId: input.userId,
model: input.requestModel,
status: input.response.status,
durationMs: input.durationMs,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
fluxConsumed: actualCharged,
stream: false,
}).log('chat completion delivered')
if (selectedIndex < 0)
return routes
return Response.json(responseBody)
const selected = routes[selectedIndex]
return [
selected,
...routes.filter((_, index) => index !== selectedIndex),
]
}
@@ -9,16 +9,12 @@ import { createBadGatewayError, createBadRequestError, createServiceUnavailableE
const VOICE_PACK_MODEL_ID = 'voice-pack'
function voicePackCatalogVoice(pack: VoicePack) {
const cost = `Flux cost: ${pack.costMultiplier}x`
return {
id: pack.voiceId,
name: pack.name,
description: pack.description ? `${pack.description} · ${cost}` : cost,
labels: { type: 'voice_pack' },
tags: ['voice_pack'],
languages: [{ code: 'en', title: 'English' }],
}
export interface ListStreamingVoicesInput {
model?: string
}
export interface ListVoicesInput {
requestedModel?: string
}
export interface SpeechCatalogOperation {
@@ -28,14 +24,6 @@ export interface SpeechCatalogOperation {
listVoices: (input: ListVoicesInput) => Promise<Response>
}
export interface ListStreamingVoicesInput {
model?: string
}
export interface ListVoicesInput {
requestedModel?: string
}
export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOperation {
const logger = useLogger('v1-completions').useGlobalConfig()
@@ -61,7 +49,7 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
const voicePacks = await deps.voicePackService.listEnabled()
if (model === VOICE_PACK_MODEL_ID) {
logger.withFields({ model, voiceCount: voicePacks.length, voicePackCount: voicePacks.length }).debug('list tts voices')
return Response.json({ voices: voicePacks.map(voicePackCatalogVoice), recommended: {} })
return Response.json({ recommended: {}, voices: voicePacks.map(voicePackCatalogVoice) })
}
const voices = await deps.providerCatalogService.listEnabledTtsVoices(model)
@@ -70,7 +58,7 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
// billing / user-facing side effect — useful only when debugging
// voice-picker drift, never as a permanent audit trail line.
logger.withFields({ model, voiceCount: voices.length, voicePackCount: voicePacks.length }).debug('list tts voices')
return Response.json({ voices: voices.map(catalogVoiceResponse), recommended })
return Response.json({ recommended, voices: voices.map(catalogVoiceResponse) })
}
/**
@@ -130,7 +118,7 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
snippet = String(res._data)
}
}
logger.withFields({ voicesURL, status: res.status, snippet: snippet.slice(0, 256) }).warn('streaming-voices: unspeech non-2xx')
logger.withFields({ snippet: snippet.slice(0, 256), status: res.status, voicesURL }).warn('streaming-voices: unspeech non-2xx')
throw createBadGatewayError(`streaming voices upstream ${res.status}`, { lastStatusCode: res.status })
}
@@ -141,7 +129,7 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
const recommended = model
? ((await deps.configKV.getOptional('DEFAULT_TTS_VOICES'))?.[model] ?? {})
: {}
return Response.json({ voices: data.voices, recommended })
return Response.json({ recommended, voices: data.voices })
}
async function listSpeechModels() {
@@ -151,11 +139,11 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
? defaultModel
: null
return Response.json({
default: publicDefaultModel,
models: [
{ id: VOICE_PACK_MODEL_ID, name: 'Voice Pack', description: 'Server-curated voices' },
{ description: 'Server-curated voices', id: VOICE_PACK_MODEL_ID, name: 'Voice Pack' },
...models.map(model => ({ id: model.routerModelId, name: model.displayName })),
],
default: publicDefaultModel,
})
}
@@ -169,12 +157,12 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
// surfaces the provider rather than silently hiding it.
return Response.json({
available: !!unspeech?.streaming?.baseURL,
default: unspeech?.streaming?.defaultModel ?? null,
models: models.map(m => ({
description: m.description,
id: m.id,
name: m.name ?? m.id,
description: m.description,
})),
default: unspeech?.streaming?.defaultModel ?? null,
})
}
@@ -185,3 +173,15 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
listVoices,
}
}
function voicePackCatalogVoice(pack: VoicePack) {
const cost = `Flux cost: ${pack.costMultiplier}x`
return {
description: pack.description ? `${pack.description} · ${cost}` : cost,
id: pack.voiceId,
labels: { type: 'voice_pack' },
languages: [{ code: 'en', title: 'English' }],
name: pack.name,
tags: ['voice_pack'],
}
}
@@ -4,10 +4,10 @@ import type { V1RouteDeps } from '../../types'
import { createOpenAiSpeechService } from '../../../../../services/domain/openai-speech'
export interface SpeechGenerationOperationRequest {
userId: string
abortSignal?: AbortSignal
body: Record<string, unknown>
sessionId?: string
abortSignal?: AbortSignal
userId: string
}
export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.generate'> {
File diff suppressed because it is too large Load Diff
@@ -18,19 +18,19 @@ export interface LlmTracingDeps {
}
export interface V1RouteDeps {
fluxService: FluxService
billingService: BillingService
configKV: ConfigKVService
requestLogService: RequestLogService
productEventService: ProductEventService
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
providerCatalogService: ProviderCatalogService
fluxService: FluxService
genAi?: GenAiMetrics | null
revenue?: RevenueMetrics | null
rateLimitMetrics?: RateLimitMetrics | null
llmRouter: LlmRouterService
llmTracing: LlmTracingDeps
productEventService: ProductEventService
providerCatalogService: ProviderCatalogService
rateLimitMetrics?: null | RateLimitMetrics
requestLogService: RequestLogService
revenue?: null | RevenueMetrics
ttsMeter: FluxMeter
voicePackService: VoicePackService
}
export const defaultLlmTracing: LlmTracingDeps = {
@@ -23,9 +23,9 @@ describe('providerRoutes', () => {
// Create a test user
const [user] = await db.insert(schema.user).values({
email: 'test@example.com',
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
@@ -35,9 +35,9 @@ describe('providerRoutes', () => {
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
details: err.details,
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
@@ -67,15 +67,15 @@ describe('providerRoutes', () => {
it('post / should create provider config', async () => {
const payload = {
config: { apiKey: 'sk-123' },
definitionId: 'openai',
name: 'My OpenAI',
config: { apiKey: 'sk-123' },
}
const res = await app.fetch(new Request('http://localhost/', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}), { user: testUser } as any)
expect(res.status).toBe(201)
@@ -87,10 +87,10 @@ describe('providerRoutes', () => {
it('get / should return unified list (user + system)', async () => {
// Create a system config directly in DB
await db.insert(schema.systemProviderConfigs).values({
id: 'sys-1',
definitionId: 'anthropic',
name: 'System Anthropic',
config: { apiKey: 'sys-sk' },
definitionId: 'anthropic',
id: 'sys-1',
name: 'System Anthropic',
})
const res = await app.fetch(new Request('http://localhost/'), { user: testUser } as any)
@@ -124,9 +124,9 @@ describe('providerRoutes', () => {
const providerId = providers[0].id
const res = await app.fetch(new Request(`http://localhost/${providerId}`, {
method: 'PATCH',
body: JSON.stringify({ name: 'Updated Name' }),
headers: { 'Content-Type': 'application/json' },
method: 'PATCH',
}), { user: testUser } as any)
expect(res.status).toBe(200)
@@ -137,18 +137,18 @@ describe('providerRoutes', () => {
it('patch /:id should return 403 if not owner', async () => {
// Create another user
const [otherUser] = await db.insert(schema.user).values({
email: 'other@example.com',
id: 'user-2',
name: 'Other User',
email: 'other@example.com',
}).returning()
const providers = await providerService.findUserConfigsByOwnerId(testUser.id)
const providerId = providers[0].id
const res = await app.fetch(new Request(`http://localhost/${providerId}`, {
method: 'PATCH',
body: JSON.stringify({ name: 'Hacked Name' }),
headers: { 'Content-Type': 'application/json' },
method: 'PATCH',
}), { user: otherUser } as any)
expect(res.status).toBe(403)
@@ -12,18 +12,18 @@ export const InsertSystemProviderConfigSchema = createInsertSchema(schema.system
// TODO: Replace these schemas with explicit HTTP request DTOs.
// validated/validationBypassed are server-managed state and should not be client-writable.
export const CreateProviderConfigSchema = object({
id: optional(string()),
definitionId: string(),
name: string(),
config: optional(record(string(), string())),
definitionId: string(),
id: optional(string()),
name: string(),
validated: optional(boolean()),
validationBypassed: optional(boolean()),
})
// TODO: Restrict updates to user-editable fields only.
export const UpdateProviderConfigSchema = object({
name: optional(string()),
config: optional(record(string(), string())),
name: optional(string()),
validated: optional(boolean()),
validationBypassed: optional(boolean()),
})
+13 -13
View File
@@ -44,21 +44,21 @@ export function createStripeRoutes(
configKV: ConfigKVService,
env: Env,
redis: Redis,
metrics?: RevenueMetrics | null,
rateLimitMetrics?: RateLimitMetrics | null,
metrics?: null | RevenueMetrics,
rateLimitMetrics?: null | RateLimitMetrics,
productEventService?: ProductEventService,
) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
const priceCatalog = stripe ? createStripePriceCatalog(stripe, redis) : null
const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics, productEventService })
const checkout = createCheckoutOperation({ configKV, env, metrics, priceCatalog, productEventService, stripe, stripeService })
const webhook = createWebhookOperation({
stripe,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
fluxService,
stripeService,
billingService,
fluxService,
metrics,
productEventService,
stripe,
stripeService,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
})
return new Hono<HonoEnv>()
@@ -79,20 +79,20 @@ export function createStripeRoutes(
}
return {
stripePriceId: p.id,
label: `${p.metadata.fluxAmount ?? '?'} Flux`,
defaultCurrency: p.currency,
currencies,
defaultCurrency: p.currency,
label: `${p.metadata.fluxAmount ?? '?'} Flux`,
recommended: p.metadata.recommended === 'true',
stripePriceId: p.id,
}
}))
})
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'stripe.checkout' }), async (c) => {
.post('/checkout', authGuard, rateLimiter({ max: 10, metrics: rateLimitMetrics, routeLabel: 'stripe.checkout', windowSec: 60 }), async (c) => {
const body = await c.req.json()
return c.json(await checkout({
user: c.get('user')!,
body,
request: c.req.raw,
user: c.get('user')!,
}))
})
.get('/orders', authGuard, async (c) => {
@@ -126,6 +126,6 @@ export function createStripeRoutes(
.post('/webhook', async (c) => {
const signature = c.req.header('stripe-signature') ?? null
const body = signature ? await c.req.text() : ''
return c.json(await webhook({ signature, body }))
return c.json(await webhook({ body, signature }))
})
}
@@ -14,25 +14,25 @@ import { createBadRequestError, createServiceUnavailableError } from '../../../u
import { resolveCheckoutRedirectBase } from '../../../utils/origin'
import { CheckoutBodySchema } from '../schema'
type AuthenticatedUser = NonNullable<HonoEnv['Variables']['user']>
type CheckoutSessionCreateParams = NonNullable<Parameters<Stripe['checkout']['sessions']['create']>[0]>
export interface CheckoutOperationDeps {
stripe: Stripe | null
priceCatalog: StripePriceCatalog | null
stripeService: StripeService
configKV: ConfigKVService
env: Env
metrics?: RevenueMetrics | null
metrics?: null | RevenueMetrics
priceCatalog: null | StripePriceCatalog
productEventService?: ProductEventService
stripe: null | Stripe
stripeService: StripeService
}
export interface CheckoutOperationInput {
user: AuthenticatedUser
body: unknown
request: Request
user: AuthenticatedUser
}
type AuthenticatedUser = NonNullable<HonoEnv['Variables']['user']>
type CheckoutSessionCreateParams = NonNullable<Parameters<Stripe['checkout']['sessions']['create']>[0]>
interface PosthogIdentityHeaders {
distinctId?: string
sessionId?: string
@@ -53,7 +53,7 @@ interface PosthogIdentityHeaders {
* - A Stripe-hosted checkout URL.
*/
export function createCheckoutOperation(deps: CheckoutOperationDeps) {
return async (input: CheckoutOperationInput): Promise<{ url: string | null }> => {
return async (input: CheckoutOperationInput): Promise<{ url: null | string }> => {
const fluxProductId = await deps.configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!deps.stripe || !deps.priceCatalog || !fluxProductId)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
@@ -62,7 +62,7 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
if (!result.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', result.issues)
const { stripePriceId, currency } = result.output
const { currency, stripePriceId } = result.output
const price = await deps.priceCatalog.findActivePrice(fluxProductId, stripePriceId)
if (!price)
@@ -83,19 +83,19 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
const posthogIdentity = readPosthogIdentityHeaders(input.request)
const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
cancel_url: `${redirectBase}/settings/flux?canceled=true`,
customer: stripeCustomerId,
customer_email: stripeCustomerId ? undefined : input.user.email,
line_items: [{ price: stripePriceId, quantity: 1 }],
metadata: {
userId: input.user.id,
fluxAmount: String(fluxAmount),
userId: input.user.id,
...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }),
},
mode: 'payment',
success_url: `${redirectBase}/settings/flux?success=true`,
}
// When STRIPE_PAYMENT_METHODS is not set, omit payment_method_types to let Stripe
@@ -114,37 +114,37 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
// Persist the checkout session.
await deps.stripeService.upsertCheckoutSession({
userId: input.user.id,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
currency: session.currency,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
mode: session.mode ?? 'payment',
paymentStatus: session.payment_status,
status: session.status,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSessionId: session.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
successUrl: session.success_url,
userId: input.user.id,
})
deps.metrics?.stripeCheckoutCreated.add(1)
void deps.productEventService?.track({
userId: input.user.id,
feature: 'billing',
action: 'checkout_started',
status: 'succeeded',
eventId: session.id,
source: 'stripe.checkout',
feature: 'billing',
metadata: {
flux_amount: fluxAmount,
amount_total: session.amount_total,
currency: session.currency,
flux_amount: fluxAmount,
...(posthogIdentity.distinctId && { posthog_distinct_id: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthog_session_id: posthogIdentity.sessionId }),
},
source: 'stripe.checkout',
status: 'succeeded',
userId: input.user.id,
})
return { url: session.url }
@@ -13,29 +13,29 @@ import { errorMessageFromUnknown } from '../../../utils/error-message'
const logger = useLogger('stripe')
interface StripeSubscriptionEventContext {
userId: string
stripeCustomerId: string
stripeSubscriptionId: string
stripePriceId?: string
subscriptionStatus?: string
amountPaid?: number
currency?: string
}
export interface WebhookOperationDeps {
stripe: Stripe | null
webhookSecret: string | undefined
fluxService: FluxService
stripeService: StripeService
billingService: BillingService
metrics?: RevenueMetrics | null
fluxService: FluxService
metrics?: null | RevenueMetrics
productEventService?: ProductEventService
stripe: null | Stripe
stripeService: StripeService
webhookSecret: string | undefined
}
export interface WebhookOperationInput {
signature: string | null
body: string
signature: null | string
}
interface StripeSubscriptionEventContext {
amountPaid?: number
currency?: string
stripeCustomerId: string
stripePriceId?: string
stripeSubscriptionId: string
subscriptionStatus?: string
userId: string
}
/**
@@ -67,7 +67,7 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
}
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
logger.withFields({ id: event.id, type: event.type }).log('Webhook event received')
deps.metrics?.stripeEvents.add(1, { event_type: event.type })
switch (event.type) {
@@ -92,12 +92,9 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
const posthogDistinctId = event.data.object.metadata?.posthogDistinctId
const posthogSessionId = event.data.object.metadata?.posthogSessionId
void deps.productEventService?.track({
userId,
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: event.data.object.id,
source: 'stripe.webhook',
feature: 'billing',
metadata: {
amount_total: event.data.object.amount_total,
currency: event.data.object.currency,
@@ -107,6 +104,9 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { posthog_session_id: posthogSessionId }),
},
source: 'stripe.webhook',
status: 'succeeded',
userId,
})
}
}
@@ -118,16 +118,16 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
case 'customer.subscription.deleted':
case 'customer.subscription.updated': {
await handleSubscriptionEvent(event.data.object, deps.stripeService)
deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') })
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
case 'invoice.payment_failed':
case 'invoice.updated': {
await handleInvoiceEvent(event.data.object, deps.stripeService)
if (event.type === 'invoice.payment_failed')
deps.metrics?.stripePaymentFailed.add(1)
@@ -158,34 +158,34 @@ async function handleCheckoutSessionCompleted(
return { processed: false }
}
logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session')
logger.withFields({ amount: session.amount_total, currency: session.currency, mode: session.mode, sessionId: session.id, userId }).log('Processing checkout session')
// Upsert customer record if we got a customer back.
if (session.customer) {
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id
await stripeService.upsertCustomer({
userId,
stripeCustomerId,
email: session.customer_email ?? undefined,
stripeCustomerId,
userId,
})
await fluxService.updateStripeCustomerId(userId, stripeCustomerId)
}
await stripeService.upsertCheckoutSession({
userId,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
currency: session.currency,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
mode: session.mode ?? 'payment',
paymentStatus: session.payment_status,
status: session.status,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSessionId: session.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
successUrl: session.success_url,
userId,
})
// Idempotent flux credit: use fluxCredited flag inside a transaction
@@ -198,35 +198,35 @@ async function handleCheckoutSessionCompleted(
// a card) deliberately skip crediting and still count as processed.
if (session.mode === 'payment') {
if (session.amount_total == null) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing amount_total; skipping credit and capture')
logger.withFields({ sessionId: session.id, userId }).warn('Payment-mode checkout missing amount_total; skipping credit and capture')
return { processed: false }
}
const metadataFlux = session.metadata?.fluxAmount
if (!metadataFlux) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing metadata.fluxAmount; skipping credit and capture')
logger.withFields({ sessionId: session.id, userId }).warn('Payment-mode checkout missing metadata.fluxAmount; skipping credit and capture')
return { processed: false }
}
const fluxAmount = Number(metadataFlux)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
logger.withFields({ userId, sessionId: session.id, metadataFlux }).warn('Invalid fluxAmount in session metadata, skipping credit')
logger.withFields({ metadataFlux, sessionId: session.id, userId }).warn('Invalid fluxAmount in session metadata, skipping credit')
return { processed: false }
}
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId,
userId,
stripeSessionId: session.id,
amountTotal: session.amount_total,
currency: session.currency,
fluxAmount,
stripeEventId,
stripeSessionId: session.id,
userId,
})
logger.withFields({
userId,
fluxAmount,
amountTotal: session.amount_total,
applied: result.applied,
balanceAfter: result.balanceAfter,
fluxAmount,
userId,
}).log('Processed flux credit for one-time payment')
}
@@ -246,51 +246,17 @@ async function handleCustomerEvent(
return
await stripeService.upsertCustomer({
userId: existing.userId,
stripeCustomerId: customer.id,
email: customer.email ?? undefined,
name: customer.name ?? undefined,
stripeCustomerId: customer.id,
userId: existing.userId,
})
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return null
// In newer Stripe API, period info is on subscription items.
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
userId: customer.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
status: subscription.status,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscription.id,
stripePriceId: firstItem?.price?.id,
subscriptionStatus: subscription.status,
}
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
stripeService: StripeService,
): Promise<StripeSubscriptionEventContext | null> {
): Promise<null | StripeSubscriptionEventContext> {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return null
@@ -306,32 +272,66 @@ async function handleInvoiceEvent(
: undefined
await stripeService.upsertInvoice({
userId: customer.userId,
stripeInvoiceId: invoice.id,
stripeCustomerId,
stripeSubscriptionId: subscriptionId,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
invoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
periodStart: new Date(invoice.period_start * 1000),
periodEnd: new Date(invoice.period_end * 1000),
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
invoiceUrl: invoice.hosted_invoice_url,
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
periodEnd: new Date(invoice.period_end * 1000),
periodStart: new Date(invoice.period_start * 1000),
status: invoice.status,
stripeCustomerId,
stripeInvoiceId: invoice.id,
stripeSubscriptionId: subscriptionId,
userId: customer.userId,
})
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId)
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
logger.withFields({ amountPaid: invoice.amount_paid, invoiceId: invoice.id, userId: customer.userId }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
return {
userId: customer.userId,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
stripeCustomerId,
stripeSubscriptionId: subscriptionId ?? '',
subscriptionStatus: invoice.status ?? undefined,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
userId: customer.userId,
}
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
): Promise<null | StripeSubscriptionEventContext> {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return null
// In newer Stripe API, period info is on subscription items.
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
status: subscription.status,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
stripeSubscriptionId: subscription.id,
userId: customer.userId,
})
return {
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
stripeSubscriptionId: subscription.id,
subscriptionStatus: subscription.status,
userId: customer.userId,
}
}
@@ -10,23 +10,23 @@ const logger = useLogger('stripe')
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices')
const PRICES_CACHE_TTL_SEC = 5 * 60
interface CachedCurrencyOption {
unitAmount: number | null
}
export interface CachedPrice {
id: string
unitAmount: number | null
currency: string
product: string
active: boolean
metadata: Record<string, string>
currency: string
currencyOptions: Record<string, CachedCurrencyOption>
id: string
metadata: Record<string, string>
product: string
unitAmount: null | number
}
export interface StripePriceCatalog {
getActivePrices: (productId: string) => Promise<CachedPrice[]>
findActivePrice: (productId: string, stripePriceId: string) => Promise<CachedPrice | null>
getActivePrices: (productId: string) => Promise<CachedPrice[]>
}
interface CachedCurrencyOption {
unitAmount: null | number
}
/**
@@ -44,34 +44,6 @@ export interface StripePriceCatalog {
*/
export function createStripePriceCatalog(stripe: Stripe, redis: Redis): StripePriceCatalog {
return {
async getActivePrices(productId: string): Promise<CachedPrice[]> {
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached) as { productId: string, prices: CachedPrice[] }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
let result: Stripe.ApiList<Stripe.Price>
try {
result = await stripe.prices.list({ product: productId, active: true, expand: ['data.currency_options'] })
}
catch (err) {
logger.withError(err).warn('Failed to fetch prices from Stripe')
return []
}
const prices = result.data
.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0))
.map(toCachedPrice)
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ productId, prices }), 'EX', PRICES_CACHE_TTL_SEC)
return prices
},
async findActivePrice(productId: string, stripePriceId: string): Promise<CachedPrice | null> {
// Validate against cached prices first, fall back to direct Stripe API.
const cachedPrices = await this.getActivePrices(productId)
@@ -96,20 +68,34 @@ export function createStripePriceCatalog(stripe: Stripe, redis: Redis): StripePr
await redis.del(PRICES_CACHE_KEY)
return toCachedPrice(fetched)
},
}
}
function toCachedPrice(price: Stripe.Price): CachedPrice {
return {
id: price.id,
unitAmount: price.unit_amount,
currency: price.currency,
product: typeof price.product === 'string' ? price.product : price.product.id,
active: price.active,
metadata: price.metadata,
currencyOptions: Object.fromEntries(
Object.entries(price.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
async getActivePrices(productId: string): Promise<CachedPrice[]> {
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached) as { prices: CachedPrice[], productId: string }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
let result: Stripe.ApiList<Stripe.Price>
try {
result = await stripe.prices.list({ active: true, expand: ['data.currency_options'], product: productId })
}
catch (err) {
logger.withError(err).warn('Failed to fetch prices from Stripe')
return []
}
const prices = result.data
.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0))
.map(toCachedPrice)
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ prices, productId }), 'EX', PRICES_CACHE_TTL_SEC)
return prices
},
}
}
@@ -124,12 +110,12 @@ function toCachedPrice(price: Stripe.Price): CachedPrice {
* - `"$3.00"`
* - `"¥500"`
*/
export function formatPrice(unitAmount: number | null, currency: string): string {
export function formatPrice(unitAmount: null | number, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
try {
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency })
const formatter = new Intl.NumberFormat('en-US', { currency, style: 'currency' })
const fractionDigits = formatter.resolvedOptions().minimumFractionDigits ?? 2
const amount = unitAmount / (10 ** fractionDigits)
return formatter.format(amount)
@@ -138,3 +124,17 @@ export function formatPrice(unitAmount: number | null, currency: string): string
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}
function toCachedPrice(price: Stripe.Price): CachedPrice {
return {
active: price.active,
currency: price.currency,
currencyOptions: Object.fromEntries(
Object.entries(price.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
id: price.id,
metadata: price.metadata,
product: typeof price.product === 'string' ? price.product : price.product.id,
unitAmount: price.unit_amount,
}
}
+190 -190
View File
@@ -16,51 +16,12 @@ import { createWebhookOperation } from './operations/webhook'
// --- Mock helpers ---
function createMockFluxService(): FluxService {
return {
getFlux: vi.fn(async () => ({ userId: 'user-1', flux: 100 })),
updateStripeCustomerId: vi.fn(),
} as any
}
function createMockStripeService(overrides: Partial<StripeService> = {}): StripeService {
return {
upsertCustomer: vi.fn(async data => ({ id: 'id-1', createdAt: new Date(), updatedAt: new Date(), ...data })),
getCustomerByUserId: vi.fn(async () => undefined),
getCustomerByStripeId: vi.fn(async () => undefined),
upsertCheckoutSession: vi.fn(async data => ({ id: 'id-1', fluxCredited: false, createdAt: new Date(), updatedAt: new Date(), ...data })),
getCheckoutSessionsByUserId: vi.fn(async () => []),
upsertSubscription: vi.fn(async data => ({ id: 'id-1', createdAt: new Date(), updatedAt: new Date(), ...data })),
getActiveSubscription: vi.fn(async () => undefined),
upsertInvoice: vi.fn(async data => ({ id: 'id-1', fluxCredited: false, createdAt: new Date(), updatedAt: new Date(), ...data })),
getInvoicesByUserId: vi.fn(async () => []),
...overrides,
} as any
}
function createMockStripeCustomer(
overrides: Partial<NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>>> = {},
): NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>> {
const now = new Date()
return {
id: 'stripe-customer-1',
name: null,
email: null,
createdAt: now,
updatedAt: now,
userId: 'user-1',
deletedAt: null,
stripeCustomerId: 'cus_1',
...overrides,
}
}
function createMockBillingService(): BillingService {
return {
debitFlux: vi.fn(),
creditFlux: vi.fn(),
creditFluxFromStripeCheckout: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
creditFluxFromInvoice: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
creditFluxFromStripeCheckout: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
debitFlux: vi.fn(),
} as any
}
@@ -71,71 +32,110 @@ function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVServic
...overrides,
}
return {
get: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
getOrThrow: vi.fn(async (key: string) => {
if (defaults[key] === undefined)
throw new Error(`Config key "${key}" is not set`)
return defaults[key]
}),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
get: vi.fn(async (key: string) => defaults[key]),
set: vi.fn(),
} as any
}
function createMockFluxService(): FluxService {
return {
getFlux: vi.fn(async () => ({ flux: 100, userId: 'user-1' })),
updateStripeCustomerId: vi.fn(),
} as any
}
function createMockStripeCustomer(
overrides: Partial<NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>>> = {},
): NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>> {
const now = new Date()
return {
createdAt: now,
deletedAt: null,
email: null,
id: 'stripe-customer-1',
name: null,
stripeCustomerId: 'cus_1',
updatedAt: now,
userId: 'user-1',
...overrides,
}
}
function createMockStripeService(overrides: Partial<StripeService> = {}): StripeService {
return {
getActiveSubscription: vi.fn(async () => undefined),
getCheckoutSessionsByUserId: vi.fn(async () => []),
getCustomerByStripeId: vi.fn(async () => undefined),
getCustomerByUserId: vi.fn(async () => undefined),
getInvoicesByUserId: vi.fn(async () => []),
upsertCheckoutSession: vi.fn(async data => ({ createdAt: new Date(), fluxCredited: false, id: 'id-1', updatedAt: new Date(), ...data })),
upsertCustomer: vi.fn(async data => ({ createdAt: new Date(), id: 'id-1', updatedAt: new Date(), ...data })),
upsertInvoice: vi.fn(async data => ({ createdAt: new Date(), fluxCredited: false, id: 'id-1', updatedAt: new Date(), ...data })),
upsertSubscription: vi.fn(async data => ({ createdAt: new Date(), id: 'id-1', updatedAt: new Date(), ...data })),
...overrides,
} as any
}
const testEnv = {
API_SERVER_URL: 'http://localhost:8787',
STRIPE_SECRET_KEY: 'sk_test_fake',
STRIPE_WEBHOOK_SECRET: 'whsec_test_fake',
API_SERVER_URL: 'http://localhost:8787',
} as any
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
const testUser = { email: 'test@example.com', id: 'user-1', name: 'Test User' }
function createCheckoutSession(overrides: Partial<StripeCheckoutSession> = {}): StripeCheckoutSession {
return {
id: 'checkout-1',
userId: 'user-1',
stripeSessionId: 'cs_1',
stripeCustomerId: null,
mode: 'payment',
status: 'open',
paymentStatus: null,
amountTotal: 500,
currency: 'usd',
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel',
stripePaymentIntentId: null,
stripeSubscriptionId: null,
fluxCredited: false,
metadata: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
currency: 'usd',
deletedAt: null,
expiresAt: null,
fluxCredited: false,
id: 'checkout-1',
metadata: null,
mode: 'payment',
paymentStatus: null,
status: 'open',
stripeCustomerId: null,
stripePaymentIntentId: null,
stripeSessionId: 'cs_1',
stripeSubscriptionId: null,
successUrl: 'http://localhost/success',
updatedAt: new Date(),
userId: 'user-1',
...overrides,
}
}
function createInvoice(overrides: Partial<StripeInvoice> = {}): StripeInvoice {
return {
id: 'invoice-1',
userId: 'user-1',
stripeInvoiceId: 'inv_1',
stripeCustomerId: null,
stripeSubscriptionId: null,
status: 'paid',
amountDue: 500,
amountPaid: 500,
currency: 'usd',
invoiceUrl: null,
invoicePdf: null,
periodStart: null,
periodEnd: null,
paidAt: null,
fluxCredited: false,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
currency: 'usd',
deletedAt: null,
fluxCredited: false,
id: 'invoice-1',
invoicePdf: null,
invoiceUrl: null,
metadata: null,
paidAt: null,
periodEnd: null,
periodStart: null,
status: 'paid',
stripeCustomerId: null,
stripeInvoiceId: 'inv_1',
stripeSubscriptionId: null,
updatedAt: new Date(),
userId: 'user-1',
...overrides,
}
}
@@ -153,9 +153,9 @@ function createTestApp(
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
details: err.details,
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
@@ -231,9 +231,9 @@ describe('stripeRoutes', () => {
)
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
expect(res.status).toBe(401)
})
@@ -248,9 +248,9 @@ describe('stripeRoutes', () => {
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: '' }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}),
{ user: testUser } as any,
)
@@ -267,9 +267,9 @@ describe('stripeRoutes', () => {
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}),
{ user: testUser } as any,
)
@@ -287,9 +287,9 @@ describe('stripeRoutes', () => {
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}),
{ user: testUser } as any,
)
@@ -298,23 +298,36 @@ describe('stripeRoutes', () => {
it('stores browser PostHog identity in Stripe checkout metadata', async () => {
const createSession = vi.fn(async input => ({
id: 'cs_1',
url: 'https://checkout.stripe.com/cs_1',
customer: null,
mode: 'payment',
status: 'open',
payment_status: 'unpaid',
amount_total: 500,
currency: 'usd',
success_url: 'http://localhost/settings/flux?success=true',
cancel_url: 'http://localhost/settings/flux?canceled=true',
payment_intent: null,
subscription: null,
metadata: input.metadata,
currency: 'usd',
customer: null,
expires_at: null,
id: 'cs_1',
metadata: input.metadata,
mode: 'payment',
payment_intent: null,
payment_status: 'unpaid',
status: 'open',
subscription: null,
success_url: 'http://localhost/settings/flux?success=true',
url: 'https://checkout.stripe.com/cs_1',
}))
const productEventService = { track: vi.fn() }
const operation = createCheckoutOperation({
configKV: createMockConfigKV({ STRIPE_PAYMENT_METHODS: undefined }),
env: testEnv,
priceCatalog: {
findActivePrice: vi.fn(async () => ({
currency: 'usd',
currencyOptions: {},
id: 'price_test_500',
metadata: { fluxAmount: '500' },
unitAmount: 500,
})),
getActivePrices: vi.fn(),
} as any,
productEventService: productEventService as any,
stripe: {
checkout: {
sessions: {
@@ -322,24 +335,10 @@ describe('stripeRoutes', () => {
},
},
} as any,
priceCatalog: {
findActivePrice: vi.fn(async () => ({
id: 'price_test_500',
currency: 'usd',
unitAmount: 500,
currencyOptions: {},
metadata: { fluxAmount: '500' },
})),
getActivePrices: vi.fn(),
} as any,
stripeService: createMockStripeService(),
configKV: createMockConfigKV({ STRIPE_PAYMENT_METHODS: undefined }),
env: testEnv,
productEventService: productEventService as any,
})
await operation({
user: testUser as any,
body: { stripePriceId: 'price_test_500' },
request: new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
@@ -347,23 +346,24 @@ describe('stripeRoutes', () => {
'x-posthog-session-id': 'ph-session-1',
},
}),
user: testUser as any,
})
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
metadata: {
userId: 'user-1',
fluxAmount: '500',
posthogDistinctId: 'anon-browser-1',
posthogSessionId: 'ph-session-1',
userId: 'user-1',
},
}))
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
action: 'checkout_started',
metadata: expect.objectContaining({
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
}),
userId: 'user-1',
}))
})
})
@@ -383,8 +383,8 @@ describe('stripeRoutes', () => {
it('returns checkout sessions for the authenticated user', async () => {
const mockSessions = [
createCheckoutSession({ id: '1', stripeSessionId: 'cs_1', status: 'complete' }),
createCheckoutSession({ id: '2', stripeSessionId: 'cs_2', status: 'open' }),
createCheckoutSession({ id: '1', status: 'complete', stripeSessionId: 'cs_1' }),
createCheckoutSession({ id: '2', status: 'open', stripeSessionId: 'cs_2' }),
]
const stripeService = createMockStripeService({
getCheckoutSessionsByUserId: vi.fn(async () => mockSessions),
@@ -422,7 +422,7 @@ describe('stripeRoutes', () => {
})
it('returns invoices for the authenticated user', async () => {
const mockInvoices = [createInvoice({ id: '1', stripeInvoiceId: 'inv_1', status: 'paid' })]
const mockInvoices = [createInvoice({ id: '1', status: 'paid', stripeInvoiceId: 'inv_1' })]
const stripeService = createMockStripeService({
getInvoicesByUserId: vi.fn(async () => mockInvoices),
})
@@ -490,8 +490,8 @@ describe('stripeRoutes', () => {
)
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
body: '{}',
method: 'POST',
})
expect(res.status).toBe(400)
@@ -508,9 +508,9 @@ describe('stripeRoutes', () => {
)
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'invalid_sig' },
body: '{}',
headers: { 'stripe-signature': 'invalid_sig' },
method: 'POST',
})
expect(res.status).toBe(400)
@@ -528,192 +528,192 @@ describe('stripeRoutes', () => {
)
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'test_sig' },
body: '{}',
headers: { 'stripe-signature': 'test_sig' },
method: 'POST',
})
expect(res.status).toBe(503)
})
it('records payment completion with Stripe and PostHog identity from checkout metadata', async () => {
const checkoutEvent = {
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_1',
amount_total: 500,
cancel_url: 'http://localhost/settings/flux?canceled=true',
currency: 'usd',
customer: 'cus_1',
customer_email: 'test@example.com',
mode: 'payment',
status: 'complete',
payment_status: 'paid',
amount_total: 500,
currency: 'usd',
success_url: 'http://localhost/settings/flux?success=true',
cancel_url: 'http://localhost/settings/flux?canceled=true',
payment_intent: 'pi_1',
subscription: null,
expires_at: null,
id: 'cs_1',
metadata: {
userId: 'user-1',
fluxAmount: '500',
posthogDistinctId: 'anon-browser-1',
posthogSessionId: 'ph-session-1',
userId: 'user-1',
},
expires_at: null,
mode: 'payment',
payment_intent: 'pi_1',
payment_status: 'paid',
status: 'complete',
subscription: null,
success_url: 'http://localhost/settings/flux?success=true',
},
},
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
}
const productEventService = { track: vi.fn() }
const billingService = createMockBillingService()
const webhook = createWebhookOperation({
billingService,
fluxService: createMockFluxService(),
productEventService: productEventService as any,
stripe: {
webhooks: {
constructEvent: vi.fn(() => checkoutEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService: createMockStripeService(),
billingService,
productEventService: productEventService as any,
webhookSecret: 'whsec_test',
})
await webhook({ signature: 'test_sig', body: '{}' })
await webhook({ body: '{}', signature: 'test_sig' })
expect(billingService.creditFluxFromStripeCheckout).toHaveBeenCalledWith(expect.objectContaining({
stripeEventId: 'evt_checkout_completed',
userId: 'user-1',
stripeSessionId: 'cs_1',
fluxAmount: 500,
stripeEventId: 'evt_checkout_completed',
stripeSessionId: 'cs_1',
userId: 'user-1',
}))
expect(productEventService.track).toHaveBeenCalledWith({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: 'cs_1',
source: 'stripe.webhook',
feature: 'billing',
metadata: {
amount_total: 500,
currency: 'usd',
flux_amount: 500,
stripe_checkout_session_id: 'cs_1',
stripe_customer_id: 'cus_1',
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
stripe_checkout_session_id: 'cs_1',
stripe_customer_id: 'cus_1',
},
source: 'stripe.webhook',
status: 'succeeded',
userId: 'user-1',
})
})
it('processes subscription lifecycle webhooks without product events', async () => {
const subscriptionEvent = {
id: 'evt_sub_created',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_1',
customer: 'cus_1',
status: 'active',
items: {
data: [{
price: { id: 'price_1' },
current_period_start: 1_000,
current_period_end: 2_000,
}],
},
cancel_at_period_end: false,
canceled_at: null,
customer: 'cus_1',
ended_at: null,
id: 'sub_1',
items: {
data: [{
current_period_end: 2_000,
current_period_start: 1_000,
price: { id: 'price_1' },
}],
},
metadata: {},
status: 'active',
},
},
id: 'evt_sub_created',
type: 'customer.subscription.created',
}
const stripeService = createMockStripeService({
getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()),
})
const productEventService = { track: vi.fn(async () => undefined) }
const webhook = createWebhookOperation({
billingService: createMockBillingService(),
fluxService: createMockFluxService(),
productEventService: productEventService as any,
stripe: {
webhooks: {
constructEvent: vi.fn(() => subscriptionEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService,
billingService: createMockBillingService(),
productEventService: productEventService as any,
webhookSecret: 'whsec_test',
})
await webhook({ signature: 'test_sig', body: '{}' })
await webhook({ body: '{}', signature: 'test_sig' })
expect(stripeService.upsertSubscription).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
stripeSubscriptionId: 'sub_1',
cancelAtPeriodEnd: false,
status: 'active',
stripeCustomerId: 'cus_1',
stripePriceId: 'price_1',
status: 'active',
cancelAtPeriodEnd: false,
stripeSubscriptionId: 'sub_1',
userId: 'user-1',
}))
expect(productEventService.track).not.toHaveBeenCalled()
})
it('records subscription renewals only for subscription-cycle paid invoices', async () => {
const invoiceEvent = {
id: 'evt_invoice_paid',
type: 'invoice.paid',
data: {
object: {
id: 'inv_1',
amount_due: 1_200,
amount_paid: 1_200,
billing_reason: 'subscription_cycle',
currency: 'usd',
customer: 'cus_1',
hosted_invoice_url: null,
id: 'inv_1',
invoice_pdf: null,
metadata: {},
parent: {
subscription_details: {
subscription: 'sub_1',
},
},
billing_reason: 'subscription_cycle',
status: 'paid',
amount_due: 1_200,
amount_paid: 1_200,
currency: 'usd',
hosted_invoice_url: null,
invoice_pdf: null,
period_start: 1_000,
period_end: 2_000,
period_start: 1_000,
status: 'paid',
status_transitions: {
paid_at: 1_500,
},
metadata: {},
},
},
id: 'evt_invoice_paid',
type: 'invoice.paid',
}
const stripeService = createMockStripeService({
getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()),
})
const productEventService = { track: vi.fn(async () => undefined) }
const webhook = createWebhookOperation({
billingService: createMockBillingService(),
fluxService: createMockFluxService(),
productEventService: productEventService as any,
stripe: {
webhooks: {
constructEvent: vi.fn(() => invoiceEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService,
billingService: createMockBillingService(),
productEventService: productEventService as any,
webhookSecret: 'whsec_test',
})
await webhook({ signature: 'test_sig', body: '{}' })
await webhook({ body: '{}', signature: 'test_sig' })
expect(stripeService.upsertInvoice).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
stripeInvoiceId: 'inv_1',
stripeCustomerId: 'cus_1',
stripeSubscriptionId: 'sub_1',
status: 'paid',
amountDue: 1_200,
amountPaid: 1_200,
status: 'paid',
stripeCustomerId: 'cus_1',
stripeInvoiceId: 'inv_1',
stripeSubscriptionId: 'sub_1',
userId: 'user-1',
}))
expect(productEventService.track).not.toHaveBeenCalled()
})
+1 -1
View File
@@ -1,6 +1,6 @@
import { minLength, object, optional, pipe, string } from 'valibot'
export const CheckoutBodySchema = object({
stripePriceId: pipe(string(), minLength(1)),
currency: optional(string()),
stripePriceId: pipe(string(), minLength(1)),
})
+14 -14
View File
@@ -5,20 +5,6 @@ import { Hono } from 'hono'
import { authGuard } from '../../middlewares/auth'
function publicVoicePack(pack: Awaited<ReturnType<VoicePackService['listEnabled']>>[number]) {
return {
id: pack.id,
name: pack.name,
description: pack.description,
voiceId: pack.voiceId,
params: pack.params,
costMultiplier: pack.costMultiplier,
enabled: pack.enabled,
createdAt: pack.createdAt,
updatedAt: pack.updatedAt,
}
}
/**
* User-facing Voice Pack routes.
*
@@ -34,3 +20,17 @@ export function createVoicePackRoutes(service: VoicePackService) {
return c.json(packs.map(publicVoicePack))
})
}
function publicVoicePack(pack: Awaited<ReturnType<VoicePackService['listEnabled']>>[number]) {
return {
costMultiplier: pack.costMultiplier,
createdAt: pack.createdAt,
description: pack.description,
enabled: pack.enabled,
id: pack.id,
name: pack.name,
params: pack.params,
updatedAt: pack.updatedAt,
voiceId: pack.voiceId,
}
}
@@ -7,7 +7,33 @@ import { describe, expect, it, vi } from 'vitest'
import { createVoicePackRoutes } from '.'
import { ApiError } from '../../utils/error'
function createTestApp(service: VoicePackService, user: { id: string } | null) {
function createService() {
return {
create: vi.fn(),
disable: vi.fn(),
findById: vi.fn(),
findEnabledByVoiceId: vi.fn(),
list: vi.fn(),
listEnabled: vi.fn(async () => [{
costMultiplier: 2,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
description: 'Public description',
enabled: true,
id: 'vp-1',
model: 'microsoft/v1',
name: 'Enabled',
params: { pitch: 10 },
provider: 'azure',
ttsModelId: 'microsoft/v1',
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
voiceId: 'friendly-voice',
}]),
update: vi.fn(),
} as unknown as VoicePackService
}
function createTestApp(service: VoicePackService, user: null | { id: string }) {
return new Hono<HonoEnv>()
.use('*', async (c, next) => {
c.set('user', user as HonoEnv['Variables']['user'])
@@ -21,32 +47,6 @@ function createTestApp(service: VoicePackService, user: { id: string } | null) {
})
}
function createService() {
return {
listEnabled: vi.fn(async () => [{
id: 'vp-1',
name: 'Enabled',
description: 'Public description',
provider: 'azure',
model: 'microsoft/v1',
voiceId: 'friendly-voice',
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
ttsModelId: 'microsoft/v1',
params: { pitch: 10 },
costMultiplier: 2,
enabled: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
}]),
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
disable: vi.fn(),
findById: vi.fn(),
findEnabledByVoiceId: vi.fn(),
} as unknown as VoicePackService
}
describe('voice packs routes', () => {
it('requires auth before listing enabled packs', async () => {
// @example anonymous users cannot enumerate curated packs.
@@ -66,15 +66,15 @@ describe('voice packs routes', () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual([{
costMultiplier: 2,
createdAt: '2026-01-01T00:00:00.000Z',
description: 'Public description',
enabled: true,
id: 'vp-1',
name: 'Enabled',
description: 'Public description',
voiceId: 'friendly-voice',
params: { pitch: 10 },
costMultiplier: 2,
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
voiceId: 'friendly-voice',
}])
expect(service.listEnabled).toHaveBeenCalled()
})
+48 -48
View File
@@ -13,31 +13,31 @@ import { characterBookmarks, characterLikes } from './user-character'
export const character = pgTable(
'characters',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
version: text('version').notNull(),
coverUrl: text('cover_url').notNull(),
avatarUrl: text('avatar_url'),
bookmarksCount: integer('bookmarks_count').default(0).notNull(),
characterId: text('character_id').notNull(),
// TODO: json patch?
coverUrl: text('cover_url').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
// NOTICE: bare creatorId / ownerId is intentional — no FK to user.id.
// better-auth hard-deletes the user row; a cascade would wipe these
// soft-delete archive rows.
// See `server/apps/api/docs/ai-context/account-deletion.md`.
creatorId: text('creator_id').notNull(),
ownerId: text('owner_id').notNull(),
characterId: text('character_id').notNull(),
avatarUrl: text('avatar_url'),
creatorRole: text('creator_role'),
priceCredit: text('price_credit').default('0').notNull(),
likesCount: integer('likes_count').default(0).notNull(),
bookmarksCount: integer('bookmarks_count').default(0).notNull(),
interactionsCount: integer('interactions_count').default(0).notNull(),
deletedAt: timestamp('deleted_at'),
forksCount: integer('forks_count').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
interactionsCount: integer('interactions_count').default(0).notNull(),
likesCount: integer('likes_count').default(0).notNull(),
ownerId: text('owner_id').notNull(),
priceCredit: text('price_credit').default('0').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
version: text('version').notNull(),
},
)
@@ -47,15 +47,15 @@ export type NewCharacter = InferInsertModel<typeof character>
export const characterCovers = pgTable(
'character_covers',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
backgroundUrl: text('background_url').notNull(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
foregroundUrl: text('foreground_url').notNull(),
backgroundUrl: text('background_url').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
foregroundUrl: text('foreground_url').notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export type CharacterCover = InferSelectModel<typeof characterCovers>
@@ -64,17 +64,17 @@ export type NewCharacterCover = InferInsertModel<typeof characterCovers>
export const avatarModel = pgTable(
'avatar_model',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull().$type<keyof AvatarModelConfig>(),
config: jsonb('config').notNull().$type<AvatarModelConfig[keyof AvatarModelConfig]>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
description: text('description').notNull(),
config: jsonb('config').notNull().$type<AvatarModelConfig[keyof AvatarModelConfig]>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
type: text('type').notNull().$type<keyof AvatarModelConfig>(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
@@ -84,12 +84,12 @@ export type NewAvatarModel = InferInsertModel<typeof avatarModel>
export const characterCapabilities = pgTable(
'character_capabilities',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
config: jsonb('config').notNull().$type<CharacterCapabilityConfig[keyof CharacterCapabilityConfig]>(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
type: text('type').notNull().$type<keyof CharacterCapabilityConfig>(),
config: jsonb('config').notNull().$type<CharacterCapabilityConfig[keyof CharacterCapabilityConfig]>(),
},
)
@@ -99,15 +99,15 @@ export type NewCharacterCapability = InferInsertModel<typeof characterCapabiliti
export const characterI18n = pgTable(
'character_i18n',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
language: text('language').notNull(),
deletedAt: timestamp('deleted_at'),
name: text('name').notNull(),
tagline: text('tagline'),
description: text('description').notNull(),
tags: text('tags').array().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
language: text('language').notNull(),
name: text('name').notNull(),
// TODO: Implement the system prompt
// systemPrompt: text('system_prompt').notNull(),
@@ -121,26 +121,26 @@ export const characterI18n = pgTable(
// TODO: notes?
// TODO: metadata?
createdAt: timestamp('created_at').defaultNow().notNull(),
tagline: text('tagline'),
tags: text('tags').array().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
export type CharacterI18n = InferSelectModel<typeof characterI18n>
export type NewCharacterI18n = InferInsertModel<typeof characterI18n>
type PromptType = 'system' | 'personality' | 'greetings'
type PromptType = 'greetings' | 'personality' | 'system'
export const characterPrompts = pgTable(
'character_prompts',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
language: text('language').notNull(),
type: text('type').notNull().$type<PromptType>(),
content: text('content').notNull(),
},
)
@@ -149,25 +149,25 @@ export type NewCharacterPrompt = InferInsertModel<typeof characterPrompts>
export const characterRelations = relations(
character,
({ one, many }) => ({
capabilities: many(characterCapabilities),
({ many, one }) => ({
avatarModels: many(avatarModel),
i18n: many(characterI18n),
prompts: many(characterPrompts),
likes: many(characterLikes),
bookmarks: many(characterBookmarks),
owner: one(user, {
fields: [character.ownerId],
references: [user.id],
capabilities: many(characterCapabilities),
cover: one(characterCovers, {
fields: [character.id],
references: [characterCovers.characterId],
}),
creator: one(user, {
fields: [character.creatorId],
references: [user.id],
}),
cover: one(characterCovers, {
fields: [character.id],
references: [characterCovers.characterId],
i18n: many(characterI18n),
likes: many(characterLikes),
owner: one(user, {
fields: [character.ownerId],
references: [user.id],
}),
prompts: many(characterPrompts),
}),
)
+25 -25
View File
@@ -8,50 +8,50 @@ import { nanoid } from '../utils/id'
export const media = pgTable(
'media',
{
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
url: text('url').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
url: text('url').notNull(),
},
)
export const stickers = pgTable(
'stickers',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
url: text('url').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
url: text('url').notNull(),
},
)
export const stickerPacks = pgTable(
'sticker_packs',
{
createdAt: timestamp('created_at').defaultNow().notNull(),
description: text('description').notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
description: text('description').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
type ChatType = 'private' | 'bot' | 'group' | 'channel'
type ChatMemberType = 'user' | 'character' | 'bot'
type ChatMemberType = 'bot' | 'character' | 'user'
type ChatType = 'bot' | 'channel' | 'group' | 'private'
export const chats = pgTable(
'chats',
{
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
type: text('type').notNull().$type<ChatType>(),
title: text('title'),
createdAt: timestamp('created_at').defaultNow().notNull(),
type: text('type').notNull().$type<ChatType>(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
@@ -61,11 +61,11 @@ export type NewChat = InferInsertModel<typeof chats>
export const chatMembers = pgTable(
'chat_members',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
characterId: text('character_id'),
chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
memberType: text('member_type').notNull().$type<ChatMemberType>(),
userId: text('user_id'),
characterId: text('character_id'),
},
table => [
index('chat_members_user_id_member_type_chat_id_idx').on(table.userId, table.memberType, table.chatId),
@@ -76,23 +76,23 @@ export const chatMembers = pgTable(
export const messages = pgTable(
'messages',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
senderId: text('sender_id'),
role: text('role').notNull(),
seq: integer('seq'),
content: text('content').notNull(),
mediaIds: text('media_ids').array().notNull(),
stickerIds: text('sticker_ids').array().notNull(),
replyToMessageId: text('reply_message_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
forwardFromMessageId: text('forward_from_message_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
mediaIds: text('media_ids').array().notNull(),
replyToMessageId: text('reply_message_id'),
role: text('role').notNull(),
senderId: text('sender_id'),
seq: integer('seq'),
stickerIds: text('sticker_ids').array().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
table => [
index('messages_chat_id_seq_idx').on(table.chatId, table.seq),
+1 -1
View File
@@ -3,6 +3,6 @@ import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
/** Operator-managed configuration stored as its canonical JSON text. */
export const configKV = pgTable('config_kv', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
value: text('value').notNull(),
})
@@ -8,16 +8,16 @@ import { nanoid } from '../utils/id'
// hard-delete of user.id must not cascade-wipe the ledger.
// See `server/apps/api/docs/ai-context/account-deletion.md`.
export const fluxTransaction = pgTable('flux_transaction', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
type: text('type').notNull(), // 'credit' | 'debit' | 'initial' | 'promo' | 'admin_set'
amount: bigint('amount', { mode: 'number' }).notNull(), // always positive
balanceBefore: bigint('balance_before', { mode: 'number' }).notNull(),
balanceAfter: bigint('balance_after', { mode: 'number' }).notNull(),
requestId: text('request_id'), // nullable; used for idempotency on debit/credit
description: text('description').notNull(),
metadata: jsonb('metadata'), // { promptTokens, completionTokens, stripeSessionId, ... }
balanceBefore: bigint('balance_before', { mode: 'number' }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
description: text('description').notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
metadata: jsonb('metadata'), // { promptTokens, completionTokens, stripeSessionId, ... }
requestId: text('request_id'), // nullable; used for idempotency on debit/credit
type: text('type').notNull(), // 'credit' | 'debit' | 'initial' | 'promo' | 'admin_set'
userId: text('user_id').notNull(),
}, table => [
index('flux_tx_user_id_idx').on(table.userId),
index('flux_tx_created_at_idx').on(table.createdAt),
+2 -2
View File
@@ -4,9 +4,9 @@ import { bigint, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
// the user row; a cascade would wipe these soft-delete archive rows.
// See `server/apps/api/docs/ai-context/account-deletion.md`.
export const userFlux = pgTable('user_flux', {
userId: text('user_id').primaryKey(),
deletedAt: timestamp('deleted_at'),
flux: bigint('flux', { mode: 'number' }).notNull().default(0),
stripeCustomerId: text('stripe_customer_id'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
userId: text('user_id').primaryKey(),
})
@@ -3,13 +3,13 @@ import { bigint, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
export const llmRequestLog = pgTable('llm_request_log', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(), // NOTICE: do NOT use foreign key constraint here to avoid potential performance issues on high-concurrency writes
model: text('model').notNull(),
status: integer('status').notNull(),
durationMs: integer('duration_ms').notNull(),
fluxConsumed: bigint('flux_consumed', { mode: 'number' }).notNull(),
promptTokens: integer('prompt_tokens'),
completionTokens: integer('completion_tokens'),
createdAt: timestamp('created_at').defaultNow().notNull(),
durationMs: integer('duration_ms').notNull(),
fluxConsumed: bigint('flux_consumed', { mode: 'number' }).notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
model: text('model').notNull(),
promptTokens: integer('prompt_tokens'),
status: integer('status').notNull(),
userId: text('user_id').notNull(), // NOTICE: do NOT use foreign key constraint here to avoid potential performance issues on high-concurrency writes
})
+38 -38
View File
@@ -4,28 +4,28 @@ import { boolean, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from '
import { nanoid } from '../utils/id'
export type CapabilityAliasSurface = 'llm' | 'asr'
export type CapabilityAliasRoutePool = 'primary' | 'fallback'
export type CapabilityAliasRoutePool = 'fallback' | 'primary'
export type CapabilityAliasSurface = 'asr' | 'llm'
export type ProviderCatalogTtsVoiceLabels = Record<string, unknown>
export interface ProviderCatalogTtsVoiceLanguage {
code: string
title?: string
}
export type ProviderCatalogTtsVoiceLabels = Record<string, unknown>
export const capabilityAliases = pgTable(
'capability_aliases',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
surface: text('surface').notNull().$type<CapabilityAliasSurface>(),
aliasId: text('alias_id').notNull(),
displayName: text('display_name').notNull(),
enabled: boolean('enabled').notNull().default(true),
displayOrder: integer('display_order').notNull().default(0),
fallbackEnabled: boolean('fallback_enabled').notNull().default(true),
loadBalancingEnabled: boolean('load_balancing_enabled').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
displayName: text('display_name').notNull(),
displayOrder: integer('display_order').notNull().default(0),
enabled: boolean('enabled').notNull().default(true),
fallbackEnabled: boolean('fallback_enabled').notNull().default(true),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
loadBalancingEnabled: boolean('load_balancing_enabled').notNull().default(false),
surface: text('surface').notNull().$type<CapabilityAliasSurface>(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
@@ -36,15 +36,15 @@ export const capabilityAliases = pgTable(
export const capabilityAliasRoutes = pgTable(
'capability_alias_routes',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
aliasId: text('alias_id').notNull().references(() => capabilityAliases.id, { onDelete: 'cascade' }),
routerModelId: text('router_model_id').notNull(),
pool: text('pool').notNull().$type<CapabilityAliasRoutePool>().default('primary'),
enabled: boolean('enabled').notNull().default(true),
weight: integer('weight').notNull().default(1),
displayOrder: integer('display_order').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
displayOrder: integer('display_order').notNull().default(0),
enabled: boolean('enabled').notNull().default(true),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
pool: text('pool').notNull().$type<CapabilityAliasRoutePool>().default('primary'),
routerModelId: text('router_model_id').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
weight: integer('weight').notNull().default(1),
},
table => [
uniqueIndex('capability_alias_routes_alias_model_pool_uidx').on(table.aliasId, table.routerModelId, table.pool),
@@ -54,14 +54,14 @@ export const capabilityAliasRoutes = pgTable(
export const providerCatalogTtsModels = pgTable(
'provider_catalog_tts_models',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
routerModelId: text('router_model_id').notNull(),
provider: text('provider').notNull(),
displayName: text('display_name').notNull(),
enabled: boolean('enabled').notNull().default(true),
displayOrder: integer('display_order').notNull().default(0),
lastSyncedAt: timestamp('last_synced_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
displayName: text('display_name').notNull(),
displayOrder: integer('display_order').notNull().default(0),
enabled: boolean('enabled').notNull().default(true),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
lastSyncedAt: timestamp('last_synced_at'),
provider: text('provider').notNull(),
routerModelId: text('router_model_id').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
@@ -72,18 +72,18 @@ export const providerCatalogTtsModels = pgTable(
export const providerCatalogTtsVoices = pgTable(
'provider_catalog_tts_voices',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
ttsModelId: text('tts_model_id').notNull().references(() => providerCatalogTtsModels.id, { onDelete: 'cascade' }),
providerVoiceId: text('provider_voice_id').notNull(),
displayName: text('display_name').notNull(),
enabled: boolean('enabled').notNull().default(false),
displayOrder: integer('display_order').notNull().default(0),
languages: jsonb('languages').notNull().$type<ProviderCatalogTtsVoiceLanguage[]>().default([]),
labels: jsonb('labels').notNull().$type<ProviderCatalogTtsVoiceLabels>().default({}),
previewAudioUrl: text('preview_audio_url'),
source: text('source').notNull().default('provider-sync'),
lastSyncedAt: timestamp('last_synced_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
displayName: text('display_name').notNull(),
displayOrder: integer('display_order').notNull().default(0),
enabled: boolean('enabled').notNull().default(false),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
labels: jsonb('labels').notNull().$type<ProviderCatalogTtsVoiceLabels>().default({}),
languages: jsonb('languages').notNull().$type<ProviderCatalogTtsVoiceLanguage[]>().default([]),
lastSyncedAt: timestamp('last_synced_at'),
previewAudioUrl: text('preview_audio_url'),
providerVoiceId: text('provider_voice_id').notNull(),
source: text('source').notNull().default('provider-sync'),
ttsModelId: text('tts_model_id').notNull().references(() => providerCatalogTtsModels.id, { onDelete: 'cascade' }),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
@@ -92,10 +92,10 @@ export const providerCatalogTtsVoices = pgTable(
)
export type CapabilityAlias = InferSelectModel<typeof capabilityAliases>
export type NewCapabilityAlias = InferInsertModel<typeof capabilityAliases>
export type CapabilityAliasRoute = InferSelectModel<typeof capabilityAliasRoutes>
export type NewCapabilityAlias = InferInsertModel<typeof capabilityAliases>
export type NewCapabilityAliasRoute = InferInsertModel<typeof capabilityAliasRoutes>
export type ProviderCatalogTtsModel = InferSelectModel<typeof providerCatalogTtsModels>
export type NewProviderCatalogTtsModel = InferInsertModel<typeof providerCatalogTtsModels>
export type ProviderCatalogTtsVoice = InferSelectModel<typeof providerCatalogTtsVoices>
export type NewProviderCatalogTtsVoice = InferInsertModel<typeof providerCatalogTtsVoices>
export type ProviderCatalogTtsModel = InferSelectModel<typeof providerCatalogTtsModels>
export type ProviderCatalogTtsVoice = InferSelectModel<typeof providerCatalogTtsVoices>
+17 -17
View File
@@ -12,22 +12,22 @@ import { nanoid } from '../utils/id'
export const userProviderConfigs = pgTable(
'user_provider_configs',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
ownerId: text('owner_id').notNull(),
definitionId: text('definition_id').notNull(),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
createdAt: timestamp('created_at').defaultNow().notNull(),
definitionId: text('definition_id').notNull(),
deletedAt: timestamp('deleted_at'),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
ownerId: text('owner_id').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
validated: boolean('validated').notNull().default(false),
validationBypassed: boolean('validation_bypassed').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
export type UserProviderConfig = InferSelectModel<typeof userProviderConfigs>
export type NewUserProviderConfig = InferInsertModel<typeof userProviderConfigs>
export type UserProviderConfig = InferSelectModel<typeof userProviderConfigs>
export const userProviderConfigsRelations = relations(
userProviderConfigs,
@@ -42,18 +42,18 @@ export const userProviderConfigsRelations = relations(
export const systemProviderConfigs = pgTable(
'system_provider_configs',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
definitionId: text('definition_id').notNull(),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
createdAt: timestamp('created_at').defaultNow().notNull(),
definitionId: text('definition_id').notNull(),
deletedAt: timestamp('deleted_at'),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
validated: boolean('validated').notNull().default(false),
validationBypassed: boolean('validation_bypassed').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
export type SystemProviderConfig = InferSelectModel<typeof systemProviderConfigs>
export type NewSystemProviderConfig = InferInsertModel<typeof systemProviderConfigs>
export type SystemProviderConfig = InferSelectModel<typeof systemProviderConfigs>
+59 -59
View File
@@ -15,121 +15,121 @@ import { nanoid } from '../utils/id'
* Stripe customers linked to our users.
*/
export const stripeCustomer = pgTable('stripe_customer', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeCustomerId: text('stripe_customer_id').notNull().unique(),
email: text('email'),
name: text('name'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
email: text('email'),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name'),
stripeCustomerId: text('stripe_customer_id').notNull().unique(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
userId: text('user_id').notNull(),
})
/**
* Stripe checkout sessions every checkout attempt is recorded.
*/
export const stripeCheckoutSession = pgTable('stripe_checkout_session', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeSessionId: text('stripe_session_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup'
status: text('status'), // 'open' | 'complete' | 'expired'
paymentStatus: text('payment_status'), // 'paid' | 'unpaid' | 'no_payment_required'
amountTotal: integer('amount_total'), // in cents
currency: text('currency'),
successUrl: text('success_url'),
cancelUrl: text('cancel_url'),
stripePaymentIntentId: text('stripe_payment_intent_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
fluxCredited: boolean('flux_credited').notNull().default(false),
metadata: text('metadata'), // JSON stringified
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
currency: text('currency'),
deletedAt: timestamp('deleted_at'),
expiresAt: timestamp('expires_at'),
fluxCredited: boolean('flux_credited').notNull().default(false),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
metadata: text('metadata'), // JSON stringified
mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup'
paymentStatus: text('payment_status'), // 'paid' | 'unpaid' | 'no_payment_required'
status: text('status'), // 'open' | 'complete' | 'expired'
stripeCustomerId: text('stripe_customer_id'),
stripePaymentIntentId: text('stripe_payment_intent_id'),
stripeSessionId: text('stripe_session_id').notNull().unique(),
stripeSubscriptionId: text('stripe_subscription_id'),
successUrl: text('success_url'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
userId: text('user_id').notNull(),
})
/**
* Stripe subscriptions.
*/
export const stripeSubscription = pgTable('stripe_subscription', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id').notNull(),
stripePriceId: text('stripe_price_id'),
status: text('status').notNull(), // 'active' | 'past_due' | 'canceled' | 'incomplete' | etc
currentPeriodStart: timestamp('current_period_start'),
currentPeriodEnd: timestamp('current_period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
canceledAt: timestamp('canceled_at'),
endedAt: timestamp('ended_at'),
metadata: text('metadata'), // JSON stringified
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
currentPeriodEnd: timestamp('current_period_end'),
currentPeriodStart: timestamp('current_period_start'),
deletedAt: timestamp('deleted_at'),
endedAt: timestamp('ended_at'),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
metadata: text('metadata'), // JSON stringified
status: text('status').notNull(), // 'active' | 'past_due' | 'canceled' | 'incomplete' | etc
stripeCustomerId: text('stripe_customer_id').notNull(),
stripePriceId: text('stripe_price_id'),
stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
userId: text('user_id').notNull(),
})
/**
* Stripe invoices both one-time and subscription invoices.
*/
export const stripeInvoice = pgTable('stripe_invoice', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeInvoiceId: text('stripe_invoice_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
status: text('status'), // 'draft' | 'open' | 'paid' | 'uncollectible' | 'void'
amountDue: integer('amount_due'), // in cents
amountPaid: integer('amount_paid'), // in cents
currency: text('currency'),
invoiceUrl: text('invoice_url'),
invoicePdf: text('invoice_pdf'),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
paidAt: timestamp('paid_at'),
fluxCredited: boolean('flux_credited').notNull().default(false),
metadata: text('metadata'), // JSON stringified
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
currency: text('currency'),
deletedAt: timestamp('deleted_at'),
fluxCredited: boolean('flux_credited').notNull().default(false),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
invoicePdf: text('invoice_pdf'),
invoiceUrl: text('invoice_url'),
metadata: text('metadata'), // JSON stringified
paidAt: timestamp('paid_at'),
periodEnd: timestamp('period_end'),
periodStart: timestamp('period_start'),
status: text('status'), // 'draft' | 'open' | 'paid' | 'uncollectible' | 'void'
stripeCustomerId: text('stripe_customer_id'),
stripeInvoiceId: text('stripe_invoice_id').notNull().unique(),
stripeSubscriptionId: text('stripe_subscription_id'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
userId: text('user_id').notNull(),
})
// ---------- Relations ----------
export const stripeCustomerRelations = relations(stripeCustomer, ({ one, many }) => ({
user: one(user, { fields: [stripeCustomer.userId], references: [user.id] }),
export const stripeCustomerRelations = relations(stripeCustomer, ({ many, one }) => ({
checkoutSessions: many(stripeCheckoutSession),
subscriptions: many(stripeSubscription),
invoices: many(stripeInvoice),
subscriptions: many(stripeSubscription),
user: one(user, { fields: [stripeCustomer.userId], references: [user.id] }),
}))
export const stripeCheckoutSessionRelations = relations(stripeCheckoutSession, ({ one }) => ({
user: one(user, { fields: [stripeCheckoutSession.userId], references: [user.id] }),
customer: one(stripeCustomer, { fields: [stripeCheckoutSession.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
user: one(user, { fields: [stripeCheckoutSession.userId], references: [user.id] }),
}))
export const stripeSubscriptionRelations = relations(stripeSubscription, ({ one }) => ({
user: one(user, { fields: [stripeSubscription.userId], references: [user.id] }),
customer: one(stripeCustomer, { fields: [stripeSubscription.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
user: one(user, { fields: [stripeSubscription.userId], references: [user.id] }),
}))
export const stripeInvoiceRelations = relations(stripeInvoice, ({ one }) => ({
user: one(user, { fields: [stripeInvoice.userId], references: [user.id] }),
customer: one(stripeCustomer, { fields: [stripeInvoice.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
user: one(user, { fields: [stripeInvoice.userId], references: [user.id] }),
}))
// ---------- Types ----------
export type StripeCustomer = InferSelectModel<typeof stripeCustomer>
export type NewStripeCheckoutSession = InferInsertModel<typeof stripeCheckoutSession>
export type NewStripeCustomer = InferInsertModel<typeof stripeCustomer>
export type StripeCheckoutSession = InferSelectModel<typeof stripeCheckoutSession>
export type NewStripeCheckoutSession = InferInsertModel<typeof stripeCheckoutSession>
export type StripeSubscription = InferSelectModel<typeof stripeSubscription>
export type NewStripeInvoice = InferInsertModel<typeof stripeInvoice>
export type NewStripeSubscription = InferInsertModel<typeof stripeSubscription>
export type StripeCheckoutSession = InferSelectModel<typeof stripeCheckoutSession>
export type StripeCustomer = InferSelectModel<typeof stripeCustomer>
export type StripeInvoice = InferSelectModel<typeof stripeInvoice>
export type NewStripeInvoice = InferInsertModel<typeof stripeInvoice>
export type StripeSubscription = InferSelectModel<typeof stripeSubscription>
+10 -10
View File
@@ -12,10 +12,10 @@ import { character } from './characters'
export const characterLikes = pgTable(
'user_character_likes',
{
userId: text('user_id').notNull(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
userId: text('user_id').notNull(),
},
table => [
primaryKey({ columns: [table.userId, table.characterId] }),
@@ -28,10 +28,10 @@ export type NewCharacterLike = InferInsertModel<typeof characterLikes>
export const characterBookmarks = pgTable(
'user_character_bookmarks',
{
userId: text('user_id').notNull(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
userId: text('user_id').notNull(),
},
table => [
primaryKey({ columns: [table.userId, table.characterId] }),
@@ -44,27 +44,27 @@ export type NewCharacterBookmark = InferInsertModel<typeof characterBookmarks>
export const characterLikesRelations = relations(
characterLikes,
({ one }) => ({
user: one(user, {
fields: [characterLikes.userId],
references: [user.id],
}),
character: one(character, {
fields: [characterLikes.characterId],
references: [character.id],
}),
user: one(user, {
fields: [characterLikes.userId],
references: [user.id],
}),
}),
)
export const characterBookmarksRelations = relations(
characterBookmarks,
({ one }) => ({
user: one(user, {
fields: [characterBookmarks.userId],
references: [user.id],
}),
character: one(character, {
fields: [characterBookmarks.characterId],
references: [character.id],
}),
user: one(user, {
fields: [characterBookmarks.userId],
references: [user.id],
}),
}),
)
+13 -13
View File
@@ -6,30 +6,30 @@ import { nanoid } from '../utils/id'
export interface VoicePackParams {
pitch?: number
volume?: number
rate?: number
volume?: number
}
export const voicePacks = pgTable(
'voice_packs',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
costMultiplier: real('cost_multiplier').notNull().default(1),
createdAt: timestamp('created_at').defaultNow().notNull(),
description: text('description'),
provider: text('provider').notNull(),
model: text('model').notNull(),
voiceId: text('voice_id').notNull(),
upstreamVoiceId: text('upstream_voice_id').notNull(),
ttsModelId: text('tts_model_id').notNull(),
params: jsonb('params').notNull().$type<VoicePackParams>().default({}),
costMultiplier: real('cost_multiplier').notNull().default(1),
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey().$defaultFn(() => nanoid()),
model: text('model').notNull(),
name: text('name').notNull(),
params: jsonb('params').notNull().$type<VoicePackParams>().default({}),
provider: text('provider').notNull(),
ttsModelId: text('tts_model_id').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
upstreamVoiceId: text('upstream_voice_id').notNull(),
voiceId: text('voice_id').notNull(),
},
)
export type VoicePack = InferSelectModel<typeof voicePacks>
export type NewVoicePack = InferInsertModel<typeof voicePacks>
export type VoicePack = InferSelectModel<typeof voicePacks>
@@ -37,13 +37,13 @@ if (env.OTEL_DEBUG === 'true')
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE)
const sdk = new NodeSDK({
resource: resourceFromAttributes({ 'service.name': 'otel-http-smoke' }),
metricReaders: [new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 200 })],
instrumentations: [
// Mirrors prod: incoming is owned by @hono/otel; auto instrumentation only
// covers outbound. If this hook were dropped we'd double-record.
new HttpInstrumentation({ ignoreIncomingRequestHook: () => true }),
],
metricReaders: [new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 200 })],
resource: resourceFromAttributes({ 'service.name': 'otel-http-smoke' }),
})
sdk.start()
@@ -54,7 +54,7 @@ app.get('/health-test', c => c.text('ok'))
// `serve` returns the http.Server synchronously but binding is async — wait
// for the listen callback to capture the port (port: 0 = auto-assigned).
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 })
const port = await new Promise<number>((resolve) => {
server.once('listening', () => {
const addr = server.address()
+19 -19
View File
@@ -39,9 +39,9 @@ env.OTEL_LOGS_EXPORTER = 'none'
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE)
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 })
const sdk = new NodeSDK({
resource: resourceFromAttributes({ 'service.name': 'otel-ws-smoke' }),
metricReaders: [reader],
instrumentations: [],
metricReaders: [reader],
resource: resourceFromAttributes({ 'service.name': 'otel-ws-smoke' }),
})
sdk.start()
@@ -73,15 +73,6 @@ app.get('/ws', upgradeWebSocket((c) => {
// Symbol — mirrors how chat-ws keys by `HonoWsInvocableEventContext`.
const connectionKey = Symbol('conn')
return {
onOpen() {
let conns = userConnections.get(userId)
if (!conns) {
conns = new Set()
userConnections.set(userId, conns)
}
conns.add(connectionKey)
console.info(`[ws-smoke] onOpen user=${userId} (now ${conns.size} for this user)`)
},
onClose() {
const conns = userConnections.get(userId)
if (!conns)
@@ -91,10 +82,19 @@ app.get('/ws', upgradeWebSocket((c) => {
userConnections.delete(userId)
console.info(`[ws-smoke] onClose user=${userId}`)
},
onOpen() {
let conns = userConnections.get(userId)
if (!conns) {
conns = new Set()
userConnections.set(userId, conns)
}
conns.add(connectionKey)
console.info(`[ws-smoke] onOpen user=${userId} (now ${conns.size} for this user)`)
},
}
}))
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 })
const port = await new Promise<number>((resolve) => {
server.once('listening', () => {
const addr = server.address()
@@ -105,7 +105,7 @@ const port = await new Promise<number>((resolve) => {
injectWebSocket(server)
console.info(`[ws-smoke] listening on 127.0.0.1:${port}\n`)
async function readGaugeNow(): Promise<number | null> {
async function readGaugeNow(): Promise<null | number> {
await reader.forceFlush()
const all = exporter.getMetrics()
const last = all.at(-1)
@@ -124,12 +124,17 @@ async function readGaugeNow(): Promise<number | null> {
}
const results: boolean[] = []
function assert(label: string, expected: number, actual: number | null) {
function assert(label: string, expected: number, actual: null | number) {
const ok = actual === expected
console.info(`[ws-smoke] ${ok ? '✅' : '❌'} ${label}: expected=${expected}, observed=${actual}\n`)
results.push(ok)
}
async function closeClient(ws: WebSocket) {
ws.close()
await sleep(150)
}
async function openClient(user: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?user=${encodeURIComponent(user)}`)
await new Promise<void>((res, rej) => {
@@ -141,11 +146,6 @@ async function openClient(user: string): Promise<WebSocket> {
return ws
}
async function closeClient(ws: WebSocket) {
ws.close()
await sleep(150)
}
console.info('=== Phase A: open 3 alice + 2 bob ===')
const a1 = await openClient('alice')
const a2 = await openClient('alice')
+6 -6
View File
@@ -5,10 +5,6 @@ import { serve } from '@hono/node-server'
import { createApp } from './app'
function handleProcessError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}
/**
* Starts the dedicated resource API HTTP/WebSocket process.
*
@@ -20,8 +16,8 @@ function handleProcessError(error: unknown, type: string) {
* -> business HTTP and WebSocket routes
*/
export async function runApiServer(): Promise<void> {
const { app, injectWebSocket, port, hostname } = await createApp()
const server = serve({ fetch: app.fetch, port, hostname })
const { app, hostname, injectWebSocket, port } = await createApp()
const server = serve({ fetch: app.fetch, hostname, port })
injectWebSocket(server)
process.on('uncaughtException', error => handleProcessError(error, 'Uncaught exception'))
@@ -32,3 +28,7 @@ export async function runApiServer(): Promise<void> {
server.once('error', error => reject(error))
})
}
function handleProcessError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}
@@ -6,16 +6,16 @@ describe('configKV invalidation contract', () => {
it('accepts a declared ConfigKV key', () => {
expect(parseConfigKVInvalidation(JSON.stringify({
key: 'FLUX_PER_REQUEST',
version: 1,
publishedAt: 1,
version: 1,
}))).toMatchObject({ key: 'FLUX_PER_REQUEST' })
})
it('rejects an unknown ConfigKV key', () => {
expect(() => parseConfigKVInvalidation(JSON.stringify({
key: 'UNKNOWN_CONFIG_KEY',
version: 1,
publishedAt: 1,
version: 1,
}))).toThrow('ConfigKV invalidation key is unknown')
})
@@ -14,14 +14,14 @@ const configKVInvalidationPayloadSchema = object({
object(configEntrySchemas),
'ConfigKV invalidation key is unknown',
),
version: pipe(
number('ConfigKV invalidation version must be a number'),
finite('ConfigKV invalidation version must be a number'),
),
publishedAt: pipe(
number('ConfigKV invalidation publishedAt must be a number'),
finite('ConfigKV invalidation publishedAt must be a number'),
),
version: pipe(
number('ConfigKV invalidation version must be a number'),
finite('ConfigKV invalidation version must be a number'),
),
})
const configKVInvalidationSchema = pipe(
@@ -39,36 +39,36 @@ export const routeFailureTriggersSchema = object({
})
export const keyEntrySchema = object({
ciphertext: pipe(string(), nonEmpty('keys[].ciphertext must not be empty')),
id: pipe(
string(),
nonEmpty('keys[].id must not be empty'),
regex(/^[^|]+$/, 'keys[].id must not contain "|" (reserved AAD separator)'),
),
ciphertext: pipe(string(), nonEmpty('keys[].ciphertext must not be empty')),
})
export const llmUpstreamSchema = object({
baseURL: pipe(string(), nonEmpty('llm.upstreams[].baseURL must not be empty')),
headerTemplate: optional(string(), 'Bearer {KEY}'),
id: optional(pipe(
string(),
nonEmpty('llm.upstreams[].id must not be empty'),
regex(/^[^|]+$/, 'llm.upstreams[].id must not contain "|"'),
)),
baseURL: pipe(string(), nonEmpty('llm.upstreams[].baseURL must not be empty')),
overrideModel: optional(string()),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'llm.upstreams[].keys must contain at least 1 entry')),
headerTemplate: optional(string(), 'Bearer {KEY}'),
overrideModel: optional(string()),
timeoutMs: optional(number()),
})
export const llmRoutingGroupSchema = object({
continueOn: optional(routeFailureTriggersSchema),
id: pipe(string(), nonEmpty('llm.routing.groups[].id must not be empty')),
retryOn: routeFailureTriggersSchema,
upstreamIds: pipe(
array(pipe(string(), nonEmpty('llm.routing.groups[].upstreamIds[] must not be empty'))),
check(v => v.length >= 1, 'llm.routing.groups[].upstreamIds must contain at least 1 entry'),
check(v => new Set(v).size === v.length, 'llm.routing.groups[].upstreamIds must be unique'),
),
retryOn: routeFailureTriggersSchema,
continueOn: optional(routeFailureTriggersSchema),
})
export const llmRoutingSchema = object({
@@ -81,9 +81,9 @@ export const llmRoutingSchema = object({
export const llmModelSchema = pipe(
object({
upstreams: pipe(array(llmUpstreamSchema), check(v => v.length >= 1, 'llm.models[].upstreams must contain at least 1 entry')),
routing: optional(llmRoutingSchema),
fallbackTriggers: fallbackTriggersSchema,
routing: optional(llmRoutingSchema),
upstreams: pipe(array(llmUpstreamSchema), check(v => v.length >= 1, 'llm.models[].upstreams must contain at least 1 entry')),
}),
check((model) => {
if (model.routing == null)
@@ -107,14 +107,14 @@ const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', '
const asrProviderSchema = picklist(['aliyun-nls'])
export const ttsUpstreamSchema = object({
adapterParams: optional(record(string(), any()), {}),
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
id: optional(pipe(
string(),
nonEmpty('tts.upstreams[].id must not be empty'),
regex(/^[^|]+$/, 'tts.upstreams[].id must not contain "|"'),
)),
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'tts.upstreams[].keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
// Per-app_id concurrency cap for the pool load balancer. One upstream maps to
// one app_id (Volcengine `adapterParams.appid`), capped by the provider at a
// small number (e.g. 10). When set on any upstream of a model, the router
@@ -125,15 +125,15 @@ export const ttsUpstreamSchema = object({
})
export const ttsRoutingGroupSchema = object({
continueOn: optional(routeFailureTriggersSchema),
id: pipe(string(), nonEmpty('tts.routing.groups[].id must not be empty')),
retryOn: routeFailureTriggersSchema,
strategy: optional(picklist(['ordered', 'least-inflight']), 'ordered'),
upstreamIds: pipe(
array(pipe(string(), nonEmpty('tts.routing.groups[].upstreamIds[] must not be empty'))),
check(v => v.length >= 1, 'tts.routing.groups[].upstreamIds must contain at least 1 entry'),
check(v => new Set(v).size === v.length, 'tts.routing.groups[].upstreamIds must be unique'),
),
strategy: optional(picklist(['ordered', 'least-inflight']), 'ordered'),
retryOn: routeFailureTriggersSchema,
continueOn: optional(routeFailureTriggersSchema),
})
export const ttsRoutingSchema = object({
@@ -145,18 +145,18 @@ export const ttsRoutingSchema = object({
})
export const streamingTtsUpstreamSchema = object({
baseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.baseURL must not be empty')),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'UNSPEECH_UPSTREAM.streaming.keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
baseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.baseURL must not be empty')),
defaultModel: optional(string()),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'UNSPEECH_UPSTREAM.streaming.keys must contain at least 1 entry')),
models: optional(
array(object({
description: optional(string()),
id: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.models[].id must not be empty')),
name: optional(string()),
description: optional(string()),
})),
[],
),
defaultModel: optional(string()),
})
export const unspeechUpstreamSchema = object({
@@ -166,10 +166,10 @@ export const unspeechUpstreamSchema = object({
export const ttsModelSchema = pipe(
object({
provider: ttsProviderSchema,
upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')),
routing: optional(ttsRoutingSchema),
fallbackTriggers: fallbackTriggersSchema,
provider: ttsProviderSchema,
routing: optional(ttsRoutingSchema),
upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')),
}),
check((model) => {
if (model.routing == null)
@@ -199,8 +199,8 @@ export const ttsModelSchema = pipe(
)
export const asrUpstreamSchema = object({
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'asr.upstreams[].keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'asr.upstreams[].keys must contain at least 1 entry')),
})
export const asrModelSchema = object({
@@ -210,24 +210,24 @@ export const asrModelSchema = object({
export const llmRouterDefaultsSchema = optional(
object({
perAttemptTimeoutMs: optional(number(), 30000),
fullChainTimeoutMs: optional(number(), 60000),
fallbackHttpCodes: optional(array(number()), [401, 402, 403, 429, 500, 502, 503, 504]),
fullChainTimeoutMs: optional(number(), 60000),
perAttemptTimeoutMs: optional(number(), 30000),
}),
{ perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] },
{ fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504], fullChainTimeoutMs: 60000, perAttemptTimeoutMs: 30000 },
)
export const llmRouterConfigSchema = object({
asr: optional(object({
models: record(string(), asrModelSchema),
})),
defaults: llmRouterDefaultsSchema,
llm: object({
models: record(string(), llmModelSchema),
}),
tts: object({
models: record(string(), ttsModelSchema),
}),
asr: optional(object({
models: record(string(), asrModelSchema),
})),
defaults: llmRouterDefaultsSchema,
})
/**
@@ -237,24 +237,6 @@ export const llmRouterConfigSchema = object({
* - stored JSON shape
*/
export const configEntrySchemas = {
FLUX_PER_REQUEST: optional(number(), 5),
INITIAL_USER_FLUX: optional(number(), 0),
FLUX_PER_1K_TOKENS: optional(number(), 1),
FLUX_PER_1K_CHARS_TTS: number(),
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
// model id → (BCP-47 locale → recommended voice id). Outer key is either a
// router TTS model id (LLM_ROUTER_CONFIG.tts.models key) for REST or a
// streaming api_resource_id (e.g. `seed-tts-2.0`) for the streaming surface.
// The two key spaces do not overlap. Consumed by the client to preselect a
// voice matching UI locale per active model.
DEFAULT_TTS_VOICES: optional(record(string(), record(string(), string())), {}),
// Server-side alias resolution for `model: 'auto'` in /chat/completions and
// /audio/speech. The modelName written here must exist as a key in
// LLM_ROUTER_CONFIG.{llm,tts}.models — the router itself doesn't understand
@@ -265,9 +247,27 @@ export const configEntrySchemas = {
// type tight (`string` rather than `string | undefined`) for call sites.
DEFAULT_CHAT_MODEL: pipe(string(), nonEmpty('DEFAULT_CHAT_MODEL must not be empty')),
DEFAULT_TTS_MODEL: pipe(string(), nonEmpty('DEFAULT_TTS_MODEL must not be empty')),
// model id → (BCP-47 locale → recommended voice id). Outer key is either a
// router TTS model id (LLM_ROUTER_CONFIG.tts.models key) for REST or a
// streaming api_resource_id (e.g. `seed-tts-2.0`) for the streaming surface.
// The two key spaces do not overlap. Consumed by the client to preselect a
// voice matching UI locale per active model.
DEFAULT_TTS_VOICES: optional(record(string(), record(string(), string())), {}),
FLUX_PER_1K_CHARS_TTS: number(),
FLUX_PER_1K_TOKENS: optional(number(), 1),
FLUX_PER_REQUEST: optional(number(), 5),
INITIAL_USER_FLUX: optional(number(), 0),
// No default — the router throws CONFIG_NOT_SET when this entry is absent
// so deployment configuration must populate it before traffic flows.
LLM_ROUTER_CONFIG: optional(llmRouterConfigSchema),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
// Single unspeech deployment used for every TTS surface: REST audio/speech,
// REST voices catalog, ws audio/speech/stream. `streaming` is optional —
// operator may run REST-only without the ws upstream. `streaming.keys`
@@ -5,10 +5,10 @@ import { createConfigKVService } from './index'
function createMockStore() {
const store = new Map<string, string>()
return {
getRaw: vi.fn(async (key: string) => store.get(key) ?? null),
getFreshRaw: vi.fn(async (key: string) => store.get(key) ?? null),
invalidateCache: vi.fn(async () => {}),
_store: store,
getFreshRaw: vi.fn(async (key: string) => store.get(key) ?? null),
getRaw: vi.fn(async (key: string) => store.get(key) ?? null),
invalidateCache: vi.fn(async () => {}),
}
}
@@ -76,8 +76,8 @@ describe('configKVService', () => {
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
statusCode: 503,
})
})
@@ -87,8 +87,8 @@ describe('configKVService', () => {
await expect(service.getOptional('FLUX_PER_REQUEST'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
statusCode: 503,
})
})
@@ -98,8 +98,8 @@ describe('configKVService', () => {
await expect(service.getOrThrow('FLUX_PER_REQUEST'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_UNAVAILABLE',
statusCode: 503,
})
})
@@ -109,28 +109,28 @@ describe('configKVService', () => {
*/
it('llm router config should preserve official ASR model config', async () => {
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
llm: { models: {} },
tts: { models: {} },
asr: {
models: {
auto: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext: 'ciphertext' }],
adapterParams: {
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
},
keys: [{ ciphertext: 'ciphertext', id: 'aliyun-nls-asr-prod-1' }],
}],
},
},
},
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
fullChainTimeoutMs: 60000,
perAttemptTimeoutMs: 30000,
},
llm: { models: {} },
tts: { models: {} },
}))
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
@@ -148,93 +148,93 @@ describe('configKVService', () => {
it('llm router config should preserve explicit LLM and TTS provider groups', async () => {
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
defaults: {
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
fullChainTimeoutMs: 60000,
perAttemptTimeoutMs: 30000,
},
llm: {
models: {
'step-3.5-flash': {
upstreams: [
{
id: 'plan',
baseURL: 'https://api.stepfun.com/step_plan/v1',
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
},
{
id: 'paygo',
baseURL: 'https://api.stepfun.com/v1',
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
},
],
routing: {
groups: [
{
id: 'plan',
upstreamIds: ['plan'],
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
continueOn: { httpCodes: [402], onTimeout: false },
},
{
id: 'paygo',
upstreamIds: ['paygo'],
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
},
],
},
fallbackTriggers: {
httpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
onTimeout: true,
},
routing: {
groups: [
{
continueOn: { httpCodes: [402], onTimeout: false },
id: 'plan',
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
upstreamIds: ['plan'],
},
{
id: 'paygo',
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
upstreamIds: ['paygo'],
},
],
},
upstreams: [
{
baseURL: 'https://api.stepfun.com/step_plan/v1',
headerTemplate: 'Bearer {KEY}',
id: 'plan',
keys: [{ ciphertext: 'plan-ciphertext', id: 'plan-key' }],
},
{
baseURL: 'https://api.stepfun.com/v1',
headerTemplate: 'Bearer {KEY}',
id: 'paygo',
keys: [{ ciphertext: 'paygo-ciphertext', id: 'paygo-key' }],
},
],
},
},
},
tts: {
models: {
'stepfun/stepaudio-2.5-tts': {
provider: 'stepfun',
upstreams: [
{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
adapterParams: { endpointProfile: 'step-plan' },
maxConcurrency: 1,
},
{
id: 'paygo',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
adapterParams: { endpointProfile: 'default' },
},
],
routing: {
groups: [
{
id: 'plan',
upstreamIds: ['plan'],
strategy: 'least-inflight',
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
continueOn: { httpCodes: [402], onTimeout: false },
},
{
id: 'paygo',
upstreamIds: ['paygo'],
strategy: 'ordered',
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
},
],
},
fallbackTriggers: {
httpCodes: [401, 402, 429, 500, 502, 503, 504],
onTimeout: true,
},
provider: 'stepfun',
routing: {
groups: [
{
continueOn: { httpCodes: [402], onTimeout: false },
id: 'plan',
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
strategy: 'least-inflight',
upstreamIds: ['plan'],
},
{
id: 'paygo',
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
strategy: 'ordered',
upstreamIds: ['paygo'],
},
],
},
upstreams: [
{
adapterParams: { endpointProfile: 'step-plan' },
baseURL: 'https://api.stepfun.com',
id: 'plan',
keys: [{ ciphertext: 'plan-ciphertext', id: 'plan-key' }],
maxConcurrency: 1,
},
{
adapterParams: { endpointProfile: 'default' },
baseURL: 'https://api.stepfun.com',
id: 'paygo',
keys: [{ ciphertext: 'paygo-ciphertext', id: 'paygo-key' }],
},
],
},
},
},
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
}))
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
@@ -255,19 +255,19 @@ describe('configKVService', () => {
models: {
tts: {
provider: 'stepfun',
upstreams: [{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }],
}],
routing: {
groups: [{
id: 'plan',
upstreamIds: ['missing'],
strategy: 'ordered',
retryOn: { httpCodes: [402], onTimeout: false },
strategy: 'ordered',
upstreamIds: ['missing'],
}],
},
upstreams: [{
baseURL: 'https://api.stepfun.com',
id: 'plan',
keys: [{ ciphertext: 'ciphertext', id: 'plan-key' }],
}],
},
},
},
@@ -276,8 +276,8 @@ describe('configKVService', () => {
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
statusCode: 503,
})
})
@@ -288,19 +288,19 @@ describe('configKVService', () => {
models: {
tts: {
provider: 'stepfun',
upstreams: [{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }],
}],
routing: {
groups: [{
id: 'plan',
upstreamIds: ['plan'],
strategy: 'least-inflight',
retryOn: { httpCodes: [402], onTimeout: false },
strategy: 'least-inflight',
upstreamIds: ['plan'],
}],
},
upstreams: [{
baseURL: 'https://api.stepfun.com',
id: 'plan',
keys: [{ ciphertext: 'ciphertext', id: 'plan-key' }],
}],
},
},
},
@@ -309,8 +309,8 @@ describe('configKVService', () => {
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
statusCode: 503,
})
})
@@ -9,6 +9,63 @@ import { configEntrySchemas } from './definitions'
export * from './definitions'
export type ConfigKVService = ReturnType<typeof createConfigKVService>
/**
* Creates the API's typed, read-only ConfigKV boundary.
*
* PostgreSQL owns persisted values. Redis must be available for every store
* operation. This layer preserves validation, defaults, and API errors.
*/
export function createConfigKVService(store: ConfigKVStore) {
async function loadRaw(key: ConfigKey, fresh = false): Promise<null | string> {
try {
return fresh ? await store.getFreshRaw(key) : await store.getRaw(key)
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is unavailable',
'CONFIG_UNAVAILABLE',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config store error',
},
)
}
}
return {
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value as Exclude<ConfigDefinitions[K], undefined>
},
async invalidateCache<K extends ConfigKey>(key: K): Promise<void> {
await store.invalidateCache(key)
},
async refresh<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key, true)
const value = resolveWithDefault(key, raw)
return value ?? null
},
}
}
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
try {
return parse(configEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
@@ -26,7 +83,7 @@ function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions
}
/** Resolves a config value and applies the Valibot default when the row is missing. */
function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): ConfigDefinitions[K] | undefined {
function resolveWithDefault<K extends ConfigKey>(key: K, raw: null | string): ConfigDefinitions[K] | undefined {
if (raw !== null)
return parseValue(key, raw)
@@ -37,60 +94,3 @@ function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): Co
return undefined
}
}
/**
* Creates the API's typed, read-only ConfigKV boundary.
*
* PostgreSQL owns persisted values. Redis must be available for every store
* operation. This layer preserves validation, defaults, and API errors.
*/
export function createConfigKVService(store: ConfigKVStore) {
async function loadRaw(key: ConfigKey, fresh = false): Promise<string | null> {
try {
return fresh ? await store.getFreshRaw(key) : await store.getRaw(key)
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is unavailable',
'CONFIG_UNAVAILABLE',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config store error',
},
)
}
}
return {
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value as Exclude<ConfigDefinitions[K], undefined>
},
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
async refresh<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key, true)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async invalidateCache<K extends ConfigKey>(key: K): Promise<void> {
await store.invalidateCache(key)
},
}
}
export type ConfigKVService = ReturnType<typeof createConfigKVService>
@@ -8,6 +8,8 @@ import { eq } from 'drizzle-orm'
import { configKV } from '../../../schemas/config-kv'
import { CONFIG_KV_CACHE_TTL_SECONDS, configKVCacheKey } from './contracts'
export type ConfigKVStore = ReturnType<typeof createConfigKVStore>
export interface ConfigKVStoreOptions {
/**
* Maximum lifetime of one derived Redis entry.
@@ -29,7 +31,7 @@ export function createConfigKVStore<TSchema extends Record<string, unknown>>(
) {
const cacheTtlSeconds = options.cacheTtlSeconds ?? CONFIG_KV_CACHE_TTL_SECONDS
async function readDatabase(key: ConfigKey): Promise<string | null> {
async function readDatabase(key: ConfigKey): Promise<null | string> {
const rows = await db
.select({ value: configKV.value })
.from(configKV)
@@ -47,18 +49,7 @@ export function createConfigKVStore<TSchema extends Record<string, unknown>>(
}
return {
async getRaw(key: ConfigKey): Promise<string | null> {
const cached = await redis.get(configKVCacheKey(key))
if (cached !== null)
return cached
const value = await readDatabase(key)
if (value !== null)
await cacheValue(key, value)
return value
},
async getFreshRaw(key: ConfigKey): Promise<string | null> {
async getFreshRaw(key: ConfigKey): Promise<null | string> {
const value = await readDatabase(key)
if (value !== null) {
await cacheValue(key, value)
@@ -69,10 +60,19 @@ export function createConfigKVStore<TSchema extends Record<string, unknown>>(
return value
},
async getRaw(key: ConfigKey): Promise<null | string> {
const cached = await redis.get(configKVCacheKey(key))
if (cached !== null)
return cached
const value = await readDatabase(key)
if (value !== null)
await cacheValue(key, value)
return value
},
async invalidateCache(key: ConfigKey): Promise<void> {
await deleteCachedValue(key)
},
}
}
export type ConfigKVStore = ReturnType<typeof createConfigKVStore>
@@ -20,13 +20,13 @@ export interface PosthogCaptureInput {
* an interface so tests inject a fake instead of mocking the SDK.
*/
export interface PosthogSink {
capture: (input: PosthogCaptureInput) => Promise<void>
/**
* Queue a high-volume analytics event without waiting for a network
* roundtrip. Use on request hot paths where occasional process-exit loss is
* preferable to user-visible latency.
*/
captureQueued?: (input: PosthogCaptureInput) => void
capture: (input: PosthogCaptureInput) => Promise<void>
/** Flush and close the underlying client. Call on server shutdown. */
shutdown: () => Promise<void>
}
@@ -42,24 +42,10 @@ export interface PosthogSink {
* Capture failures are logged and swallowed. Analytics forwarding never
* fails the Stripe webhook or auth flow that produced the business fact.
*/
export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink {
export function createPosthogSink(options: { host: string, projectKey: string }): PosthogSink {
const client = new PostHog(options.projectKey, { host: options.host })
return {
captureQueued(input: PosthogCaptureInput): void {
try {
client.capture({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
...(input.uuid && { uuid: input.uuid }),
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to enqueue product event to PostHog')
}
},
async capture(input: PosthogCaptureInput): Promise<void> {
try {
await client.captureImmediate({
@@ -74,6 +60,20 @@ export function createPosthogSink(options: { projectKey: string, host: string })
}
},
captureQueued(input: PosthogCaptureInput): void {
try {
client.capture({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
...(input.uuid && { uuid: input.uuid }),
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to enqueue product event to PostHog')
}
},
async shutdown(): Promise<void> {
await client.shutdown()
},
@@ -25,6 +25,25 @@ import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
* present, otherwise inferred from the requested format.
*/
export const azureAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// Azure has no static catalog. Voices live at Microsoft's `voices/list`
// REST endpoint, which we reach via the unspeech `microsoft` backend
// because unspeech already maps the proprietary response shape to
// `types.Voice` (full formats table, masterpiece preview URLs, locale
// metadata). Calling unspeech also keeps a single integration point for
// every other provider that could grow this way later.
if (!ctx.region)
throw createServiceUnavailableError('azure tts region not configured', 'AZURE_TTS_NOT_CONFIGURED')
if (!ctx.keyPlaintext)
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'azure',
query: `provider=microsoft&region=${encodeURIComponent(ctx.region)}`,
})
},
id: 'azure',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -52,32 +71,13 @@ export const azureAdapter: TtsAdapter = {
return sendSpeechViaUnSpeech({
ctx,
model: 'microsoft/v1',
input: ssml,
voice,
responseFormat: outputFormat,
extraBody: { region, disable_ssml: true },
extraBody: { disable_ssml: true, region },
fallbackContentType: inferMicrosoftContentType(outputFormat),
input: ssml,
model: 'microsoft/v1',
providerLabel: 'azure',
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// Azure has no static catalog. Voices live at Microsoft's `voices/list`
// REST endpoint, which we reach via the unspeech `microsoft` backend
// because unspeech already maps the proprietary response shape to
// `types.Voice` (full formats table, masterpiece preview URLs, locale
// metadata). Calling unspeech also keeps a single integration point for
// every other provider that could grow this way later.
if (!ctx.region)
throw createServiceUnavailableError('azure tts region not configured', 'AZURE_TTS_NOT_CONFIGURED')
if (!ctx.keyPlaintext)
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
return listVoicesViaUnSpeech({
ctx,
query: `provider=microsoft&region=${encodeURIComponent(ctx.region)}`,
providerLabel: 'azure',
responseFormat: outputFormat,
voice,
})
},
}
@@ -120,13 +120,13 @@ function buildAzureSsml(
return `<speak version='1.0' xml:lang='en-US'><voice name='${voice}'>${inner}</voice></speak>`
}
function speedToProsodyRate(speed: number | undefined): string {
if (speed == null || speed === 1)
return ''
const delta = Math.round((speed - 1) * 100)
if (delta === 0)
return ''
return delta > 0 ? `+${delta}%` : `${delta}%`
function escapeForSsml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
}
function percentToProsodyValue(value: number | undefined): string {
@@ -139,11 +139,11 @@ function percentToProsodyValue(value: number | undefined): string {
return '0%'
}
function escapeForSsml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
function speedToProsodyRate(speed: number | undefined): string {
if (speed == null || speed === 1)
return ''
const delta = Math.round((speed - 1) * 100)
if (delta === 0)
return ''
return delta > 0 ? `+${delta}%` : `${delta}%`
}
@@ -9,8 +9,8 @@ const SPEECH_URL = `${UNSPEECH}/v1/audio/speech`
function binaryResponse(bytes: Uint8Array, status = 200) {
return new Response(bytes, {
status,
headers: { 'content-type': 'audio/mpeg' },
status,
})
}
@@ -20,13 +20,13 @@ describe('dashscopeCosyvoiceAdapter', () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(audioBytes))
const result = await dashscopeCosyvoiceAdapter.send(
{ text: 'hi there', voice: 'longxiaochun_v2', responseFormat: 'mp3' },
{ responseFormat: 'mp3', text: 'hi there', voice: 'longxiaochun_v2' },
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: { model: 'cosyvoice-v2' },
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
unspeechBaseURL: UNSPEECH,
},
)
@@ -37,10 +37,10 @@ describe('dashscopeCosyvoiceAdapter', () => {
const body = JSON.parse(init.body as string)
expect(body).toEqual({
model: 'alibaba/cosyvoice-v2',
input: 'hi there',
voice: 'longxiaochun_v2',
model: 'alibaba/cosyvoice-v2',
response_format: 'mp3',
voice: 'longxiaochun_v2',
})
const headers = init.headers as Record<string, string>
@@ -59,14 +59,14 @@ describe('dashscopeCosyvoiceAdapter', () => {
dashscopeCosyvoiceAdapter.send(
{ text: 'hi', voice: 'longxiaochun_v2' },
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: {},
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
unspeechBaseURL: UNSPEECH,
},
),
).rejects.toMatchObject({ status: 401, message: expect.stringContaining('401') })
).rejects.toMatchObject({ message: expect.stringContaining('401'), status: 401 })
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
@@ -77,11 +77,11 @@ describe('dashscopeCosyvoiceAdapter', () => {
await expect(dashscopeCosyvoiceAdapter.send(
{ text: 'hi' },
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: {},
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
unspeechBaseURL: UNSPEECH,
},
)).rejects.toMatchObject({ statusCode: 400 })
@@ -97,18 +97,18 @@ describe('dashscopeCosyvoiceAdapter', () => {
await expect(dashscopeCosyvoiceAdapter.send(
{
text: 'hi',
voice: 'longxiaochun_v2',
extraOptions: {
volume: 5,
},
text: 'hi',
voice: 'longxiaochun_v2',
},
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: {},
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
unspeechBaseURL: UNSPEECH,
},
)).rejects.toMatchObject({ statusCode: 400 })
@@ -125,15 +125,15 @@ describe('dashscopeCosyvoiceAdapter', () => {
}), { status: 200 })) as unknown as typeof fetch
const catalog = await dashscopeCosyvoiceAdapter.getVoiceCatalog({
adapterParams: { model: 'cosyvoice-v2' },
unspeechBaseURL: UNSPEECH,
fetchImpl,
unspeechBaseURL: UNSPEECH,
})
expect(catalog).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
expect(fetchImpl).toHaveBeenCalledWith(
`${UNSPEECH}/api/voices?provider=alibaba&model=cosyvoice-v2`,
expect.objectContaining({
method: 'GET',
headers: { Accept: 'application/json' },
method: 'GET',
}),
)
})
@@ -47,6 +47,21 @@ const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v2'
* contract as the Azure / Volcengine paths.
*/
export const dashscopeCosyvoiceAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// unspeech's alibaba backend embeds the catalog at build time
// (unspeech/pkg/backend/alibaba/voices.go `//go:embed voices.json`),
// so this call is in-memory on unspeech's side and only crosses a TCP
// hop. No upstream credential is required.
const params = new URLSearchParams({ provider: 'alibaba' })
if (typeof ctx.adapterParams.model === 'string')
params.set('model', ctx.adapterParams.model)
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'cosyvoice',
query: params.toString(),
})
},
id: 'dashscope-cosyvoice',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -66,27 +81,12 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
return sendSpeechViaUnSpeech({
ctx,
model: `alibaba/${model}`,
input: input.text,
voice,
responseFormat: format,
fallbackContentType: audioMimeFromFormat(format),
input: input.text,
model: `alibaba/${model}`,
providerLabel: 'dashscope-cosyvoice',
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// unspeech's alibaba backend embeds the catalog at build time
// (unspeech/pkg/backend/alibaba/voices.go `//go:embed voices.json`),
// so this call is in-memory on unspeech's side and only crosses a TCP
// hop. No upstream credential is required.
const params = new URLSearchParams({ provider: 'alibaba' })
if (typeof ctx.adapterParams.model === 'string')
params.set('model', ctx.adapterParams.model)
return listVoicesViaUnSpeech({
ctx,
query: params.toString(),
providerLabel: 'cosyvoice',
responseFormat: format,
voice,
})
},
}
@@ -38,8 +38,8 @@ describe('getAdapter', () => {
expect(apiErr.errorCode).toBe('BAD_REQUEST')
expect(apiErr.details).toEqual(
expect.objectContaining({
id: 'unknown-provider',
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']),
id: 'unknown-provider',
}),
)
}
@@ -56,8 +56,8 @@ describe('getAdapter', () => {
const voices = await adapter.getVoiceCatalog({
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
})
expect(voices).toEqual([{ id: 'v1', name: 'v1' }])
expect(fetchImpl).toHaveBeenCalledTimes(1)
@@ -74,8 +74,8 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
const voices = await adapter.getVoiceCatalog({
adapterParams: { model: 'cosyvoice-v2' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
})
expect(voices).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
@@ -90,8 +90,8 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
const fetchImpl = vi.fn(async () => new Response('boom', { status: 502 })) as unknown as typeof fetch
await expect(adapter.getVoiceCatalog({
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
})).rejects.toMatchObject({ statusCode: 502 })
})
})
@@ -105,8 +105,8 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
const voices = await adapter.getVoiceCatalog({
adapterParams: { model: 'seed-tts-2.0' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
})
expect(voices).toEqual([{ id: 'zh_female_x', name: 'X' }])
@@ -119,8 +119,8 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ voices: [] }), { status: 200 })) as unknown as typeof fetch
await adapter.getVoiceCatalog({
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
})
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine')
@@ -132,14 +132,14 @@ describe('azureAdapter.getVoiceCatalog', () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch
}), { headers: { 'Content-Type': 'application/json' }, status: 200 })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: { region: 'eastasia' },
fetchImpl,
keyPlaintext: Buffer.from('subscription-key-XYZ', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local:5933',
fetchImpl,
})
expect(voices).toEqual([{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }])
@@ -153,32 +153,32 @@ describe('azureAdapter.getVoiceCatalog', () => {
it('throws 503 AZURE_TTS_NOT_CONFIGURED when region is missing', async () => {
const adapter = getAdapter('azure')
await expect(adapter.getVoiceCatalog({
keyPlaintext: Buffer.from('k', 'utf8'),
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
fetchImpl: vi.fn() as unknown as typeof fetch,
})).rejects.toMatchObject({ statusCode: 503, errorCode: 'AZURE_TTS_NOT_CONFIGURED' })
keyPlaintext: Buffer.from('k', 'utf8'),
unspeechBaseURL: 'http://unspeech.local',
})).rejects.toMatchObject({ errorCode: 'AZURE_TTS_NOT_CONFIGURED', statusCode: 503 })
})
it('throws 503 AZURE_TTS_NOT_CONFIGURED when keyPlaintext is missing', async () => {
const adapter = getAdapter('azure')
await expect(adapter.getVoiceCatalog({
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl: vi.fn() as unknown as typeof fetch,
})).rejects.toMatchObject({ statusCode: 503, errorCode: 'AZURE_TTS_NOT_CONFIGURED' })
region: 'eastasia',
unspeechBaseURL: 'http://unspeech.local',
})).rejects.toMatchObject({ errorCode: 'AZURE_TTS_NOT_CONFIGURED', statusCode: 503 })
})
it('throws 502 BAD_GATEWAY when unspeech responds non-2xx', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response('upstream down', { status: 502 })) as unknown as typeof fetch
await expect(adapter.getVoiceCatalog({
adapterParams: { region: 'eastasia' },
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
@@ -188,11 +188,11 @@ describe('azureAdapter.getVoiceCatalog', () => {
throw new Error('ECONNREFUSED')
}) as unknown as typeof fetch
await expect(adapter.getVoiceCatalog({
adapterParams: { region: 'eastasia' },
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
})
@@ -201,26 +201,26 @@ describe('azureAdapter.send', () => {
it('posts SSML to unspeech /v1/audio/speech with model=microsoft/v1 + region extra_body', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
status: 200,
})) as unknown as typeof fetch
await adapter.send(
{
text: 'hi there',
voice: 'en-US-AvaMultilingualNeural',
speed: 1.2,
extraOptions: {
pitch: 20,
volume: 5,
},
speed: 1.2,
text: 'hi there',
voice: 'en-US-AvaMultilingualNeural',
},
{
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)
@@ -242,18 +242,18 @@ describe('azureAdapter.send', () => {
it('uses adapterParams.defaultVoice when the request omits voice', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
status: 200,
})) as unknown as typeof fetch
await adapter.send(
{ text: 'hi there' },
{
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
adapterParams: { defaultVoice: 'en-US-AvaMultilingualNeural', region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { region: 'eastasia', defaultVoice: 'en-US-AvaMultilingualNeural' },
fetchImpl,
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)
@@ -269,11 +269,11 @@ describe('azureAdapter.send', () => {
await expect(adapter.send(
{ text: 'hi' },
{
keyPlaintext: Buffer.from('k', 'utf8'),
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)).rejects.toMatchObject({ statusCode: 400 })
@@ -287,11 +287,11 @@ describe('azureAdapter.send', () => {
await expect(adapter.send(
{ text: 'hi', voice: 'en-US-AvaMultilingualNeural' },
{
keyPlaintext: Buffer.from('k', 'utf8'),
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)).rejects.toMatchObject({ status: 401 })
})
@@ -302,24 +302,24 @@ describe('stepfunAdapter', () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{
compatible_models: ['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'],
id: 'cixingnansheng',
name: '磁性男声',
compatible_models: ['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'],
}],
}), { status: 200 })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
})
expect(voices).toEqual(
expect.arrayContaining([
expect.objectContaining({
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
id: 'cixingnansheng',
name: '磁性男声',
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
}),
]),
)
@@ -331,28 +331,28 @@ describe('stepfunAdapter', () => {
it('posts OpenAI-compatible speech JSON to unspeech with model=stepfun/<model>', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
status: 200,
})) as unknown as typeof fetch
const result = await adapter.send(
{
text: '(轻声)你好',
voice: 'cixingnansheng',
responseFormat: 'mp3',
speed: 1.2,
extraOptions: {
instruction: '温柔、克制、有一点笑意',
volume: 1.1,
sampleRate: 24000,
volume: 1.1,
},
responseFormat: 'mp3',
speed: 1.2,
text: '(轻声)你好',
voice: 'cixingnansheng',
},
{
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)
@@ -365,16 +365,16 @@ describe('stepfunAdapter', () => {
})
const body = JSON.parse(init.body as string) as Record<string, unknown>
expect(body).toEqual({
model: 'stepfun/stepaudio-2.5-tts',
extra_body: {
instruction: '温柔、克制、有一点笑意',
sample_rate: 24000,
volume: 1.1,
},
input: '(轻声)你好',
voice: 'cixingnansheng',
model: 'stepfun/stepaudio-2.5-tts',
response_format: 'mp3',
speed: 1.2,
extra_body: {
volume: 1.1,
sample_rate: 24000,
instruction: '温柔、克制、有一点笑意',
},
voice: 'cixingnansheng',
})
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
@@ -383,29 +383,29 @@ describe('stepfunAdapter', () => {
it('passes the Step Plan endpoint profile to unspeech', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
status: 200,
})) as unknown as typeof fetch
const result = await adapter.send(
{
text: '你好',
voice: 'cixingnansheng',
responseFormat: 'mp3',
speed: 1.1,
extraOptions: {
instruction: '温柔、克制',
},
responseFormat: 'mp3',
speed: 1.1,
text: '你好',
voice: 'cixingnansheng',
},
{
keyPlaintext: Buffer.from('step-plan-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: {
endpointProfile: 'step-plan',
model: 'stepaudio-2.5-tts',
},
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-plan-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)
@@ -417,15 +417,15 @@ describe('stepfunAdapter', () => {
'Content-Type': 'application/json',
})
expect(JSON.parse(init.body as string)).toEqual({
model: 'stepfun/stepaudio-2.5-tts',
input: '你好',
voice: 'cixingnansheng',
response_format: 'mp3',
speed: 1.1,
extra_body: {
endpoint_profile: 'step-plan',
instruction: '温柔、克制',
},
input: '你好',
model: 'stepfun/stepaudio-2.5-tts',
response_format: 'mp3',
speed: 1.1,
voice: 'cixingnansheng',
})
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
@@ -434,23 +434,23 @@ describe('stepfunAdapter', () => {
it('passes voice_label through to unspeech for provider-level validation', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
status: 200,
})) as unknown as typeof fetch
await adapter.send(
{
text: 'hi',
extraOptions: {
voice_label: { emotion: '高兴' },
},
text: 'hi',
},
{
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local',
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local',
},
)
@@ -466,11 +466,11 @@ describe('stepfunAdapter', () => {
await expect(adapter.send(
{ text: 'hi', voice: 'cixingnansheng' },
{
keyPlaintext: Buffer.from('bad-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local',
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('bad-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local',
},
)).rejects.toMatchObject({ status: 401 })
})
@@ -480,19 +480,19 @@ describe('stepfunAdapter', () => {
const abortController = new AbortController()
const abortError = new Error('attempt-timeout')
abortController.abort(abortError)
const fetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const fetchImpl = vi.fn(async (_input: Request | string | URL, init?: RequestInit) => {
throw init?.signal?.reason ?? new Error('aborted')
}) as unknown as typeof fetch
await expect(adapter.send(
{ text: 'hi', voice: 'cixingnansheng' },
{
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local',
adapterParams: { model: 'stepaudio-2.5-tts' },
fetchImpl,
abortSignal: abortController.signal,
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local',
},
)).rejects.toBe(abortError)
})
@@ -502,18 +502,18 @@ describe('volcengineAdapter.send', () => {
it('posts to unspeech with model=volcengine/<api_resource_id> and app/cluster in extra_body', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49, 0x44, 0x33]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
status: 200,
})) as unknown as typeof fetch
const result = await adapter.send(
{ text: 'hi', voice: 'BV001_streaming', responseFormat: 'mp3', speed: 1.0 },
{ responseFormat: 'mp3', speed: 1.0, text: 'hi', voice: 'BV001_streaming' },
{
keyPlaintext: Buffer.from('volc-token', 'utf8'),
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { appid: 'APP-123', cluster: 'volcano_tts', model: 'seed-tts-2.0' },
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
fetchImpl,
keyPlaintext: Buffer.from('volc-token', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)
@@ -546,18 +546,18 @@ describe('volcengineAdapter.send', () => {
await expect(adapter.send(
{
text: 'hi',
voice: 'BV001_streaming',
extraOptions: {
pitch: 20,
},
text: 'hi',
voice: 'BV001_streaming',
},
{
keyPlaintext: Buffer.from('volc-token', 'utf8'),
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { appid: 'APP-123' },
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
fetchImpl,
keyPlaintext: Buffer.from('volc-token', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)).rejects.toMatchObject({ statusCode: 400 })
@@ -570,11 +570,11 @@ describe('volcengineAdapter.send', () => {
await expect(adapter.send(
{ text: 'hi' },
{
keyPlaintext: Buffer.from('k', 'utf8'),
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: {},
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)).rejects.toMatchObject({ statusCode: 500 })
})
@@ -36,7 +36,7 @@ export function getAdapter(id: string): TtsAdapter {
throw createBadRequestError(
`unknown_tts_provider: ${id}`,
'BAD_REQUEST',
{ id, available: Object.keys(ADAPTERS) },
{ available: Object.keys(ADAPTERS), id },
)
}
@@ -31,6 +31,14 @@ const STEPFUN_DEFAULT_VOICE = 'cixingnansheng'
* - {@link TtsResult} with the upstream audio body and content type.
*/
export const stepfunAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'stepfun',
query: 'provider=stepfun',
})
},
id: 'stepfun',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -47,22 +55,14 @@ export const stepfunAdapter: TtsAdapter = {
return sendSpeechViaUnSpeech({
ctx,
model: `stepfun/${model}`,
input: input.text,
voice,
speed: input.speed,
responseFormat,
extraBody,
fallbackContentType: audioMimeFromFormat(responseFormat),
input: input.text,
model: `stepfun/${model}`,
providerLabel: 'stepfun',
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
return listVoicesViaUnSpeech({
ctx,
query: 'provider=stepfun',
providerLabel: 'stepfun',
responseFormat,
speed: input.speed,
voice,
})
},
}
+107 -107
View File
@@ -2,109 +2,6 @@ import type { Buffer } from 'node:buffer'
import type { Voice } from 'unspeech'
/**
* Inbound TTS request shape passed to every adapter.
*
* Adapters translate this provider-neutral payload into the
* provider's native protocol body (Azure SSML, DashScope JSON,
* Volcengine JSON, etc.).
*/
export interface TtsInput {
/** Caller-supplied speech text (raw text or SSML when {@link extraOptions} signals so). */
text: string
/** Provider voice id (e.g. `en-US-AvaMultilingualNeural`, `longxiaochun`, `BV001_streaming`). */
voice?: string
/**
* Speech rate multiplier. `1.0` = native rate, `1.2` = 20% faster, `0.8` = 20% slower.
*
* @default 1
*/
speed?: number
/** Provider format key (e.g. `mp3`, `wav`, Azure-specific `audio-24khz-48kbitrate-mono-mp3`). */
responseFormat?: string
/**
* Adapter-specific escape hatch for niche flags that aren't worth promoting
* to the canonical shape (e.g. Azure's `disableSsml`, future per-call quirks).
*/
extraOptions?: Record<string, unknown>
}
/**
* Per-call context carrying the resolved key, upstream wiring, and abort
* plumbing. The router builds this before delegating to {@link TtsAdapter.send}.
*
* The plaintext key is held in a Node Buffer so callers can zero/scrub it on
* exit; adapters MUST NOT log or persist it.
*/
export interface TtsAdapterContext {
/** Decrypted upstream credential. Plain text — keep in-memory only. */
keyPlaintext: Buffer
/**
* Per-upstream baseURL from `LLM_ROUTER_CONFIG.tts.upstreams[i].baseURL`.
*
* Adapters forward through unspeech and may use this as provider metadata.
* Provider endpoint selection belongs to unspeech, not this URL.
*/
baseURL: string
/** unspeech REST base URL (no trailing slash). */
unspeechBaseURL: string
/** Free-form adapter-specific params from `tts.upstreams[i].adapterParams` (e.g. Volcengine `appid` / `cluster`). */
adapterParams: Record<string, unknown>
/** Fetch implementation. Tests inject a `vi.fn()`; production passes `globalThis.fetch`. */
fetchImpl: typeof fetch
/** Caller-side abort signal — propagated to the upstream fetch. */
abortSignal?: AbortSignal
}
/**
* Result of a successful upstream call.
*
* `body` is either a fully-buffered `ArrayBuffer` (current v1 behavior Azure
* REST + DashScope JSON + Volcengine JSON are all one-shot) or a streaming
* body for future streaming adapters.
*/
export interface TtsResult {
/** MIME type to forward to the caller (e.g. `audio/mpeg`, `audio/wav`). */
contentType: string
/** Audio payload (buffered or streamed). */
body: ArrayBuffer | ReadableStream<Uint8Array>
}
/**
* Stable provider identifier for the v1 adapter registry.
*
* Adding a new adapter means adding a new id here AND registering it in
* `./index.ts` the union is intentionally tight so unknown ids fail at the
* type level (router config validation handles runtime).
*/
export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'stepfun' | 'volcengine'
/**
* Per-call context for {@link TtsAdapter.getVoiceCatalog}.
*
* `keyPlaintext` and `region` are mandatory for live providers (Azure) that
* proxy through unspeech and call the upstream provider with a subscription
* key; the router decrypts the envelope key and forwards `adapterParams.region`
* verbatim. Unspeech-backed static catalogs ignore both fields.
*
* `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the router.
* Passing it through the context keeps adapters free of configKV coupling.
*/
export interface TtsVoiceCatalogContext {
/** Decrypted upstream credential (live providers only). */
keyPlaintext?: Buffer
/** Provider region (live providers only). */
region?: string
/** Free-form adapter-specific params (mirrors `tts.upstreams[i].adapterParams`). */
adapterParams: Record<string, unknown>
/** unspeech REST base URL, no trailing slash. */
unspeechBaseURL: string
/** Fetch implementation. Tests inject `vi.fn()`; production passes `globalThis.fetch`. */
fetchImpl: typeof fetch
/** Caller-side abort signal — propagated to the upstream fetch. */
abortSignal?: AbortSignal
}
/**
* Pure protocol translator between OpenAI-shaped `/v1/audio/speech` requests
* and one upstream TTS provider.
@@ -125,10 +22,6 @@ export interface TtsVoiceCatalogContext {
* NOT swallow upstream failures.
*/
export interface TtsAdapter {
/** Stable id used by the registry and config (`tts.upstreams[i].adapter`). */
id: TtsAdapterId
/** Dispatches one TTS request and resolves with the audio payload. */
send: (input: TtsInput, ctx: TtsAdapterContext) => Promise<TtsResult>
/**
* Returns the voice catalog for the provider.
*
@@ -137,4 +30,111 @@ export interface TtsAdapter {
* by unspeech. Adapters MUST throw on upstream failure no empty fallback.
*/
getVoiceCatalog: (ctx: TtsVoiceCatalogContext) => Promise<Voice[]>
/** Stable id used by the registry and config (`tts.upstreams[i].adapter`). */
id: TtsAdapterId
/** Dispatches one TTS request and resolves with the audio payload. */
send: (input: TtsInput, ctx: TtsAdapterContext) => Promise<TtsResult>
}
/**
* Per-call context carrying the resolved key, upstream wiring, and abort
* plumbing. The router builds this before delegating to {@link TtsAdapter.send}.
*
* The plaintext key is held in a Node Buffer so callers can zero/scrub it on
* exit; adapters MUST NOT log or persist it.
*/
export interface TtsAdapterContext {
/** Caller-side abort signal — propagated to the upstream fetch. */
abortSignal?: AbortSignal
/** Free-form adapter-specific params from `tts.upstreams[i].adapterParams` (e.g. Volcengine `appid` / `cluster`). */
adapterParams: Record<string, unknown>
/**
* Per-upstream baseURL from `LLM_ROUTER_CONFIG.tts.upstreams[i].baseURL`.
*
* Adapters forward through unspeech and may use this as provider metadata.
* Provider endpoint selection belongs to unspeech, not this URL.
*/
baseURL: string
/** Fetch implementation. Tests inject a `vi.fn()`; production passes `globalThis.fetch`. */
fetchImpl: typeof fetch
/** Decrypted upstream credential. Plain text — keep in-memory only. */
keyPlaintext: Buffer
/** unspeech REST base URL (no trailing slash). */
unspeechBaseURL: string
}
/**
* Stable provider identifier for the v1 adapter registry.
*
* Adding a new adapter means adding a new id here AND registering it in
* `./index.ts` the union is intentionally tight so unknown ids fail at the
* type level (router config validation handles runtime).
*/
export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'stepfun' | 'volcengine'
/**
* Inbound TTS request shape passed to every adapter.
*
* Adapters translate this provider-neutral payload into the
* provider's native protocol body (Azure SSML, DashScope JSON,
* Volcengine JSON, etc.).
*/
export interface TtsInput {
/**
* Adapter-specific escape hatch for niche flags that aren't worth promoting
* to the canonical shape (e.g. Azure's `disableSsml`, future per-call quirks).
*/
extraOptions?: Record<string, unknown>
/** Provider format key (e.g. `mp3`, `wav`, Azure-specific `audio-24khz-48kbitrate-mono-mp3`). */
responseFormat?: string
/**
* Speech rate multiplier. `1.0` = native rate, `1.2` = 20% faster, `0.8` = 20% slower.
*
* @default 1
*/
speed?: number
/** Caller-supplied speech text (raw text or SSML when {@link extraOptions} signals so). */
text: string
/** Provider voice id (e.g. `en-US-AvaMultilingualNeural`, `longxiaochun`, `BV001_streaming`). */
voice?: string
}
/**
* Result of a successful upstream call.
*
* `body` is either a fully-buffered `ArrayBuffer` (current v1 behavior Azure
* REST + DashScope JSON + Volcengine JSON are all one-shot) or a streaming
* body for future streaming adapters.
*/
export interface TtsResult {
/** Audio payload (buffered or streamed). */
body: ArrayBuffer | ReadableStream<Uint8Array>
/** MIME type to forward to the caller (e.g. `audio/mpeg`, `audio/wav`). */
contentType: string
}
/**
* Per-call context for {@link TtsAdapter.getVoiceCatalog}.
*
* `keyPlaintext` and `region` are mandatory for live providers (Azure) that
* proxy through unspeech and call the upstream provider with a subscription
* key; the router decrypts the envelope key and forwards `adapterParams.region`
* verbatim. Unspeech-backed static catalogs ignore both fields.
*
* `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the router.
* Passing it through the context keeps adapters free of configKV coupling.
*/
export interface TtsVoiceCatalogContext {
/** Caller-side abort signal — propagated to the upstream fetch. */
abortSignal?: AbortSignal
/** Free-form adapter-specific params (mirrors `tts.upstreams[i].adapterParams`). */
adapterParams: Record<string, unknown>
/** Fetch implementation. Tests inject `vi.fn()`; production passes `globalThis.fetch`. */
fetchImpl: typeof fetch
/** Decrypted upstream credential (live providers only). */
keyPlaintext?: Buffer
/** Provider region (live providers only). */
region?: string
/** unspeech REST base URL, no trailing slash. */
unspeechBaseURL: string
}
@@ -7,16 +7,60 @@ import { generateSpeechResponse, listVoices, UnSpeechAPIError } from 'unspeech'
import { createBadGatewayError, createInternalError } from '../../../utils/error'
interface ListVoicesOptions {
ctx: TtsVoiceCatalogContext
providerLabel: string
query: string
}
interface SendSpeechOptions {
ctx: TtsAdapterContext
model: string
input: string
voice: string
speed?: number
responseFormat: string
extraBody?: Record<string, unknown>
fallbackContentType: string
input: string
model: string
providerLabel: string
responseFormat: string
speed?: number
voice: string
}
/**
* Lists unspeech voices and maps SDK failures into AIRI gateway errors.
*
* Use when:
* - A TTS adapter needs unspeech's normalized `Voice[]` catalog.
*
* Expects:
* - `query` is an unspeech `/api/voices` query string such as
* `provider=microsoft&region=eastasia`.
*
* Returns:
* - The parsed voice catalog.
*/
export async function listVoicesViaUnSpeech(options: ListVoicesOptions): Promise<Voice[]> {
const { ctx, providerLabel, query } = options
try {
return await listVoices({
abortSignal: ctx.abortSignal,
apiKey: ctx.keyPlaintext?.toString('utf8'),
baseURL: ctx.unspeechBaseURL.replace(/\/+$/, ''),
fetch: ctx.fetchImpl,
headers: { Accept: 'application/json' },
query,
})
}
catch (error) {
if (error instanceof UnSpeechAPIError) {
throw createBadGatewayError(
`${providerLabel} voices upstream ${error.status}: ${error.responseBody.slice(0, 256)}`,
{ lastStatusCode: error.status },
)
}
throw createBadGatewayError(`${providerLabel} voices fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
/**
@@ -48,21 +92,21 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise
try {
const result = await generateSpeechResponse({
abortSignal: ctx.abortSignal,
apiKey: ctx.keyPlaintext.toString('utf8'),
baseURL: `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/v1/`,
extraBody,
fetch: ctx.fetchImpl,
input,
model,
responseFormat,
speed,
voice,
abortSignal: ctx.abortSignal,
extraBody,
})
return {
contentType: result.contentType ?? fallbackContentType,
body: result.body,
contentType: result.contentType ?? fallbackContentType,
}
}
catch (error) {
@@ -80,47 +124,3 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise
throw createInternalError(`${providerLabel} tts fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
interface ListVoicesOptions {
ctx: TtsVoiceCatalogContext
query: string
providerLabel: string
}
/**
* Lists unspeech voices and maps SDK failures into AIRI gateway errors.
*
* Use when:
* - A TTS adapter needs unspeech's normalized `Voice[]` catalog.
*
* Expects:
* - `query` is an unspeech `/api/voices` query string such as
* `provider=microsoft&region=eastasia`.
*
* Returns:
* - The parsed voice catalog.
*/
export async function listVoicesViaUnSpeech(options: ListVoicesOptions): Promise<Voice[]> {
const { ctx, providerLabel, query } = options
try {
return await listVoices({
apiKey: ctx.keyPlaintext?.toString('utf8'),
baseURL: ctx.unspeechBaseURL.replace(/\/+$/, ''),
fetch: ctx.fetchImpl,
query,
abortSignal: ctx.abortSignal,
headers: { Accept: 'application/json' },
})
}
catch (error) {
if (error instanceof UnSpeechAPIError) {
throw createBadGatewayError(
`${providerLabel} voices upstream ${error.status}: ${error.responseBody.slice(0, 256)}`,
{ lastStatusCode: error.status },
)
}
throw createBadGatewayError(`${providerLabel} voices fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
@@ -43,6 +43,26 @@ const DEFAULT_VOLCENGINE_CLUSTER = 'volcano_tts'
* decoded from the upstream JSON `data` base64 field.
*/
export const volcengineAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// unspeech embeds the Volcengine catalog at build time
// (unspeech/pkg/backend/volcengine/voices.go), filtered server-side to
// streaming-compatible voices. Passing `model=<api_resource_id>` narrows
// further by `compatible_models` — adapterParams.model is the operator-
// configured resource id (e.g. `seed-tts-2.0`).
const params = new URLSearchParams({ provider: 'volcengine' })
const apiResourceId = typeof ctx.adapterParams?.model === 'string'
? ctx.adapterParams.model
: undefined
if (apiResourceId)
params.set('model', apiResourceId)
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'volcengine',
query: params.toString(),
})
},
id: 'volcengine',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -77,38 +97,18 @@ export const volcengineAdapter: TtsAdapter = {
// - decodes the upstream base64 audio frame itself and returns binary.
return sendSpeechViaUnSpeech({
ctx,
model: apiResourceId ? `volcengine/${apiResourceId}` : 'volcengine',
input: input.text,
voice,
responseFormat: encoding,
extraBody: {
app: { appid, cluster },
user: { uid: 'airi-server' },
audio: { speed_ratio: speed },
request: { reqid: nanoid(), operation: 'query' },
request: { operation: 'query', reqid: nanoid() },
user: { uid: 'airi-server' },
},
fallbackContentType: audioMimeFromFormat(encoding),
input: input.text,
model: apiResourceId ? `volcengine/${apiResourceId}` : 'volcengine',
providerLabel: 'volcengine',
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// unspeech embeds the Volcengine catalog at build time
// (unspeech/pkg/backend/volcengine/voices.go), filtered server-side to
// streaming-compatible voices. Passing `model=<api_resource_id>` narrows
// further by `compatible_models` — adapterParams.model is the operator-
// configured resource id (e.g. `seed-tts-2.0`).
const params = new URLSearchParams({ provider: 'volcengine' })
const apiResourceId = typeof ctx.adapterParams?.model === 'string'
? ctx.adapterParams.model
: undefined
if (apiResourceId)
params.set('model', apiResourceId)
return listVoicesViaUnSpeech({
ctx,
query: params.toString(),
providerLabel: 'volcengine',
responseFormat: encoding,
voice,
})
},
}
@@ -16,11 +16,13 @@ import * as stripeSchema from '../../../schemas/stripe'
const logger = useLogger('billing-service')
export type BillingService = ReturnType<typeof createBillingService>
export function createBillingService(
db: Database,
redis: Redis,
_configKV: ConfigKVService,
metrics?: RevenueMetrics | null,
metrics?: null | RevenueMetrics,
) {
/**
* Update Redis cache after a successful DB transaction.
@@ -56,13 +58,13 @@ export function createBillingService(
* Private call domain-specific wrappers (e.g. consumeFluxForLLM) instead.
*/
async function debitFlux(input: {
userId: string
amount: number
requestId?: string
description?: string
source: string
metadata?: Record<string, unknown>
}): Promise<{ userId: string, flux: number, charged: number, requested: number }> {
requestId?: string
source: string
userId: string
}): Promise<{ charged: number, flux: number, requested: number, userId: string }> {
const result = await db.transaction(async (tx) => {
// Idempotency: a previous successful debit with the same requestId
// returns the prior post-balance and skips the second deduction.
@@ -87,11 +89,11 @@ export function createBillingService(
// current `amount`, so the caller doesn't double-fire unbilled
// counters on retries.
return {
userId: input.userId,
flux: existing.balanceAfter,
charged: existing.amount,
requested: existing.amount,
flux: existing.balanceAfter,
idempotent: true as const,
requested: existing.amount,
userId: input.userId,
}
}
}
@@ -128,12 +130,9 @@ export function createBillingService(
.where(eq(fluxSchema.userFlux.userId, input.userId))
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'debit',
amount: chargedAmount,
balanceBefore,
balanceAfter,
requestId: input.requestId,
balanceBefore,
description: input.description ?? input.source,
metadata: {
...input.metadata,
@@ -143,14 +142,17 @@ export function createBillingService(
unbilled: input.amount - chargedAmount,
}),
},
requestId: input.requestId,
type: 'debit',
userId: input.userId,
})
return {
userId: input.userId,
flux: balanceAfter,
charged: chargedAmount,
requested: input.amount,
flux: balanceAfter,
idempotent: false as const,
requested: input.amount,
userId: input.userId,
}
})
@@ -159,17 +161,17 @@ export function createBillingService(
}
logger.withFields({
userId: input.userId,
amount: input.amount,
charged: result.charged,
balance: result.flux,
charged: result.charged,
idempotent: result.idempotent,
userId: input.userId,
}).log('Debited flux')
return {
userId: result.userId,
flux: result.flux,
charged: result.charged,
flux: result.flux,
requested: result.requested,
userId: result.userId,
}
}
@@ -180,25 +182,25 @@ export function createBillingService(
* the existing transaction-history UI can render per-request token counts.
*/
async consumeFluxForLLM(input: {
userId: string
amount: number
requestId?: string
completionTokens?: number
description?: string
model?: string
promptTokens?: number
completionTokens?: number
}): Promise<{ userId: string, flux: number, charged: number, requested: number }> {
requestId?: string
userId: string
}): Promise<{ charged: number, flux: number, requested: number, userId: string }> {
return debitFlux({
userId: input.userId,
amount: input.amount,
requestId: input.requestId,
description: input.description,
source: 'llm.request',
metadata: {
...(input.model != null && { model: input.model }),
...(input.promptTokens != null && { promptTokens: input.promptTokens }),
...(input.completionTokens != null && { completionTokens: input.completionTokens }),
},
requestId: input.requestId,
source: 'llm.request',
userId: input.userId,
})
},
@@ -223,10 +225,10 @@ export function createBillingService(
* even though the user was already credited.
*/
async creditFlux(input: {
userId: string
amount: number
requestId?: string
auditMetadata?: Record<string, unknown>
description: string
requestId?: string
source: string
/**
* Ledger row `type`. Defaults to `'credit'` for backward compatibility
@@ -234,17 +236,17 @@ export function createBillingService(
* `'promo'` so reports / dashboards can distinguish them.
*/
type?: 'credit' | 'promo'
auditMetadata?: Record<string, unknown>
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string, idempotent: boolean }> {
userId: string
}): Promise<{ balanceAfter: number, balanceBefore: number, fluxTransactionId: string, idempotent: boolean }> {
const ledgerType = input.type ?? 'credit'
const txResult = await db.transaction(async (tx) => {
if (input.requestId != null) {
const [existing] = await tx
.select({
id: fluxTxSchema.fluxTransaction.id,
balanceBefore: fluxTxSchema.fluxTransaction.balanceBefore,
balanceAfter: fluxTxSchema.fluxTransaction.balanceAfter,
balanceBefore: fluxTxSchema.fluxTransaction.balanceBefore,
id: fluxTxSchema.fluxTransaction.id,
})
.from(fluxTxSchema.fluxTransaction)
.where(and(
@@ -255,8 +257,8 @@ export function createBillingService(
if (existing) {
return {
balanceBefore: existing.balanceBefore,
balanceAfter: existing.balanceAfter,
balanceBefore: existing.balanceBefore,
fluxTransactionId: existing.id,
idempotent: true,
}
@@ -264,7 +266,7 @@ export function createBillingService(
}
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.values({ flux: 0, userId: input.userId })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [row] = await tx
@@ -281,19 +283,19 @@ export function createBillingService(
.where(eq(fluxSchema.userFlux.userId, input.userId))
const [insertedTx] = await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: ledgerType,
amount: input.amount,
balanceBefore,
balanceAfter,
requestId: input.requestId,
balanceBefore,
description: input.description,
metadata: input.auditMetadata,
requestId: input.requestId,
type: ledgerType,
userId: input.userId,
}).returning({ id: fluxTxSchema.fluxTransaction.id })
return {
balanceBefore,
balanceAfter,
balanceBefore,
fluxTransactionId: insertedTx!.id,
idempotent: false,
}
@@ -301,9 +303,9 @@ export function createBillingService(
if (txResult.idempotent) {
logger.withFields({
userId: input.userId,
requestId: input.requestId,
fluxTransactionId: txResult.fluxTransactionId,
requestId: input.requestId,
userId: input.userId,
}).log('Credited flux (idempotent replay — no side effects emitted)')
return txResult
}
@@ -311,7 +313,159 @@ export function createBillingService(
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.amount, { source: input.source, type: ledgerType })
logger.withFields({ userId: input.userId, amount: input.amount, balance: txResult.balanceAfter }).log('Credited flux')
logger.withFields({ amount: input.amount, balance: txResult.balanceAfter, userId: input.userId }).log('Credited flux')
return txResult
},
/**
* Credit flux from a Stripe invoice payment (subscription).
* Idempotent: claims the invoice row by flipping `fluxCredited`
* from false to true; replays observe it already claimed and apply nothing.
*/
async creditFluxFromInvoice(input: {
amountPaid: number
currency: string
fluxAmount: number
stripeEventId: string
stripeInvoiceId: string
userId: string
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Invoice webhook idempotency follows the same object-level claim model
// as checkout sessions. We intentionally dedupe on the invoice record instead of
// only on Stripe `event.id`, because Stripe may emit multiple events that map to
// the same paid invoice while the balance must only be credited once.
const [claimed] = await tx.update(stripeSchema.stripeInvoice)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId),
eq(stripeSchema.stripeInvoice.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ flux: 0, userId: input.userId })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
amount: input.fluxAmount,
balanceAfter,
balanceBefore,
description,
metadata: {
source: 'invoice.paid',
stripeEventId: input.stripeEventId,
stripeInvoiceId: input.stripeInvoiceId,
},
requestId: input.stripeEventId,
type: 'credit',
userId: input.userId,
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.invoice', type: 'credit' })
}
return txResult
},
/**
* Credit flux from a Stripe checkout session (one-time payment).
* Idempotent: claims the checkout session row by flipping `fluxCredited`
* from false to true; replays of the same Stripe event observe the row
* already claimed and apply nothing.
*/
async creditFluxFromStripeCheckout(input: {
amountTotal: number
currency: null | string
fluxAmount: number
stripeEventId: string
stripeSessionId: string
userId: string
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Webhook idempotency is enforced at the business-object level, not by a
// dedicated processed-events table keyed on Stripe `event.id`. We claim the
// checkout session row exactly once via `fluxCredited = false -> true`, which
// covers both Stripe retries of the same event and distinct Event objects that
// still refer to the same checkout session.
const [claimed] = await tx.update(stripeSchema.stripeCheckoutSession)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId),
eq(stripeSchema.stripeCheckoutSession.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ flux: 0, userId: input.userId })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
amount: input.fluxAmount,
balanceAfter,
balanceBefore,
description,
metadata: {
source: 'stripe.checkout.completed',
stripeEventId: input.stripeEventId,
stripeSessionId: input.stripeSessionId,
},
requestId: input.stripeEventId,
type: 'credit',
userId: input.userId,
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.checkout', type: 'credit' })
}
return txResult
},
@@ -333,14 +487,14 @@ export function createBillingService(
* `metadata.direction` since a set can move the balance either way.
*/
async setFlux(input: {
userId: string
balance: number
description: string
issuedByUserId: string
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string }> {
userId: string
}): Promise<{ balanceAfter: number, balanceBefore: number, fluxTransactionId: string }> {
const txResult = await db.transaction(async (tx) => {
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.values({ flux: 0, userId: input.userId })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [row] = await tx
@@ -358,21 +512,21 @@ export function createBillingService(
.where(eq(fluxSchema.userFlux.userId, input.userId))
const [insertedTx] = await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'admin_set',
amount: Math.abs(delta),
balanceBefore,
balanceAfter,
balanceBefore,
description: input.description,
metadata: {
source: 'admin_set',
requestedBalance: input.balance,
direction: delta >= 0 ? 'credit' : 'debit',
issuedByUserId: input.issuedByUserId,
requestedBalance: input.balance,
source: 'admin_set',
},
type: 'admin_set',
userId: input.userId,
}).returning({ id: fluxTxSchema.fluxTransaction.id })
return { balanceBefore, balanceAfter, fluxTransactionId: insertedTx!.id }
return { balanceAfter, balanceBefore, fluxTransactionId: insertedTx!.id }
})
// NOTICE:
@@ -393,167 +547,13 @@ export function createBillingService(
}
logger.withFields({
userId: input.userId,
balanceBefore: txResult.balanceBefore,
balanceAfter: txResult.balanceAfter,
balanceBefore: txResult.balanceBefore,
issuedByUserId: input.issuedByUserId,
userId: input.userId,
}).log('Set flux balance')
return txResult
},
/**
* Credit flux from a Stripe checkout session (one-time payment).
* Idempotent: claims the checkout session row by flipping `fluxCredited`
* from false to true; replays of the same Stripe event observe the row
* already claimed and apply nothing.
*/
async creditFluxFromStripeCheckout(input: {
stripeEventId: string
userId: string
stripeSessionId: string
amountTotal: number
currency: string | null
fluxAmount: number
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Webhook idempotency is enforced at the business-object level, not by a
// dedicated processed-events table keyed on Stripe `event.id`. We claim the
// checkout session row exactly once via `fluxCredited = false -> true`, which
// covers both Stripe retries of the same event and distinct Event objects that
// still refer to the same checkout session.
const [claimed] = await tx.update(stripeSchema.stripeCheckoutSession)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId),
eq(stripeSchema.stripeCheckoutSession.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'credit',
amount: input.fluxAmount,
balanceBefore,
balanceAfter,
requestId: input.stripeEventId,
description,
metadata: {
stripeEventId: input.stripeEventId,
stripeSessionId: input.stripeSessionId,
source: 'stripe.checkout.completed',
},
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.checkout', type: 'credit' })
}
return txResult
},
/**
* Credit flux from a Stripe invoice payment (subscription).
* Idempotent: claims the invoice row by flipping `fluxCredited`
* from false to true; replays observe it already claimed and apply nothing.
*/
async creditFluxFromInvoice(input: {
stripeEventId: string
userId: string
stripeInvoiceId: string
amountPaid: number
currency: string
fluxAmount: number
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Invoice webhook idempotency follows the same object-level claim model
// as checkout sessions. We intentionally dedupe on the invoice record instead of
// only on Stripe `event.id`, because Stripe may emit multiple events that map to
// the same paid invoice while the balance must only be credited once.
const [claimed] = await tx.update(stripeSchema.stripeInvoice)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId),
eq(stripeSchema.stripeInvoice.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'credit',
amount: input.fluxAmount,
balanceBefore,
balanceAfter,
requestId: input.stripeEventId,
description,
metadata: {
stripeEventId: input.stripeEventId,
stripeInvoiceId: input.stripeInvoiceId,
source: 'invoice.paid',
},
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.invoice', type: 'credit' })
}
return txResult
},
}
}
export type BillingService = ReturnType<typeof createBillingService>
@@ -1,6 +1,15 @@
export interface UsageInfo {
promptTokens?: number
completionTokens?: number
promptTokens?: number
}
export function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number {
const { completionTokens, promptTokens } = usage
if (promptTokens != null && completionTokens != null) {
const totalTokens = promptTokens + completionTokens
return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens))
}
return fallbackRate
}
export function extractUsageFromBody(body: any): UsageInfo {
@@ -8,16 +17,7 @@ export function extractUsageFromBody(body: any): UsageInfo {
if (!usage)
return {}
return {
promptTokens: usage.prompt_tokens ?? undefined,
completionTokens: usage.completion_tokens ?? undefined,
promptTokens: usage.prompt_tokens ?? undefined,
}
}
export function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number {
const { promptTokens, completionTokens } = usage
if (promptTokens != null && completionTokens != null) {
const totalTokens = promptTokens + completionTokens
return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens))
}
return fallbackRate
}
@@ -35,11 +35,30 @@ end
return {0, debt}
`
interface FluxMeterRuntime {
/** How many small units equal one Flux. */
unitsPerFlux: number
/** Debt key TTL. Residual debt below unitsPerFlux is forgiven on expiry. */
debtTtlSeconds: number
export type FluxMeter = ReturnType<typeof createFluxMeter>
interface AccumulateInput {
currentBalance: number
metadata?: Record<string, unknown>
requestId: string
units: number
userId: string
}
interface AccumulateResult {
/** User's flux balance after this call. */
balanceAfter: number
/** Residual debt left in Redis after this call. Includes unbilled units restored on partial drain. */
debtAfter: number
/** Actual flux charged to the user (== amount we are sure was billed). */
fluxDebited: number
/**
* Flux that crossed the meter threshold but couldn't be charged because the
* user's balance was lower than what the request required. > 0 means the
* user received service they only partially paid for. Reflects the gap
* between `requested` and `charged` returned by `billingService.consumeFluxForLLM`.
*/
unbilledFlux: number
}
interface FluxMeterConfig {
@@ -57,28 +76,11 @@ interface FluxMeterConfig {
resolveRuntime: () => Promise<FluxMeterRuntime>
}
interface AccumulateInput {
userId: string
units: number
currentBalance: number
requestId: string
metadata?: Record<string, unknown>
}
interface AccumulateResult {
/** Actual flux charged to the user (== amount we are sure was billed). */
fluxDebited: number
/** Residual debt left in Redis after this call. Includes unbilled units restored on partial drain. */
debtAfter: number
/** User's flux balance after this call. */
balanceAfter: number
/**
* Flux that crossed the meter threshold but couldn't be charged because the
* user's balance was lower than what the request required. > 0 means the
* user received service they only partially paid for. Reflects the gap
* between `requested` and `charged` returned by `billingService.consumeFluxForLLM`.
*/
unbilledFlux: number
interface FluxMeterRuntime {
/** Debt key TTL. Residual debt below unitsPerFlux is forgiven on expiry. */
debtTtlSeconds: number
/** How many small units equal one Flux. */
unitsPerFlux: number
}
/**
@@ -92,7 +94,7 @@ export function createFluxMeter(
redis: Redis,
billingService: BillingService,
config: FluxMeterConfig,
metrics?: RevenueMetrics | null,
metrics?: null | RevenueMetrics,
) {
async function getRuntime(): Promise<FluxMeterRuntime> {
const runtime = await config.resolveRuntime()
@@ -145,7 +147,7 @@ export function createFluxMeter(
*/
async function accumulate(input: AccumulateInput): Promise<AccumulateResult> {
if (!Number.isFinite(input.units) || input.units <= 0)
return { fluxDebited: 0, debtAfter: await readDebt(input.userId), balanceAfter: input.currentBalance, unbilledFlux: 0 }
return { balanceAfter: input.currentBalance, debtAfter: await readDebt(input.userId), fluxDebited: 0, unbilledFlux: 0 }
const modelLabel = typeof input.metadata?.model === 'string' ? input.metadata.model : 'unknown'
metrics?.ttsChars.add(input.units, { meter: config.name, model: modelLabel })
@@ -156,21 +158,21 @@ export function createFluxMeter(
if (fluxRequested === 0) {
logger.withFields({
userId: input.userId,
debtAfter: debtAfterSettlement,
meter: config.name,
units: input.units,
debtAfter: debtAfterSettlement,
userId: input.userId,
}).debug('Accumulated units below flux threshold')
return { fluxDebited: 0, debtAfter: debtAfterSettlement, balanceAfter: input.currentBalance, unbilledFlux: 0 }
return { balanceAfter: input.currentBalance, debtAfter: debtAfterSettlement, fluxDebited: 0, unbilledFlux: 0 }
}
let result: Awaited<ReturnType<typeof billingService.consumeFluxForLLM>>
try {
result = await billingService.consumeFluxForLLM({
userId: input.userId,
amount: fluxRequested,
requestId: input.requestId,
description: `${config.name}_request`,
requestId: input.requestId,
userId: input.userId,
...(typeof input.metadata?.model === 'string' && { model: input.metadata.model }),
})
}
@@ -186,10 +188,10 @@ export function createFluxMeter(
}
catch (rollbackError) {
logger.withError(rollbackError).withFields({
userId: input.userId,
meter: config.name,
restoreUnits,
requestId: input.requestId,
restoreUnits,
userId: input.userId,
}).error('Failed to roll back meter debt after billing failure')
}
throw error
@@ -203,11 +205,11 @@ export function createFluxMeter(
// same usage). Surface loud, but don't compensate.
if (!Number.isInteger(result.charged) || result.charged < 0 || result.charged > result.requested) {
logger.withFields({
userId: input.userId,
meter: config.name,
requestId: input.requestId,
requested: result.requested,
charged: result.charged,
meter: config.name,
requested: result.requested,
requestId: input.requestId,
userId: input.userId,
}).error('billing-service returned invalid charged/requested — manual reconciliation needed')
throw new Error(`billing-service returned invalid charged=${result.charged} for requested=${result.requested}`)
}
@@ -234,9 +236,9 @@ export function createFluxMeter(
const restoreUnits = unbilledFlux * runtime.unitsPerFlux
metrics?.fluxUnbilled.add(unbilledFlux, {
source: 'tts_meter',
meter: config.name,
reason: 'partial_debit_drained',
source: 'tts_meter',
...(typeof input.metadata?.model === 'string' && { [GEN_AI_ATTR_REQUEST_MODEL]: input.metadata.model }),
})
@@ -249,40 +251,38 @@ export function createFluxMeter(
// Log loudly so on-call can reconcile manually; don't shadow the
// partial-debit signal by re-throwing.
logger.withError(rollbackError).withFields({
userId: input.userId,
meter: config.name,
restoreUnits,
requestId: input.requestId,
restoreUnits,
userId: input.userId,
}).error('Failed to restore meter debt after partial-debit drain')
}
logger.withFields({
userId: input.userId,
meter: config.name,
requestId: input.requestId,
requested: result.requested,
charged: result.charged,
unbilledFlux,
meter: config.name,
requested: result.requested,
requestId: input.requestId,
restoreUnits,
unbilledFlux,
userId: input.userId,
}).warn('Partial debit on flux meter — flux drained to zero')
return {
fluxDebited: result.charged,
debtAfter: debtAfterRestore,
balanceAfter: result.flux,
debtAfter: debtAfterRestore,
fluxDebited: result.charged,
unbilledFlux,
}
}
return { fluxDebited: result.charged, debtAfter: debtAfterSettlement, balanceAfter: result.flux, unbilledFlux: 0 }
return { balanceAfter: result.flux, debtAfter: debtAfterSettlement, fluxDebited: result.charged, unbilledFlux: 0 }
}
return {
assertCanAfford,
accumulate,
peekDebt: readDebt,
assertCanAfford,
config,
peekDebt: readDebt,
}
}
export type FluxMeter = ReturnType<typeof createFluxMeter>

Some files were not shown because too many files have changed in this diff Show More