style: lint
This commit is contained in:
@@ -3,12 +3,12 @@ import { env } from 'node:process'
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: ['./src/schemas/**/*.ts', '../../packages/auth-shared/src/schema.ts'],
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL!,
|
||||
},
|
||||
dialect: 'postgresql',
|
||||
out: './drizzle',
|
||||
schema: ['./src/schemas/**/*.ts', '../../packages/auth-shared/src/schema.ts'],
|
||||
// https://github.com/drizzle-team/drizzle-orm/issues/4008
|
||||
tablesFilter: ['!vchordrq_sampled_queries'],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
},
|
||||
)()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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®ion=${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®ion=${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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('\'', ''')
|
||||
}
|
||||
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('\'', ''')
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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®ion=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®ion=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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user