chore: move the server to an independent folder

This commit is contained in:
RainbowBird
2026-08-02 18:43:57 +08:00
parent 771ba3f8a7
commit 4ccde2c96e
293 changed files with 176 additions and 3020 deletions
@@ -0,0 +1,327 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { configRedisKey } from '../../utils/redis-keys'
import { createConfigKVService } from './config-kv'
function createMockRedis() {
const store = new Map<string, string>()
return {
get: vi.fn(async (key: string) => store.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => { store.set(key, value) }),
_store: store,
}
}
describe('configKVService', () => {
let redis: ReturnType<typeof createMockRedis>
let service: ReturnType<typeof createConfigKVService>
beforeEach(() => {
redis = createMockRedis()
service = createConfigKVService(redis as any)
})
it('get should throw 503 when key is not set', async () => {
await expect(service.getOrThrow('FLUX_PER_1K_CHARS_TTS'))
.rejects
.toThrow('Service configuration is incomplete')
})
it('get should return numeric value when key is set', async () => {
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '5')
const value = await service.getOrThrow('FLUX_PER_REQUEST')
expect(value).toBe(5)
})
it('get should read from correct prefixed key', async () => {
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '3')
await service.getOrThrow('FLUX_PER_REQUEST')
expect(redis.get).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'))
})
it('getOptional should return schema default when key has one', async () => {
const value = await service.getOptional('FLUX_PER_REQUEST')
expect(value).toBe(5)
})
it('getOptional should return null when required key is not set', async () => {
const value = await service.getOptional('FLUX_PER_1K_CHARS_TTS')
expect(value).toBeNull()
})
it('getOptional should return numeric value when key is set', async () => {
redis._store.set(configRedisKey('INITIAL_USER_FLUX'), '200')
const value = await service.getOptional('INITIAL_USER_FLUX')
expect(value).toBe(200)
})
it('getOptional should throw CONFIG_INVALID when Redis contains malformed JSON', async () => {
// ROOT CAUSE:
//
// If an operator edits config:LLM_ROUTER_CONFIG directly with invalid JSON,
// JSON.parse used to throw SyntaxError through the request handler and log
// it as an unhandled 500.
//
// We fixed this by translating stored config parse/validation failures into
// a stable API error at the configKV boundary.
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), '{"llm":{}')
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('getOptional should throw CONFIG_INVALID when Redis contains schema-invalid JSON', async () => {
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), JSON.stringify('5'))
await expect(service.getOptional('FLUX_PER_REQUEST'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('set should write value to Redis with prefix', async () => {
await service.set('FLUX_PER_REQUEST', 10)
expect(redis.set).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'), '10')
expect(redis._store.get(configRedisKey('FLUX_PER_REQUEST'))).toBe('10')
})
it('set should reject invalid values for string config keys', async () => {
await expect(service.set('STRIPE_FLUX_PRODUCT_ID', { id: 'prod_123' } as any))
.rejects
.toThrow()
})
it('set then get should round-trip correctly', async () => {
await service.set('INITIAL_USER_FLUX', 500)
const value = await service.getOrThrow('INITIAL_USER_FLUX')
expect(value).toBe(500)
})
/**
* @example
* service.set('LLM_ROUTER_CONFIG', { asr: { models: { auto: model } } })
*/
it('llm router config should preserve official ASR model config', async () => {
await service.set('LLM_ROUTER_CONFIG', {
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',
},
}],
},
},
},
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
})
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
const asr = value.asr
if (!asr)
throw new Error('Expected ASR config to be preserved')
expect(asr.models.auto.provider).toBe('aliyun-nls')
expect(asr.models.auto.upstreams[0].adapterParams).toEqual({
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
})
})
it('llm router config should preserve explicit LLM and TTS provider groups', async () => {
await service.set('LLM_ROUTER_CONFIG', {
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,
},
},
},
},
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,
},
},
},
},
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
})
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
const model = value.tts.models['stepfun/stepaudio-2.5-tts']
expect(value.llm.models['step-3.5-flash'].routing?.groups.map(group => group.id)).toEqual(['plan', 'paygo'])
expect(model.routing?.groups.map(group => group.id)).toEqual(['plan', 'paygo'])
expect(model.routing?.groups[0].continueOn).toEqual({
httpCodes: [402],
onTimeout: false,
})
})
it('rejects a TTS provider group that references an unknown upstream', async () => {
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({
llm: { models: {} },
tts: {
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 },
}],
},
},
},
},
}))
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('rejects least-inflight routing without an explicit concurrency cap', async () => {
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({
llm: { models: {} },
tts: {
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 },
}],
},
},
},
},
}))
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('set should store string values as JSON strings', async () => {
await service.set('STRIPE_FLUX_PRODUCT_ID', 'prod_abc123')
expect(redis._store.get(configRedisKey('STRIPE_FLUX_PRODUCT_ID'))).toBe(JSON.stringify('prod_abc123'))
})
})
@@ -0,0 +1,357 @@
import type Redis from 'ioredis'
import type { InferOutput } from 'valibot'
import { errorMessageFrom } from '@moeru/std'
import { any, array, boolean, check, nonEmpty, number, object, optional, parse, picklist, pipe, record, regex, string } from 'valibot'
import { createServiceUnavailableError } from '../../utils/error'
import { configRedisKey } from '../../utils/redis-keys'
/**
* LLM/TTS router config tree. Single composite entry under configKV holds the
* entire routing surface: per-model upstream list, optional candidate groups,
* per-upstream key array (envelope-encrypted ciphertexts), transition policies,
* and default timeouts.
*
* Schema enforces:
* - key entry id must not contain `|` — the envelope-crypto AAD uses `|` as
* a reserved separator between `modelName` and `keyEntryId`.
* - keys array is non-empty per upstream (an upstream with zero keys can
* never serve a request and is almost certainly an admin mistake).
*
* Defaults at this layer apply when the admin omits the `defaults` object;
* the router service is responsible for surfacing CONFIG_NOT_SET when the
* whole `LLM_ROUTER_CONFIG` entry is absent.
*/
export const fallbackTriggersSchema = optional(
object({
httpCodes: optional(array(number()), [401, 402, 403, 429, 500, 502, 503, 504]),
onTimeout: optional(boolean(), true),
}),
{ httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
)
/**
* Explicit allow-list for one routing transition.
*
* Unlike {@link fallbackTriggersSchema}, this contract has no permissive
* defaults: an omitted status or timeout never authorizes a transition across
* a configured routing boundary.
*/
export const routeFailureTriggersSchema = object({
httpCodes: optional(array(number()), []),
onTimeout: optional(boolean(), false),
})
export const keyEntrySchema = object({
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({
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}'),
timeoutMs: optional(number()),
})
export const llmRoutingGroupSchema = object({
id: pipe(string(), nonEmpty('llm.routing.groups[].id must not be empty')),
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({
groups: pipe(
array(llmRoutingGroupSchema),
check(v => v.length >= 1, 'llm.routing.groups must contain at least 1 entry'),
check(v => new Set(v.map(group => group.id)).size === v.length, 'llm.routing.groups[].id must be unique'),
),
})
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,
}),
check((model) => {
if (model.routing == null)
return true
const upstreamIds = model.upstreams.map(upstream => upstream.id)
return upstreamIds.every(id => id != null)
&& new Set(upstreamIds).size === upstreamIds.length
}, 'llm.models[].upstreams must have unique ids when routing is configured'),
check((model) => {
if (model.routing == null)
return true
const upstreamIds = new Set(model.upstreams.map(upstream => upstream.id))
const referencedIds = model.routing.groups.flatMap(group => group.upstreamIds)
return referencedIds.length === upstreamIds.size
&& new Set(referencedIds).size === referencedIds.length
&& referencedIds.every(id => upstreamIds.has(id))
}, 'llm.routing.groups must reference every upstream id exactly once'),
)
const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine'])
const asrProviderSchema = picklist(['aliyun-nls'])
export const ttsUpstreamSchema = object({
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
// switches from fixed-order fallback to capacity-aware routing across pools.
// Absent = unlimited: that model keeps the original fixed-order behavior and
// makes zero Redis calls (no regression for existing single-app configs).
maxConcurrency: optional(pipe(number(), check(v => v >= 1, 'tts.upstreams[].maxConcurrency must be >= 1 when set'))),
})
export const ttsRoutingGroupSchema = object({
id: pipe(string(), nonEmpty('tts.routing.groups[].id must not be empty')),
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({
groups: pipe(
array(ttsRoutingGroupSchema),
check(v => v.length >= 1, 'tts.routing.groups must contain at least 1 entry'),
check(v => new Set(v.map(group => group.id)).size === v.length, 'tts.routing.groups[].id must be unique'),
),
})
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()), {}),
models: optional(
array(object({
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({
restBaseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.restBaseURL must not be empty')),
streaming: optional(streamingTtsUpstreamSchema),
})
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,
}),
check((model) => {
if (model.routing == null)
return true
const upstreamIds = model.upstreams.map(upstream => upstream.id)
return upstreamIds.every(id => id != null)
&& new Set(upstreamIds).size === upstreamIds.length
}, 'tts.models[].upstreams must have unique ids when routing is configured'),
check((model) => {
if (model.routing == null)
return true
const upstreamIds = new Set(model.upstreams.map(upstream => upstream.id))
const referencedIds = model.routing.groups.flatMap(group => group.upstreamIds)
return referencedIds.length === upstreamIds.size
&& new Set(referencedIds).size === referencedIds.length
&& referencedIds.every(id => upstreamIds.has(id))
}, 'tts.routing.groups must reference every upstream id exactly once'),
check((model) => {
if (model.routing == null)
return true
const upstreamById = new Map(model.upstreams.map(upstream => [upstream.id, upstream]))
return model.routing.groups.every(group =>
group.strategy !== 'least-inflight'
|| group.upstreamIds.every(id => upstreamById.get(id)?.maxConcurrency != null),
)
}, 'tts.routing least-inflight groups require maxConcurrency on every upstream'),
)
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()), {}),
})
export const asrModelSchema = object({
provider: asrProviderSchema,
upstreams: pipe(array(asrUpstreamSchema), check(v => v.length >= 1, 'asr.models[].upstreams must contain at least 1 entry')),
})
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]),
}),
{ perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] },
)
export const llmRouterConfigSchema = object({
llm: object({
models: record(string(), llmModelSchema),
}),
tts: object({
models: record(string(), ttsModelSchema),
}),
asr: optional(object({
models: record(string(), asrModelSchema),
})),
defaults: llmRouterDefaultsSchema,
})
/**
* Config entry schemas are the single source of truth for:
* - runtime validation
* - default values
* - Redis serialization/deserialization shape
*/
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),
AUTH_RATE_LIMIT_MAX: optional(number(), 20),
AUTH_RATE_LIMIT_WINDOW_SEC: optional(number(), 60),
// 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
// `auto`, this layer translates before dispatch. No default: missing entry
// surfaces CONFIG_NOT_SET (resolveWithDefault swallows ValiError) so a
// misconfigured deploy fails the request instead of silently routing to an
// empty modelName. Naked schema (not wrapped in optional) keeps the inferred
// 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')),
// No default — the router throws CONFIG_NOT_SET when this entry is absent
// so the admin endpoint (U9) is forced to populate it before traffic flows.
LLM_ROUTER_CONFIG: optional(llmRouterConfigSchema),
// 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`
// carry the upstream-provider API key (Volcengine X-Api-Key), not an
// unspeech tenant token (unspeech itself is unauthenticated).
UNSPEECH_UPSTREAM: optional(unspeechUpstreamSchema),
} as const
type ConfigDefinitions = {
[K in keyof typeof ConfigEntrySchemas]: InferOutput<(typeof ConfigEntrySchemas)[K]>
}
type ConfigKey = keyof ConfigDefinitions
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
try {
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is invalid',
'CONFIG_INVALID',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config parse error',
},
)
}
}
function serializeValue<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): string {
return JSON.stringify(parse(ConfigEntrySchemas[key], value))
}
/**
* Resolve a config value: read from Redis, then apply valibot default if missing.
* Returns `undefined` if both Redis and schema have no value (required key, not set).
*/
function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): ConfigDefinitions[K] | undefined {
if (raw !== null)
return parseValue(key, raw)
// Use the per-key schema with `undefined` to trigger the key default
try {
return parse(ConfigEntrySchemas[key], undefined) as ConfigDefinitions[K]
}
catch {
return undefined
}
}
export function createConfigKVService(redis: Redis) {
return {
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await redis.get(configRedisKey(key))
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await redis.get(configRedisKey(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 set<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): Promise<void> {
const serialized = serializeValue(key, value)
await redis.set(configRedisKey(key), serialized)
},
}
}
export type ConfigKVService = ReturnType<typeof createConfigKVService>
@@ -0,0 +1,328 @@
import type { Logger } from '@guiiai/logg'
import type { EmailMetrics } from '../../otel'
import { useLogger } from '@guiiai/logg'
import { errorMessageFrom } from '@moeru/std'
import { Resend } from 'resend'
import { ApiError } from '../../utils/error'
/**
* Outbound email payload accepted by {@link EmailService.send}.
*
* Use when:
* - Building a higher-level transactional template (verification, reset, magic link, change-email).
*
* Expects:
* - Both `html` and `text` set so deliverability scoring stays high (text fallback
* is what spam filters score when HTML is hostile or stripped).
* - `to` is already validated by Better Auth (we trust caller for internal flows).
*/
export interface EmailPayload {
/** Recipient address. Single address — Better Auth callbacks always emit one. */
to: string
/** Subject line. Plain text. */
subject: string
/** HTML body. */
html: string
/** Plain-text body. Required for spam-filter parity and accessibility. */
text: string
}
/**
* Email service abstraction shared by all Better Auth callbacks.
*
* Use when:
* - Wiring `sendVerificationEmail` / `sendResetPassword` / `sendMagicLink` /
* `sendChangeEmailConfirmation` in `createAuth()`.
*
* Expects:
* - Service is constructed once per process by `injeca` and shared across requests.
*
* Returns:
* - A `send` method plus four high-level helpers that own subject/body composition.
*/
export interface EmailService {
send: (payload: EmailPayload) => Promise<void>
sendVerification: (params: { to: string, url: string }) => Promise<void>
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
/**
* Send the irreversible-action confirmation for `user.deleteUser` flow.
*
* Wired into better-auth's `user.deleteUser.sendDeleteAccountVerification`.
* The link expires per `deleteTokenExpiresIn` (default 24h) and is
* single-use; clicking it triggers `beforeDelete` → soft-delete handlers →
* hard-delete user.
*
* Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-300.
*/
sendDeleteAccountVerification: (params: { to: string, url: string }) => Promise<void>
}
interface EmailConfig {
apiKey: string
fromEmail: string
fromName?: string
}
/**
* Format an RFC 5322 display-name + address pair for the `From` header.
*
* Before:
* - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }`
*
* After:
* - `'AIRI <noreply@a.io>'`
*/
function formatFrom(config: EmailConfig): string {
if (config.fromName)
return `${config.fromName} <${config.fromEmail}>`
return config.fromEmail
}
/**
* Construct the email service.
*
* Use when:
* - DI assembly in `server/apps/api/src/app.ts`.
*
* Expects:
* - `RESEND_API_KEY` is set in env. When empty, `send` throws an `ApiError`
* instead of silently dropping mail — Better Auth surfaces it back to the
* caller so frontend can show a clear "email service not configured" error.
*/
export function createEmailService(config: EmailConfig, logger: Logger = useLogger('email'), metrics?: EmailMetrics | null): EmailService {
// NOTICE:
// Construct Resend lazily so the server can boot in environments where the
// RESEND_API_KEY is intentionally empty (e.g. local dev that never exercises
// email flows). Calls to `send` will throw, which Better Auth surfaces.
// Root cause summary: Resend's constructor logs but does not throw on empty
// keys; explicit guard keeps the failure mode visible at the call site.
// Source: node_modules/.pnpm/resend@*/node_modules/resend/dist/index.cjs
// Removal condition: when we make RESEND_API_KEY required at env-parse time.
let client: Resend | null = null
function getClient(): Resend {
if (!client) {
if (!config.apiKey) {
throw new ApiError(
503,
'email/service_not_configured',
'Email service not configured (RESEND_API_KEY is missing).',
)
}
client = new Resend(config.apiKey)
}
return client
}
const from = formatFrom(config)
async function send(payload: EmailPayload, template: string = 'unknown'): Promise<void> {
const startedAt = Date.now()
try {
const { error } = await getClient().emails.send({
from,
to: [payload.to],
subject: payload.subject,
html: payload.html,
text: payload.text,
})
if (error) {
logger.withFields({ to: payload.to, subject: payload.subject, errorName: error.name }).error(error.message)
metrics?.failures.add(1, { template, error_name: error.name })
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'error' })
throw new ApiError(502, 'email/send_failed', error.message, { providerError: error.name })
}
metrics?.send.add(1, { template })
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'ok' })
}
catch (error) {
if (error instanceof ApiError)
throw error
const message = errorMessageFrom(error) ?? 'Unknown email send error'
logger.withFields({ to: payload.to, subject: payload.subject }).error(message)
metrics?.failures.add(1, { template, error_name: 'unhandled' })
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'error' })
throw new ApiError(502, 'email/send_failed', message)
}
}
return {
send,
async sendVerification({ to, url }) {
await send({
to,
subject: 'Verify your email for Project AIRI',
html: renderVerificationHtml(url),
text: renderVerificationText(url),
}, 'verification')
},
async sendPasswordReset({ to, url }) {
await send({
to,
subject: 'Reset your Project AIRI password',
html: renderPasswordResetHtml(url),
text: renderPasswordResetText(url),
}, 'password_reset')
},
async sendMagicLink({ to, url }) {
await send({
to,
subject: 'Your Project AIRI sign-in link',
html: renderMagicLinkHtml(url),
text: renderMagicLinkText(url),
}, 'magic_link')
},
async sendChangeEmailConfirmation({ to, newEmail, url }) {
await send({
to,
subject: 'Confirm your new email address for Project AIRI',
html: renderChangeEmailHtml(url, newEmail),
text: renderChangeEmailText(url, newEmail),
}, 'change_email')
},
async sendDeleteAccountVerification({ to, url }) {
await send({
to,
subject: 'Confirm account deletion for Project AIRI',
html: renderDeleteAccountHtml(url),
text: renderDeleteAccountText(url),
}, 'delete_account')
},
}
}
// NOTICE:
// Templates are intentionally minimal inline HTML. Goal here is functional
// delivery + plaintext fallback. Visual design is deferred (see
// docs/ai/context/email-auth-resend.md "不做" section).
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string {
const safeUrl = escapeHtml(args.url)
return `<!doctype html>
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; color: #111; max-width: 480px; margin: 24px auto; padding: 0 16px;">
<h2 style="margin: 0 0 16px;">${escapeHtml(args.heading)}</h2>
<p style="margin: 0 0 16px;">${escapeHtml(args.body)}</p>
<p style="margin: 0 0 16px;"><a href="${safeUrl}" style="display: inline-block; padding: 10px 16px; background: #111; color: #fff; border-radius: 6px; text-decoration: none;">${escapeHtml(args.ctaLabel)}</a></p>
<p style="margin: 0 0 16px; font-size: 12px; color: #666;">If the button doesn't work, copy this URL into your browser:<br/><span style="word-break: break-all;">${safeUrl}</span></p>
<p style="margin: 24px 0 0; font-size: 12px; color: #888;">${escapeHtml(args.footer)}</p>
</body></html>`
}
function renderActionEmailText(args: { heading: string, body: string, url: string, footer: string }): string {
return `${args.heading}\n\n${args.body}\n\n${args.url}\n\n${args.footer}\n`
}
function renderVerificationHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.',
ctaLabel: 'Verify email',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderVerificationText(url: string): string {
return renderActionEmailText({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Open this link to confirm your email address:',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderPasswordResetHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Reset your password',
body: 'We received a request to reset the password for your Project AIRI account.',
ctaLabel: 'Reset password',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderPasswordResetText(url: string): string {
return renderActionEmailText({
heading: 'Reset your password',
body: 'Open this link to reset your Project AIRI password:',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderMagicLinkHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Sign in to Project AIRI',
body: 'Click the button below to sign in. This link expires shortly and can be used once.',
ctaLabel: 'Sign in',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderMagicLinkText(url: string): string {
return renderActionEmailText({
heading: 'Sign in to Project AIRI',
body: 'Open this link to sign in (single-use, expires shortly):',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderChangeEmailHtml(url: string, newEmail: string): string {
return renderActionEmailHtml({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email.`,
ctaLabel: 'Confirm new email',
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
function renderChangeEmailText(url: string, newEmail: string): string {
return renderActionEmailText({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`,
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
// NOTICE:
// Wording is intentionally short and direct. Account deletion hard-deletes
// the auth identity (cascade) and soft-archives business records; the user
// cannot recover the account through the UI.
// See `server/apps/api/docs/ai-context/account-deletion.md`.
function renderDeleteAccountHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Confirm account deletion',
body: 'Click below to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
ctaLabel: 'Delete my account',
url,
footer: 'Did not request this? Ignore this email and rotate your password.',
})
}
function renderDeleteAccountText(url: string): string {
return renderActionEmailText({
heading: 'Confirm account deletion',
body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
url,
footer: 'Did not request this? Ignore this email and rotate your password.',
})
}
@@ -0,0 +1,78 @@
import { useLogger } from '@guiiai/logg'
import { PostHog } from 'posthog-node'
const logger = useLogger('posthog')
/**
* One product event forwarded to PostHog, keyed by the Better Auth user id
* so it merges with the browser person identified via `posthog.identify()`.
*/
export interface PosthogCaptureInput {
distinctId: string
event: string
properties: Record<string, unknown>
}
/**
* Minimal capture boundary the product-events service depends on. Kept as
* an interface so tests inject a fake instead of mocking the SDK.
*/
export interface PosthogSink {
/**
* 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>
}
/**
* PostHog sink for server-side product events.
*
* Low-frequency conversion facts use `captureImmediate` (one HTTP roundtrip
* per event) because they terminate money/auth funnels. High-frequency AI
* generation facts use `captureQueued`, which is buffered by the SDK and
* flushed on shutdown, so chat completion requests don't wait on PostHog.
*
* Capture failures are logged and swallowed — analytics forwarding must
* never fail the Stripe webhook or auth flow that triggered it. The
* Postgres `product_events` row is the source of truth either way.
*/
export function createPosthogSink(options: { projectKey: string, host: 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,
})
}
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({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
}
},
async shutdown(): Promise<void> {
await client.shutdown()
},
}
}
@@ -0,0 +1,26 @@
const AUDIO_MIME_TYPES: Record<string, string> = {
flac: 'audio/flac',
mp3: 'audio/mpeg',
ogg_opus: 'audio/ogg',
opus: 'audio/opus',
pcm: 'audio/L16',
wav: 'audio/wav',
}
/**
* Maps provider audio format keys to response MIME types.
*
* Use when:
* - A TTS adapter forwards OpenAI-shaped `response_format` / provider
* encoding keys through unspeech and needs a gateway fallback MIME type.
*
* Expects:
* - `format` is the exact provider/OpenAI format key.
*
* Returns:
* - A known audio MIME type, or `application/octet-stream` for unknown custom
* formats so operators can still experiment through config.
*/
export function audioMimeFromFormat(format: string): string {
return AUDIO_MIME_TYPES[format] ?? 'application/octet-stream'
}
@@ -0,0 +1,149 @@
import type { Voice } from 'unspeech'
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
import { inferMicrosoftContentType, isMicrosoftVoiceId, resolveMicrosoftOutputFormat } from 'unspeech'
import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
/**
* Azure Cognitive Services REST adapter.
*
* Use when:
* - The router routes a hosted TTS request to an Azure upstream (e.g.
* `https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1`).
*
* Expects:
* - `ctx.baseURL` is the full Azure REST endpoint (region-prefixed).
* - `ctx.keyPlaintext` is the subscription key string the gateway will send as
* `Ocp-Apim-Subscription-Key`.
*
* Returns:
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. The
* `contentType` is taken from the upstream `content-type` header when
* present, otherwise inferred from the requested format.
*/
export const azureAdapter: TtsAdapter = {
id: 'azure',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
const defaultVoice = typeof ctx.adapterParams.defaultVoice === 'string'
? ctx.adapterParams.defaultVoice
: undefined
const voice = input.voice ?? defaultVoice
if (!voice)
throw createBadRequestError('azure voice is required when adapterParams.defaultVoice is not configured', 'BAD_REQUEST')
if (!isMicrosoftVoiceId(voice))
throw createBadRequestError(`azure voice id contains unsupported characters: ${voice}`, 'BAD_REQUEST', { voice })
const outputFormat = resolveMicrosoftOutputFormat(input.responseFormat)
const disableSsml = input.extraOptions?.disableSsml === true
const ssml = disableSsml
? input.text
: buildAzureSsml(input.text, voice, input.speed, {
pitch: typeof input.extraOptions?.pitch === 'number' ? input.extraOptions.pitch : undefined,
volume: typeof input.extraOptions?.volume === 'number' ? input.extraOptions.volume : undefined,
})
const region = ctx.adapterParams?.region
if (typeof region !== 'string' || !region)
throw createInternalError('azure tts upstream is missing adapterParams.region')
return sendSpeechViaUnSpeech({
ctx,
model: 'microsoft/v1',
input: ssml,
voice,
responseFormat: outputFormat,
extraBody: { region, disable_ssml: true },
fallbackContentType: inferMicrosoftContentType(outputFormat),
providerLabel: 'azure',
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// Azure has no static catalog. Voices live at Microsoft's `voices/list`
// REST endpoint, which we reach via the unspeech `microsoft` backend
// because unspeech already maps the proprietary response shape to
// `types.Voice` (full formats table, masterpiece preview URLs, locale
// metadata). Calling unspeech also keeps a single integration point for
// every other provider that could grow this way later.
if (!ctx.region)
throw createServiceUnavailableError('azure tts region not configured', 'AZURE_TTS_NOT_CONFIGURED')
if (!ctx.keyPlaintext)
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
return listVoicesViaUnSpeech({
ctx,
query: `provider=microsoft&region=${encodeURIComponent(ctx.region)}`,
providerLabel: 'azure',
})
},
}
/**
* Builds Azure-compatible SSML, preserving Voice Pack prosody settings.
*
* NOTICE:
* `unspeech` owns the canonical Microsoft helpers, but the currently consumed
* helper surface only lets AIRI pass speed. Voice Pack pitch and volume must be
* encoded before the request reaches unspeech because AIRI sends pre-built SSML
* with `disable_ssml: true`.
* Source/context: this adapter's `extraOptions.pitch` and `extraOptions.volume`
* contract, covered by `azureAdapter.send` tests.
* Removal condition: delete this helper once `unspeech` exposes a
* `buildMicrosoftSsml` overload that accepts pitch and volume.
*/
function buildAzureSsml(
text: string,
voice: string,
speed: number | undefined,
options: {
pitch?: number
volume?: number
},
): string {
const safe = escapeForSsml(text)
const rate = speedToProsodyRate(speed)
const pitch = percentToProsodyValue(options.pitch)
const volume = percentToProsodyValue(options.volume)
const prosodyAttrs = [
rate ? `rate='${rate}'` : undefined,
pitch ? `pitch='${pitch}'` : undefined,
volume ? `volume='${volume}'` : undefined,
].filter(Boolean).join(' ')
const inner = prosodyAttrs
? `<prosody ${prosodyAttrs}>${safe}</prosody>`
: safe
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 percentToProsodyValue(value: number | undefined): string {
if (value == null)
return ''
if (value > 0)
return `+${value}%`
if (value < 0)
return `${value}%`
return '0%'
}
function escapeForSsml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
}
@@ -0,0 +1,140 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice'
const UNSPEECH = 'http://unspeech.local:5933'
const SPEECH_URL = `${UNSPEECH}/v1/audio/speech`
function binaryResponse(bytes: Uint8Array, status = 200) {
return new Response(bytes, {
status,
headers: { 'content-type': 'audio/mpeg' },
})
}
describe('dashscopeCosyvoiceAdapter', () => {
it('forwards to unspeech with model=alibaba/<adapterParams.model>, voice + response_format passthrough', async () => {
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x04, 0x00, 0x00]) // ID3v2 mp3 header
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(audioBytes))
const result = await dashscopeCosyvoiceAdapter.send(
{ text: 'hi there', voice: 'longxiaochun_v2', responseFormat: 'mp3' },
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: { model: 'cosyvoice-v2' },
fetchImpl: fetchImpl as unknown as typeof fetch,
},
)
expect(fetchImpl).toHaveBeenCalledTimes(1)
const [calledURL, init] = fetchImpl.mock.calls[0]
expect(calledURL).toBe(SPEECH_URL)
expect(init.method).toBe('POST')
const body = JSON.parse(init.body as string)
expect(body).toEqual({
model: 'alibaba/cosyvoice-v2',
input: 'hi there',
voice: 'longxiaochun_v2',
response_format: 'mp3',
})
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer sk-test')
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
const out = new Uint8Array(result.body as ArrayBuffer)
expect(Array.from(out)).toEqual(Array.from(audioBytes))
})
it('throws Error with .status when unspeech returns non-2xx (router walks to next key)', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(new Response('bad key', { status: 401 }))
await expect(
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: {},
fetchImpl: fetchImpl as unknown as typeof fetch,
},
),
).rejects.toMatchObject({ status: 401, message: expect.stringContaining('401') })
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('rejects missing voice instead of hardcoding a model-specific default', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0])))
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: {},
fetchImpl: fetchImpl as unknown as typeof fetch,
},
)).rejects.toMatchObject({ statusCode: 400 })
expect(fetchImpl).not.toHaveBeenCalled()
})
/**
* @example
* dashscopeCosyvoiceAdapter.send({ text: 'hi', extraOptions: { volume: 5 } }, ctx)
*/
it('fails fast when Voice Pack pitch or volume params reach DashScope cosyvoice', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0])))
await expect(dashscopeCosyvoiceAdapter.send(
{
text: 'hi',
voice: 'longxiaochun_v2',
extraOptions: {
volume: 5,
},
},
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: {},
fetchImpl: fetchImpl as unknown as typeof fetch,
},
)).rejects.toMatchObject({ statusCode: 400 })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('voice catalog is proxied through unspeech with the selected cosyvoice model', async () => {
// The catalog itself is unspeech-owned now (embedded JSON in
// unspeech/pkg/backend/alibaba/voices.go). This test only verifies the
// wire contract — fixture content is intentionally minimal so an
// unspeech-side roster change doesn't break us.
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }],
}), { status: 200 })) as unknown as typeof fetch
const catalog = await dashscopeCosyvoiceAdapter.getVoiceCatalog({
adapterParams: { model: 'cosyvoice-v2' },
unspeechBaseURL: UNSPEECH,
fetchImpl,
})
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' },
}),
)
})
})
@@ -0,0 +1,92 @@
import type { Voice } from 'unspeech'
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
import { createBadRequestError } from '../../../utils/error'
import { audioMimeFromFormat } from './audio-format'
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
/**
* Default cosyvoice audio format. Mirrors the OpenAI `mp3` default expected by
* downstream consumers.
*/
const DEFAULT_COSYVOICE_FORMAT = 'mp3'
/**
* Default cosyvoice model id. v1 was dropped from the official "REST-supported
* models" list (the official list now starts at v2 and runs through v3.5);
* v2 is the most conservative current default and shares a request body shape
* with v3/v3.5 so ops can retarget via `adapterParams.model` without code.
* NOTICE:
* If you bump this past v2, verify the configured default voice exists for
* that model — voice catalogs differ between v2 (`*_v2`) and v3 (`*_v3`).
*/
const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v2'
/**
* DashScope cosyvoice adapter.
*
* Use when:
* - Routing a hosted TTS request to Alibaba DashScope's cosyvoice v2 / v3
* family of models (Chinese + English + selected multilingual voices).
*
* Expects:
* - `ctx.baseURL` is the **full** non-streaming endpoint, e.g.
* `https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer`
* (or `dashscope-intl.aliyuncs.com` for the Singapore region). The adapter
* does not append a path — pointing at a bare `/api/v1` will 404.
* - `ctx.keyPlaintext` is the DashScope API key (sent as `Bearer ...`).
* - `ctx.adapterParams.model` (optional) names the cosyvoice variant; defaults
* to {@link DEFAULT_COSYVOICE_MODEL}.
*
* Returns:
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. The non-
* streaming endpoint returns a JSON envelope whose `output.audio.url` is
* a short-lived signed URL; this adapter performs the follow-up GET and
* surfaces the final bytes so router callers get the same single-shot
* contract as the Azure / Volcengine paths.
*/
export const dashscopeCosyvoiceAdapter: TtsAdapter = {
id: 'dashscope-cosyvoice',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
const model = typeof ctx.adapterParams.model === 'string'
? ctx.adapterParams.model
: DEFAULT_COSYVOICE_MODEL
if (!input.voice)
throw createBadRequestError('dashscope-cosyvoice voice is required', 'BAD_REQUEST')
if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') {
throw createBadRequestError(
'dashscope-cosyvoice does not support Voice Pack pitch or volume parameters',
'BAD_REQUEST',
)
}
const voice = input.voice
const format = input.responseFormat ?? DEFAULT_COSYVOICE_FORMAT
return sendSpeechViaUnSpeech({
ctx,
model: `alibaba/${model}`,
input: input.text,
voice,
responseFormat: format,
fallbackContentType: audioMimeFromFormat(format),
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',
})
},
}
@@ -0,0 +1,581 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import { ApiError } from '../../../utils/error'
import { getAdapter } from './index'
describe('getAdapter', () => {
it('returns the azure adapter by id', () => {
const adapter = getAdapter('azure')
expect(adapter.id).toBe('azure')
})
it('returns the dashscope-cosyvoice adapter by id', () => {
const adapter = getAdapter('dashscope-cosyvoice')
expect(adapter.id).toBe('dashscope-cosyvoice')
})
it('returns the volcengine adapter by id', () => {
const adapter = getAdapter('volcengine')
expect(adapter.id).toBe('volcengine')
})
it('returns the stepfun adapter by id', () => {
const adapter = getAdapter('stepfun')
expect(adapter.id).toBe('stepfun')
})
it('throws BAD_REQUEST on unknown id with the available list in details', () => {
expect(() => getAdapter('unknown-provider')).toThrow(ApiError)
try {
getAdapter('unknown-provider')
}
catch (err) {
expect(err).toBeInstanceOf(ApiError)
const apiErr = err as ApiError
expect(apiErr.statusCode).toBe(400)
expect(apiErr.errorCode).toBe('BAD_REQUEST')
expect(apiErr.details).toEqual(
expect.objectContaining({
id: 'unknown-provider',
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']),
}),
)
}
})
it('every adapter delegates getVoiceCatalog to unspeech and returns the parsed list', async () => {
for (const id of ['dashscope-cosyvoice', 'volcengine'] as const) {
const adapter = getAdapter(id)
expect(typeof adapter.send).toBe('function')
expect(typeof adapter.getVoiceCatalog).toBe('function')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{ id: 'v1', name: 'v1' }],
}), { status: 200 })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual([{ id: 'v1', name: 'v1' }])
expect(fetchImpl).toHaveBeenCalledTimes(1)
}
})
})
describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
it('calls unspeech with provider=alibaba + model (no Bearer)', async () => {
const adapter = getAdapter('dashscope-cosyvoice')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }],
}), { status: 200 })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: { model: 'cosyvoice-v2' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=alibaba&model=cosyvoice-v2')
const headers = (init.headers ?? {}) as Record<string, string>
expect(headers.Authorization).toBeUndefined()
})
it('throws 502 BAD_GATEWAY when unspeech non-2xx', async () => {
const adapter = getAdapter('dashscope-cosyvoice')
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,
})).rejects.toMatchObject({ statusCode: 502 })
})
})
describe('volcengineAdapter.getVoiceCatalog', () => {
it('calls unspeech with provider=volcengine and forwards adapterParams.model as ?model=', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{ id: 'zh_female_x', name: 'X' }],
}), { status: 200 })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: { model: 'seed-tts-2.0' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual([{ id: 'zh_female_x', name: 'X' }])
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine&model=seed-tts-2.0')
})
it('omits ?model= when adapterParams.model is not set', async () => {
const adapter = getAdapter('volcengine')
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,
})
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine')
})
})
describe('azureAdapter.getVoiceCatalog', () => {
it('sends bearer + region to unspeech and returns voices on 200', async () => {
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
const voices = await adapter.getVoiceCatalog({
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' }])
expect(fetchImpl).toHaveBeenCalledTimes(1)
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledUrl).toBe('http://unspeech.local:5933/api/voices?provider=microsoft&region=eastasia')
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer subscription-key-XYZ')
})
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' })
})
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' })
})
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({
keyPlaintext: Buffer.from('k', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
it('throws 502 BAD_GATEWAY when unspeech fetch throws', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => {
throw new Error('ECONNREFUSED')
}) as unknown as typeof fetch
await expect(adapter.getVoiceCatalog({
keyPlaintext: Buffer.from('k', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
})
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' },
})) as unknown as typeof fetch
await adapter.send(
{
text: 'hi there',
voice: 'en-US-AvaMultilingualNeural',
speed: 1.2,
extraOptions: {
pitch: 20,
volume: 5,
},
},
{
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { region: 'eastasia' },
fetchImpl,
},
)
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
const body = JSON.parse(init.body as string) as Record<string, unknown>
expect(body.model).toBe('microsoft/v1')
expect(body.voice).toBe('en-US-AvaMultilingualNeural')
expect((body.extra_body as { region?: string }).region).toBe('eastasia')
expect((body.extra_body as { disable_ssml?: boolean }).disable_ssml).toBe(true)
// SSML is built on our side so speed survives — verify the prosody tag is in
// the input field unspeech receives.
expect(body.input).toContain('<prosody rate=\'+20%\' pitch=\'+20%\' volume=\'+5%\'>')
expect(body.input).toContain('hi there')
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer azure-sub-key')
})
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' },
})) as unknown as typeof fetch
await adapter.send(
{ text: 'hi there' },
{
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { region: 'eastasia', defaultVoice: 'en-US-AvaMultilingualNeural' },
fetchImpl,
},
)
const [, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
const body = JSON.parse(init.body as string) as Record<string, unknown>
expect(body.voice).toBe('en-US-AvaMultilingualNeural')
})
it('rejects missing voice when adapterParams.defaultVoice is not configured', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn() as unknown as typeof fetch
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' },
fetchImpl,
},
)).rejects.toMatchObject({ statusCode: 400 })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('throws Error with .status when unspeech non-2xx', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response('upstream rejected', { status: 401 })) as unknown as typeof fetch
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' },
fetchImpl,
},
)).rejects.toMatchObject({ status: 401 })
})
})
describe('stepfunAdapter', () => {
it('uses unspeech as the StepFun voice-catalog source', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{
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,
})
expect(voices).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'cixingnansheng',
name: '磁性男声',
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
}),
]),
)
expect(fetchImpl).toHaveBeenCalledTimes(1)
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=stepfun')
})
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' },
})) 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,
},
},
{
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { model: 'stepaudio-2.5-tts' },
fetchImpl,
},
)
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
expect(init.method).toBe('POST')
expect(init.headers).toMatchObject({
'Authorization': 'Bearer step-key',
'Content-Type': 'application/json',
})
const body = JSON.parse(init.body as string) as Record<string, unknown>
expect(body).toEqual({
model: 'stepfun/stepaudio-2.5-tts',
input: '(轻声)你好',
voice: 'cixingnansheng',
response_format: 'mp3',
speed: 1.2,
extra_body: {
volume: 1.1,
sample_rate: 24000,
instruction: '温柔、克制、有一点笑意',
},
})
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
})
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' },
})) as unknown as typeof fetch
const result = await adapter.send(
{
text: '你好',
voice: 'cixingnansheng',
responseFormat: 'mp3',
speed: 1.1,
extraOptions: {
instruction: '温柔、克制',
},
},
{
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',
},
fetchImpl,
},
)
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
expect(init.method).toBe('POST')
expect(init.headers).toMatchObject({
'Authorization': 'Bearer step-plan-key',
'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: '温柔、克制',
},
})
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
})
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' },
})) as unknown as typeof fetch
await adapter.send(
{
text: 'hi',
extraOptions: {
voice_label: { emotion: '高兴' },
},
},
{
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local',
adapterParams: { model: 'stepaudio-2.5-tts' },
fetchImpl,
},
)
const [, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
const body = JSON.parse(init.body as string) as Record<string, { voice_label?: unknown }>
expect(body.extra_body.voice_label).toEqual({ emotion: '高兴' })
})
it('throws Error with .status when unspeech returns non-2xx', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response('bad key', { status: 401 })) as unknown as typeof fetch
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' },
fetchImpl,
},
)).rejects.toMatchObject({ status: 401 })
})
it('preserves an unspeech request abort for router timeout classification', async () => {
const adapter = getAdapter('stepfun')
const abortController = new AbortController()
const abortError = new Error('attempt-timeout')
abortController.abort(abortError)
const fetchImpl = vi.fn(async (_input: string | URL | Request, 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,
},
)).rejects.toBe(abortError)
})
})
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' },
})) as unknown as typeof fetch
const result = await adapter.send(
{ text: 'hi', voice: 'BV001_streaming', responseFormat: 'mp3', speed: 1.0 },
{
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' },
fetchImpl,
},
)
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
const body = JSON.parse(init.body as string) as Record<string, any>
expect(body.model).toBe('volcengine/seed-tts-2.0')
expect(body.voice).toBe('BV001_streaming')
expect(body.response_format).toBe('mp3')
expect(body.extra_body.app).toEqual({ appid: 'APP-123', cluster: 'volcano_tts' })
expect(typeof body.extra_body.request.reqid).toBe('string')
expect(body.extra_body.request.operation).toBe('query')
// Plain Bearer — unspeech itself re-attaches as `Bearer; <token>` to the
// upstream Volcengine call.
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer volc-token')
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
})
/**
* @example
* volcengineAdapter.send({ text: 'hi', extraOptions: { pitch: 20 } }, ctx)
*/
it('fails fast when Voice Pack pitch or volume params reach Volcengine', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49]))) as unknown as typeof fetch
await expect(adapter.send(
{
text: 'hi',
voice: 'BV001_streaming',
extraOptions: {
pitch: 20,
},
},
{
keyPlaintext: Buffer.from('volc-token', 'utf8'),
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { appid: 'APP-123' },
fetchImpl,
},
)).rejects.toMatchObject({ statusCode: 400 })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('rejects when adapterParams.appid is missing', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn() as unknown as typeof fetch
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: {},
fetchImpl,
},
)).rejects.toMatchObject({ statusCode: 500 })
})
})
@@ -0,0 +1,43 @@
import type { TtsAdapter, TtsAdapterId } from './types'
import { createBadRequestError } from '../../../utils/error'
import { azureAdapter } from './azure'
import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice'
import { stepfunAdapter } from './stepfun'
import { volcengineAdapter } from './volcengine'
const ADAPTERS: Record<TtsAdapterId, TtsAdapter> = {
'azure': azureAdapter,
'dashscope-cosyvoice': dashscopeCosyvoiceAdapter,
'stepfun': stepfunAdapter,
'volcengine': volcengineAdapter,
}
/**
* Resolves a TTS adapter by its stable id.
*
* Use when:
* - The router has loaded a TTS model config slice and needs to dispatch the
* request to the matching provider adapter.
*
* Expects:
* - `id` is one of the {@link TtsAdapterId} union members. Anything else means
* the configKV entry is desynced from the code (admin added a provider id we
* don't ship yet) — surface as a 400 with the offending id so ops can
* diagnose without digging through logs.
*
* Returns:
* - The adapter implementation. Throws `BAD_REQUEST` on unknown id.
*/
export function getAdapter(id: string): TtsAdapter {
if (id in ADAPTERS)
return ADAPTERS[id as TtsAdapterId]
throw createBadRequestError(
`unknown_tts_provider: ${id}`,
'BAD_REQUEST',
{ id, available: Object.keys(ADAPTERS) },
)
}
export type { TtsAdapter, TtsAdapterContext, TtsAdapterId, TtsInput, TtsResult } from './types'
@@ -0,0 +1,110 @@
import type { Voice } from 'unspeech'
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
import { isPlainObject } from 'es-toolkit'
import { audioMimeFromFormat } from './audio-format'
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
const STEPFUN_DEFAULT_MODEL = 'stepaudio-2.5-tts'
const STEPFUN_DEFAULT_FORMAT = 'mp3'
const STEPFUN_DEFAULT_VOICE = 'cixingnansheng'
/**
* StepFun TTS adapter.
*
* Use when:
* - Routing speech synthesis to StepFun through unspeech's OpenAI-compatible
* `stepfun/*` backend.
*
* Expects:
* - `ctx.unspeechBaseURL` points at an unspeech deployment that includes the
* StepFun backend.
* - `ctx.keyPlaintext` is the StepFun API key.
* - `ctx.adapterParams.model` optionally selects `stepaudio-2.5-tts`,
* `step-tts-2`, or `step-tts-mini`.
* - `ctx.adapterParams.endpointProfile` optionally selects a provider-owned
* endpoint profile such as `step-plan`; AIRI never owns the endpoint URL.
*
* Returns:
* - {@link TtsResult} with the upstream audio body and content type.
*/
export const stepfunAdapter: TtsAdapter = {
id: 'stepfun',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
const model = typeof ctx.adapterParams.model === 'string' && ctx.adapterParams.model
? ctx.adapterParams.model
: STEPFUN_DEFAULT_MODEL
const voice = input.voice ?? (typeof ctx.adapterParams.defaultVoice === 'string' && ctx.adapterParams.defaultVoice
? ctx.adapterParams.defaultVoice
: STEPFUN_DEFAULT_VOICE)
const responseFormat = input.responseFormat ?? (typeof ctx.adapterParams.responseFormat === 'string' && ctx.adapterParams.responseFormat
? ctx.adapterParams.responseFormat
: STEPFUN_DEFAULT_FORMAT)
const extraBody = buildExtraBody(input, ctx)
return sendSpeechViaUnSpeech({
ctx,
model: `stepfun/${model}`,
input: input.text,
voice,
speed: input.speed,
responseFormat,
extraBody,
fallbackContentType: audioMimeFromFormat(responseFormat),
providerLabel: 'stepfun',
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
return listVoicesViaUnSpeech({
ctx,
query: 'provider=stepfun',
providerLabel: 'stepfun',
})
},
}
function buildExtraBody(input: TtsInput, ctx: TtsAdapterContext): Record<string, unknown> {
const extraOptions = input.extraOptions ?? {}
const body: Record<string, unknown> = {}
if (typeof ctx.adapterParams.endpointProfile === 'string' && ctx.adapterParams.endpointProfile)
body.endpoint_profile = ctx.adapterParams.endpointProfile
if (typeof extraOptions.volume === 'number' && Number.isFinite(extraOptions.volume))
body.volume = extraOptions.volume
else if (typeof ctx.adapterParams.volume === 'number' && Number.isFinite(ctx.adapterParams.volume))
body.volume = ctx.adapterParams.volume
if (typeof extraOptions.sample_rate === 'number' && Number.isFinite(extraOptions.sample_rate))
body.sample_rate = extraOptions.sample_rate
else if (typeof extraOptions.sampleRate === 'number' && Number.isFinite(extraOptions.sampleRate))
body.sample_rate = extraOptions.sampleRate
else if (typeof ctx.adapterParams.sampleRate === 'number' && Number.isFinite(ctx.adapterParams.sampleRate))
body.sample_rate = ctx.adapterParams.sampleRate
if (isPlainObject(extraOptions.pronunciation_map))
body.pronunciation_map = extraOptions.pronunciation_map
else if (isPlainObject(extraOptions.pronunciationMap))
body.pronunciation_map = extraOptions.pronunciationMap
if (typeof extraOptions.markdown_filter === 'boolean')
body.markdown_filter = extraOptions.markdown_filter
else if (typeof extraOptions.markdownFilter === 'boolean')
body.markdown_filter = extraOptions.markdownFilter
if (typeof extraOptions.instruction === 'string' && extraOptions.instruction)
body.instruction = extraOptions.instruction
else if (typeof ctx.adapterParams.instruction === 'string' && ctx.adapterParams.instruction)
body.instruction = ctx.adapterParams.instruction
if (isPlainObject(extraOptions.voice_label))
body.voice_label = extraOptions.voice_label
else if (isPlainObject(extraOptions.voiceLabel))
body.voice_label = extraOptions.voiceLabel
return body
}
@@ -0,0 +1,140 @@
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.
*
* Use when:
* - Routing a hosted TTS request through the gateway.
* - Listing supported voices for a provider via {@link getVoiceCatalog}.
*
* Expects:
* - {@link TtsAdapterContext.fetchImpl} is wired by the caller.
* - {@link TtsAdapterContext.keyPlaintext} has already been decrypted from the
* key entry — adapters never touch envelope ciphertext.
*
* Returns:
* - A {@link TtsResult} on 2xx upstream responses.
* - Throws (Error subclass) on upstream non-2xx — the router maps the error to
* the next fallback key/upstream or to a 5xx for the caller. Adapters MUST
* 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.
*
* Live providers (Azure) call upstream through unspeech using the supplied
* region + plaintext key. Static provider catalogs are also owned and served
* by unspeech. Adapters MUST throw on upstream failure — no empty fallback.
*/
getVoiceCatalog: (ctx: TtsVoiceCatalogContext) => Promise<Voice[]>
}
@@ -0,0 +1,126 @@
import type { Voice } from 'unspeech'
import type { TtsAdapterContext, TtsResult, TtsVoiceCatalogContext } from './types'
import { errorMessageFrom } from '@moeru/std'
import { generateSpeechResponse, listVoices, UnSpeechAPIError } from 'unspeech'
import { createBadGatewayError, createInternalError } from '../../../utils/error'
interface SendSpeechOptions {
ctx: TtsAdapterContext
model: string
input: string
voice: string
speed?: number
responseFormat: string
extraBody?: Record<string, unknown>
fallbackContentType: string
providerLabel: string
}
/**
* Sends one OpenAI-shaped speech request through the unspeech SDK.
*
* Use when:
* - A TTS adapter has resolved AIRI's provider policy and needs to delegate the
* actual HTTP request to unspeech.
*
* Expects:
* - `model`, `voice`, `responseFormat`, and `extraBody` already match the
* provider-specific unspeech contract.
*
* Returns:
* - The binary audio payload plus a content type for the OpenAI route.
*/
export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise<TtsResult> {
const {
ctx,
extraBody,
fallbackContentType,
input,
model,
providerLabel,
responseFormat,
speed,
voice,
} = options
try {
const result = await generateSpeechResponse({
apiKey: ctx.keyPlaintext.toString('utf8'),
baseURL: `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/v1/`,
fetch: ctx.fetchImpl,
input,
model,
responseFormat,
speed,
voice,
abortSignal: ctx.abortSignal,
extraBody,
})
return {
contentType: result.contentType ?? fallbackContentType,
body: result.body,
}
}
catch (error) {
// Keep abort identity intact so the router can apply `onTimeout`
// independently from HTTP 500 fallback policy.
if (ctx.abortSignal?.aborted)
throw error
if (error instanceof UnSpeechAPIError) {
const err = new Error(`${providerLabel} tts upstream ${error.status}: ${error.responseBody.slice(0, 256)}`) as Error & { status?: number }
err.status = error.status
throw err
}
throw createInternalError(`${providerLabel} tts fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
interface ListVoicesOptions {
ctx: TtsVoiceCatalogContext
query: string
providerLabel: string
}
/**
* Lists unspeech voices and maps SDK failures into AIRI gateway errors.
*
* Use when:
* - A TTS adapter needs unspeech's normalized `Voice[]` catalog.
*
* Expects:
* - `query` is an unspeech `/api/voices` query string such as
* `provider=microsoft&region=eastasia`.
*
* Returns:
* - The parsed voice catalog.
*/
export async function listVoicesViaUnSpeech(options: ListVoicesOptions): Promise<Voice[]> {
const { ctx, providerLabel, query } = options
try {
return await listVoices({
apiKey: ctx.keyPlaintext?.toString('utf8'),
baseURL: ctx.unspeechBaseURL.replace(/\/+$/, ''),
fetch: ctx.fetchImpl,
query,
abortSignal: ctx.abortSignal,
headers: { Accept: 'application/json' },
})
}
catch (error) {
if (error instanceof UnSpeechAPIError) {
throw createBadGatewayError(
`${providerLabel} voices upstream ${error.status}: ${error.responseBody.slice(0, 256)}`,
{ lastStatusCode: error.status },
)
}
throw createBadGatewayError(`${providerLabel} voices fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
@@ -0,0 +1,114 @@
import type { Voice } from 'unspeech'
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
import { createBadRequestError, createInternalError } from '../../../utils/error'
import { nanoid } from '../../../utils/id'
import { audioMimeFromFormat } from './audio-format'
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
/**
* Default Volcengine TTS voice id. `BV001_streaming` is Volcengine's standard
* Chinese general-purpose streaming voice referenced in their docs.
*/
const DEFAULT_VOLCENGINE_VOICE = 'BV001_streaming'
/**
* Default Volcengine audio encoding. Matches our OpenAI-shape `mp3` default.
*/
const DEFAULT_VOLCENGINE_FORMAT = 'mp3'
/**
* Default Volcengine cluster. Documented as `volcano_tts` for the generic
* hosted TTS endpoint; ops can override via `adapterParams.cluster`.
*/
const DEFAULT_VOLCENGINE_CLUSTER = 'volcano_tts'
/**
* Volcengine non-streaming REST adapter.
*
* Use when:
* - Routing a hosted TTS request to Volcengine OpenSpeech.
*
* Expects:
* - `ctx.baseURL` is the Volcengine TTS endpoint, e.g.
* `https://openspeech.bytedance.com/api/v1/tts`.
* - `ctx.keyPlaintext` is the access token. The auth header uses Volcengine's
* non-standard `Bearer; <token>` format (semicolon after `Bearer`).
* - `ctx.adapterParams.appid` is the Volcengine application id (required).
* - `ctx.adapterParams.cluster` overrides the default cluster id when set.
*
* Returns:
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. Body is
* decoded from the upstream JSON `data` base64 field.
*/
export const volcengineAdapter: TtsAdapter = {
id: 'volcengine',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
const appid = ctx.adapterParams.appid
if (typeof appid !== 'string' || !appid)
throw createInternalError('volcengine tts: adapterParams.appid is required')
const cluster = typeof ctx.adapterParams.cluster === 'string'
? ctx.adapterParams.cluster
: DEFAULT_VOLCENGINE_CLUSTER
const apiResourceId = typeof ctx.adapterParams.model === 'string'
? ctx.adapterParams.model
: undefined
const voice = input.voice ?? DEFAULT_VOLCENGINE_VOICE
if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') {
throw createBadRequestError(
'volcengine does not support Voice Pack pitch or volume parameters',
'BAD_REQUEST',
)
}
const encoding = input.responseFormat ?? DEFAULT_VOLCENGINE_FORMAT
const speed = input.speed ?? 1
// unspeech volcengine backend (unspeech/pkg/backend/volcengine/speech.go):
// - reads token from `Authorization: Bearer <token>` (strips "Bearer "
// prefix), then re-attaches as `Bearer; <token>` to the upstream — so
// we send a normal Bearer here, NOT the `Bearer; ` form.
// - takes `app.appid`, `app.cluster`, `user.uid`, `request.reqid`,
// `audio.encoding`, `audio.speed_ratio` from `extra_body` jsonpath.
// - 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' },
},
fallbackContentType: audioMimeFromFormat(encoding),
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',
})
},
}
@@ -0,0 +1,240 @@
import type { Database } from '../../../../libs/db'
import type { BillingService } from '../../billing/billing-service'
import { useLogger } from '@guiiai/logg'
import { errorMessageFrom } from '@moeru/std'
import { inArray } from 'drizzle-orm'
import * as accountsSchema from '../../../../schemas/accounts'
import * as fluxSchema from '../../../../schemas/flux'
const logger = useLogger('admin-flux-grants').useGlobalConfig()
export type SkipReason = 'duplicate_in_input' | 'not_found' | 'user_deleted'
export interface ResolvedRecipient {
inputEmail: string
userId: string | null
status: 'pending' | 'skipped'
errorReason: SkipReason | null
}
export interface PreviewSummary {
totalEmails: number
willGrant: number
willSkip: { notFound: number, userDeleted: number, duplicateInInput: number }
totalFluxToIssue: number
samples: { willGrant: string[], notFound: string[], userDeleted: string[] }
}
export interface GrantResult {
granted: { email: string, userId: string, fluxTransactionId: string, balanceAfter: number }[]
skipped: { email: string, reason: SkipReason }[]
failed: { email: string, userId: string, error: string }[]
}
export interface GrantInput {
amount: number
description: string
emails: string[]
createdByUserId: string
/**
* When provided, recipient `requestId`s are derived as
* `flux-grant:${idempotencyKey}:${userId}` so re-running the same call
* with the same key + recipients is a no-op (handled by the partial
* unique index on `flux_transaction(user_id, request_id)`).
* When omitted, every recipient gets a fresh requestId — re-issuing the
* same grant will double-credit, which is the right default for "I made
* a typo and want to send again".
*/
idempotencyKey?: string
}
/**
* Resolve operator-supplied input emails against the user table.
*
* Use when:
* - Either `preview` (dry-run) or the actual grant call needs the same
* per-email outcome shape
*
* Expects:
* - `user.email` is stored lowercase (better-auth normalizes on signup
* for both email/password and OAuth). Wrapping the column in `LOWER()`
* in the query would bypass the unique index on `email` and force a
* sequential scan, so input is lowercased instead.
*
* Returns:
* - One `ResolvedRecipient` per input email (duplicates included with
* `duplicate_in_input` so the caller can audit them)
*/
async function resolveEmails(db: Database, emails: string[]): Promise<ResolvedRecipient[]> {
const seenLower = new Map<string, number>()
const resolved: ResolvedRecipient[] = emails.map((email, idx) => {
const lower = email.toLowerCase()
if (seenLower.has(lower))
return { inputEmail: email, userId: null, status: 'skipped', errorReason: 'duplicate_in_input' }
seenLower.set(lower, idx)
return { inputEmail: email, userId: null, status: 'pending', errorReason: null }
})
const lowerEmails = Array.from(seenLower.keys())
const users = lowerEmails.length === 0
? []
: await db
.select({ id: accountsSchema.user.id, email: accountsSchema.user.email })
.from(accountsSchema.user)
.where(inArray(accountsSchema.user.email, lowerEmails))
const userByLowerEmail = new Map(users.map(u => [u.email.toLowerCase(), u.id]))
const matchedUserIds = users.map(u => u.id)
const fluxRows = matchedUserIds.length === 0
? []
: await db
.select({ userId: fluxSchema.userFlux.userId, deletedAt: fluxSchema.userFlux.deletedAt })
.from(fluxSchema.userFlux)
.where(inArray(fluxSchema.userFlux.userId, matchedUserIds))
const deletedUserIds = new Set(fluxRows.filter(r => r.deletedAt != null).map(r => r.userId))
for (const entry of resolved) {
if (entry.errorReason === 'duplicate_in_input')
continue
const userId = userByLowerEmail.get(entry.inputEmail.toLowerCase())
if (!userId) {
entry.status = 'skipped'
entry.errorReason = 'not_found'
continue
}
if (deletedUserIds.has(userId)) {
entry.userId = userId
entry.status = 'skipped'
entry.errorReason = 'user_deleted'
continue
}
entry.userId = userId
entry.status = 'pending'
}
return resolved
}
function buildPreviewSummary(resolved: ResolvedRecipient[], amountPerUser: number): PreviewSummary {
const willGrant = resolved.filter(r => r.status === 'pending').length
const notFound = resolved.filter(r => r.errorReason === 'not_found').length
const userDeleted = resolved.filter(r => r.errorReason === 'user_deleted').length
const duplicateInInput = resolved.filter(r => r.errorReason === 'duplicate_in_input').length
return {
totalEmails: resolved.length,
willGrant,
willSkip: { notFound, userDeleted, duplicateInInput },
totalFluxToIssue: willGrant * amountPerUser,
samples: {
willGrant: resolved.filter(r => r.status === 'pending').slice(0, 5).map(r => r.inputEmail),
notFound: resolved.filter(r => r.errorReason === 'not_found').slice(0, 5).map(r => r.inputEmail),
userDeleted: resolved.filter(r => r.errorReason === 'user_deleted').slice(0, 5).map(r => r.inputEmail),
},
}
}
export function createAdminFluxGrantsService(deps: { db: Database, billingService: BillingService }) {
const { db, billingService } = deps
return {
/**
* Dry-run preview: returns what would happen without writing anything.
*/
async preview(input: { amount: number, emails: string[] }): Promise<PreviewSummary> {
const resolved = await resolveEmails(db, input.emails)
return buildPreviewSummary(resolved, input.amount)
},
/**
* Issue a grant to every resolvable email, sequentially.
*
* Use when:
* - Admin clicks "send" on a grant. Returns once every recipient has
* either been credited, marked skipped (resolution-time issue), or
* marked failed (`creditFlux` threw).
*
* Expects:
* - Caller has admin authority (route middleware enforces this)
* - Batch size fits inside the load balancer timeout — the route
* layer caps `emails.length`
*
* Returns:
* - Per-email outcome buckets. The same `inputEmail` order is preserved
* inside each bucket so the operator can spot recipient-specific
* issues without correlating across responses.
*/
async grant(input: GrantInput): Promise<{ summary: PreviewSummary, result: GrantResult }> {
const resolved = await resolveEmails(db, input.emails)
const summary = buildPreviewSummary(resolved, input.amount)
const result: GrantResult = { granted: [], skipped: [], failed: [] }
for (const entry of resolved) {
if (entry.status === 'skipped') {
result.skipped.push({ email: entry.inputEmail, reason: entry.errorReason ?? 'not_found' })
continue
}
// entry.status === 'pending' implies userId is set
const userId = entry.userId!
const requestId = input.idempotencyKey != null
? `flux-grant:${input.idempotencyKey}:${userId}`
: undefined
try {
const credited = await billingService.creditFlux({
userId,
amount: input.amount,
type: 'promo',
requestId,
description: input.description,
source: 'admin_promo',
auditMetadata: {
description: input.description,
issuedByUserId: input.createdByUserId,
...(input.idempotencyKey != null && { idempotencyKey: input.idempotencyKey }),
},
})
result.granted.push({
email: entry.inputEmail,
userId,
fluxTransactionId: credited.fluxTransactionId,
balanceAfter: credited.balanceAfter,
})
}
catch (err) {
const message = errorMessageFrom(err) ?? 'Unknown error'
result.failed.push({ email: entry.inputEmail, userId, error: message.slice(0, 500) })
logger.withError(err).withFields({ userId, email: entry.inputEmail }).warn('Flux grant failed')
}
}
logger.withFields({
description: input.description,
attempted: summary.willGrant,
granted: result.granted.length,
skipped: result.skipped.length,
failed: result.failed.length,
amount: input.amount,
issuedByUserId: input.createdByUserId,
}).log('Admin flux grant completed')
return { summary, result }
},
}
}
export type AdminFluxGrantsService = ReturnType<typeof createAdminFluxGrantsService>
/**
* Exported for unit tests of resolution edge cases (case folding, duplicate
* handling, soft-delete detection).
*/
export { resolveEmails }
@@ -0,0 +1,328 @@
import type { Database } from '../../../../../libs/db'
import type { BillingService } from '../../../billing/billing-service'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAdminFluxGrantsService, resolveEmails } from '..'
import { mockDB } from '../../../../../libs/mock-db'
import * as schema from '../../../../../schemas'
describe('resolveEmails', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
// Three users: one normal, one with deleted user_flux, one we never insert
// user_flux for at all (so it shows up as "user exists, no flux row" → still
// pending since user.id matches; default flux init happens at credit time).
//
// NOTICE: All stored emails are lowercase. resolveEmails relies on this
// (Codex review 2026-05-08 flagged that wrapping user.email in LOWER()
// bypasses the unique index and seq-scans). better-auth normalizes emails
// on signup, so this matches production reality.
await db.insert(schema.user).values([
{ id: 'uid_normal', name: 'Normal', email: 'normal@example.com' },
{ id: 'uid_deleted', name: 'Deleted', email: 'deleted@example.com' },
{ id: 'uid_no_flux', name: 'NoFlux', email: 'noflux@example.com' },
{ id: 'uid_mixed_case', name: 'MixedCase', email: 'Mixed@Example.com' },
])
await db.insert(schema.userFlux).values([
{ userId: 'uid_normal', flux: 100 },
{ userId: 'uid_deleted', flux: 0, deletedAt: new Date() },
])
})
it('lowercases input before matching the (lowercase) stored email', async () => {
const resolved = await resolveEmails(db, ['NORMAL@example.com'])
expect(resolved).toHaveLength(1)
expect(resolved[0]).toMatchObject({
inputEmail: 'NORMAL@example.com',
userId: 'uid_normal',
status: 'pending',
errorReason: null,
})
})
it('treats non-lowercase stored emails as not_found (documented limitation)', async () => {
// Stored email is 'Mixed@Example.com' (mixed case); we look up by lowercase
// 'mixed@example.com', which won't match because we don't wrap user.email
// in LOWER() — that would defeat the unique index and seq-scan the table.
const resolved = await resolveEmails(db, ['mixed@example.com'])
expect(resolved[0]).toMatchObject({
inputEmail: 'mixed@example.com',
userId: null,
status: 'skipped',
errorReason: 'not_found',
})
})
it('marks unknown emails as not_found', async () => {
const resolved = await resolveEmails(db, ['ghost@example.com'])
expect(resolved[0]).toMatchObject({
inputEmail: 'ghost@example.com',
userId: null,
status: 'skipped',
errorReason: 'not_found',
})
})
it('marks soft-deleted users as user_deleted (userId still attached for audit)', async () => {
const resolved = await resolveEmails(db, ['deleted@example.com'])
expect(resolved[0]).toMatchObject({
inputEmail: 'deleted@example.com',
userId: 'uid_deleted',
status: 'skipped',
errorReason: 'user_deleted',
})
})
it('keeps the first occurrence and tags subsequent duplicates', async () => {
const resolved = await resolveEmails(db, [
'normal@example.com',
'NORMAL@EXAMPLE.COM',
'normal@example.com',
])
expect(resolved).toHaveLength(3)
expect(resolved[0].errorReason).toBeNull()
expect(resolved[0].status).toBe('pending')
expect(resolved[1].errorReason).toBe('duplicate_in_input')
expect(resolved[2].errorReason).toBe('duplicate_in_input')
})
it('returns empty resolution for empty input without hitting the DB', async () => {
const resolved = await resolveEmails(db, [])
expect(resolved).toEqual([])
})
})
describe('adminFluxGrantsService.preview', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values([
{ id: 'uid_prev_a', name: 'A', email: 'preva@example.com' },
])
await db.insert(schema.userFlux).values([
{ userId: 'uid_prev_a', flux: 0 },
])
})
it('returns counts and samples without writing anything', async () => {
const billingService = { creditFlux: vi.fn() } as unknown as BillingService
const service = createAdminFluxGrantsService({ db, billingService })
const summary = await service.preview({
amount: 50,
emails: ['preva@example.com', 'ghost@example.com', 'preva@example.com'],
})
expect(summary).toEqual({
totalEmails: 3,
willGrant: 1,
willSkip: { notFound: 1, userDeleted: 0, duplicateInInput: 1 },
totalFluxToIssue: 50,
samples: {
willGrant: ['preva@example.com'],
notFound: ['ghost@example.com'],
userDeleted: [],
},
})
expect(billingService.creditFlux).not.toHaveBeenCalled()
})
it('caps preview samples at 5 entries per category', async () => {
const billingService = { creditFlux: vi.fn() } as unknown as BillingService
const service = createAdminFluxGrantsService({ db, billingService })
const ghosts = Array.from({ length: 12 }, (_, i) => `ghost${i}@example.com`)
const summary = await service.preview({ amount: 50, emails: ghosts })
expect(summary.willSkip.notFound).toBe(12)
expect(summary.samples.notFound).toHaveLength(5)
})
})
describe('adminFluxGrantsService.grant', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values([
{ id: 'uid_grant_a', name: 'A', email: 'granta@example.com' },
{ id: 'uid_grant_b', name: 'B', email: 'grantb@example.com' },
{ id: 'uid_grant_c', name: 'C', email: 'grantc@example.com' },
])
await db.insert(schema.userFlux).values([
{ userId: 'uid_grant_a', flux: 0 },
{ userId: 'uid_grant_b', flux: 0, deletedAt: new Date() },
{ userId: 'uid_grant_c', flux: 0 },
])
})
beforeEach(async () => {
await db.delete(schema.fluxTransaction)
})
it('credits resolvable recipients and bucket-sorts the per-email outcomes', async () => {
const creditFlux = vi.fn(async ({ userId }: { userId: string }) => ({
balanceBefore: 0,
balanceAfter: 100,
fluxTransactionId: `ftx-${userId}`,
idempotent: false,
}))
const billingService = { creditFlux } as unknown as BillingService
const service = createAdminFluxGrantsService({ db, billingService })
const { summary, result } = await service.grant({
amount: 100,
description: 'Beta thanks',
emails: [
'granta@example.com', // pending → granted
'grantb@example.com', // soft-deleted → skipped(user_deleted)
'GRANTA@example.com', // duplicate
'ghost@example.com', // not_found
],
createdByUserId: 'uid_admin',
})
expect(summary).toMatchObject({
totalEmails: 4,
willGrant: 1,
willSkip: { notFound: 1, userDeleted: 1, duplicateInInput: 1 },
totalFluxToIssue: 100,
})
expect(result.granted).toEqual([{
email: 'granta@example.com',
userId: 'uid_grant_a',
fluxTransactionId: 'ftx-uid_grant_a',
balanceAfter: 100,
}])
expect(result.skipped).toEqual(expect.arrayContaining([
{ email: 'grantb@example.com', reason: 'user_deleted' },
{ email: 'GRANTA@example.com', reason: 'duplicate_in_input' },
{ email: 'ghost@example.com', reason: 'not_found' },
]))
expect(result.failed).toEqual([])
expect(creditFlux).toHaveBeenCalledTimes(1)
expect(creditFlux).toHaveBeenCalledWith(expect.objectContaining({
userId: 'uid_grant_a',
amount: 100,
type: 'promo',
description: 'Beta thanks',
source: 'admin_promo',
requestId: undefined, // no idempotencyKey in this test
auditMetadata: expect.objectContaining({
description: 'Beta thanks',
issuedByUserId: 'uid_admin',
}),
}))
})
it('catches per-recipient creditFlux errors and continues with the rest', async () => {
let calls = 0
const creditFlux = vi.fn(async ({ userId }: { userId: string }) => {
calls += 1
if (calls === 1)
throw new Error('DB timeout')
return {
balanceBefore: 0,
balanceAfter: 100,
fluxTransactionId: `ftx-${userId}`,
idempotent: false,
}
})
const billingService = { creditFlux } as unknown as BillingService
const service = createAdminFluxGrantsService({ db, billingService })
const { result } = await service.grant({
amount: 100,
description: 'Resilience test',
emails: ['granta@example.com', 'grantc@example.com'],
createdByUserId: 'uid_admin',
})
expect(result.granted).toHaveLength(1)
expect(result.granted[0].userId).toBe('uid_grant_c')
expect(result.failed).toHaveLength(1)
expect(result.failed[0]).toMatchObject({ email: 'granta@example.com', userId: 'uid_grant_a', error: 'DB timeout' })
})
it('forwards a deterministic requestId per recipient when idempotencyKey is provided', async () => {
const creditFlux = vi.fn(async () => ({
balanceBefore: 0,
balanceAfter: 100,
fluxTransactionId: 'ftx',
idempotent: false,
}))
const billingService = { creditFlux } as unknown as BillingService
const service = createAdminFluxGrantsService({ db, billingService })
await service.grant({
amount: 100,
description: 'Idempotent thanks',
emails: ['granta@example.com', 'grantc@example.com'],
createdByUserId: 'uid_admin',
idempotencyKey: 'beta-2026-q2',
})
expect(creditFlux).toHaveBeenNthCalledWith(1, expect.objectContaining({
userId: 'uid_grant_a',
requestId: 'flux-grant:beta-2026-q2:uid_grant_a',
}))
expect(creditFlux).toHaveBeenNthCalledWith(2, expect.objectContaining({
userId: 'uid_grant_c',
requestId: 'flux-grant:beta-2026-q2:uid_grant_c',
}))
})
it('end-to-end: actually writes flux_transaction rows for granted recipients via the real BillingService path', async () => {
// Light integration sanity check — we still mock BillingService here, but
// verify the service pipes through the right shape and granted set
// matches what the route would surface.
const inserted: { userId: string, requestId?: string }[] = []
const creditFlux = vi.fn(async ({ userId, requestId, amount }: { userId: string, requestId?: string, amount: number }) => {
const [row] = await db.insert(schema.fluxTransaction).values({
userId,
type: 'promo',
amount,
balanceBefore: 0,
balanceAfter: amount,
requestId: requestId ?? null,
description: 'mocked',
}).returning()
inserted.push({ userId, requestId })
return {
balanceBefore: 0,
balanceAfter: amount,
fluxTransactionId: row!.id,
idempotent: false,
}
})
const billingService = { creditFlux } as unknown as BillingService
const service = createAdminFluxGrantsService({ db, billingService })
const { result } = await service.grant({
amount: 25,
description: 'Integration test',
emails: ['granta@example.com', 'grantc@example.com'],
createdByUserId: 'uid_admin',
idempotencyKey: 'int-1',
})
expect(result.granted).toHaveLength(2)
const ledger = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.requestId, 'flux-grant:int-1:uid_grant_a'))
expect(ledger).toHaveLength(1)
expect(ledger[0]?.amount).toBe(25)
expect(inserted.map(r => r.requestId).sort()).toEqual([
'flux-grant:int-1:uid_grant_a',
'flux-grant:int-1:uid_grant_c',
])
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,822 @@
import type Redis from 'ioredis'
import type { ConfigKVService } from '../../../../adapters/config-kv'
import { randomBytes } from 'node:crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildAliyunNlsAsrSlice,
buildAzureSlice,
buildBedrockSlice,
buildDashscopeSlice,
buildNextRouterConfig,
buildOpenRouterSlice,
buildStepfunSlice,
buildUnspeechSlice,
createAdminRouterConfigService,
redactCiphertext,
} from '..'
import { createEnvelopeCrypto } from '../../../../../utils/envelope-crypto'
function freshEnvelope() {
return createEnvelopeCrypto({ masterKey: randomBytes(32) })
}
const DEFAULT_FALLBACK_TRIGGERS = {
httpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
onTimeout: true,
}
interface FakeConfigKV {
store: Map<string, unknown>
service: ConfigKVService
publishedChannels: { channel: string, payload: string }[]
}
/**
* In-memory fake for ConfigKVService + a thin Redis publish stub.
*
* Use when:
* - Service tests need to assert which configKV entries got written and
* which channel publishes fired, without touching real Redis.
*/
function fakeConfigKV(): FakeConfigKV {
const store = new Map<string, unknown>()
const service: Partial<ConfigKVService> = {
async getOptional(key: string) {
return (store.get(key) ?? null) as never
},
async getOrThrow(key: string) {
const v = store.get(key)
if (v === undefined)
throw new Error(`fake getOrThrow missing ${key}`)
return v as never
},
async get(key: string) {
return this.getOrThrow!(key as never)
},
async set(key: string, value: unknown) {
store.set(key, value)
},
}
return { store, service: service as ConfigKVService, publishedChannels: [] }
}
function fakeRedis(captured: { channel: string, payload: string }[]): Redis {
return {
publish: vi.fn(async (channel: string, payload: string) => {
captured.push({ channel, payload })
return 1
}),
} as unknown as Redis
}
describe('redactCiphertext', () => {
it('replaces ciphertext strings with a length tag', () => {
const input = {
keys: [{ id: 'k1', ciphertext: 'a'.repeat(100) }],
adapterParams: { nested: { ciphertext: 'b'.repeat(50) } },
}
expect(redactCiphertext(input)).toEqual({
keys: [{ id: 'k1', ciphertext: '<ciphertext: 100 chars>' }],
adapterParams: { nested: { ciphertext: '<ciphertext: 50 chars>' } },
})
})
it('leaves non-ciphertext fields unchanged', () => {
expect(redactCiphertext({ baseURL: 'https://x', count: 3, flag: true })).toEqual({
baseURL: 'https://x',
count: 3,
flag: true,
})
})
it('walks arrays', () => {
expect(redactCiphertext([{ ciphertext: 'xx' }, { ciphertext: 'yyy' }])).toEqual([
{ ciphertext: '<ciphertext: 2 chars>' },
{ ciphertext: '<ciphertext: 3 chars>' },
])
})
})
describe('buildOpenRouterSlice', () => {
it('encrypts the plaintext key under {modelName, keyEntryId} AAD', () => {
const envelope = freshEnvelope()
const built = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk-or-secret',
}, envelope)
expect(built.target).toBe('llm-router')
expect(built.surface).toBe('llm')
expect(built.modelName).toBe('chat-default')
expect(built.keyEntryId).toBe('openrouter-prod-1')
const upstream = built.model.upstreams[0]
expect(upstream.baseURL).toBe('https://openrouter.ai/api/v1')
expect(upstream.overrideModel).toBe('openai/gpt-4o-mini')
expect(upstream.headerTemplate).toBe('Bearer {KEY}')
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
// Round-trip the ciphertext under the same AAD — guards against the
// AAD getting silently changed (which would surface as DECRYPT_FAILED
// at gateway runtime, but never in tests like this if we only checked
// the ciphertext length).
const decrypted = envelope.decryptKey(upstream.keys[0].ciphertext, {
modelName: 'chat-default',
keyEntryId: 'openrouter-prod-1',
})
expect(decrypted.toString('utf8')).toBe('sk-or-secret')
})
it('respects custom baseURL, keyEntryId, and headerTemplate', () => {
const envelope = freshEnvelope()
const built = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
baseURL: 'https://proxy.example/api/v1',
keyEntryId: 'openrouter-prod-2',
headerTemplate: 'X-Custom-Token {KEY}',
}, envelope)
expect(built.keyEntryId).toBe('openrouter-prod-2')
expect(built.model.upstreams[0].baseURL).toBe('https://proxy.example/api/v1')
expect(built.model.upstreams[0].headerTemplate).toBe('X-Custom-Token {KEY}')
expect(built.model.upstreams[0].keys[0].id).toBe('openrouter-prod-2')
})
})
describe('buildBedrockSlice', () => {
it('accepts and encrypts multi-kilobyte Bedrock bearer tokens', () => {
const envelope = freshEnvelope()
const token = `bedrock-api-key-${'x'.repeat(2200)}`
const built = buildBedrockSlice({
kind: 'bedrock',
modelName: 'chat-bedrock',
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
plaintextKey: token,
}, envelope)
expect(built.kind).toBe('bedrock')
expect(built.keyEntryId).toBe('bedrock-prod-1')
expect(built.model.upstreams[0].baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'chat-bedrock',
keyEntryId: 'bedrock-prod-1',
})
expect(decrypted.toString('utf8')).toBe(token)
})
})
describe('buildAzureSlice', () => {
it('builds the cognitiveservices baseURL from region and surfaces region in adapterParams', () => {
const envelope = freshEnvelope()
const built = buildAzureSlice({
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
defaultVoice: 'en-US-AvaMultilingualNeural',
plaintextKey: 'azure-key',
}, envelope)
expect(built.kind).toBe('azure')
expect(built.model.provider).toBe('azure')
expect(built.model.upstreams[0].baseURL).toBe('https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1')
expect(built.model.upstreams[0].adapterParams).toEqual({
region: 'eastasia',
defaultVoice: 'en-US-AvaMultilingualNeural',
})
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'microsoft/v1',
keyEntryId: 'azure-tts-prod-1',
})
expect(decrypted.toString('utf8')).toBe('azure-key')
})
})
describe('buildAliyunNlsAsrSlice', () => {
/**
* @example
* buildAliyunNlsAsrSlice({ kind: 'aliyun-nls-asr', modelName: 'auto', accessKeyId: 'ak', appKey: 'app', plaintextKey: 'secret' }, envelope)
*/
it('encrypts the access key secret under the ASR model AAD', () => {
const envelope = freshEnvelope()
const built = buildAliyunNlsAsrSlice({
kind: 'aliyun-nls-asr',
modelName: 'auto',
accessKeyId: 'ak',
appKey: 'app',
plaintextKey: 'secret',
}, envelope)
expect(built.target).toBe('llm-router')
expect(built.surface).toBe('asr')
expect(built.modelName).toBe('auto')
expect(built.keyEntryId).toBe('aliyun-nls-asr-prod-1')
expect(built.model.provider).toBe('aliyun-nls')
expect(built.model.upstreams[0].adapterParams).toEqual({
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
})
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'auto',
keyEntryId: 'aliyun-nls-asr-prod-1',
})
expect(decrypted.toString('utf8')).toBe('secret')
})
})
describe('buildDashscopeSlice', () => {
it.each([
['intl', 'dashscope-intl.aliyuncs.com'],
['cn', 'dashscope.aliyuncs.com'],
] as const)('uses the %s region host', (region, host) => {
const envelope = freshEnvelope()
const built = buildDashscopeSlice({
kind: 'dashscope-cosyvoice',
modelName: 'alibaba/cosyvoice-v2',
region,
upstreamModel: 'cosyvoice-v2',
plaintextKey: 'sk-dash',
}, envelope)
expect(built.model.upstreams[0].baseURL).toBe(`https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`)
expect(built.model.upstreams[0].adapterParams).toEqual({ model: 'cosyvoice-v2' })
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
})
})
describe('buildStepfunSlice', () => {
it('builds the StepFun TTS endpoint and surfaces model defaults in adapterParams', () => {
const envelope = freshEnvelope()
const built = buildStepfunSlice({
kind: 'stepfun',
modelName: 'stepfun/stepaudio-2.5-tts',
upstreamModel: 'stepaudio-2.5-tts',
defaultVoice: 'cixingnansheng',
instruction: '温柔、克制、有一点笑意',
plaintextKey: 'step-key',
}, envelope)
expect(built.kind).toBe('stepfun')
expect(built.model.provider).toBe('stepfun')
expect(built.model.upstreams[0].baseURL).toBe('https://api.stepfun.com')
expect(built.model.upstreams[0].adapterParams).toEqual({
model: 'stepaudio-2.5-tts',
defaultVoice: 'cixingnansheng',
instruction: '温柔、克制、有一点笑意',
})
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'stepfun/stepaudio-2.5-tts',
keyEntryId: 'stepfun-tts-prod-1',
})
expect(decrypted.toString('utf8')).toBe('step-key')
})
})
describe('buildUnspeechSlice', () => {
it('writes restBaseURL with no streaming subtree when the slice omits streaming', () => {
const envelope = freshEnvelope()
const built = buildUnspeechSlice({
kind: 'unspeech',
restBaseURL: 'http://unspeech.example:5933',
}, envelope)
expect(built.target).toBe('unspeech')
expect(built.value.restBaseURL).toBe('http://unspeech.example:5933')
expect(built.value.streaming).toBeUndefined()
expect(built.keyEntryId).toBeNull()
})
it('encrypts streaming.plaintextKey under the streaming-tts AAD model label (must match audio-speech-ws decrypt)', () => {
const envelope = freshEnvelope()
const built = buildUnspeechSlice({
kind: 'unspeech',
restBaseURL: 'http://unspeech.example:5933',
streaming: {
upstreamURL: 'ws://unspeech.example:5933/v1/audio/speech/stream',
plaintextKey: 'volc-key',
},
}, envelope)
expect(built.target).toBe('unspeech')
expect(built.value.streaming?.baseURL).toBe('ws://unspeech.example:5933/v1/audio/speech/stream')
// The AAD modelName MUST be the literal 'streaming-tts' — anything else
// surfaces as DECRYPT_FAILED at session start in audio-speech-ws.
const ct = built.value.streaming!.keys[0].ciphertext
const decrypted = envelope.decryptKey(ct, {
modelName: 'streaming-tts',
keyEntryId: 'volcengine-prod-1',
})
expect(decrypted.toString('utf8')).toBe('volc-key')
})
})
describe('buildNextRouterConfig', () => {
it('merge mode preserves models not touched this run', () => {
const envelope = freshEnvelope()
const existing = {
llm: {
models: {
'untouched-chat': {
upstreams: [{ baseURL: 'https://old', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
tts: {
models: {
'untouched-tts': {
provider: 'azure' as const,
upstreams: [{ baseURL: 'https://old-tts', keys: [{ id: 'k', ciphertext: 'c' }], adapterParams: {} }],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
defaults: { perAttemptTimeoutMs: 12345, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
}
const newSlice = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}, envelope)
const next = buildNextRouterConfig('merge', existing, [newSlice])
expect(Object.keys(next.llm.models).sort()).toEqual(['chat-default', 'untouched-chat'])
expect(Object.keys(next.tts.models)).toEqual(['untouched-tts'])
// Defaults preserved verbatim in merge mode — the admin endpoint does
// not currently re-tune timeouts, and zeroing them would silently break
// gateway timeouts.
expect(next.defaults?.perAttemptTimeoutMs).toBe(12345)
})
it('reset mode drops every prior entry and uses default timeouts', () => {
const envelope = freshEnvelope()
const existing = {
llm: {
models: {
old: {
upstreams: [{ baseURL: 'https://old', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 12345, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
}
const newSlice = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}, envelope)
const next = buildNextRouterConfig('reset', existing, [newSlice])
expect(Object.keys(next.llm.models)).toEqual(['chat-default'])
expect(next.defaults?.perAttemptTimeoutMs).toBe(30000)
})
it('starts from empty when existing is null (first-time bootstrap)', () => {
const envelope = freshEnvelope()
const newSlice = buildAzureSlice({
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
plaintextKey: 'azure-key',
}, envelope)
const next = buildNextRouterConfig('merge', null, [newSlice])
expect(Object.keys(next.llm.models)).toEqual([])
expect(Object.keys(next.tts.models)).toEqual(['microsoft/v1'])
})
})
describe('createAdminRouterConfigService', () => {
let kv: FakeConfigKV
let captured: { channel: string, payload: string }[]
let redis: Redis
let envelope: ReturnType<typeof createEnvelopeCrypto>
beforeEach(() => {
kv = fakeConfigKV()
captured = []
redis = fakeRedis(captured)
envelope = freshEnvelope()
})
it('dry-run returns redacted preview without touching the store or pub/sub', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: true,
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk-or-secret',
}],
defaults: {
chatModel: 'chat-default',
ttsVoices: {
'alibaba/cosyvoice-v2': { 'zh-CN': 'longxiaochun_v2' },
},
},
})
expect(kv.store.size).toBe(0)
expect(captured).toEqual([])
expect(result.invalidatedKeys).toEqual([])
// Preview must redact ciphertext — leaking plaintext OR raw ciphertext
// back to admin response would be a regression.
const preview = result.preview.LLM_ROUTER_CONFIG as { llm: { models: Record<string, { upstreams: { keys: { ciphertext: string }[] }[] }> } }
const ct = preview.llm.models['chat-default'].upstreams[0].keys[0].ciphertext
expect(ct).toMatch(/^<ciphertext: \d+ chars>$/)
expect(ct).not.toContain('sk-or-secret')
expect(result.preview.DEFAULT_CHAT_MODEL).toBe('chat-default')
expect(result.preview.DEFAULT_TTS_VOICES).toEqual({
'alibaba/cosyvoice-v2': { 'zh-CN': 'longxiaochun_v2' },
})
})
it('writes LLM_ROUTER_CONFIG, DEFAULT_CHAT_MODEL, and publishes invalidation', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'reset',
dryRun: false,
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}],
defaults: { chatModel: 'chat-default' },
})
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(true)
expect(kv.store.get('DEFAULT_CHAT_MODEL')).toBe('chat-default')
expect(result.invalidatedKeys.sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG'])
expect(captured.map(p => JSON.parse(p.payload).key).sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG'])
})
it('writes DEFAULT_TTS_VOICES without requiring provider slices', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: false,
slices: [],
defaults: {
ttsVoices: {
'alibaba/cosyvoice-v2': {
'zh-CN': 'longxiaochun_v2',
'en-US': 'loongava_v2',
},
'volcengine/seed-tts-2.0': {
'zh-CN': 'zh_female_vv_uranus_bigtts',
},
},
},
})
expect(kv.store.get('DEFAULT_TTS_VOICES')).toEqual({
'alibaba/cosyvoice-v2': {
'zh-CN': 'longxiaochun_v2',
'en-US': 'loongava_v2',
},
'volcengine/seed-tts-2.0': {
'zh-CN': 'zh_female_vv_uranus_bigtts',
},
})
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
expect(result.preview.DEFAULT_TTS_VOICES).toEqual(kv.store.get('DEFAULT_TTS_VOICES'))
expect(result.invalidatedKeys).toEqual(['DEFAULT_TTS_VOICES'])
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['DEFAULT_TTS_VOICES'])
})
it('writes UNSPEECH_UPSTREAM and publishes invalidation when an unspeech slice is included', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: false,
slices: [{
kind: 'unspeech',
restBaseURL: 'http://unspeech.example:5933',
streaming: {
upstreamURL: 'wss://unspeech.example/v1/audio/speech/stream',
plaintextKey: 'volc',
models: [
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
],
defaultModel: 'volcengine/seed-tts-2.0',
},
}],
})
expect(kv.store.has('UNSPEECH_UPSTREAM')).toBe(true)
expect(kv.store.get('UNSPEECH_UPSTREAM')).toMatchObject({
streaming: {
models: [
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
],
defaultModel: 'volcengine/seed-tts-2.0',
},
})
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
expect(result.invalidatedKeys).toEqual(['UNSPEECH_UPSTREAM'])
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['UNSPEECH_UPSTREAM'])
})
it('rejects multiple unspeech slices', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await expect(service.apply({
mode: 'merge',
dryRun: true,
slices: [
{ kind: 'unspeech', restBaseURL: 'http://a' },
{ kind: 'unspeech', restBaseURL: 'http://b' },
],
})).rejects.toThrow(/At most one unspeech/i)
})
it('merge mode reads existing LLM_ROUTER_CONFIG and preserves untouched models', async () => {
// Seed an existing entry directly into the fake store, matching the
// shape configKV.getOptional would have returned after a prior admin call.
kv.store.set('LLM_ROUTER_CONFIG', {
llm: { models: { 'preexisting-chat': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await service.apply({
mode: 'merge',
dryRun: false,
slices: [{
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
plaintextKey: 'azure-key',
}],
})
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, unknown> }, tts: { models: Record<string, unknown> } }
expect(Object.keys(written.llm.models)).toEqual(['preexisting-chat'])
expect(Object.keys(written.tts.models)).toEqual(['microsoft/v1'])
})
it('rejects legacy merge updates that would replace a grouped TTS model', async () => {
const modelName = 'stepfun/stepaudio-2.5-tts'
const existingConfig = {
llm: { models: {} },
tts: {
models: {
[modelName]: {
provider: 'stepfun' as const,
upstreams: [
{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
adapterParams: { endpointProfile: 'step-plan', model: 'stepaudio-2.5-tts' },
},
{
id: 'paygo',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
adapterParams: { endpointProfile: 'default', model: 'stepaudio-2.5-tts' },
},
],
routing: {
groups: [
{
id: 'plan',
upstreamIds: ['plan'],
strategy: 'ordered' as const,
retryOn: { httpCodes: [402], onTimeout: false },
continueOn: { httpCodes: [402], onTimeout: false },
},
{
id: 'paygo',
upstreamIds: ['paygo'],
strategy: 'ordered' as const,
retryOn: { httpCodes: [429, 500], onTimeout: true },
},
],
},
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
}
kv.store.set('LLM_ROUTER_CONFIG', existingConfig)
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await expect(service.apply({
mode: 'merge',
dryRun: false,
slices: [{
kind: 'stepfun',
modelName,
upstreamModel: 'stepaudio-2.5-tts',
plaintextKey: 'rotated-key',
}],
})).rejects.toThrow(/cannot update grouped tts model/i)
expect(kv.store.get('LLM_ROUTER_CONFIG')).toBe(existingConfig)
expect(captured).toEqual([])
})
it('current returns editable slices from configKV without exposing raw ciphertext', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: {
models: {
'chat-live': {
upstreams: [{
baseURL: 'https://openrouter.ai/api/v1',
overrideModel: 'openai/gpt-4.1-mini',
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
kv.store.set('DEFAULT_CHAT_MODEL', 'chat-live')
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const current = await service.current()
expect(current.request.slices).toEqual([{
kind: 'openrouter',
modelName: 'chat-live',
overrideModel: 'openai/gpt-4.1-mini',
baseURL: 'https://openrouter.ai/api/v1',
headerTemplate: 'Bearer {KEY}',
keyEntryId: 'openrouter-live',
existingKeyEntryId: 'openrouter-live',
}])
expect(current.request.defaults.chatModel).toBe('chat-live')
expect(JSON.stringify(current.preview)).toContain('<ciphertext: 17 chars>')
expect(JSON.stringify(current.preview)).not.toContain('secret-ciphertext')
})
it('current classifies Bedrock and generic OpenAI-compatible LLM upstreams by baseURL', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: {
models: {
'chat-bedrock': {
upstreams: [{
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
overrideModel: 'us.amazon.nova-pro-v1:0',
keys: [{ id: 'bedrock-live', ciphertext: 'bedrock-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
'chat-compatible': {
upstreams: [{
baseURL: 'https://llm.example.com/v1',
overrideModel: 'gpt-4o-mini',
keys: [{ id: 'compatible-live', ciphertext: 'compatible-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const current = await service.current()
expect(current.request.slices).toEqual([
{
kind: 'bedrock',
modelName: 'chat-bedrock',
overrideModel: 'us.amazon.nova-pro-v1:0',
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
headerTemplate: 'Bearer {KEY}',
keyEntryId: 'bedrock-live',
existingKeyEntryId: 'bedrock-live',
},
{
kind: 'openai-compatible',
modelName: 'chat-compatible',
overrideModel: 'gpt-4o-mini',
baseURL: 'https://llm.example.com/v1',
headerTemplate: 'Bearer {KEY}',
keyEntryId: 'compatible-live',
existingKeyEntryId: 'compatible-live',
},
])
})
it('preserves an existing key entry when an applied slice omits plaintextKey', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: {
models: {
'chat-live': {
upstreams: [{
baseURL: 'https://openrouter.ai/api/v1',
overrideModel: 'openai/gpt-4.1-mini',
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await service.apply({
mode: 'merge',
dryRun: false,
slices: [{
kind: 'openrouter',
modelName: 'chat-live',
overrideModel: 'openai/gpt-4.1-mini',
baseURL: 'https://proxy.example/api/v1',
keyEntryId: 'openrouter-live',
existingKeyEntryId: 'openrouter-live',
}],
})
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, { upstreams: Array<{ baseURL: string, keys: Array<{ id: string, ciphertext: string }> }> }> } }
const upstream = written.llm.models['chat-live'].upstreams[0]
expect(upstream.baseURL).toBe('https://proxy.example/api/v1')
expect(upstream.keys).toEqual([{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }])
})
it('reset mode skips the existing read and drops prior entries', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: { models: { 'should-be-dropped': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
const getOptionalSpy = vi.spyOn(kv.service, 'getOptional')
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await service.apply({
mode: 'reset',
dryRun: false,
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}],
})
expect(getOptionalSpy).not.toHaveBeenCalled()
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, unknown> } }
expect(Object.keys(written.llm.models)).toEqual(['chat-default'])
})
it('returns per-slice applied summaries that the audit log can use', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: true,
slices: [
{ kind: 'openrouter', modelName: 'chat-default', overrideModel: 'openai/gpt-4o-mini', plaintextKey: 'sk' },
{ kind: 'dashscope-cosyvoice', modelName: 'alibaba/cosyvoice-v2', region: 'intl', upstreamModel: 'cosyvoice-v2', plaintextKey: 'sk' },
{ kind: 'stepfun', modelName: 'stepfun/stepaudio-2.5-tts', upstreamModel: 'stepaudio-2.5-tts', plaintextKey: 'sk' },
],
})
expect(result.applied).toEqual([
{ kind: 'openrouter', target: 'llm-router', surface: 'llm', modelName: 'chat-default', keyEntryId: 'openrouter-prod-1' },
{ kind: 'dashscope-cosyvoice', target: 'llm-router', surface: 'tts', modelName: 'alibaba/cosyvoice-v2', keyEntryId: 'dashscope-tts-prod-1' },
{ kind: 'stepfun', target: 'llm-router', surface: 'tts', modelName: 'stepfun/stepaudio-2.5-tts', keyEntryId: 'stepfun-tts-prod-1' },
])
})
})
@@ -0,0 +1,71 @@
import type { Database } from '../../../../libs/db'
import type { BillingService } from '../../billing/billing-service'
import type { UserSelector } from '../../users/resolve-user'
import { useLogger } from '@guiiai/logg'
import { resolveUserByIdOrEmail } from '../../users/resolve-user'
const logger = useLogger('admin-users').useGlobalConfig()
export interface SetBalanceInput extends UserSelector {
/** Target absolute balance. Non-negative integer; the route validates this. */
balance: number
/** Audit description stored on the ledger row. */
description: string
/** Admin user id issuing the change. */
issuedByUserId: string
}
export interface SetBalanceResult {
userId: string
email: string
balanceBefore: number
balanceAfter: number
fluxTransactionId: string
}
/**
* Admin operations that target a single user by id or email.
*
* Balance overrides go through {@link BillingService.setFlux} so the write +
* ledger stay in one place; this service only resolves the selector to a
* concrete user before delegating.
*/
export function createAdminUsersService(deps: { db: Database, billingService: BillingService }) {
const { db, billingService } = deps
return {
/**
* Set a user's flux balance to an absolute value (including 0).
*
* Use when:
* - An admin overrides a balance, e.g. zeroing it for testing.
*
* Returns:
* - The resolved user plus the before/after balance and ledger row id.
*/
async setBalance(input: SetBalanceInput): Promise<SetBalanceResult> {
const target = await resolveUserByIdOrEmail(db, input)
const { balanceBefore, balanceAfter, fluxTransactionId } = await billingService.setFlux({
userId: target.id,
balance: input.balance,
description: input.description,
issuedByUserId: input.issuedByUserId,
})
logger.withFields({
userId: target.id,
email: target.email,
balanceBefore,
balanceAfter,
issuedByUserId: input.issuedByUserId,
}).log('Admin set balance')
return { userId: target.id, email: target.email, balanceBefore, balanceAfter, fluxTransactionId }
},
}
}
export type AdminUsersService = ReturnType<typeof createAdminUsersService>
@@ -0,0 +1,559 @@
import type Redis from 'ioredis'
import type { Database } from '../../../libs/db'
import type { RevenueMetrics } from '../../../otel'
import type { ConfigKVService } from '../../adapters/config-kv'
import { useLogger } from '@guiiai/logg'
import { and, eq } from 'drizzle-orm'
import { createPaymentRequiredError } from '../../../utils/error'
import { userFluxRedisKey } from '../../../utils/redis-keys'
import * as fluxSchema from '../../../schemas/flux'
import * as fluxTxSchema from '../../../schemas/flux-transaction'
import * as stripeSchema from '../../../schemas/stripe'
const logger = useLogger('billing-service')
export function createBillingService(
db: Database,
redis: Redis,
_configKV: ConfigKVService,
metrics?: RevenueMetrics | null,
) {
/**
* Update Redis cache after a successful DB transaction.
* Best-effort: cache loss is harmless since DB is the source of truth.
*/
async function updateRedisCache(userId: string, balance: number): Promise<void> {
try {
await redis.set(userFluxRedisKey(userId), String(balance))
}
catch {
logger.withFields({ userId }).warn('Failed to update Redis cache after balance change')
}
}
/**
* Debit flux from a user's balance within a single DB transaction.
*
* The transaction locks the user_flux row, validates the balance, updates
* it, and writes the matching `flux_transaction` ledger entry — all in one
* commit. The unique partial index `(user_id, request_id) WHERE request_id IS NOT NULL`
* keeps retries idempotent at the DB level.
*
* Partial-debit semantics:
* When `0 < balance < amount`, the balance is drained to zero and the
* ledger row is written with `amount = charged` and metadata recording
* `requestedAmount` + `unbilled`. The function returns `charged < requested`
* so callers can attribute the delta to a metric counter. This prevents
* the post-streaming leak where a partial-balance user could replay the
* same request indefinitely (each attempt rolled back the whole tx,
* leaving the balance untouched). The very next call sees `flux <= 0`
* and hits the throw branch.
*
* 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 }> {
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.
// Mirrors creditFlux's idempotent path so retries (network errors,
// worker restarts) don't double-charge.
if (input.requestId != null) {
const [existing] = await tx
.select({
amount: fluxTxSchema.fluxTransaction.amount,
balanceAfter: fluxTxSchema.fluxTransaction.balanceAfter,
})
.from(fluxTxSchema.fluxTransaction)
.where(and(
eq(fluxTxSchema.fluxTransaction.userId, input.userId),
eq(fluxTxSchema.fluxTransaction.requestId, input.requestId),
))
.limit(1)
if (existing) {
// Replay reuses the historical `charged`; we deliberately reflect
// the original (possibly partial) outcome instead of the caller's
// 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,
idempotent: true as const,
}
}
}
const [row] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
if (!row) {
throw new Error(`No flux record for user ${input.userId}`)
}
const balanceBefore = row.flux
// Hard floor: zero (or somehow negative) balance still throws so
// streaming callers' catch path fires `fluxUnbilled` with the full
// amount and TTS meter restores its debt counter. Partial debit only
// kicks in when there is *some* balance left to drain.
if (balanceBefore <= 0) {
metrics?.fluxInsufficientBalance.add(1)
throw createPaymentRequiredError('Insufficient flux')
}
const chargedAmount = Math.min(input.amount, balanceBefore)
const balanceAfter = balanceBefore - chargedAmount
const isPartial = chargedAmount < input.amount
if (isPartial) {
metrics?.fluxInsufficientBalance.add(1)
}
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.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,
description: input.description ?? input.source,
metadata: {
...input.metadata,
source: input.source,
...(isPartial && {
requestedAmount: input.amount,
unbilled: input.amount - chargedAmount,
}),
},
})
return {
userId: input.userId,
flux: balanceAfter,
charged: chargedAmount,
requested: input.amount,
idempotent: false as const,
}
})
if (!result.idempotent) {
await updateRedisCache(input.userId, result.flux)
}
logger.withFields({
userId: input.userId,
amount: input.amount,
charged: result.charged,
balance: result.flux,
idempotent: result.idempotent,
}).log('Debited flux')
return {
userId: result.userId,
flux: result.flux,
charged: result.charged,
requested: result.requested,
}
}
return {
/**
* Debit flux for an LLM API request (chat, TTS).
* Token usage is persisted in the `flux_transaction.metadata` column so
* the existing transaction-history UI can render per-request token counts.
*/
async consumeFluxForLLM(input: {
userId: string
amount: number
requestId?: string
description?: string
model?: string
promptTokens?: number
completionTokens?: number
}): Promise<{ userId: string, flux: number, charged: number, requested: number }> {
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 }),
},
})
},
/**
* Credit flux to a user's balance within a DB transaction.
* Generic credit method for non-Stripe flows (e.g. admin grants).
*
* Idempotency:
* When `requestId` is provided, the call is idempotent across crash /
* retry boundaries. If a `flux_transaction` row with the same
* `(user_id, request_id)` already exists, this method returns that
* existing row's balance + id without re-crediting the user, without
* touching `user_flux`, and without re-emitting the Redis cache write.
*
* This guards against the worker crash window where:
* 1. `creditFlux` commits the credit
* 2. caller crashes before marking its own state (e.g. recipient row) granted
* 3. on restart, caller sees pending state and calls `creditFlux` again with same requestId
*
* Without idempotency, step 3 would hit the `(user_id, request_id)`
* unique index and throw — causing the caller to mark the work failed
* even though the user was already credited.
*/
async creditFlux(input: {
userId: string
amount: number
requestId?: string
description: string
source: string
/**
* Ledger row `type`. Defaults to `'credit'` for backward compatibility
* with existing callers (Stripe top-up). Admin promo grants pass
* `'promo'` so reports / dashboards can distinguish them.
*/
type?: 'credit' | 'promo'
auditMetadata?: Record<string, unknown>
}): Promise<{ balanceBefore: number, balanceAfter: 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,
})
.from(fluxTxSchema.fluxTransaction)
.where(and(
eq(fluxTxSchema.fluxTransaction.userId, input.userId),
eq(fluxTxSchema.fluxTransaction.requestId, input.requestId),
))
.limit(1)
if (existing) {
return {
balanceBefore: existing.balanceBefore,
balanceAfter: existing.balanceAfter,
fluxTransactionId: existing.id,
idempotent: true,
}
}
}
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [row] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = row!.flux
const balanceAfter = balanceBefore + input.amount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.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,
description: input.description,
metadata: input.auditMetadata,
}).returning({ id: fluxTxSchema.fluxTransaction.id })
return {
balanceBefore,
balanceAfter,
fluxTransactionId: insertedTx!.id,
idempotent: false,
}
})
if (txResult.idempotent) {
logger.withFields({
userId: input.userId,
requestId: input.requestId,
fluxTransactionId: txResult.fluxTransactionId,
}).log('Credited flux (idempotent replay — no side effects emitted)')
return txResult
}
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')
return txResult
},
/**
* Set a user's flux balance to an absolute value within a DB transaction.
*
* Use when:
* - An admin overrides a balance directly (e.g. zeroing it out for
* testing). Unlike credit/debit this is not request-driven and carries
* no idempotency key — every call rewrites the balance to `balance` and
* appends one `admin_set` ledger row recording the before/after.
*
* Expects:
* - `balance` is a non-negative integer. The route layer validates this.
*
* Returns:
* - The balance before and after, plus the appended ledger row id. The
* ledger `amount` is the absolute delta magnitude; direction lives in
* `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 }> {
const txResult = await db.transaction(async (tx) => {
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [row] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = row!.flux
const balanceAfter = input.balance
const delta = balanceAfter - balanceBefore
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.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,
description: input.description,
metadata: {
source: 'admin_set',
requestedBalance: input.balance,
direction: delta >= 0 ? 'credit' : 'debit',
issuedByUserId: input.issuedByUserId,
},
}).returning({ id: fluxTxSchema.fluxTransaction.id })
return { balanceBefore, balanceAfter, fluxTransactionId: insertedTx!.id }
})
// NOTICE:
// Invalidate (DEL) rather than write (SET) the cache. An admin override
// is a "truth changed" event, so we drop the key and let the next
// getFlux miss reload from Postgres — mirrors FluxService.deleteAllForUser.
// Writing the new value instead would have setFlux contribute its own
// post-commit SET to the existing cross-operation cache-write race that
// credit/debit already have (a slower concurrent SET can land last and
// clobber it); DEL keeps setFlux from adding to that and defers to truth.
// Best-effort: a failed DEL only leaves a stale cache entry that the next
// mutation or TTL-less overwrite corrects; Postgres stays authoritative.
try {
await redis.del(userFluxRedisKey(input.userId))
}
catch {
logger.withFields({ userId: input.userId }).warn('Failed to invalidate flux cache after setFlux')
}
logger.withFields({
userId: input.userId,
balanceBefore: txResult.balanceBefore,
balanceAfter: txResult.balanceAfter,
issuedByUserId: input.issuedByUserId,
}).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>
@@ -0,0 +1,23 @@
export interface UsageInfo {
promptTokens?: number
completionTokens?: number
}
export function extractUsageFromBody(body: any): UsageInfo {
const usage = body?.usage
if (!usage)
return {}
return {
promptTokens: usage.prompt_tokens ?? undefined,
completionTokens: usage.completion_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
}
@@ -0,0 +1,288 @@
import type Redis from 'ioredis'
import type { RevenueMetrics } from '../../../otel'
import type { BillingService } from './billing-service'
import { useLogger } from '@guiiai/logg'
import { createPaymentRequiredError } from '../../../utils/error'
import { GEN_AI_ATTR_REQUEST_MODEL } from '../../../utils/observability'
import { userFluxMeterDebtRedisKey } from '../../../utils/redis-keys'
const logger = useLogger('flux-meter')
// NOTICE: Atomic accumulate-and-settle. Integer Flux is the billing unit, but the
// metered service (TTS chars, STT seconds, tokens, ...) charges at sub-Flux
// granularity. We keep unsettled small units in a Redis counter and only debit
// whole Flux when the counter crosses `unitsPerFlux`. Residual <unitsPerFlux
// survives via TTL; callers accept that sub-1-Flux dust may expire unbilled.
const ACCUMULATE_SCRIPT = `
local key = KEYS[1]
local units = tonumber(ARGV[1])
local unitsPerFlux = tonumber(ARGV[2])
local ttl = tonumber(ARGV[3])
local debt = redis.call('INCRBY', key, units)
redis.call('EXPIRE', key, ttl)
if debt >= unitsPerFlux then
local flux = math.floor(debt / unitsPerFlux)
local consumed = flux * unitsPerFlux
redis.call('DECRBY', key, consumed)
return {flux, debt - consumed}
end
return {0, debt}
`
interface FluxMeterRuntime {
/** How many small units equal one Flux. */
unitsPerFlux: number
/** Debt key TTL. Residual debt below unitsPerFlux is forgiven on expiry. */
debtTtlSeconds: number
}
interface FluxMeterConfig {
/** Meter identifier, used as Redis key segment and billing description prefix. */
name: string
/**
* Resolves runtime pricing/TTL per call. Reads from Redis-backed configKV,
* so every instance sees config changes immediately. Called lazily so a
* missing pricing config surfaces as a per-request 503 (handled by the
* route's configGuard), not as a server-wide startup failure.
*
* NOTICE: Do NOT memoise across calls. Multi-instance deploys would then
* disagree on billing rate during config rollout windows.
*/
resolveRuntime: () => Promise<FluxMeterRuntime>
}
interface AccumulateInput {
userId: string
units: number
currentBalance: number
requestId: string
metadata?: Record<string, unknown>
}
interface AccumulateResult {
/** Actual flux charged to the user (== amount we are sure was billed). */
fluxDebited: number
/** Residual debt left in Redis after this call. Includes unbilled units restored on partial drain. */
debtAfter: number
/** User's flux balance after this call. */
balanceAfter: number
/**
* Flux that crossed the meter threshold but couldn't be charged because the
* user's balance was lower than what the request required. > 0 means the
* user received service they only partially paid for. Reflects the gap
* between `requested` and `charged` returned by `billingService.consumeFluxForLLM`.
*/
unbilledFlux: number
}
/**
* Creates a metered Flux consumer for services that charge in small units
* (TTS chars, STT seconds, embedding tokens). Accumulates usage in Redis and
* only triggers a Flux debit when accumulated units cross the integer boundary.
*
* @see docs/ai-context/flux-meter.md
*/
export function createFluxMeter(
redis: Redis,
billingService: BillingService,
config: FluxMeterConfig,
metrics?: RevenueMetrics | null,
) {
async function getRuntime(): Promise<FluxMeterRuntime> {
const runtime = await config.resolveRuntime()
if (runtime.unitsPerFlux <= 0)
throw new Error(`Invalid unitsPerFlux ${runtime.unitsPerFlux} for meter ${config.name}`)
return runtime
}
async function runScript(key: string, units: number, runtime: FluxMeterRuntime): Promise<[number, number]> {
const raw = await redis.eval(
ACCUMULATE_SCRIPT,
1,
key,
units,
runtime.unitsPerFlux,
runtime.debtTtlSeconds,
) as [number | string, number | string]
return [Number(raw[0]), Number(raw[1])]
}
async function readDebt(userId: string): Promise<number> {
const raw = await redis.get(userFluxMeterDebtRedisKey(userId, config.name))
return raw == null ? 0 : Number(raw)
}
/**
* Pre-flight balance check. Throws 402 if the user cannot afford the worst-case
* Flux consumption implied by current debt + new units. Call before invoking
* the upstream service so we fail fast and refuse to render unbillable usage.
*/
async function assertCanAfford(userId: string, newUnits: number, currentBalance: number): Promise<void> {
const runtime = await getRuntime()
const existingDebt = await readDebt(userId)
const projectedFlux = Math.floor((existingDebt + newUnits) / runtime.unitsPerFlux)
// At minimum require the user can cover a single Flux crossing; avoids
// letting zero-balance users accumulate indefinitely on the boundary.
const required = Math.max(projectedFlux, currentBalance <= 0 ? 1 : 0)
if (currentBalance < required) {
metrics?.ttsPreflightRejections.add(1, { meter: config.name, reason: 'insufficient_balance' })
throw createPaymentRequiredError('Insufficient flux')
}
}
/**
* Accumulate usage, atomically settle any whole-Flux portion, and record the
* debit via BillingService. Returns 0 fluxDebited when the new usage does
* not cross a Flux boundary (cheap path for short TTS segments).
*/
async function accumulate(input: AccumulateInput): Promise<AccumulateResult> {
if (!Number.isFinite(input.units) || input.units <= 0)
return { fluxDebited: 0, debtAfter: await readDebt(input.userId), balanceAfter: input.currentBalance, unbilledFlux: 0 }
const modelLabel = typeof input.metadata?.model === 'string' ? input.metadata.model : 'unknown'
metrics?.ttsChars.add(input.units, { meter: config.name, model: modelLabel })
const runtime = await getRuntime()
const key = userFluxMeterDebtRedisKey(input.userId, config.name)
const [fluxRequested, debtAfterSettlement] = await runScript(key, input.units, runtime)
if (fluxRequested === 0) {
logger.withFields({
userId: input.userId,
meter: config.name,
units: input.units,
debtAfter: debtAfterSettlement,
}).debug('Accumulated units below flux threshold')
return { fluxDebited: 0, debtAfter: debtAfterSettlement, balanceAfter: input.currentBalance, unbilledFlux: 0 }
}
let result: Awaited<ReturnType<typeof billingService.consumeFluxForLLM>>
try {
result = await billingService.consumeFluxForLLM({
userId: input.userId,
amount: fluxRequested,
requestId: input.requestId,
description: `${config.name}_request`,
...(typeof input.metadata?.model === 'string' && { model: input.metadata.model }),
})
}
catch (error) {
// The billing call threw (balance <= 0 hard floor, transient DB error,
// network blip). The debit did NOT commit, so restore the full
// already-settled portion back into the debt counter for the next
// request to retry.
const restoreUnits = fluxRequested * runtime.unitsPerFlux
try {
await redis.incrby(key, restoreUnits)
await redis.expire(key, runtime.debtTtlSeconds)
}
catch (rollbackError) {
logger.withError(rollbackError).withFields({
userId: input.userId,
meter: config.name,
restoreUnits,
requestId: input.requestId,
}).error('Failed to roll back meter debt after billing failure')
}
throw error
}
// Billing-service invariant — checked OUTSIDE the try/catch above so a
// post-debit assertion failure does NOT trigger the "restore full debt"
// rollback path. The DB tx already committed `result.charged`; restoring
// `fluxRequested * unitsPerFlux` would set up a double-charge on the
// next request (LUA re-settles the restored debt, billing re-debits the
// same usage). Surface loud, but don't compensate.
if (!Number.isInteger(result.charged) || result.charged < 0 || result.charged > result.requested) {
logger.withFields({
userId: input.userId,
meter: config.name,
requestId: input.requestId,
requested: result.requested,
charged: result.charged,
}).error('billing-service returned invalid charged/requested — manual reconciliation needed')
throw new Error(`billing-service returned invalid charged=${result.charged} for requested=${result.requested}`)
}
// Partial-debit path: balance was insufficient and `debitFlux` drained
// it to zero. We've already DECRBY'd `fluxRequested * unitsPerFlux` from
// the debt counter via the LUA script, but only `result.charged` of
// those flux were actually billed. Restore the gap so the debt counter
// reflects the user's true outstanding obligation, and surface it on
// the same `fluxUnbilled` counter the streaming/non-streaming chat
// paths use (different `reason` label).
//
// REVIEW: Settlement (LUA `runScript`) and the `INCRBY` restore below
// are not atomic. A concurrent `accumulate()` could observe the debt
// counter mid-window (between DECRBY and the restore INCRBY) and
// mis-bill. In practice the window is small (one in-flight DB tx) and
// a re-billing attempt would land in the same partial-debit branch,
// but the right long-term fix is either a short Redis lock keyed by
// `{userId, meter}` around `runScript → consumeFluxForLLM → restore`,
// or moving the unbilled portion into a separate Redis key that the
// LUA script doesn't touch. See codex review thread on PR.
if (result.charged < result.requested) {
const unbilledFlux = result.requested - result.charged
const restoreUnits = unbilledFlux * runtime.unitsPerFlux
metrics?.fluxUnbilled.add(unbilledFlux, {
source: 'tts_meter',
meter: config.name,
reason: 'partial_debit_drained',
...(typeof input.metadata?.model === 'string' && { [GEN_AI_ATTR_REQUEST_MODEL]: input.metadata.model }),
})
let debtAfterRestore = debtAfterSettlement
try {
debtAfterRestore = await redis.incrby(key, restoreUnits)
await redis.expire(key, runtime.debtTtlSeconds)
}
catch (rollbackError) {
// Log loudly so on-call can reconcile manually; don't shadow the
// partial-debit signal by re-throwing.
logger.withError(rollbackError).withFields({
userId: input.userId,
meter: config.name,
restoreUnits,
requestId: input.requestId,
}).error('Failed to restore meter debt after partial-debit drain')
}
logger.withFields({
userId: input.userId,
meter: config.name,
requestId: input.requestId,
requested: result.requested,
charged: result.charged,
unbilledFlux,
restoreUnits,
}).warn('Partial debit on flux meter — flux drained to zero')
return {
fluxDebited: result.charged,
debtAfter: debtAfterRestore,
balanceAfter: result.flux,
unbilledFlux,
}
}
return { fluxDebited: result.charged, debtAfter: debtAfterSettlement, balanceAfter: result.flux, unbilledFlux: 0 }
}
return {
assertCanAfford,
accumulate,
peekDebt: readDebt,
config,
}
}
export type FluxMeter = ReturnType<typeof createFluxMeter>
@@ -0,0 +1,430 @@
import type Redis from 'ioredis'
import type { Database } from '../../../../libs/db'
import type { createConfigKVService } from '../../../adapters/config-kv'
import { and, eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../../../libs/mock-db'
import { userFluxRedisKey } from '../../../../utils/redis-keys'
import { createBillingService } from '../billing-service'
import * as schema from '../../../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
set: vi.fn(),
} as any
}
function createMockRedis(): Redis {
const store = new Map<string, string>()
return {
get: vi.fn(async (key: string) => store.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => {
store.set(key, value)
return 'OK'
}),
del: vi.fn(async (key: string) => {
const existed = store.delete(key)
return existed ? 1 : 0
}),
} as unknown as Redis
}
describe('billingService', () => {
let db: Database
let redis: Redis
let billingService: ReturnType<typeof createBillingService>
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values({
id: 'user-billing-1',
name: 'Billing User',
email: 'billing@example.com',
})
})
beforeEach(async () => {
redis = createMockRedis()
billingService = createBillingService(db, redis, createMockConfigKV())
await db.delete(schema.fluxTransaction)
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
await db.delete(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
await db.insert(schema.stripeCheckoutSession).values({
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 500,
currency: 'usd',
fluxCredited: false,
})
})
describe('creditFluxFromStripeCheckout', () => {
it('credits flux, records transaction, and enqueues outbox events in one transaction', async () => {
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
expect(result).toEqual({ applied: true, balanceAfter: 50 })
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(50)
// Verify transaction entry
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]?.type).toBe('credit')
expect(txRecords[0]?.amount).toBe(50)
expect(txRecords[0]?.balanceBefore).toBe(0)
expect(txRecords[0]?.balanceAfter).toBe(50)
// Verify metadata on transaction entry
expect(txRecords[0]?.metadata).toMatchObject({
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
source: 'stripe.checkout.completed',
})
// Verify stripe session marked as credited
const [sessionRecord] = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
expect(sessionRecord?.fluxCredited).toBe(true)
// Verify Redis cache updated
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '50')
})
it('is idempotent when the checkout session was already credited', async () => {
await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
const second = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
expect(second).toEqual({ applied: false })
// Idempotent replay must not double-write the ledger
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
})
})
describe('consumeFluxForLLM', () => {
it('deducts balance, writes the ledger row inside the transaction, and refreshes Redis', async () => {
// Setup: give user some flux first
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 100 })
const result = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 30,
requestId: 'req-1',
description: 'gpt-4',
promptTokens: 120,
completionTokens: 80,
})
expect(result).toEqual({ userId: 'user-billing-1', flux: 70, charged: 30, requested: 30 })
// Verify DB balance
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(70)
// Ledger row written inline (no async consumer involved post-refactor)
const [txRecord] = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, 'req-1'),
))
expect(txRecord).toMatchObject({
userId: 'user-billing-1',
type: 'debit',
amount: 30,
balanceBefore: 100,
balanceAfter: 70,
requestId: 'req-1',
description: 'gpt-4',
})
expect(txRecord?.metadata).toMatchObject({
promptTokens: 120,
completionTokens: 80,
source: 'llm.request',
})
// Verify Redis cache updated
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '70')
})
// ROOT CAUSE:
//
// Before: when `0 < balance < amount`, debitFlux threw and rolled back the
// whole tx. The streaming proxy had already delivered the response, so the
// unpaid request was logged but the user's balance was untouched.
// A scripted attacker on a partial balance could replay forever — balance
// never moved, line 129 (`flux <= 0`) kept letting requests through, and
// every call landed in the catch path crediting `fluxUnbilled` for the
// full amount.
//
// After: balance is drained to zero, the ledger records `amount = charged`
// plus `metadata.requestedAmount` / `metadata.unbilled`, and the caller
// gets `charged < requested` so it can attribute the leak to
// `fluxUnbilled{reason="partial_debit_drained"}`. The next request from
// the same user is rejected at the pre-flight gate.
it('partial-debits when balance is below the requested amount and writes unbilled metadata (Issue: unpaid-usage-exploit)', async () => {
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
const result = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-partial',
description: 'gpt-4',
})
expect(result).toEqual({ userId: 'user-billing-1', flux: 0, charged: 5, requested: 38 })
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(0)
const [txRecord] = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, 'req-partial'),
))
expect(txRecord).toMatchObject({
type: 'debit',
amount: 5,
balanceBefore: 5,
balanceAfter: 0,
})
expect(txRecord?.metadata).toMatchObject({
source: 'llm.request',
requestedAmount: 38,
unbilled: 33,
})
// Redis cache reflects the zero balance, so the next pre-flight gate
// (`flux < fallbackRate`) rejects immediately.
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '0')
})
it('throws 402 when balance is already zero (no ledger row, no balance change)', async () => {
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 0 })
await expect(billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 10,
})).rejects.toThrow('Insufficient flux')
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(0)
const txRecords = await db.select().from(schema.fluxTransaction)
expect(txRecords).toHaveLength(0)
})
it('idempotent replay returns the historical charge without re-debiting (partial debits stay partial on retry)', async () => {
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
const first = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-replay',
})
const second = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-replay',
})
expect(first.charged).toBe(5)
expect(first.flux).toBe(0)
// Replay reflects the original partial outcome — equal `charged` and
// `requested` prevent the streaming caller from double-firing
// `fluxUnbilled` on retries.
expect(second.charged).toBe(5)
expect(second.requested).toBe(5)
expect(second.flux).toBe(0)
// Ledger has exactly one row for `req-replay`
const txRecords = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, 'req-replay'),
))
expect(txRecords).toHaveLength(1)
})
})
describe('creditFlux', () => {
it('credits balance and writes the ledger row in one transaction', async () => {
const result = await billingService.creditFlux({
userId: 'user-billing-1',
amount: 50,
description: 'Admin grant',
source: 'admin',
})
expect(result.balanceAfter).toBe(50)
expect(result.balanceBefore).toBe(0)
expect(result.idempotent).toBe(false)
// Verify transaction
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]).toMatchObject({
type: 'credit',
amount: 50,
balanceBefore: 0,
balanceAfter: 50,
})
})
it('is idempotent across retries with the same requestId', async () => {
// ROOT CAUSE:
//
// Worker crash window: creditFlux commits the credit, then the
// grant-batch poller crashes before marking its own state row
// (e.g. flux_grant_batch_recipient) as granted. On restart the poller
// re-claims the same row and calls creditFlux again with the same
// requestId.
//
// Before the fix: second call hit the unique index on
// (user_id, request_id) and threw, the poller's catch block marked
// the recipient as `failed` despite the user already having been credited.
// User got the FLUX but the recipient row was stuck in failed.
//
// After the fix: second call detects the existing flux_transaction row,
// returns it as an idempotent success without touching balance or cache.
// Poller advances to granted normally.
const requestId = 'campaign-replay-test'
const first = await billingService.creditFlux({
userId: 'user-billing-1',
amount: 100,
requestId,
description: 'Replay test',
source: 'admin',
})
expect(first.idempotent).toBe(false)
expect(first.balanceAfter).toBe(100)
// Second call with same requestId — simulates crash-recovery retry.
const second = await billingService.creditFlux({
userId: 'user-billing-1',
amount: 100,
requestId,
description: 'Replay test',
source: 'admin',
})
expect(second.idempotent).toBe(true)
// Same record returned, not a fresh credit
expect(second.fluxTransactionId).toBe(first.fluxTransactionId)
expect(second.balanceAfter).toBe(first.balanceAfter)
// Balance must NOT have doubled
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRow!.flux).toBe(100)
// Only one ledger row exists (unique index would prevent a second anyway,
// but verify the function didn't try to insert and silently swallow)
const txRecords = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, requestId),
))
expect(txRecords).toHaveLength(1)
})
})
describe('setFlux', () => {
it('sets the balance to an absolute value and records an admin_set ledger row', async () => {
// Start from a known balance so the delta direction is observable.
await billingService.creditFlux({ userId: 'user-billing-1', amount: 100, description: 'seed', source: 'test' })
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 250,
description: 'admin top-up',
issuedByUserId: 'admin-1',
})
expect(result.balanceBefore).toBe(100)
expect(result.balanceAfter).toBe(250)
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRow!.flux).toBe(250)
const [tx] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.id, result.fluxTransactionId))
expect(tx!.type).toBe('admin_set')
expect(tx!.amount).toBe(150)
expect(tx!.balanceBefore).toBe(100)
expect(tx!.balanceAfter).toBe(250)
expect(tx!.metadata).toMatchObject({ source: 'admin_set', direction: 'credit', requestedBalance: 250, issuedByUserId: 'admin-1' })
})
it('can zero out a balance and records the debit direction (the primary testing use case)', async () => {
await billingService.creditFlux({ userId: 'user-billing-1', amount: 500, description: 'seed', source: 'test' })
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 0,
description: 'admin zero',
issuedByUserId: 'admin-1',
})
expect(result.balanceBefore).toBe(500)
expect(result.balanceAfter).toBe(0)
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRow!.flux).toBe(0)
const [tx] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.id, result.fluxTransactionId))
expect(tx!.type).toBe('admin_set')
expect(tx!.amount).toBe(500)
expect(tx!.metadata).toMatchObject({ direction: 'debit', requestedBalance: 0 })
})
it('initializes a user_flux row when none exists and invalidates the Redis cache', async () => {
// Pre-warm the cache with a stale value to prove setFlux drops it.
await redis.set(userFluxRedisKey('user-billing-1'), '999')
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 42,
description: 'admin set from zero',
issuedByUserId: 'admin-1',
})
expect(result.balanceBefore).toBe(0)
expect(result.balanceAfter).toBe(42)
// Invalidate, not write: next getFlux miss reloads truth from Postgres.
expect(redis.del).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'))
expect(await redis.get(userFluxRedisKey('user-billing-1'))).toBeNull()
})
})
})
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { calculateFluxFromUsage, extractUsageFromBody } from '../billing'
describe('extractUsageFromBody', () => {
it('returns promptTokens and completionTokens from a normal body', () => {
const body = { usage: { prompt_tokens: 100, completion_tokens: 200 } }
expect(extractUsageFromBody(body)).toEqual({ promptTokens: 100, completionTokens: 200 })
})
it('returns empty object when body has no usage field', () => {
expect(extractUsageFromBody({ model: 'gpt-4' })).toEqual({})
})
it('returns empty object for null body', () => {
expect(extractUsageFromBody(null)).toEqual({})
})
it('returns empty object for undefined body', () => {
expect(extractUsageFromBody(undefined)).toEqual({})
})
it('returns empty object when usage is null', () => {
expect(extractUsageFromBody({ usage: null })).toEqual({})
})
it('returns empty object when usage is falsy zero-like value (0)', () => {
expect(extractUsageFromBody({ usage: 0 })).toEqual({})
})
it('returns only promptTokens when completion_tokens is missing', () => {
const body = { usage: { prompt_tokens: 50 } }
const result = extractUsageFromBody(body)
expect(result.promptTokens).toBe(50)
expect(result.completionTokens).toBeUndefined()
})
it('returns only completionTokens when prompt_tokens is missing', () => {
const body = { usage: { completion_tokens: 75 } }
const result = extractUsageFromBody(body)
expect(result.promptTokens).toBeUndefined()
expect(result.completionTokens).toBe(75)
})
it('treats explicit null fields in usage as undefined', () => {
const body = { usage: { prompt_tokens: null, completion_tokens: null } }
const result = extractUsageFromBody(body)
expect(result.promptTokens).toBeUndefined()
expect(result.completionTokens).toBeUndefined()
})
it('handles zero token values correctly', () => {
const body = { usage: { prompt_tokens: 0, completion_tokens: 0 } }
const result = extractUsageFromBody(body)
expect(result.promptTokens).toBe(0)
expect(result.completionTokens).toBe(0)
})
})
describe('calculateFluxFromUsage', () => {
it('calculates flux based on total tokens and rate', () => {
const usage = { promptTokens: 500, completionTokens: 500 }
// 1000 tokens * 1 per 1k = 1
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
})
it('applies ceiling to fractional flux values', () => {
const usage = { promptTokens: 500, completionTokens: 501 }
// 1001 tokens * 1 per 1k = 1.001 → ceil → 2
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(2)
})
it('enforces a minimum of 1 flux even when calculation yields 0', () => {
const usage = { promptTokens: 1, completionTokens: 1 }
// 2 tokens * 1 per 1k = 0.002 → ceil → 1, max(1, 1) = 1
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
})
it('enforces minimum of 1 flux when tokens are zero', () => {
const usage = { promptTokens: 0, completionTokens: 0 }
// 0 tokens * anything = 0 → ceil → 0, max(1, 0) = 1
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
})
it('falls back to fallbackRate when promptTokens is missing', () => {
const usage = { completionTokens: 500 }
expect(calculateFluxFromUsage(usage, 1, 7)).toBe(7)
})
it('falls back to fallbackRate when completionTokens is missing', () => {
const usage = { promptTokens: 500 }
expect(calculateFluxFromUsage(usage, 1, 7)).toBe(7)
})
it('falls back to fallbackRate when usage is empty', () => {
expect(calculateFluxFromUsage({}, 1, 3)).toBe(3)
})
it('uses a higher fluxPer1kTokens multiplier correctly', () => {
const usage = { promptTokens: 1000, completionTokens: 1000 }
// 2000 tokens * 5 per 1k = 10
expect(calculateFluxFromUsage(usage, 5, 1)).toBe(10)
})
it('uses a fractional fluxPer1kTokens multiplier with ceiling', () => {
const usage = { promptTokens: 200, completionTokens: 200 }
// 400 tokens * 0.5 per 1k = 0.2 → ceil → 1, max(1, 1) = 1
expect(calculateFluxFromUsage(usage, 0.5, 3)).toBe(1)
})
it('handles very large token counts', () => {
const usage = { promptTokens: 1_000_000, completionTokens: 1_000_000 }
// 2_000_000 tokens * 1 per 1k = 2000
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(2000)
})
it('handles exact 1k token boundary without ceiling', () => {
const usage = { promptTokens: 500, completionTokens: 500 }
// 1000 tokens * 2 per 1k = 2 (exact, no ceiling needed)
expect(calculateFluxFromUsage(usage, 2, 5)).toBe(2)
})
it('returns fallbackRate when both token fields are undefined (not null)', () => {
const usage = { promptTokens: undefined, completionTokens: undefined }
expect(calculateFluxFromUsage(usage, 1, 99)).toBe(99)
})
})
@@ -0,0 +1,273 @@
import type Redis from 'ioredis'
import type { BillingService } from '../billing-service'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFluxMeter } from '../flux-meter'
function createMockRedis() {
const store = new Map<string, number>()
// NOTICE: Mimic the subset of EVAL semantics used by ACCUMULATE_SCRIPT
// (INCRBY + EXPIRE + conditional DECRBY). Sufficient for unit tests; the real
// atomicity is verified by ioredis hitting Redis in integration.
const evalImpl = vi.fn(async (
_script: string,
_numKeys: number,
key: string,
units: string | number,
unitsPerFlux: string | number,
_ttl: string | number,
) => {
const u = Number(units)
const upf = Number(unitsPerFlux)
const debt = (store.get(key) ?? 0) + u
store.set(key, debt)
if (debt >= upf) {
const flux = Math.floor(debt / upf)
const consumed = flux * upf
store.set(key, debt - consumed)
return [flux, debt - consumed]
}
return [0, debt]
})
const incrby = vi.fn(async (key: string, amount: number) => {
const next = (store.get(key) ?? 0) + amount
store.set(key, next)
return next
})
const expire = vi.fn(async () => 1)
return {
redis: {
eval: evalImpl,
incrby,
expire,
get: vi.fn(async (key: string) => {
const v = store.get(key)
return v == null ? null : String(v)
}),
} as unknown as Redis,
store,
incrby,
}
}
function createMockBilling(opts: { throwOn?: number, partialChargeOn?: { amount: number, charged: number } } = {}): BillingService {
return {
consumeFluxForLLM: vi.fn(async ({ userId, amount }: { userId: string, amount: number }) => {
if (opts.throwOn != null && amount === opts.throwOn)
throw new Error('mock billing failure')
// Mirror real billing-service partial-debit semantics: drain to zero
// returns `charged < requested`.
if (opts.partialChargeOn != null && amount === opts.partialChargeOn.amount) {
return { userId, flux: 0, charged: opts.partialChargeOn.charged, requested: amount }
}
return { userId, flux: 100 - amount, charged: amount, requested: amount }
}),
} as unknown as BillingService
}
function createMockMetrics() {
const fluxUnbilled = { add: vi.fn() }
const ttsChars = { add: vi.fn() }
const ttsPreflightRejections = { add: vi.fn() }
return {
metrics: { fluxUnbilled, ttsChars, ttsPreflightRejections } as any,
fluxUnbilled,
}
}
function staticRuntime(unitsPerFlux = 1000, debtTtlSeconds = 60) {
return vi.fn(async () => ({ unitsPerFlux, debtTtlSeconds }))
}
describe('fluxMeter', () => {
let mockRedis: ReturnType<typeof createMockRedis>
let billing: BillingService
beforeEach(() => {
mockRedis = createMockRedis()
billing = createMockBilling()
})
it('does not debit when accumulated units stay below threshold', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
const result = await meter.accumulate({
userId: 'u1',
units: 500,
currentBalance: 10,
requestId: 'req-1',
})
expect(result).toEqual({ fluxDebited: 0, debtAfter: 500, balanceAfter: 10, unbilledFlux: 0 })
expect(billing.consumeFluxForLLM).not.toHaveBeenCalled()
})
it('debits exactly one flux when crossing the threshold', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await meter.accumulate({ userId: 'u1', units: 700, currentBalance: 10, requestId: 'a' })
const result = await meter.accumulate({ userId: 'u1', units: 400, currentBalance: 10, requestId: 'b' })
expect(result.fluxDebited).toBe(1)
expect(result.debtAfter).toBe(100)
expect(billing.consumeFluxForLLM).toHaveBeenCalledTimes(1)
expect(billing.consumeFluxForLLM).toHaveBeenCalledWith(expect.objectContaining({
amount: 1,
requestId: 'b',
description: 'tts_request',
}))
})
it('debits multiple flux when one request crosses several thresholds', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
const result = await meter.accumulate({ userId: 'u1', units: 3500, currentBalance: 10, requestId: 'big' })
expect(result.fluxDebited).toBe(3)
expect(result.debtAfter).toBe(500)
expect(billing.consumeFluxForLLM).toHaveBeenCalledWith(expect.objectContaining({ amount: 3 }))
})
it('returns 0 fluxDebited for zero, negative, or non-finite units', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
const result = await meter.accumulate({ userId: 'u1', units: bad, currentBalance: 10, requestId: 'x' })
expect(result.fluxDebited).toBe(0)
}
expect(billing.consumeFluxForLLM).not.toHaveBeenCalled()
})
it('throws 402 when projected debt would exceed user balance', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await expect(meter.assertCanAfford('u1', 5000, 2)).rejects.toMatchObject({ statusCode: 402 })
})
it('allows sub-threshold accumulation when balance >= 1', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await expect(meter.assertCanAfford('u1', 200, 1)).resolves.toBeUndefined()
})
it('rejects sub-threshold accumulation when balance is zero', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await expect(meter.assertCanAfford('u1', 200, 0)).rejects.toMatchObject({ statusCode: 402 })
})
it('throws from runtime resolver when unitsPerFlux is invalid', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, {
name: 'bad',
resolveRuntime: async () => ({ unitsPerFlux: 0, debtTtlSeconds: 60 }),
})
await expect(meter.accumulate({ userId: 'u1', units: 10, currentBalance: 10, requestId: 'r' })).rejects.toThrow()
})
it('peekDebt reflects current accumulated units', async () => {
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await meter.accumulate({ userId: 'u1', units: 250, currentBalance: 10, requestId: 'p' })
expect(await meter.peekDebt('u1')).toBe(250)
})
it('does not read config at construction time (lazy resolver)', async () => {
const resolver = staticRuntime()
createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: resolver })
expect(resolver).not.toHaveBeenCalled()
})
it('resolves runtime on every call so multi-instance config changes propagate immediately', async () => {
const resolver = staticRuntime()
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: resolver })
await meter.accumulate({ userId: 'u1', units: 100, currentBalance: 10, requestId: 'a' })
await meter.accumulate({ userId: 'u1', units: 100, currentBalance: 10, requestId: 'b' })
await meter.assertCanAfford('u1', 100, 10)
expect(resolver).toHaveBeenCalledTimes(3)
})
it('restores debt back into the counter when billing debit throws', async () => {
// Billing rejects the exact flux amount we expect to settle.
const failingBilling = createMockBilling({ throwOn: 2 })
const meter = createFluxMeter(mockRedis.redis, failingBilling, { name: 'tts', resolveRuntime: staticRuntime() })
await expect(
meter.accumulate({ userId: 'u1', units: 2500, currentBalance: 10, requestId: 'fail' }),
).rejects.toThrow('mock billing failure')
// Settlement was rolled back: 2500 units should be fully recovered
// (500 residual + 2000 rolled back), not 500.
expect(await meter.peekDebt('u1')).toBe(2500)
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
})
// ROOT CAUSE:
//
// Prior to commit 7267b0d6b billing-service.consumeFluxForLLM threw on
// any insufficient-balance, which let flux-meter's catch path restore
// the *entire* settled portion back to the debt counter. After that
// commit billing-service introduced partial-debit semantics: when
// 0 < balance < amount, it drains the balance to zero and returns
// `charged < requested` instead of throwing. flux-meter.accumulate
// continued to read only `{ flux }` from the result, so:
// - the un-charged portion (`requested - charged` flux) was silently
// lost — Redis debt was already DECRBY'd by the LUA script,
// - airi_billing_flux_unbilled_total never fired for tts_meter, so
// the partial-debit revenue leak was invisible in Grafana.
//
// After patch: accumulate destructures `charged / requested`, restores
// `(requested - charged) * unitsPerFlux` back into the debt counter, and
// increments airi_billing_flux_unbilled_total with
// `{ source: 'tts_meter', reason: 'partial_debit_drained' }`.
it('restores partial-drain delta to debt and reports fluxUnbilled (Issue: unpaid-usage-exploit follow-up)', async () => {
// After settlement the meter wants to debit 3 flux, but billing only
// manages to charge 1 (user balance was 1 flux). Expect:
// - fluxDebited == 1 (actual charged), not 3
// - unbilledFlux == 2
// - Redis debt restored by 2 * unitsPerFlux = 2000
// - fluxUnbilled metric incremented by 2 with partial_debit_drained reason
const partialBilling = createMockBilling({ partialChargeOn: { amount: 3, charged: 1 } })
const { metrics, fluxUnbilled } = createMockMetrics()
const meter = createFluxMeter(mockRedis.redis, partialBilling, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
const result = await meter.accumulate({
userId: 'u1',
units: 3500,
currentBalance: 1,
requestId: 'partial',
metadata: { model: 'eleven_multilingual_v2' },
})
expect(result.fluxDebited).toBe(1)
expect(result.unbilledFlux).toBe(2)
expect(result.balanceAfter).toBe(0)
// Debt = 500 residual (LUA leftover) + 2000 restored from partial drain.
expect(await meter.peekDebt('u1')).toBe(2500)
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
expect(fluxUnbilled.add).toHaveBeenCalledWith(2, expect.objectContaining({
'source': 'tts_meter',
'meter': 'tts',
'reason': 'partial_debit_drained',
'gen_ai.request.model': 'eleven_multilingual_v2',
}))
})
it('does not report fluxUnbilled when billing fully charges', async () => {
const { metrics, fluxUnbilled } = createMockMetrics()
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
const result = await meter.accumulate({ userId: 'u1', units: 1500, currentBalance: 10, requestId: 'full' })
expect(result.fluxDebited).toBe(1)
expect(result.unbilledFlux).toBe(0)
expect(fluxUnbilled.add).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,106 @@
import type { Database } from '../../libs/db'
import { beforeAll, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createCharacterService } from './characters'
import * as schema from '../../schemas'
describe('characterService', () => {
let db: Database
let service: ReturnType<typeof createCharacterService>
let testUser: any
beforeAll(async () => {
db = await mockDB(schema)
service = createCharacterService(db)
// Create a test user for foreign key constraints
const [user] = await db.insert(schema.user).values({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
it('create should handle full character creation', async () => {
const characterData = {
id: 'char-1',
version: '1.0',
coverUrl: 'url',
characterId: 'cid',
ownerId: testUser.id,
creatorId: testUser.id,
}
const result = await service.create({
character: characterData,
i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }],
cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' },
})
expect(result.id).toBe('char-1')
const found = await service.findById('char-1')
expect(found?.i18n[0].name).toBe('Aster')
expect(found?.cover?.foregroundUrl).toBe('fg')
})
it('findAll should return characters with relations', async () => {
const result = await service.findAll()
expect(result.length).toBeGreaterThan(0)
expect(result[0].i18n).toBeDefined()
})
it('like should toggle like status and update counter', async () => {
const charId = 'char-1'
// First like
const res1 = await service.like(testUser.id, charId)
expect(res1.liked).toBe(true)
let char = await service.findById(charId)
expect(char?.likesCount).toBe(1)
expect(char?.likes.length).toBe(1)
// Second like (unlike)
const res2 = await service.like(testUser.id, charId)
expect(res2.liked).toBe(false)
char = await service.findById(charId)
expect(char?.likesCount).toBe(0)
expect(char?.likes.length).toBe(0)
})
it('bookmark should toggle bookmark status and update counter', async () => {
const charId = 'char-1'
// First bookmark
const res1 = await service.bookmark(testUser.id, charId)
expect(res1.bookmarked).toBe(true)
let char = await service.findById(charId)
expect(char?.bookmarksCount).toBe(1)
// Second bookmark (unbookmark)
const res2 = await service.bookmark(testUser.id, charId)
expect(res2.bookmarked).toBe(false)
char = await service.findById(charId)
expect(char?.bookmarksCount).toBe(0)
})
it('update should update character fields', async () => {
await service.update('char-1', { version: '2.0' })
const char = await service.findById('char-1')
expect(char?.version).toBe('2.0')
})
it('delete should soft delete character', async () => {
await service.delete('char-1')
const char = await service.findById('char-1')
expect(char).toBeUndefined()
})
})
@@ -0,0 +1,297 @@
import type { Database } from '../../libs/db'
import type { EngagementMetrics } from '../../otel'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull, or, sql } from 'drizzle-orm'
import * as schema from '../../schemas/characters'
import * as userCharacterSchema from '../../schemas/user-character'
const logger = useLogger('characters')
export function createCharacterService(db: Database, metrics?: EngagementMetrics | null) {
return {
async findById(id: string) {
return await db.query.character.findFirst({
where: and(
eq(schema.character.id, id),
isNull(schema.character.deletedAt),
),
with: {
capabilities: true,
avatarModels: true,
i18n: true,
prompts: true,
likes: true,
bookmarks: true,
cover: true,
},
})
},
async findByOwnerId(ownerId: string) {
return await db.query.character.findMany({
where: and(
eq(schema.character.ownerId, ownerId),
isNull(schema.character.deletedAt),
),
with: {
i18n: true,
capabilities: true,
likes: true,
bookmarks: true,
cover: true,
},
})
},
async findAll() {
return await db.query.character.findMany({
where: isNull(schema.character.deletedAt),
with: {
i18n: true,
capabilities: true,
likes: true,
bookmarks: true,
cover: true,
},
})
},
async like(userId: string, characterId: string) {
const result = await db.transaction(async (tx) => {
const existing = await tx.query.characterLikes.findFirst({
where: and(
eq(userCharacterSchema.characterLikes.userId, userId),
eq(userCharacterSchema.characterLikes.characterId, characterId),
),
})
if (existing) {
await tx.delete(userCharacterSchema.characterLikes)
.where(and(
eq(userCharacterSchema.characterLikes.userId, userId),
eq(userCharacterSchema.characterLikes.characterId, characterId),
))
await tx.update(schema.character)
.set({
likesCount: sql`${schema.character.likesCount} - 1`,
})
.where(eq(schema.character.id, characterId))
return { liked: false }
}
else {
await tx.insert(userCharacterSchema.characterLikes).values({ userId, characterId })
await tx.update(schema.character)
.set({
likesCount: sql`${schema.character.likesCount} + 1`,
})
.where(eq(schema.character.id, characterId))
return { liked: true }
}
})
metrics?.characterEngagement.add(1, { action: result.liked ? 'like' : 'unlike' })
return result
},
async bookmark(userId: string, characterId: string) {
const result = await db.transaction(async (tx) => {
const existing = await tx.query.characterBookmarks.findFirst({
where: and(
eq(userCharacterSchema.characterBookmarks.userId, userId),
eq(userCharacterSchema.characterBookmarks.characterId, characterId),
),
})
if (existing) {
await tx.delete(userCharacterSchema.characterBookmarks)
.where(and(
eq(userCharacterSchema.characterBookmarks.userId, userId),
eq(userCharacterSchema.characterBookmarks.characterId, characterId),
))
await tx.update(schema.character)
.set({
bookmarksCount: sql`${schema.character.bookmarksCount} - 1`,
})
.where(eq(schema.character.id, characterId))
return { bookmarked: false }
}
else {
await tx.insert(userCharacterSchema.characterBookmarks).values({ userId, characterId })
await tx.update(schema.character)
.set({
bookmarksCount: sql`${schema.character.bookmarksCount} + 1`,
})
.where(eq(schema.character.id, characterId))
return { bookmarked: true }
}
})
metrics?.characterEngagement.add(1, { action: result.bookmarked ? 'bookmark' : 'unbookmark' })
return result
},
async create(data: {
character: schema.NewCharacter
cover?: Omit<schema.NewCharacterCover, 'characterId'>
capabilities?: Omit<schema.NewCharacterCapability, 'characterId'>[]
avatarModels?: Omit<schema.NewAvatarModel, 'characterId'>[]
i18n?: Omit<schema.NewCharacterI18n, 'characterId'>[]
prompts?: Omit<schema.NewCharacterPrompt, 'characterId'>[]
}) {
const inserted = await db.transaction(async (tx) => {
const [inserted] = await tx.insert(schema.character).values(data.character).returning()
logger.withFields({ id: inserted.id, ownerId: data.character.ownerId }).log('Created character')
if (data.cover) {
await tx.insert(schema.characterCovers).values({
...data.cover,
characterId: inserted.id,
})
}
if (data.capabilities?.length) {
await tx.insert(schema.characterCapabilities).values(
data.capabilities.map(c => ({ ...c, characterId: inserted.id })),
)
}
if (data.avatarModels?.length) {
await tx.insert(schema.avatarModel).values(
data.avatarModels.map(a => ({ ...a, characterId: inserted.id })),
)
}
if (data.i18n?.length) {
await tx.insert(schema.characterI18n).values(
data.i18n.map(i => ({ ...i, characterId: inserted.id })),
)
}
if (data.prompts?.length) {
await tx.insert(schema.characterPrompts).values(
data.prompts.map(p => ({ ...p, characterId: inserted.id })),
)
}
return inserted
})
metrics?.characterCreated.add(1)
return inserted
},
async update(id: string, data: Partial<schema.NewCharacter>) {
// TODO: Return a stable single-object response shape for HTTP callers.
// leaking Drizzle returning() arrays across the service boundary makes route contracts drift.
const result = await db.update(schema.character)
.set({ ...data, updatedAt: new Date() })
.where(and(
eq(schema.character.id, id),
isNull(schema.character.deletedAt),
))
.returning()
logger.withFields({ id }).log('Updated character')
return result
},
async delete(id: string) {
const result = await db.update(schema.character)
.set({ deletedAt: new Date() })
.where(and(
eq(schema.character.id, id),
isNull(schema.character.deletedAt),
))
.returning()
if (result.length > 0) {
logger.withFields({ id }).log('Deleted character')
metrics?.characterDeleted.add(1)
}
return result
},
/**
* Soft-delete every character owned or created by the user, plus their
* likes and bookmarks. Called from the user-deletion pipeline.
*
* Marks `creatorId === userId` rows too — fork attribution is tied to the
* creator's identity, so removing the creator soft-archives the lineage
* even if the current owner is someone else.
*
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
const result = await db.transaction(async (tx) => {
const charRows = await tx.update(schema.character)
.set({ deletedAt: now, updatedAt: now })
.where(and(
or(
eq(schema.character.ownerId, userId),
eq(schema.character.creatorId, userId),
),
isNull(schema.character.deletedAt),
))
.returning({ id: schema.character.id })
const likeRows = await tx.update(userCharacterSchema.characterLikes)
.set({ deletedAt: now })
.where(and(
eq(userCharacterSchema.characterLikes.userId, userId),
isNull(userCharacterSchema.characterLikes.deletedAt),
))
.returning({ characterId: userCharacterSchema.characterLikes.characterId })
const bookmarkRows = await tx.update(userCharacterSchema.characterBookmarks)
.set({ deletedAt: now })
.where(and(
eq(userCharacterSchema.characterBookmarks.userId, userId),
isNull(userCharacterSchema.characterBookmarks.deletedAt),
))
.returning({ characterId: userCharacterSchema.characterBookmarks.characterId })
for (const characterId of new Set(likeRows.map(row => row.characterId))) {
await tx.update(schema.character)
.set({
likesCount: sql`greatest(${schema.character.likesCount} - 1, 0)`,
updatedAt: now,
})
.where(eq(schema.character.id, characterId))
}
for (const characterId of new Set(bookmarkRows.map(row => row.characterId))) {
await tx.update(schema.character)
.set({
bookmarksCount: sql`greatest(${schema.character.bookmarksCount} - 1, 0)`,
updatedAt: now,
})
.where(eq(schema.character.id, characterId))
}
return { charRows, likeRows, bookmarkRows }
})
logger
.withFields({
userId,
characters: result.charRows.length,
likes: result.likeRows.length,
bookmarks: result.bookmarkRows.length,
})
.log('Characters / likes / bookmarks soft-deleted for user')
},
}
}
export type CharacterService = ReturnType<typeof createCharacterService>
@@ -0,0 +1,255 @@
import type { Database } from '../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { clampLimit, createChatService, resolveSenderId } from './chats'
import * as schema from '../../schemas'
describe('resolveSenderId', () => {
it('returns userId for user role', () => {
expect(resolveSenderId('user', 'user-123')).toBe('user-123')
})
it('returns userId for assistant role', () => {
expect(resolveSenderId('assistant', 'user-123')).toBe('user-123')
expect(resolveSenderId('system', 'user-123')).toBeNull()
})
})
describe('clampLimit', () => {
it('returns default 100 when no limit', () => {
expect(clampLimit()).toBe(100)
expect(clampLimit(undefined)).toBe(100)
})
it('returns default 100 for zero or negative', () => {
expect(clampLimit(0)).toBe(100)
expect(clampLimit(-5)).toBe(100)
})
it('returns limit when within range', () => {
expect(clampLimit(50)).toBe(50)
expect(clampLimit(500)).toBe(500)
})
it('clamps to max 500', () => {
expect(clampLimit(501)).toBe(500)
expect(clampLimit(1000)).toBe(500)
})
})
describe('pushMessages', () => {
let db: Database
beforeEach(async () => {
db = await mockDB(schema)
})
it('rejects a member attempt to update another members message', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values([
{ chatId: 'group', memberType: 'user', userId: 'author' },
{ chatId: 'group', memberType: 'user', userId: 'member' },
])
await db.insert(schema.messages).values({
id: 'message',
chatId: 'group',
senderId: 'author',
role: 'user',
seq: 1,
content: 'original',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'user', content: 'forged' }]))
.rejects
.toMatchObject({ statusCode: 403, errorCode: 'FORBIDDEN', message: 'Forbidden' })
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
expect(message?.content).toBe('original')
expect(message?.senderId).toBe('author')
expect(message?.seq).toBe(1)
})
it('rejects an existing message ID from another chat', async () => {
await db.insert(schema.chats).values([
{ id: 'source', type: 'group' },
{ id: 'target', type: 'group' },
])
await db.insert(schema.chatMembers).values([
{ chatId: 'source', memberType: 'user', userId: 'member' },
{ chatId: 'target', memberType: 'user', userId: 'member' },
])
await db.insert(schema.messages).values({
id: 'message',
chatId: 'source',
senderId: 'member',
role: 'user',
seq: 1,
content: 'source message',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'target', [{ id: 'message', role: 'user', content: 'target message' }]))
.rejects
.toMatchObject({ statusCode: 409, errorCode: 'CONFLICT', message: 'Message already belongs to another chat' })
const sourceMessage = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
const targetMessages = await db.query.messages.findMany({ where: eq(schema.messages.chatId, 'target') })
expect(sourceMessage?.content).toBe('source message')
expect(targetMessages).toHaveLength(0)
})
it('allows an author to update their own message', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'author' })
await db.insert(schema.messages).values({
id: 'message',
chatId: 'group',
senderId: 'author',
role: 'user',
seq: 1,
content: 'original',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('author', 'group', [{ id: 'message', role: 'user', content: 'updated' }]))
.resolves
.toMatchObject({ seq: 2, fromSeq: 2, toSeq: 2 })
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
expect(message?.content).toBe('updated')
expect(message?.senderId).toBe('author')
expect(message?.role).toBe('user')
expect(message?.chatId).toBe('group')
expect(message?.seq).toBe(2)
})
it('acknowledges an unchanged legacy assistant retry without mutating it', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
await db.insert(schema.messages).values({
id: 'message',
chatId: 'group',
senderId: null,
role: 'assistant',
seq: 1,
content: 'original response',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'original response' }]))
.resolves
.toMatchObject({ seq: 1, fromSeq: 2, toSeq: 1 })
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
expect(message?.content).toBe('original response')
expect(message?.senderId).toBeNull()
expect(message?.role).toBe('assistant')
expect(message?.seq).toBe(1)
})
it('persists later messages batched with an unchanged legacy assistant retry', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
await db.insert(schema.messages).values({
id: 'legacy-assistant',
chatId: 'group',
senderId: null,
role: 'assistant',
seq: 1,
content: 'original response',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [
{ id: 'legacy-assistant', role: 'assistant', content: 'original response' },
{ id: 'new-user-message', role: 'user', content: 'next turn' },
]))
.resolves
.toMatchObject({ seq: 2, fromSeq: 2, toSeq: 2 })
const messages = await db.query.messages.findMany({
where: eq(schema.messages.chatId, 'group'),
orderBy: schema.messages.seq,
})
expect(messages).toHaveLength(2)
expect(messages[0]?.id).toBe('legacy-assistant')
expect(messages[0]?.seq).toBe(1)
expect(messages[1]?.id).toBe('new-user-message')
expect(messages[1]?.senderId).toBe('member')
expect(messages[1]?.seq).toBe(2)
})
it('accepts an assistant message from local-first sync', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'response' }]))
.resolves
.toMatchObject({ seq: 1, fromSeq: 1, toSeq: 1 })
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
expect(message?.role).toBe('assistant')
expect(message?.content).toBe('response')
expect(message?.senderId).toBe('member')
})
it('rejects updates to unowned assistant messages', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values([
{ chatId: 'group', memberType: 'user', userId: 'author' },
{ chatId: 'group', memberType: 'user', userId: 'member' },
])
await db.insert(schema.messages).values({
id: 'message',
chatId: 'group',
senderId: null,
role: 'assistant',
seq: 1,
content: 'original response',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'forged response' }]))
.rejects
.toMatchObject({ statusCode: 403, errorCode: 'FORBIDDEN', message: 'Forbidden' })
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
expect(message?.content).toBe('original response')
expect(message?.seq).toBe(1)
})
it('rejects roles that are not part of cloud chat sync', async () => {
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'system', content: 'local prompt' }]))
.rejects
.toMatchObject({ statusCode: 400, errorCode: 'BAD_REQUEST', message: 'Only user and assistant messages can be synchronized' })
const messages = await db.query.messages.findMany({ where: eq(schema.messages.chatId, 'group') })
expect(messages).toHaveLength(0)
})
})
@@ -0,0 +1,505 @@
import type { MessageRole, WireMessage } from '@proj-airi/server-sdk-shared'
import type { Database } from '../../libs/db'
import type { EngagementMetrics } from '../../otel'
import type { ProductEventService } from './product-events'
import { useLogger } from '@guiiai/logg'
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm'
import { createBadRequestError, createConflictError, createForbiddenError, createNotFoundError } from '../../utils/error'
import { nanoid } from '../../utils/id'
import * as schema from '../../schemas/chats'
const logger = useLogger('chats')
type ChatType = 'private' | 'bot' | 'group' | 'channel'
type ChatMemberType = 'user' | 'character' | 'bot'
interface CreateChatPayload {
id?: string
type?: ChatType
title?: string
members?: { type: ChatMemberType, userId?: string, characterId?: string }[]
}
interface PushMessage {
id: string
role: string
content: string
}
// ---------------------------------------------------------------------------
// Pure helpers (exported for testing)
// ---------------------------------------------------------------------------
export function clampLimit(limit?: number): number {
if (!limit || limit <= 0)
return 100
return Math.min(limit, 500)
}
export function resolveSenderId(role: string, userId: string): string | null {
if (role === 'user' || role === 'assistant')
return userId
return null
}
// ---------------------------------------------------------------------------
// Service factory
// ---------------------------------------------------------------------------
export function createChatService(db: Database, metrics?: EngagementMetrics | null, productEventService?: ProductEventService) {
// ---- internal helpers ---------------------------------------------------
async function verifyMembership(tx: Parameters<Parameters<Database['transaction']>[0]>[0], chatId: string, userId: string) {
const chat = await tx.query.chats.findFirst({
where: and(eq(schema.chats.id, chatId), isNull(schema.chats.deletedAt)),
})
if (!chat)
throw createNotFoundError('Chat not found')
const member = await tx.query.chatMembers.findFirst({
where: and(
eq(schema.chatMembers.chatId, chatId),
eq(schema.chatMembers.memberType, 'user'),
eq(schema.chatMembers.userId, userId),
),
})
if (!member) {
logger.withFields({ userId, chatId }).warn('User not a member of chat, forbidden')
throw createForbiddenError()
}
return chat
}
// ---- public API ---------------------------------------------------------
return {
// -- Chat management (REST) ---------------------------------------------
async createChat(userId: string, payload: CreateChatPayload) {
return db.transaction(async (tx) => {
const chatId = payload.id ?? nanoid()
const now = new Date()
await tx.insert(schema.chats).values({
id: chatId,
type: payload.type ?? 'group',
title: payload.title ?? null,
createdAt: now,
updatedAt: now,
})
// Always add creator as a user member
await tx.insert(schema.chatMembers).values({
chatId,
memberType: 'user',
userId,
characterId: null,
})
// Add additional members if provided
if (payload.members && payload.members.length > 0) {
const extra = payload.members
.filter(m => m.type !== 'user' || m.userId !== userId) // skip duplicate creator
.map(m => ({
chatId,
memberType: m.type,
userId: m.type === 'user' ? (m.userId ?? null) : null,
characterId: m.type !== 'user' ? (m.characterId ?? null) : null,
}))
if (extra.length > 0) {
await tx.insert(schema.chatMembers).values(extra)
}
}
return { id: chatId, type: payload.type ?? 'group', title: payload.title ?? null, createdAt: now, updatedAt: now }
})
},
async getChat(userId: string, chatId: string) {
return db.transaction(async (tx) => {
const chat = await verifyMembership(tx, chatId, userId)
const members = await tx.query.chatMembers.findMany({
where: eq(schema.chatMembers.chatId, chatId),
})
return { ...chat, members }
})
},
async listChats(userId: string) {
const rows = await db
.select({ chat: schema.chats })
.from(schema.chatMembers)
.innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id))
.where(and(
eq(schema.chatMembers.memberType, 'user'),
eq(schema.chatMembers.userId, userId),
isNull(schema.chats.deletedAt),
))
return rows.map(r => r.chat)
},
async updateChat(userId: string, chatId: string, updates: { title?: string }) {
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
const now = new Date()
const [updated] = await tx.update(schema.chats)
.set({ ...updates, updatedAt: now })
.where(eq(schema.chats.id, chatId))
.returning()
return updated
})
},
async deleteChat(userId: string, chatId: string) {
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
const now = new Date()
const [deleted] = await tx.update(schema.chats)
.set({ deletedAt: now, updatedAt: now })
.where(eq(schema.chats.id, chatId))
.returning()
return deleted
})
},
async addMember(userId: string, chatId: string, member: { type: ChatMemberType, userId?: string, characterId?: string }) {
// TODO: Push these invariants up into the HTTP schema and convert failures to API errors instead of generic Error.
// Validate that user-type members have a userId and non-user members have a characterId
if (member.type === 'user' && !member.userId) {
throw new Error('userId is required for user-type members')
}
if (member.type !== 'user' && !member.characterId) {
throw new Error('characterId is required for non-user-type members')
}
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
const [added] = await tx.insert(schema.chatMembers).values({
chatId,
memberType: member.type,
userId: member.type === 'user' ? (member.userId ?? null) : null,
characterId: member.type !== 'user' ? (member.characterId ?? null) : null,
}).returning()
return added
})
},
async getMembers(chatId: string) {
return db.query.chatMembers.findMany({
where: eq(schema.chatMembers.chatId, chatId),
})
},
async removeMember(userId: string, chatId: string, memberId: string) {
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
const [removed] = await tx.delete(schema.chatMembers)
.where(and(
eq(schema.chatMembers.id, memberId),
eq(schema.chatMembers.chatId, chatId),
))
.returning()
if (!removed)
throw createNotFoundError('Member not found')
return removed
})
},
// -- Message sync (WS) --------------------------------------------------
async pushMessages(userId: string, chatId: string, messages: PushMessage[]) {
if (messages.some(message => message.role !== 'user' && message.role !== 'assistant'))
throw createBadRequestError('Only user and assistant messages can be synchronized')
const result = await db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
// Lock chat row to serialize seq assignment
const [chatRow] = await tx
.select({ id: schema.chats.id })
.from(schema.chats)
.where(eq(schema.chats.id, chatId))
.for('update')
if (!chatRow)
throw createNotFoundError('Chat not found')
// Get current max seq for this chat
const [{ maxSeq }] = await tx
.select({ maxSeq: sql<number>`coalesce(max(${schema.messages.seq}), 0)` })
.from(schema.messages)
.where(eq(schema.messages.chatId, chatId))
const now = new Date()
// Split into new vs existing messages
const messageIds = messages.map(m => m.id)
const existingMessages = messageIds.length > 0
? await tx.select({
id: schema.messages.id,
chatId: schema.messages.chatId,
senderId: schema.messages.senderId,
role: schema.messages.role,
content: schema.messages.content,
}).from(schema.messages).where(inArray(schema.messages.id, messageIds))
: []
if (existingMessages.some(message => message.chatId !== chatId))
throw createConflictError('Message already belongs to another chat')
const existingMessagesById = new Map(existingMessages.map(message => [message.id, message]))
const unchangedLegacyAssistantIds = new Set<string>()
if (messages.some((message) => {
const existingMessage = existingMessagesById.get(message.id)
if (existingMessage == null)
return false
if (existingMessage.senderId === resolveSenderId(message.role, userId))
return false
// A pre-ownership assistant row cannot be safely attributed to a user.
// An exact retry is nevertheless safe to acknowledge because it does
// not mutate the stored message or its sequence.
if (
existingMessage.senderId == null
&& existingMessage.role === 'assistant'
&& message.role === 'assistant'
&& existingMessage.content === message.content
) {
unchangedLegacyAssistantIds.add(message.id)
return false
}
return true
})) {
throw createForbiddenError()
}
const existingIds = new Set(existingMessages.map(m => m.id))
const newMsgs = messages.filter(m => !existingIds.has(m.id))
const updateMsgs = messages.filter(m => existingIds.has(m.id) && !unchangedLegacyAssistantIds.has(m.id))
let currentSeq = maxSeq
// Insert new messages with seq
if (newMsgs.length > 0) {
const values = newMsgs.map((m) => {
currentSeq++
return {
id: m.id,
chatId,
senderId: resolveSenderId(m.role, userId),
role: m.role,
seq: currentSeq,
content: m.content,
mediaIds: [] as string[],
stickerIds: [] as string[],
createdAt: now,
updatedAt: now,
}
})
await tx.insert(schema.messages).values(values)
}
// Update existing messages (content + updatedAt + seq bump)
for (const m of updateMsgs) {
currentSeq++
await tx.update(schema.messages)
.set({ content: m.content, seq: currentSeq, updatedAt: now })
.where(and(eq(schema.messages.id, m.id), eq(schema.messages.chatId, chatId)))
}
// Update chat updatedAt
await tx.update(schema.chats)
.set({ updatedAt: now })
.where(eq(schema.chats.id, chatId))
return {
seq: currentSeq,
fromSeq: maxSeq + 1,
toSeq: currentSeq,
newCount: newMsgs.length,
totalCount: messages.length,
}
})
if (result.totalCount > 0) {
metrics?.chatMessages.add(result.totalCount)
void productEventService?.track({
userId,
feature: 'chat',
action: 'message_pushed',
status: 'succeeded',
source: 'chat.ws.push_messages',
metadata: {
message_count: result.totalCount,
new_count: result.newCount,
},
})
}
metrics?.wsMessagesReceived.add(result.totalCount)
return { seq: result.seq, fromSeq: result.fromSeq, toSeq: result.toSeq }
},
/**
* Soft-delete the user's footprint in chats. Per-chat strategy depends
* on `chat.type`:
*
* - `private` / `bot` (1-on-1, user IS the chat): soft-delete the chat
* row + the user's messages. Nothing else can read those messages
* (chat is gone), so soft-deleting them is just keeping audit consistent.
*
* - `group` / `channel` (shared): drop only this user's `chat_members`
* row; the chat + other members survive. The user's messages are
* **kept intact** — deleting them would corrupt the conversation
* context for remaining members ("B replied to nothing"). Sender
* anonymization is automatic: `messages.senderId` is bare text with
* no FK, so after better-auth hard-deletes the user row, the senderId
* string still groups the user's messages together but cannot be
* joined to any PII (name / email are gone with the user row). The
* UI is expected to render `senderId` whose user lookup misses as
* "Deleted User".
*
* `chat_members` rows for shared chats are **hard-deleted** because the
* table was designed without a `deletedAt` column; auditing who was in
* which chat is preserved through `messages.senderId` for the messages
* the user actually authored.
*
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows on
* retry; re-deleting an already-removed `chat_members` row is a no-op.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
// Join chat_members → chats so we can branch by chat.type without a
// second round-trip per row.
const memberChats = await db
.select({ chatId: schema.chatMembers.chatId, chatType: schema.chats.type })
.from(schema.chatMembers)
.innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id))
.where(eq(schema.chatMembers.userId, userId))
const soloChatIds = memberChats
.filter(r => r.chatType === 'private' || r.chatType === 'bot')
.map(r => r.chatId)
const sharedChatIds = memberChats
.filter(r => r.chatType === 'group' || r.chatType === 'channel')
.map(r => r.chatId)
let soloChatCount = 0
let droppedMemberships = 0
let soloMessageCount = 0
let preservedSharedMessages = 0
if (soloChatIds.length > 0) {
const updatedChats = await db.update(schema.chats)
.set({ deletedAt: now, updatedAt: now })
.where(and(
inArray(schema.chats.id, soloChatIds),
isNull(schema.chats.deletedAt),
))
.returning({ id: schema.chats.id })
soloChatCount = updatedChats.length
// Soft-delete user-authored messages in solo chats only. The chat
// itself is gone, so this is purely audit/consistency hygiene.
const updatedMessages = await db.update(schema.messages)
.set({ deletedAt: now, updatedAt: now })
.where(and(
inArray(schema.messages.chatId, soloChatIds),
eq(schema.messages.senderId, userId),
isNull(schema.messages.deletedAt),
))
.returning({ id: schema.messages.id })
soloMessageCount = updatedMessages.length
}
if (sharedChatIds.length > 0) {
const dropped = await db.delete(schema.chatMembers)
.where(and(
inArray(schema.chatMembers.chatId, sharedChatIds),
eq(schema.chatMembers.userId, userId),
))
.returning({ id: schema.chatMembers.id })
droppedMemberships = dropped.length
// Count (do not mutate) the user's messages in shared chats to make
// the preservation visible in logs. These rows stay live so other
// members keep their conversation context; sender anonymizes itself
// once better-auth hard-deletes the user row.
const kept = await db.select({ id: schema.messages.id })
.from(schema.messages)
.where(and(
inArray(schema.messages.chatId, sharedChatIds),
eq(schema.messages.senderId, userId),
isNull(schema.messages.deletedAt),
))
preservedSharedMessages = kept.length
}
logger.withFields({
userId,
soloChats: soloChatCount,
sharedChatMembershipsDropped: droppedMemberships,
soloMessages: soloMessageCount,
preservedSharedMessages,
}).log('Chats footprint processed for user (solo soft-deleted, shared anonymized)')
},
async pullMessages(userId: string, chatId: string, afterSeq: number, limit?: number) {
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
const clamped = clampLimit(limit)
const rows = await tx
.select()
.from(schema.messages)
.where(and(
eq(schema.messages.chatId, chatId),
gt(schema.messages.seq, afterSeq),
))
.orderBy(schema.messages.seq)
.limit(clamped)
// Get current max seq
const [{ maxSeq }] = await tx
.select({ maxSeq: sql<number>`coalesce(max(${schema.messages.seq}), 0)` })
.from(schema.messages)
.where(eq(schema.messages.chatId, chatId))
const wireMessages: WireMessage[] = rows.map(r => ({
id: r.id,
chatId: r.chatId,
senderId: r.senderId,
role: r.role as MessageRole,
content: r.content,
seq: r.seq!,
createdAt: r.createdAt.getTime(),
updatedAt: r.updatedAt.getTime(),
}))
return { messages: wireMessages, seq: maxSeq }
})
},
}
}
export type ChatService = ReturnType<typeof createChatService>
@@ -0,0 +1,28 @@
export type FluxBalanceBucket = 'zero' | '1_100' | '101_1000' | '1001_10000' | '10000_plus' | 'unknown'
/**
* Normalizes exact Flux balance values into analytics-safe buckets.
*
* Before:
* - 0
* - 42
* - 1200
*
* After:
* - "zero"
* - "1_100"
* - "1001_10000"
*/
export function fluxBalanceBucket(balance: number | null | undefined): FluxBalanceBucket {
if (balance == null || Number.isNaN(balance))
return 'unknown'
if (balance <= 0)
return 'zero'
if (balance <= 100)
return '1_100'
if (balance <= 1000)
return '101_1000'
if (balance <= 10000)
return '1001_10000'
return '10000_plus'
}
@@ -0,0 +1,79 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createFluxTransactionService } from './flux-transaction'
import * as schema from '../../schemas'
describe('fluxTransactionService', () => {
let db: any
let service: ReturnType<typeof createFluxTransactionService>
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values({
id: 'user-tx',
name: 'Transaction User',
email: 'tx@example.com',
})
service = createFluxTransactionService(db)
})
it('log should insert a single transaction entry', async () => {
await service.log({
userId: 'user-tx',
type: 'credit',
amount: 500,
balanceBefore: 0,
balanceAfter: 500,
description: 'Stripe payment',
metadata: { stripeSessionId: 'sess_123' },
})
const { records } = await service.getHistory('user-tx', 10, 0)
expect(records).toHaveLength(1)
expect(records[0].type).toBe('credit')
expect(records[0].amount).toBe(500)
})
it('logBatch should insert multiple entries', async () => {
await service.logBatch([
{ userId: 'user-tx', type: 'debit', amount: 10, balanceBefore: 500, balanceAfter: 490, description: 'gpt-4o' },
{ userId: 'user-tx', type: 'debit', amount: 5, balanceBefore: 490, balanceAfter: 485, description: 'gpt-4o-mini' },
])
const { records } = await service.getHistory('user-tx', 10, 0)
expect(records).toHaveLength(3) // 1 from previous test + 2 batch
})
it('logBatch with empty array should be a no-op', async () => {
await service.logBatch([])
const { records } = await service.getHistory('user-tx', 10, 0)
expect(records).toHaveLength(3)
})
it('getHistory should paginate correctly with hasMore', async () => {
const { records, hasMore } = await service.getHistory('user-tx', 2, 0)
expect(records).toHaveLength(2)
expect(hasMore).toBe(true)
})
it('getHistory should return hasMore=false on last page', async () => {
const { records, hasMore } = await service.getHistory('user-tx', 10, 0)
expect(records).toHaveLength(3)
expect(hasMore).toBe(false)
})
it('getHistory should respect offset', async () => {
const { records } = await service.getHistory('user-tx', 10, 2)
expect(records).toHaveLength(1)
})
it('getHistory should return records ordered by createdAt desc', async () => {
const { records } = await service.getHistory('user-tx', 10, 0)
for (let i = 1; i < records.length; i++) {
expect(new Date(records[i - 1].createdAt).getTime())
.toBeGreaterThanOrEqual(new Date(records[i].createdAt).getTime())
}
})
})
@@ -0,0 +1,72 @@
import type { Database } from '../../libs/db'
import { useLogger } from '@guiiai/logg'
import { and, desc, eq, inArray } from 'drizzle-orm'
import * as schema from '../../schemas/flux-transaction'
const logger = useLogger('flux-transaction')
export interface TransactionEntry {
userId: string
type: 'credit' | 'debit' | 'initial' | 'promo'
amount: number
balanceBefore: number
balanceAfter: number
requestId?: string
description: string
metadata?: Record<string, unknown>
}
export function createFluxTransactionService(db: Database) {
return {
async log(entry: TransactionEntry) {
await db.insert(schema.fluxTransaction).values(entry)
logger.withFields({ userId: entry.userId, type: entry.type, amount: entry.amount }).log('Transaction recorded')
},
async logBatch(entries: TransactionEntry[]) {
if (entries.length === 0)
return
await db.insert(schema.fluxTransaction).values(entries)
logger.withFields({ count: entries.length }).log('Transaction batch recorded')
},
async getHistory(userId: string, limit: number, offset: number) {
const records = await db.query.fluxTransaction.findMany({
where: eq(schema.fluxTransaction.userId, userId),
orderBy: [desc(schema.fluxTransaction.createdAt)],
limit: limit + 1, // fetch one extra to determine hasMore
offset,
})
const hasMore = records.length > limit
if (hasMore)
records.pop()
return { records, hasMore }
},
async getStats(userId: string) {
// Get the balance right after the most recent credit/initial/promo transaction
// as the "capacity" for the progress bar. 'promo' (admin grant) bumps capacity
// so the user's progress bar reflects the new total they have to spend.
const [latestCredit] = await db.select({
balanceAfter: schema.fluxTransaction.balanceAfter,
})
.from(schema.fluxTransaction)
.where(
and(
eq(schema.fluxTransaction.userId, userId),
inArray(schema.fluxTransaction.type, ['credit', 'initial', 'promo']),
),
)
.orderBy(desc(schema.fluxTransaction.createdAt))
.limit(1)
return { capacity: latestCredit?.balanceAfter ?? 0 }
},
}
}
export type FluxTransactionService = ReturnType<typeof createFluxTransactionService>
@@ -0,0 +1,103 @@
import type Redis from 'ioredis'
import type { Database } from '../../libs/db'
import type { createConfigKVService } from '../adapters/config-kv'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { userFluxRedisKey } from '../../utils/redis-keys'
import { createFluxService } from './flux'
import * as schema from '../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
set: vi.fn(),
} as any
}
function createMockRedis(): Redis {
const store = new Map<string, string>()
return {
get: vi.fn(async (key: string) => store.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => {
store.set(key, value)
return 'OK'
}),
} as unknown as Redis
}
describe('fluxService (DB-backed)', () => {
let db: Database
let redis: Redis
let service: ReturnType<typeof createFluxService>
let testUser: any
beforeAll(async () => {
db = await mockDB(schema)
const [user] = await db.insert(schema.user).values({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
beforeEach(async () => {
redis = createMockRedis()
service = createFluxService(db, redis, createMockConfigKV())
// Clean up flux-related tables
await db.delete(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, testUser.id))
})
it('getFlux should initialize new user with INITIAL_USER_FLUX and populate Redis', async () => {
const record = await service.getFlux(testUser.id)
expect(record.flux).toBe(100)
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '100')
})
it('getFlux should write a transaction entry on initialization', async () => {
await service.getFlux(testUser.id)
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]).toMatchObject({
type: 'initial',
amount: 100,
balanceBefore: 0,
balanceAfter: 100,
})
})
it('getFlux should return cached value from Redis on subsequent calls', async () => {
await service.getFlux(testUser.id)
await service.getFlux(testUser.id)
// Second call hits Redis cache
expect(redis.get).toHaveBeenCalledTimes(2)
})
it('getFlux should load from DB when Redis cache misses', async () => {
// Pre-insert user flux directly
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 42 })
const record = await service.getFlux(testUser.id)
expect(record.flux).toBe(42)
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '42')
})
it('updateStripeCustomerId should update DB only', async () => {
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 100 })
const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123')
expect(result!.stripeCustomerId).toBe('cus_abc123')
})
})
+128
View File
@@ -0,0 +1,128 @@
import type Redis from 'ioredis'
import type { Database } from '../../libs/db'
import type { ConfigKVService } from '../adapters/config-kv'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull } from 'drizzle-orm'
import { userFluxRedisKey } from '../../utils/redis-keys'
import * as schema from '../../schemas/flux'
import * as fluxTxSchema from '../../schemas/flux-transaction'
const logger = useLogger('flux-service')
// NOTICE:
// All read paths here treat soft-deleted rows (`deletedAt IS NOT NULL`) as
// invisible. After account deletion the auth tables hard-delete the user
// so this filter is mostly defense-in-depth against routes that bypass
// `sessionMiddleware`. See `server/apps/api/docs/ai-context/account-deletion.md`.
export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) {
return {
async getFlux(userId: string) {
// 1. Try Redis cache
const cached = await redis.get(userFluxRedisKey(userId))
if (cached !== null) {
return { userId, flux: Number.parseInt(cached, 10) }
}
// 2. Cache miss — load from DB
let record = await db.query.userFlux.findFirst({
where: and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
),
})
if (!record) {
const initialFlux = await configKV.getOrThrow('INITIAL_USER_FLUX')
// Transaction: create user_flux + flux_transaction atomically
await db.transaction(async (tx) => {
const [inserted] = await tx.insert(schema.userFlux)
.values({ userId, flux: initialFlux })
.onConflictDoNothing({ target: schema.userFlux.userId })
.returning()
// Only write transaction if we actually created the record (not a conflict)
if (inserted) {
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId,
type: 'initial',
amount: initialFlux,
balanceBefore: 0,
balanceAfter: initialFlux,
description: 'Initial grant',
})
}
})
// Re-read to handle race condition (another request may have initialized first)
record = await db.query.userFlux.findFirst({
where: and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
),
})
if (!record) {
throw new Error(`Failed to initialize flux for user ${userId}`)
}
logger.withFields({ userId, initialFlux }).log('Initialized new user flux')
}
// 3. Populate Redis cache
await redis.set(userFluxRedisKey(userId), String(record.flux))
return record
},
async updateStripeCustomerId(userId: string, stripeCustomerId: string) {
const [updated] = await db.update(schema.userFlux)
.set({
stripeCustomerId,
updatedAt: new Date(),
})
.where(and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
))
.returning()
return updated
},
/**
* Soft-delete the user's flux balance and drop the cached value from
* Redis. Does NOT touch `flux_transaction` — that ledger is preserved
* across user deletion for billing audit (and the table has no
* `deletedAt` column by design).
*
* Idempotent: `WHERE deletedAt IS NULL` skips an already-stamped row,
* `redis.del` is a no-op when the key is absent.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
const result = await db.update(schema.userFlux)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
))
.returning({ flux: schema.userFlux.flux })
// Drop the cached balance so any in-flight read does not see a
// ghost balance for the soft-deleted user.
await redis.del(userFluxRedisKey(userId))
logger
.withFields({ userId, clearedFlux: result[0]?.flux ?? 0 })
.log('Flux balance soft-deleted and cache invalidated')
},
}
}
export type FluxService = ReturnType<typeof createFluxService>
@@ -0,0 +1,149 @@
import type Redis from 'ioredis'
import {
ttsPoolInflightRedisKey,
ttsPoolKnownRedisKey,
ttsPoolSaturatedRedisKey,
} from '../../../utils/redis-keys'
// NOTICE: Atomic capacity-gated acquire. The TTSpool routes requests across
// multiple app_ids, each capped at a small concurrency limit (e.g. 10). To use
// the pooled capacity without overshooting any single app_id, we track in-flight
// requests per pool in Redis (shared across replicas — the server is multi-instance
// on Railway). A check-then-INCR done in two round-trips would race between
// replicas and overshoot the cap, so the check + increment happen inside one Lua
// script. The EXPIRE bounds leakage: if a replica crashes between acquire and
// release, the counter self-heals after `inflightTtlSeconds` instead of pinning
// the pool as permanently full. Source: flux-meter.ts ACCUMULATE_SCRIPT (same
// "INCR + EXPIRE, TTL survives crash" shape).
const ACQUIRE_SCRIPT = `
local inflightKey = KEYS[1]
local knownKey = KEYS[2]
local max = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local poolId = ARGV[3]
local current = tonumber(redis.call('GET', inflightKey) or '0')
if current < max then
local next = redis.call('INCR', inflightKey)
redis.call('EXPIRE', inflightKey, ttl)
redis.call('SADD', knownKey, poolId)
return next
end
return -1
`
// NOTICE: Floor-guarded release. A bare DECR on a missing/expired key would
// drive the counter negative (Redis DECR on a nonexistent key yields -1), which
// would then let the pool accept more than `max` concurrent requests. Guarding
// with GET>0 inside Lua keeps release idempotent against the TTL self-heal: if
// the inflight key already expired, release is a no-op rather than a corruption.
const RELEASE_SCRIPT = `
local inflightKey = KEYS[1]
local current = tonumber(redis.call('GET', inflightKey) or '0')
if current > 0 then
return redis.call('DECR', inflightKey)
end
return 0
`
/**
* Tracks per-pool in-flight concurrency in Redis so the TTS router can spread
* load across multiple app_ids without overshooting any one app_id's cap.
*
* Use when:
* - Building the LLM/TTS router service (`createLlmRouterService`), which
* acquires a slot before dispatching to a capacity-capped upstream and
* releases it once the attempt finishes.
*
* Expects:
* - `redis` is the shared cluster Redis (the same instance the flux meter and
* config cache use). Counts are cluster-wide, not per-process.
*
* Returns:
* - An acquire/release/saturation API. `tryAcquire` is the only capacity
* decision; everything else is bookkeeping the router and the watermark
* gauge read.
*/
export function createConcurrencyLedger(redis: Redis, options?: {
/**
* TTL (seconds) on the in-flight counter. Bounds leakage when a replica
* crashes between acquire and release. Should comfortably exceed the longest
* single TTS attempt so a live request is never evicted mid-flight.
* @default 60
*/
inflightTtlSeconds?: number
}) {
const inflightTtlSeconds = options?.inflightTtlSeconds ?? 60
const knownKey = ttsPoolKnownRedisKey()
/**
* Atomically acquire one slot on `poolId` if it is below `maxConcurrency`.
* Returns true when the slot was taken (caller MUST later call `release`),
* false when the pool is already at capacity (caller should try another pool).
*/
async function tryAcquire(poolId: string, maxConcurrency: number): Promise<boolean> {
const result = await redis.eval(
ACQUIRE_SCRIPT,
2,
ttsPoolInflightRedisKey(poolId),
knownKey,
maxConcurrency,
inflightTtlSeconds,
poolId,
) as number | string
return Number(result) >= 0
}
/**
* Release one slot previously taken via {@link tryAcquire}. Idempotent and
* floor-guarded — releasing an already-zero/expired counter is a no-op.
*/
async function release(poolId: string): Promise<void> {
await redis.eval(RELEASE_SCRIPT, 1, ttsPoolInflightRedisKey(poolId))
}
/**
* Flag `poolId` as saturated for `ttlSeconds`. Called when an upstream
* exhausts with a 429 (app_id concurrency exceeded upstream-side) so the
* router skips this pool during the cool-down instead of re-probing a pool it
* already knows is full.
*/
async function markSaturated(poolId: string, ttlSeconds: number): Promise<void> {
await redis.set(ttsPoolSaturatedRedisKey(poolId), '1', 'EX', ttlSeconds)
}
/** Whether `poolId` is within a saturation cool-down window. */
async function isSaturated(poolId: string): Promise<boolean> {
const exists = await redis.exists(ttsPoolSaturatedRedisKey(poolId))
return exists === 1
}
/** Current in-flight count for `poolId` (0 when the counter is absent). */
async function currentInflight(poolId: string): Promise<number> {
const raw = await redis.get(ttsPoolInflightRedisKey(poolId))
return raw == null ? 0 : Number(raw)
}
/**
* Snapshot every known pool's in-flight count. Backs the watermark gauge —
* reads the known-pools set, then MGETs each counter in one round-trip.
* Returns an empty array when no pool has ever been acquired.
*/
async function snapshot(): Promise<Array<{ poolId: string, inflight: number }>> {
const poolIds = await redis.smembers(knownKey)
if (poolIds.length === 0)
return []
const values = await redis.mget(poolIds.map(ttsPoolInflightRedisKey))
return poolIds.map((poolId, i) => ({
poolId,
inflight: values[i] == null ? 0 : Number(values[i]),
}))
}
return { tryAcquire, release, markSaturated, isSaturated, currentInflight, snapshot }
}
export type ConcurrencyLedger = ReturnType<typeof createConcurrencyLedger>
@@ -0,0 +1,111 @@
import type { ConfigKVService } from '../../adapters/config-kv'
import type { LlmModel, ModelKind, RouterConfig, TtsModel } from './types'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
/**
* Default TTL for the in-memory config cache. Plan KTD-4 fallback path:
* Pub/Sub invalidation is best-effort; TTL is the self-heal upper bound on
* staleness when a Pub/Sub message is missed. 5s keeps admin config edits
* propagating to every instance within a 5s window even with no Pub/Sub.
*/
const DEFAULT_CACHE_TTL_MS = 5_000
export interface ConfigLoaderOptions {
/** ConfigKV service used to read `LLM_ROUTER_CONFIG`. */
configKV: ConfigKVService
/**
* Cache TTL in milliseconds.
* @default 5_000
*/
ttlMs?: number
/**
* Clock injected for tests. Defaults to `Date.now`. We do NOT mock the
* global Date object — tests pass a stub instead.
* @default Date.now
*/
now?: () => number
}
/**
* Per-model config slice for a specific kind. Distinguished as a tagged
* union so callers handle `llm` and `tts` shapes explicitly.
*/
export type ModelConfigSlice
= | { kind: 'llm', model: LlmModel, defaults: RouterConfig['defaults'] }
| { kind: 'tts', model: TtsModel, defaults: RouterConfig['defaults'] }
/**
* Build the in-process config loader for the router.
*
* Use when:
* - The router or admin endpoint needs to resolve `LLM_ROUTER_CONFIG` and
* wants a single shared in-memory cache across requests.
*
* Expects:
* - `configKV.getOptional('LLM_ROUTER_CONFIG')` returns either a parsed
* config tree or `null` when the entry is missing.
*
* Returns:
* - `getModelConfig(kind, modelName)` — resolves one model slice; throws
* `BAD_REQUEST` for unknown models and `CONFIG_NOT_SET` (503) when the
* whole config entry is absent.
* - `invalidate()` — clears the cache. Wired to Pub/Sub in U7 for cross-
* instance propagation; admin endpoint calls it on write.
*/
export function createConfigLoader(options: ConfigLoaderOptions) {
const ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS
const now = options.now ?? Date.now
let cached: { value: RouterConfig, loadedAt: number } | null = null
async function loadFresh(): Promise<RouterConfig> {
const value = await options.configKV.getOptional('LLM_ROUTER_CONFIG')
if (value == null) {
throw createServiceUnavailableError(
'LLM_ROUTER_CONFIG not set',
'CONFIG_NOT_SET',
)
}
cached = { value, loadedAt: now() }
return value
}
async function getConfig(): Promise<RouterConfig> {
if (cached != null && now() - cached.loadedAt < ttlMs)
return cached.value
return loadFresh()
}
async function getModelConfig(kind: ModelKind, modelName: string): Promise<ModelConfigSlice> {
const config = await getConfig()
if (kind === 'llm') {
const model = config.llm.models[modelName]
if (model == null) {
throw createBadRequestError(
'unknown_model',
'BAD_REQUEST',
{ requested: modelName, available: Object.keys(config.llm.models) },
)
}
return { kind: 'llm', model, defaults: config.defaults }
}
const model = config.tts.models[modelName]
if (model == null) {
throw createBadRequestError(
'unknown_model',
'BAD_REQUEST',
{ requested: modelName, available: Object.keys(config.tts.models) },
)
}
return { kind: 'tts', model, defaults: config.defaults }
}
function invalidate(): void {
cached = null
}
return { getModelConfig, invalidate }
}
export type ConfigLoader = ReturnType<typeof createConfigLoader>
@@ -0,0 +1,127 @@
import type { useLogger } from '@guiiai/logg'
import type Redis from 'ioredis'
import type { GatewayMetrics } from '../../../otel'
import type { LlmRouterService } from './router'
/**
* Dependencies needed to wire the cross-instance config invalidation
* subscriber.
*/
export interface ConfigSyncSubscriberOptions {
/**
* Primary Redis client. The subscriber takes its own connection via
* `.duplicate()` because ioredis forbids non-pubsub commands on a
* connection in subscribe mode.
*/
redis: Redis
/** Router service whose in-memory `LLM_ROUTER_CONFIG` cache we invalidate. */
llmRouter: LlmRouterService
/**
* OTel gateway metric bundle. `null` when OTel is disabled — emit calls
* become no-ops.
*/
gatewayMetrics: GatewayMetrics | null
/** Value attached to the `service_instance_id` label on emitted metrics. */
instanceId: string
/** Logger handle. Caller supplies a scoped logger so namespacing is theirs. */
logger: ReturnType<typeof useLogger>
}
/**
* Per-call shape returned to the caller. Kept narrow so the caller can hold
* the subscriber handle for graceful shutdown or tests without leaking the
* internal emit closure.
*/
export interface ConfigSyncSubscriber {
/** Underlying ioredis subscriber connection. */
subscriber: Redis
}
/**
* Wires the cross-instance `configkv:invalidate` subscriber to the router's
* cache and OTel gateway metrics.
*
* Use when:
* - Booting a server replica that has a live `LlmRouterService` and needs
* to react to peer-instance config writes within the Pub/Sub propagation
* window (R16 / KTD-4, ≤5s under healthy Redis).
*
* Expects:
* - `redis` is the application's primary client. We `.duplicate()` it here
* because ioredis forbids non-pubsub commands on a subscribed connection.
* - `llmRouter` is already constructed. The caller owns its lifecycle.
*
* Returns:
* - `subscriber` — the dedicated ioredis subscriber connection, so the
* caller can `await subscriber.quit()` during graceful shutdown.
*
* Emits the following `airi.gen_ai.gateway.*` metrics:
* - `config_reload` (source = `pubsub`) once per accepted invalidation msg
* - `subscriber_state` with `state` = `connected` / `error` / `reconnecting`
*
* The router's in-memory cache reloads on either a pub/sub message OR the
* `configCacheTtlMs` fallback (default 5s); a silently-disconnected
* subscriber means the instance drifts inside that window. `subscriber_state`
* is the only direct signal for that drift.
*/
export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): ConfigSyncSubscriber {
const subscriber = opts.redis.duplicate()
function recordSubscriberState(state: 'connected' | 'error' | 'reconnecting') {
opts.gatewayMetrics?.subscriberState.add(1, {
state,
service_instance_id: opts.instanceId,
})
}
subscriber.on('message', (channel, message) => {
if (channel !== 'configkv:invalidate')
return
try {
const payload = JSON.parse(message) as { key?: unknown }
// LLM_ROUTER_CONFIG drives a model-config cache + voice-catalog cache
// invalidation (key rotation, model add/remove, region swap all need to
// surface immediately). UNSPEECH_UPSTREAM only affects the voice catalog
// cache because no other in-process structure references it.
if (payload?.key === 'LLM_ROUTER_CONFIG') {
opts.llmRouter.invalidateConfig()
void opts.llmRouter.invalidateTtsVoicesCache().catch((err) => {
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on LLM_ROUTER_CONFIG change')
})
opts.gatewayMetrics?.configReload.add(1, {
source: 'pubsub',
service_instance_id: opts.instanceId,
})
return
}
if (payload?.key === 'UNSPEECH_UPSTREAM') {
void opts.llmRouter.invalidateTtsVoicesCache().catch((err) => {
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on UNSPEECH_UPSTREAM change')
})
}
}
catch (err) {
opts.logger.withError(err).warn('Failed to parse configkv:invalidate payload')
}
})
subscriber.on('error', (err: Error) => {
opts.logger.withError(err).warn('configkv:invalidate subscriber connection error')
recordSubscriberState('error')
})
// ioredis emits `reconnecting` before each reconnect attempt; the
// subscription itself is restored automatically because `autoResubscribe`
// defaults to true.
subscriber.on('reconnecting', () => recordSubscriberState('reconnecting'))
subscriber.subscribe('configkv:invalidate')
.then(() => recordSubscriberState('connected'))
.catch((err: unknown) => {
opts.logger.withError(err).warn('Failed to subscribe to configkv:invalidate channel')
recordSubscriberState('error')
})
return { subscriber }
}
@@ -0,0 +1,119 @@
import type { ApiError } from '../../../utils/error'
import { createBadGatewayError, createGatewayTimeoutError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
/**
* Sanitized context for `mapUpstreamError`.
*
* Per SEC-5: upstream response bodies and headers must never enter this
* shape. Body content can leak provider-internal info (subscription IDs,
* region tags, rate-limit metadata) to the end client. Only counts and the
* final status code are safe to surface.
*/
export interface UpstreamErrorContext {
/** How many distinct keys were attempted across all upstreams. */
triedKeys: number
/** How many distinct upstreams were attempted. */
triedUpstreams: number
/** The status of the **last** attempt — drives the 502/503/504 selection. */
lastStatusCode: number | 'timeout'
}
/**
* One recorded upstream attempt. Carries the raw provider response snippet
* and network error message so operators can diagnose 502s without re-
* probing the upstream. Lives on `ApiError.cause`, never on `details`, so
* SEC-5 (no upstream content in client-facing response body) still holds.
*/
export interface UpstreamAttempt {
provider: string
keyId: string
status: number | 'timeout'
/**
* First ≤256 bytes of the upstream response body when the attempt
* received an HTTP response. Helps tell apart "key invalid", "region
* blocked", "model not enabled" without re-running the request.
*/
bodySnippet?: string
/**
* Result of `errorMessageFrom(err)` when the attempt threw before getting
* an HTTP response (per-attempt timeout, DNS, ECONNRESET) or when an
* adapter wrapped a network failure. TTS adapters bake the body snippet
* into this message, so chat upstreams populate `bodySnippet` and TTS
* upstreams populate `errorMessage`.
*/
errorMessage?: string
}
/**
* Server-only cause attached to the {@link ApiError} that
* {@link mapUpstreamError} produces. Surfaced through logger + OTel
* span attributes, never through the HTTP response body.
*/
export interface RouterErrorCause {
attempts: UpstreamAttempt[]
}
/**
* Map a final upstream failure to a client-facing {@link ApiError} per
* KTD-1 last-attempt-wins policy.
*
* Use when:
* - The router has exhausted every (upstream, key) combo. The status code
* of the **last** attempt drives the response.
*
* Expects:
* - `status` is a non-2xx HTTP code or the literal `'timeout'` token. Passing
* a 2xx code is a programmer error (this mapper should only run after the
* router decides every attempt failed) and throws an internal error.
* - `attempts` (when provided) lists every recorded upstream attempt.
* Attached to `ApiError.cause` so logger / OTel can surface the real
* upstream message; SEC-5 forbids the same content on `details`.
*
* Returns:
* - `504 GATEWAY_TIMEOUT` when the last attempt timed out.
* - `503 SERVICE_UNAVAILABLE` when the last attempt was a 429 (so retry-able
* rate-limit hints reach the client correctly).
* - `502 BAD_GATEWAY` for every other non-2xx upstream status (401/402/403,
* 5xx, anything else).
*/
export function mapUpstreamError(
status: number | 'timeout',
context: UpstreamErrorContext,
attempts?: UpstreamAttempt[],
): ApiError {
const details = {
triedKeys: context.triedKeys,
triedUpstreams: context.triedUpstreams,
lastStatusCode: context.lastStatusCode,
}
const apiErr = buildApiError(status, details)
if (attempts != null && attempts.length > 0) {
// Server-only cause. Logger / OTel pick this up; SEC-5 keeps it out
// of the client-facing response body.
const cause: RouterErrorCause = { attempts }
;(apiErr as { cause?: unknown }).cause = cause
}
return apiErr
}
function buildApiError(status: number | 'timeout', details: UpstreamErrorContext): ApiError {
if (status === 'timeout')
return createGatewayTimeoutError('Upstream timeout', details)
// Programmer error: only non-2xx statuses should reach this mapper. We
// refuse to return 502 for a 2xx because that masks a real bug — the
// caller decided the request succeeded somewhere upstream of here.
if (status >= 200 && status < 300) {
throw createInternalError(
`mapUpstreamError received success status ${status} — only non-2xx upstream statuses should reach this mapper`,
details,
)
}
if (status === 429)
return createServiceUnavailableError('Upstream rate-limited', 'SERVICE_UNAVAILABLE', details)
return createBadGatewayError('Upstream unavailable', details)
}
@@ -0,0 +1,9 @@
export { createConcurrencyLedger } from './concurrency-ledger'
export type { ConcurrencyLedger } from './concurrency-ledger'
export { createConfigSyncSubscriber } from './config-sync-subscriber'
export { createLlmRouterService } from './router'
export type { LlmRouterService } from './router'
export type { LlmModel, LlmRouteContext, TtsModel, TtsUpstream } from './types'
@@ -0,0 +1,92 @@
import type { Buffer } from 'node:buffer'
import type { GatewayMetrics } from '../../../otel'
import type { EnvelopeCrypto } from '../../../utils/envelope-crypto'
import { errorMessageFrom } from '@moeru/std'
import { createServiceUnavailableError } from '../../../utils/error'
/**
* Minimal shape of one upstream as needed by the rotator. We do not depend
* on the inferred `LlmUpstream` / `TtsUpstream` types directly here so the
* same rotator works for both surfaces without a union split — both surfaces
* carry `keys` + each key has `id` + `ciphertext`.
*/
export interface RotatableUpstream {
keys: ReadonlyArray<{ id: string, ciphertext: string }>
}
/**
* One yielded key entry. `plaintext` is a {@link Buffer} so the caller can
* `buf.fill(0)` after use to wipe the secret from memory promptly.
*/
export interface RotatedKey {
/** Stable key id from the config entry — safe for OTel labels. */
id: string
/**
* Decrypted plaintext key bytes. The caller is expected to wipe this with
* `plaintext.fill(0)` in a `finally` once the request attempt finishes.
*/
plaintext: Buffer
}
/**
* Build a per-request iterator over decrypted keys for one upstream.
*
* Use when:
* - The router needs to walk an upstream's `keys[]` in order, decrypting
* each only when it's actually about to be used.
*
* Expects:
* - `upstream.keys` is non-empty (configKV Valibot enforces this).
* - `modelName` matches the AAD the keys were encrypted under. A mismatch
* surfaces as a decrypt failure (AAD binding — see envelope-crypto.ts).
* - `gatewayMetrics` may be `null` when OTel is disabled.
*
* Returns:
* - An iterable that yields `{id, plaintext: Buffer}` per key in config
* order. On decrypt failure it throws `createServiceUnavailableError`
* immediately (does NOT silently skip — silent skip would hide config
* poisoning attempts).
*/
export function createKeyRotator(
upstream: RotatableUpstream,
envelopeCrypto: EnvelopeCrypto,
modelName: string,
gatewayMetrics: GatewayMetrics | null,
provider: string,
): Iterable<RotatedKey> {
return {
* [Symbol.iterator](): Iterator<RotatedKey> {
for (const entry of upstream.keys) {
let plaintext: Buffer
try {
plaintext = envelopeCrypto.decryptKey(entry.ciphertext, {
modelName,
keyEntryId: entry.id,
})
}
catch (err) {
gatewayMetrics?.decryptFailures.add(1, {
provider,
key_entry_id: entry.id,
})
// NOTICE:
// Surfacing decrypt failure as CONFIG_NOT_SET (503) rather than
// letting the raw crypto error bubble. Silent skip would hide
// config poisoning / forged-blob attempts. Source: plan U3 test
// scenario (3) "Decrypt failure on one key … does NOT silently
// skip (security: silent skip would hide config-poisoning)".
// Removal condition: never — security-critical surfacing.
throw createServiceUnavailableError(
`Failed to decrypt key ${entry.id} for model ${modelName}: ${errorMessageFrom(err) ?? 'unknown error'}`,
'DECRYPT_FAILED',
{ keyEntryId: entry.id, modelName },
)
}
yield { id: entry.id, plaintext }
}
},
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
import type Redis from 'ioredis'
import { beforeEach, describe, expect, it } from 'vitest'
import { createConcurrencyLedger } from '../concurrency-ledger'
// NOTICE: Mimic the subset of Redis semantics the ledger uses (EVAL for the
// ACQUIRE/RELEASE Lua, plus SET/EXISTS/GET/SADD/SMEMBERS/MGET). The two Lua
// scripts are told apart by numKeys (acquire passes 2 keys, release passes 1) —
// same approach flux-meter.test.ts uses for its single script. Real Lua
// atomicity is exercised by ioredis hitting Redis in integration; here we verify
// the capacity decision, floor-guarded release, saturation flags, and snapshot.
function createMockRedis() {
const inflight = new Map<string, number>()
const saturated = new Set<string>()
const known = new Set<string>()
const evalImpl = async (_script: string, numKeys: number, ...args: Array<string | number>) => {
if (numKeys === 2) {
// ACQUIRE_SCRIPT: inflightKey, knownKey, max, ttl, poolId
const inflightKey = String(args[0])
const knownKey = String(args[1])
const max = Number(args[2])
const poolId = String(args[4])
const current = inflight.get(inflightKey) ?? 0
if (current < max) {
const next = current + 1
inflight.set(inflightKey, next)
known.add(`${knownKey}::${poolId}`)
return next
}
return -1
}
// RELEASE_SCRIPT: inflightKey
const inflightKey = String(args[0])
const current = inflight.get(inflightKey) ?? 0
if (current > 0) {
const next = current - 1
inflight.set(inflightKey, next)
return next
}
return 0
}
const redis = {
eval: evalImpl,
set: async (key: string, _val: string, _mode: string, _ttl: number) => {
saturated.add(key)
return 'OK'
},
exists: async (key: string) => (saturated.has(key) ? 1 : 0),
get: async (key: string) => {
const v = inflight.get(key)
return v == null ? null : String(v)
},
smembers: async (key: string) => {
const prefix = `${key}::`
return [...known].filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length))
},
mget: async (keys: string[]) => keys.map(k => (inflight.has(k) ? String(inflight.get(k)) : null)),
} as unknown as Redis
return { redis, inflight, saturated }
}
describe('concurrencyLedger', () => {
let mock: ReturnType<typeof createMockRedis>
let ledger: ReturnType<typeof createConcurrencyLedger>
beforeEach(() => {
mock = createMockRedis()
ledger = createConcurrencyLedger(mock.redis)
})
it('tryAcquire grants a slot while the pool is below max and increments inflight', async () => {
// @example acquire on an empty pool (cap 10) -> granted, inflight becomes 1
const granted = await ledger.tryAcquire('app-1', 10)
expect(granted).toBe(true)
expect(await ledger.currentInflight('app-1')).toBe(1)
})
it('tryAcquire rejects once the pool is at max without incrementing past the cap', async () => {
// @example cap 2 -> first two granted, third rejected, inflight stays 2
expect(await ledger.tryAcquire('app-1', 2)).toBe(true)
expect(await ledger.tryAcquire('app-1', 2)).toBe(true)
expect(await ledger.tryAcquire('app-1', 2)).toBe(false)
expect(await ledger.currentInflight('app-1')).toBe(2)
})
it('grants no more than max across many acquires on one pool (capacity invariant)', async () => {
// @example cap 10, attempt 15 acquires -> exactly 10 granted
const results = await Promise.all(
Array.from({ length: 15 }, () => ledger.tryAcquire('app-1', 10)),
)
expect(results.filter(Boolean)).toHaveLength(10)
expect(await ledger.currentInflight('app-1')).toBe(10)
})
it('release returns a slot so a previously-full pool can grant again', async () => {
// @example cap 1: acquire, reject second, release, then acquire succeeds
expect(await ledger.tryAcquire('app-1', 1)).toBe(true)
expect(await ledger.tryAcquire('app-1', 1)).toBe(false)
await ledger.release('app-1')
expect(await ledger.currentInflight('app-1')).toBe(0)
expect(await ledger.tryAcquire('app-1', 1)).toBe(true)
})
it('release floors at zero and never drives the counter negative', async () => {
// @example releasing an idle pool keeps inflight at 0 (no negative overshoot)
await ledger.release('app-1')
expect(await ledger.currentInflight('app-1')).toBe(0)
})
it('isSaturated reflects markSaturated', async () => {
// @example before mark -> false; after mark -> true
expect(await ledger.isSaturated('app-1')).toBe(false)
await ledger.markSaturated('app-1', 5)
expect(await ledger.isSaturated('app-1')).toBe(true)
})
it('snapshot lists every acquired pool with its current inflight count', async () => {
// @example acquire on two pools -> snapshot reports both with counts
await ledger.tryAcquire('app-1', 10)
await ledger.tryAcquire('app-1', 10)
await ledger.tryAcquire('app-2', 10)
const snap = await ledger.snapshot()
expect(snap).toContainEqual({ poolId: 'app-1', inflight: 2 })
expect(snap).toContainEqual({ poolId: 'app-2', inflight: 1 })
})
it('snapshot is empty before any pool is acquired', async () => {
// @example fresh ledger -> snapshot returns []
expect(await ledger.snapshot()).toEqual([])
})
})
@@ -0,0 +1,163 @@
import type { ConfigKVService } from '../../../adapters/config-kv'
import type { RouterConfig } from '../types'
import { describe, expect, it, vi } from 'vitest'
import { ApiError } from '../../../../utils/error'
import { createConfigLoader } from '../config-loader'
function makeConfig(): RouterConfig {
return {
llm: {
models: {
'openai/gpt-5-mini': {
upstreams: [
{
baseURL: 'https://openrouter.example/v1',
keys: [{ id: 'k1', ciphertext: 'v1.aa.bb.cc' }],
headerTemplate: 'Bearer {KEY}',
},
],
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
},
},
},
tts: {
models: {
'tts-1': {
provider: 'azure',
upstreams: [
{
baseURL: 'https://azure.example/tts',
keys: [{ id: 'tk1', ciphertext: 'v1.aa.bb.cc' }],
adapterParams: {},
},
],
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
},
},
},
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] },
} as RouterConfig
}
function makeMockConfigKV(value: RouterConfig | null): ConfigKVService {
return {
getOptional: vi.fn(async (key: string) => (key === 'LLM_ROUTER_CONFIG' ? value : null)),
getOrThrow: vi.fn(),
get: vi.fn(),
set: vi.fn(),
} as unknown as ConfigKVService
}
describe('createConfigLoader', () => {
/**
* @example loader.getModelConfig('llm', 'x') hits configKV once, then serves cache
*/
it('first call reads from configKV; subsequent calls within TTL serve from cache (one read)', async () => {
const configKV = makeMockConfigKV(makeConfig())
let nowValue = 1000
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
nowValue = 2000
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
nowValue = 4999
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
expect((configKV.getOptional as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
})
it('invalidate() clears cache; next call re-reads from configKV', async () => {
const configKV = makeMockConfigKV(makeConfig())
let nowValue = 1000
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
loader.invalidate()
nowValue = 1001
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
expect((configKV.getOptional as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
})
it('tTL expiry triggers fresh read on next call', async () => {
const configKV = makeMockConfigKV(makeConfig())
let nowValue = 1000
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
nowValue = 1000 + 5001
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
expect((configKV.getOptional as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
})
it('missing LLM_ROUTER_CONFIG → throws CONFIG_NOT_SET (503)', async () => {
const configKV = makeMockConfigKV(null)
const loader = createConfigLoader({ configKV })
await expect(loader.getModelConfig('llm', 'any-model')).rejects.toBeInstanceOf(ApiError)
try {
await loader.getModelConfig('llm', 'any-model')
}
catch (err) {
expect((err as ApiError).statusCode).toBe(503)
expect((err as ApiError).errorCode).toBe('CONFIG_NOT_SET')
}
})
it('unknown LLM model name → 400 BAD_REQUEST with requested + available list (pre-upstream rejection)', async () => {
const configKV = makeMockConfigKV(makeConfig())
const loader = createConfigLoader({ configKV })
try {
await loader.getModelConfig('llm', 'nope/does-not-exist')
throw new Error('expected throw')
}
catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).statusCode).toBe(400)
expect((err as ApiError).errorCode).toBe('BAD_REQUEST')
expect((err as ApiError).details).toEqual({
requested: 'nope/does-not-exist',
available: ['openai/gpt-5-mini'],
})
}
})
it('unknown TTS model name → 400 BAD_REQUEST with TTS model list (not LLM list)', async () => {
const configKV = makeMockConfigKV(makeConfig())
const loader = createConfigLoader({ configKV })
try {
await loader.getModelConfig('tts', 'nope-tts')
throw new Error('expected throw')
}
catch (err) {
expect((err as ApiError).statusCode).toBe(400)
expect((err as ApiError).details).toEqual({
requested: 'nope-tts',
available: ['tts-1'],
})
}
})
it('returns the tagged model slice for both kinds', async () => {
const configKV = makeMockConfigKV(makeConfig())
const loader = createConfigLoader({ configKV })
const llm = await loader.getModelConfig('llm', 'openai/gpt-5-mini')
expect(llm.kind).toBe('llm')
if (llm.kind === 'llm') {
expect(llm.model.upstreams).toHaveLength(1)
expect(llm.model.upstreams[0].baseURL).toBe('https://openrouter.example/v1')
}
const tts = await loader.getModelConfig('tts', 'tts-1')
expect(tts.kind).toBe('tts')
if (tts.kind === 'tts') {
expect(tts.model.provider).toBe('azure')
}
})
})
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '../../../../utils/error'
import { mapUpstreamError } from '../error-mapping'
const exampleContext = { triedKeys: 2, triedUpstreams: 1, lastStatusCode: 401 as const }
describe('mapUpstreamError', () => {
/**
* @example mapUpstreamError(401, ctx) 502 BAD_GATEWAY
*/
it('401 → 502 BAD_GATEWAY', () => {
const err = mapUpstreamError(401, { ...exampleContext, lastStatusCode: 401 })
expect(err).toBeInstanceOf(ApiError)
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('402 → 502 BAD_GATEWAY (payment required from upstream is still gateway-side per KTD-1)', () => {
const err = mapUpstreamError(402, { ...exampleContext, lastStatusCode: 402 })
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('403 → 502 BAD_GATEWAY', () => {
const err = mapUpstreamError(403, { ...exampleContext, lastStatusCode: 403 })
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('429 → 503 SERVICE_UNAVAILABLE (rate-limit hint preserves retry-ability)', () => {
const err = mapUpstreamError(429, { ...exampleContext, lastStatusCode: 429 })
expect(err.statusCode).toBe(503)
expect(err.errorCode).toBe('SERVICE_UNAVAILABLE')
})
it('500 → 502 BAD_GATEWAY', () => {
const err = mapUpstreamError(500, { ...exampleContext, lastStatusCode: 500 })
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('502 → 502 BAD_GATEWAY (upstream 5xx still surfaces as our 502)', () => {
const err = mapUpstreamError(502, { ...exampleContext, lastStatusCode: 502 })
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('503 → 502 BAD_GATEWAY (upstream 503 is not retry-after, treat as bad gateway)', () => {
const err = mapUpstreamError(503, { ...exampleContext, lastStatusCode: 503 })
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('504 upstream → 502 BAD_GATEWAY (only our own timeouts produce 504)', () => {
const err = mapUpstreamError(504, { ...exampleContext, lastStatusCode: 504 })
expect(err.statusCode).toBe(502)
expect(err.errorCode).toBe('BAD_GATEWAY')
})
it('timeout → 504 GATEWAY_TIMEOUT', () => {
const err = mapUpstreamError('timeout', { ...exampleContext, lastStatusCode: 'timeout' })
expect(err.statusCode).toBe(504)
expect(err.errorCode).toBe('GATEWAY_TIMEOUT')
})
it('attaches sanitized details (triedKeys / triedUpstreams / lastStatusCode) — no upstream body', () => {
const err = mapUpstreamError(500, { triedKeys: 4, triedUpstreams: 2, lastStatusCode: 500 })
expect(err.details).toEqual({ triedKeys: 4, triedUpstreams: 2, lastStatusCode: 500 })
})
it('2xx input is a programmer error and throws an internal error (never maps to 5xx)', () => {
// ROOT CAUSE:
//
// If a 2xx status reaches this mapper the caller has already decided the
// request succeeded but is still trying to map it to a failure. Silently
// returning 502 would hide that bug. We throw INTERNAL_SERVER_ERROR to
// surface it instead.
expect(() => mapUpstreamError(200, { ...exampleContext, lastStatusCode: 200 as unknown as 401 })).toThrow(/success status/)
expect(() => mapUpstreamError(299, { ...exampleContext, lastStatusCode: 299 as unknown as 401 })).toThrow(/success status/)
})
})
@@ -0,0 +1,165 @@
import type { Buffer } from 'node:buffer'
import type { Counter } from '@opentelemetry/api'
import type { GatewayMetrics } from '../../../../otel'
import { randomBytes } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { createEnvelopeCrypto } from '../../../../utils/envelope-crypto'
import { ApiError } from '../../../../utils/error'
import { createKeyRotator } from '../key-rotator'
function freshMasterKey(): Buffer {
return randomBytes(32)
}
function makeCounter(): Counter {
return { add: vi.fn() } as unknown as Counter
}
function makeMetrics(): { metrics: GatewayMetrics, decryptFailures: Counter } {
const decryptFailures = makeCounter()
// We only exercise decryptFailures here; the rest are unused stubs.
const metrics = {
fallbackCount: makeCounter(),
upstreamErrors: makeCounter(),
keyExhaustedCount: makeCounter(),
sameStatusExhaustion: makeCounter(),
configReload: makeCounter(),
decryptFailures,
subscriberState: makeCounter(),
configWrite: makeCounter(),
configInvalidHmac: makeCounter(),
} as GatewayMetrics
return { metrics, decryptFailures }
}
describe('createKeyRotator', () => {
/**
* @example iterator yields {id, plaintext} for each key in config order
*/
it('yields keys in config order with decrypted plaintext', () => {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'openai/gpt-5-mini'
const upstream = {
keys: [
{ id: 'k1', ciphertext: crypto.encryptKey('sk-key-one', { modelName, keyEntryId: 'k1' }) },
{ id: 'k2', ciphertext: crypto.encryptKey('sk-key-two', { modelName, keyEntryId: 'k2' }) },
{ id: 'k3', ciphertext: crypto.encryptKey('sk-key-three', { modelName, keyEntryId: 'k3' }) },
],
}
const { metrics } = makeMetrics()
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
const collected: { id: string, secret: string }[] = []
for (const entry of rotator) {
collected.push({ id: entry.id, secret: entry.plaintext.toString('utf8') })
entry.plaintext.fill(0)
}
expect(collected).toHaveLength(3)
expect(collected[0]).toEqual({ id: 'k1', secret: 'sk-key-one' })
expect(collected[1]).toEqual({ id: 'k2', secret: 'sk-key-two' })
expect(collected[2]).toEqual({ id: 'k3', secret: 'sk-key-three' })
})
it('iterator stops after final key (no extra yields)', () => {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'm'
const upstream = {
keys: [{ id: 'only', ciphertext: crypto.encryptKey('sk-only', { modelName, keyEntryId: 'only' }) }],
}
const { metrics } = makeMetrics()
const it1 = createKeyRotator(upstream, crypto, modelName, metrics, 'p')[Symbol.iterator]()
const first = it1.next()
const second = it1.next()
expect(first.done).toBe(false)
expect(first.value?.id).toBe('only')
expect(second.done).toBe(true)
})
it('decrypt failure throws DECRYPT_FAILED (503) and increments decryptFailures counter — does NOT silently skip', () => {
// ROOT CAUSE:
//
// If a stored ciphertext is corrupted or AAD-forged, decryptKey throws.
// Silently skipping that key would mask config-poisoning attempts (an
// attacker with configKV write access could overwrite a valid blob with
// a junk blob to force the router to fall back). Per plan U3 test (3)
// we surface as 503 DECRYPT_FAILED and increment the metric.
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'm'
const upstream = {
keys: [
{ id: 'bad', ciphertext: 'v1.AAAA.BBBB.CCCC' },
],
}
const { metrics, decryptFailures } = makeMetrics()
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
expect(() => {
for (const _ of rotator) {
// unreachable on the first key
}
}).toThrow(ApiError)
try {
for (const _ of rotator) {
// re-walk so we can inspect the thrown ApiError details
}
}
catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).statusCode).toBe(503)
expect((err as ApiError).errorCode).toBe('DECRYPT_FAILED')
expect((err as ApiError).details).toMatchObject({ keyEntryId: 'bad', modelName: 'm' })
}
// Counter incremented twice (we walked the iterator twice above).
expect((decryptFailures.add as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1)
const firstCall = (decryptFailures.add as ReturnType<typeof vi.fn>).mock.calls[0]
expect(firstCall[0]).toBe(1)
expect(firstCall[1]).toEqual({ provider: 'openrouter', key_entry_id: 'bad' })
})
it('decrypt failure on a later key still aborts iteration immediately (no partial yields)', () => {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'm'
const upstream = {
keys: [
{ id: 'k1', ciphertext: crypto.encryptKey('sk-good', { modelName, keyEntryId: 'k1' }) },
{ id: 'bad', ciphertext: 'v1.AAAA.BBBB.CCCC' },
],
}
const { metrics } = makeMetrics()
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
const collected: string[] = []
expect(() => {
for (const entry of rotator) {
collected.push(entry.id)
entry.plaintext.fill(0)
}
}).toThrow(/DECRYPT_FAILED|Failed to decrypt/)
expect(collected).toEqual(['k1'])
})
it('tolerates null gatewayMetrics (OTel disabled) without throwing', () => {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'm'
const upstream = {
keys: [{ id: 'k1', ciphertext: crypto.encryptKey('sk-x', { modelName, keyEntryId: 'k1' }) }],
}
const rotator = createKeyRotator(upstream, crypto, modelName, null, 'openrouter')
const first = rotator[Symbol.iterator]().next()
expect(first.value?.id).toBe('k1')
expect(first.value?.plaintext.toString('utf8')).toBe('sk-x')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
import type { InferOutput } from 'valibot'
// NOTICE:
// The Valibot schemas in `services/config-kv.ts` are the single source of
// truth for the router config tree. We re-export inferred types so downstream
// modules don't redeclare the shape. New fields belong in config-kv.ts, not
// here.
// Source: server/apps/api/src/services/config-kv.ts (llmRouterConfigSchema).
import type {
asrModelSchema,
asrUpstreamSchema,
fallbackTriggersSchema,
keyEntrySchema,
llmModelSchema,
llmRouterConfigSchema,
llmRouterDefaultsSchema,
llmRoutingGroupSchema,
llmRoutingSchema,
llmUpstreamSchema,
routeFailureTriggersSchema,
ttsModelSchema,
ttsRoutingGroupSchema,
ttsRoutingSchema,
ttsUpstreamSchema,
} from '../../adapters/config-kv'
/**
* Composite router config (the value at `LLM_ROUTER_CONFIG` in configKV).
*/
export type RouterConfig = InferOutput<typeof llmRouterConfigSchema>
/**
* Top-level routing defaults (per-attempt + full-chain timeouts and
* fallback HTTP codes).
*/
export type RouterDefaults = InferOutput<typeof llmRouterDefaultsSchema>
/**
* LLM upstream one candidate endpoint with its ordered key list.
*/
export type LlmUpstream = InferOutput<typeof llmUpstreamSchema>
/**
* LLM model entry upstream candidates plus an optional grouped route.
*/
export type LlmModel = InferOutput<typeof llmModelSchema>
/**
* LLM route composed from ordered candidate groups.
*/
export type LlmRouting = InferOutput<typeof llmRoutingSchema>
/**
* One ordered group of interchangeable LLM candidates.
*/
export type LlmRoutingGroup = InferOutput<typeof llmRoutingGroupSchema>
/**
* TTS upstream one candidate endpoint with adapter params + key list.
*/
export type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
/**
* TTS model entry provider tag, upstream candidates, and optional grouped route.
*/
export type TtsModel = InferOutput<typeof ttsModelSchema>
/**
* TTS route composed from ordered candidate groups.
*/
export type TtsRouting = InferOutput<typeof ttsRoutingSchema>
/**
* One group of interchangeable TTS candidates.
*/
export type TtsRoutingGroup = InferOutput<typeof ttsRoutingGroupSchema>
/**
* ASR model entry provider tag + ordered upstreams for realtime transcription.
*/
export type AsrModel = InferOutput<typeof asrModelSchema>
/**
* ASR upstream one provider credential set plus adapter params.
*/
export type AsrUpstream = InferOutput<typeof asrUpstreamSchema>
/**
* Per-(upstream) fallback trigger config: which upstream HTTP codes should
* cause the router to move on to the next key/upstream.
*/
export type FallbackTriggers = InferOutput<typeof fallbackTriggersSchema>
/**
* Failure allow-list that authorizes a routing transition.
*/
export type RouteFailureTriggers = InferOutput<typeof routeFailureTriggersSchema>
/**
* One entry in `upstream.keys`: stable id + at-rest envelope ciphertext.
* The plaintext key is only produced lazily by the key-rotator at call time.
*/
export type KeyEntry = InferOutput<typeof keyEntrySchema>
/**
* Which surface the router orchestrates for a given route call.
*/
export type ModelKind = 'llm' | 'tts'
/**
* A single inbound request the router knows how to dispatch.
*
* The body is **already-parsed JSON** (not a Buffer). The router clones it
* per attempt and injects `model` + the auth header before forwarding to the
* chosen upstream.
*/
export interface LlmRouteRequest {
/**
* Model name from the caller (e.g. `openai/gpt-5-mini`). Used to look up
* the per-model upstream list in `LLM_ROUTER_CONFIG`.
*/
modelName: string
/** Already-parsed JSON body (OpenAI-shaped chat-completions payload). */
body: Record<string, unknown>
/**
* Caller-supplied headers to forward. The router overwrites `authorization`
* and `content-type`; everything else passes through.
*/
headers?: Record<string, string>
/**
* Caller-side abort signal (typically the client disconnect signal). When
* fired mid-flight, the active upstream fetch is aborted and the router
* stops without trying further keys/upstreams.
*/
abortSignal?: AbortSignal
}
/**
* Auxiliary context shape kept alongside one `route()` invocation. Not part
* of the public input exists so future billing-attribution work can thread
* userId / billing tags through without changing the call signature.
*
* Per SEC-5: upstream response bodies must never enter this shape. Only
* status codes (or `'timeout'`) and counts are safe to carry.
*/
export interface LlmRouteContext {
/** Provider tag for OTel labels (e.g. `openrouter`). */
provider: string
/** Actual model id sent to the winning upstream after `overrideModel` rewrites. */
upstreamModel?: string
/** Number of upstreams attempted so far. */
triedUpstreams: number
/** Number of keys attempted across all upstreams so far. */
triedKeys: number
/** Most recent upstream failure status or `'timeout'`. */
lastStatus: number | 'timeout' | null
}
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startChatGeneration, startTtsGeneration } from '.'
// Mock the Langfuse SDK so tests assert what the module sends to it without a
// real exporter. `startObservation` returns a stub generation whose methods are
// spies; `otelSpan.setAttribute` captures trace-identity attributes.
const generationStub = {
otelSpan: { setAttribute: vi.fn() },
update: vi.fn(),
end: vi.fn(),
}
const startObservation = vi.fn((_name: string, _attributes: unknown, _options: unknown) => generationStub)
vi.mock('@langfuse/tracing', () => ({
startObservation: (name: string, attributes: unknown, options: unknown) => startObservation(name, attributes, options),
}))
const BASE_INPUT = {
input: [{ role: 'user', content: 'hi' }],
model: 'openai/gpt-5-mini',
requestId: 'req-1',
stream: false,
userId: 'user-1',
}
beforeEach(() => {
startObservation.mockClear()
generationStub.otelSpan.setAttribute.mockClear()
generationStub.update.mockClear()
generationStub.end.mockClear()
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('startChatGeneration', () => {
describe('when LANGFUSE_TRACING_ACTIVE is not "1"', () => {
it('returns a no-op trace and never calls the SDK', () => {
// @example disabled deployment: no env set
const trace = startChatGeneration(BASE_INPUT)
trace.appendStreamChunk('data: {"choices":[{"delta":{"content":"x"}}]}\n')
trace.succeed({ output: 'x', promptTokens: 1, completionTokens: 1 })
trace.fail('should be ignored')
expect(startObservation).not.toHaveBeenCalled()
expect(generationStub.update).not.toHaveBeenCalled()
expect(generationStub.end).not.toHaveBeenCalled()
})
})
describe('when LANGFUSE_TRACING_ACTIVE is "1"', () => {
beforeEach(() => {
vi.stubEnv('LANGFUSE_TRACING_ACTIVE', '1')
})
it('creates a generation with input/model/metadata and trace identity', () => {
// @example a request with a client conversation id
startChatGeneration({ ...BASE_INPUT, sessionId: 'sess-9', stream: true })
expect(startObservation).toHaveBeenCalledWith(
'chat.completion',
{
input: BASE_INPUT.input,
model: BASE_INPUT.model,
metadata: { requestId: 'req-1', stream: true },
},
{ asType: 'generation' },
)
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.user.id', 'user-1')
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.session.id', 'sess-9')
})
it('omits session attribute when no sessionId is supplied', () => {
// @example a request without x-airi-session-id → user-only attribution
startChatGeneration(BASE_INPUT)
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.user.id', 'user-1')
expect(generationStub.otelSpan.setAttribute).not.toHaveBeenCalledWith('langfuse.session.id', expect.anything())
})
it('records explicit output + usage + flux on succeed (non-streaming)', () => {
// @example non-streaming completion passes the parsed response body
const trace = startChatGeneration(BASE_INPUT)
trace.succeed({ output: { ok: true }, promptTokens: 12, completionTokens: 34, fluxConsumed: 5 })
expect(generationStub.update).toHaveBeenCalledWith({
output: { ok: true },
usageDetails: { input: 12, output: 34 },
metadata: { requestId: 'req-1', stream: false, fluxConsumed: 5 },
})
expect(generationStub.end).toHaveBeenCalledTimes(1)
})
it('assembles streamed assistant text across chunk boundaries for succeed output', () => {
// @example a delta whose JSON is split mid-key across two chunks
const trace = startChatGeneration({ ...BASE_INPUT, stream: true })
trace.appendStreamChunk('data: {"choices":[{"delta":{"role":"assistant"}}]}\n')
trace.appendStreamChunk('data: {"choices":[{"delta":{"con')
trace.appendStreamChunk('tent":"Hel"}}]}\ndata: {"choices":[{"delta":{"content":"lo"}}]}\n')
trace.appendStreamChunk('data: [DONE]\n')
trace.succeed({ promptTokens: 2, completionTokens: 1, fluxConsumed: 1 })
expect(generationStub.update).toHaveBeenCalledWith({
output: 'Hello',
usageDetails: { input: 2, output: 1 },
metadata: { requestId: 'req-1', stream: true, fluxConsumed: 1 },
})
})
it('ignores malformed and non-text SSE lines when assembling output', () => {
// @example usage-only chunk, blank line, and broken JSON contribute nothing
const trace = startChatGeneration({ ...BASE_INPUT, stream: true })
trace.appendStreamChunk('\n')
trace.appendStreamChunk('data: {bad json\n')
trace.appendStreamChunk('data: {"choices":[],"usage":{"prompt_tokens":5}}\n')
trace.appendStreamChunk('data: {"choices":[{"delta":{"content":"A"}}]}\n')
trace.succeed({})
expect(generationStub.update).toHaveBeenCalledWith(
expect.objectContaining({ output: 'A' }),
)
})
it('records ERROR level + message on fail', () => {
// @example router exhaustion / upstream non-2xx
const trace = startChatGeneration(BASE_INPUT)
trace.fail('Gateway 502')
expect(generationStub.update).toHaveBeenCalledWith({
level: 'ERROR',
statusMessage: 'Gateway 502',
metadata: { requestId: 'req-1', stream: false },
})
expect(generationStub.end).toHaveBeenCalledTimes(1)
})
it('ends only once even if succeed/fail are called repeatedly', () => {
// @example defensive: overlapping transport exit branches
const trace = startChatGeneration(BASE_INPUT)
trace.succeed({ output: 'first' })
trace.succeed({ output: 'second' })
trace.fail('late failure')
expect(generationStub.end).toHaveBeenCalledTimes(1)
expect(generationStub.update).toHaveBeenCalledTimes(1)
expect(generationStub.update).toHaveBeenCalledWith(expect.objectContaining({ output: 'first' }))
})
it('hard-caps streamed assistant text even when one SSE delta exceeds the remaining space', () => {
// @example a single very large provider delta should not overflow the buffer cap
const trace = startChatGeneration({ ...BASE_INPUT, stream: true })
trace.appendStreamChunk(`data: ${JSON.stringify({ choices: [{ delta: { content: 'x'.repeat(1_100_000) } }] })}\n`)
trace.succeed({})
const output = generationStub.update.mock.calls[0][0].output
expect(output).toHaveLength(1_000_000)
})
it('creates a TTS generation and records character usage without buffering audio', () => {
// @example /audio/speech request: text in, content-type metadata out
const trace = startTtsGeneration({
input: { text: 'hello', voice: 'alloy', responseFormat: 'mp3' },
model: 'tts-1',
requestId: 'tts-1',
userId: 'user-1',
sessionId: 'sess-1',
})
trace.succeed({
inputChars: 5,
fluxConsumed: 2,
output: { contentType: 'audio/mpeg' },
})
expect(startObservation).toHaveBeenCalledWith(
'tts.speech',
{
input: { text: 'hello', voice: 'alloy', responseFormat: 'mp3' },
model: 'tts-1',
metadata: {
requestId: 'tts-1',
inputChars: 5,
voice: 'alloy',
speed: undefined,
responseFormat: 'mp3',
},
},
{ asType: 'generation' },
)
expect(generationStub.update).toHaveBeenCalledWith({
output: { contentType: 'audio/mpeg' },
usageDetails: { input: 5 },
metadata: {
requestId: 'tts-1',
inputChars: 5,
voice: 'alloy',
speed: undefined,
responseFormat: 'mp3',
fluxConsumed: 2,
},
})
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.session.id', 'sess-1')
expect(generationStub.end).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,331 @@
import process from 'node:process'
import { startObservation } from '@langfuse/tracing'
/**
* Upper bound on the assistant text buffered for a streaming generation's
* `output`. Without a cap a pathological long completion under concurrency would
* pin `N × completion_size` in memory; Langfuse truncates large payloads
* server-side anyway, so a generous cap loses nothing useful.
*/
const STREAM_OUTPUT_CHAR_CAP = 1_000_000
/**
* Whether per-request Langfuse generations should be created.
*
* Gated on the `LANGFUSE_TRACING_ACTIVE` sentinel that `instrumentation.ts` sets
* ONLY after `setLangfuseTracerProvider()` succeeds not on a raw key check.
* Why: if the isolated Langfuse provider is not actually wired, `startObservation`
* falls back to the GLOBAL OTel TracerProvider, which would ship prompt/completion
* text to the OTLP/Grafana exporter. Binding to the real provider state (single
* source of truth in instrumentation.ts) keeps a future change to the enable
* condition there from silently desyncing this gate and leaking PII to the wrong
* backend. Read per call (cheap; the value is process-constant after the preload
* sets it) so the boundary stays self-contained and trivially testable.
*/
function tracingActive(): boolean {
return process.env.LANGFUSE_TRACING_ACTIVE === '1'
}
/**
* Extracts the assistant text delta from a single OpenAI streaming SSE line.
*
* Before:
* - `data: {"choices":[{"delta":{"content":"Hel"}}]}`
*
* After:
* - `"Hel"`
*
* Returns `''` for lines that carry no assistant text `[DONE]`, role-only
* deltas, usage-only chunks, blank/comment lines, or malformed JSON. The upstream
* SSE is an external boundary, so per-line JSON parsing is tolerated and a parse
* failure degrades to "this line added no text" rather than aborting capture:
* output is best-effort trace data, not billing.
*/
function extractSseDeltaText(sseLine: string): string {
const trimmed = sseLine.trimStart()
if (!trimmed.startsWith('data:'))
return ''
const payload = trimmed.slice(5).trim()
if (!payload || payload === '[DONE]')
return ''
try {
const json = JSON.parse(payload)
const content = json?.choices?.[0]?.delta?.content
return typeof content === 'string' ? content : ''
}
catch {
return ''
}
}
/** Parameters identifying a request a Langfuse generation traces. */
interface GenerationInput {
/** Provider-domain input payload, recorded verbatim as trace input. */
input: unknown
/** Resolved upstream model id (after `auto` aliases are replaced). */
model: string
/** Correlation id shared with billing / request-log rows. */
requestId: string
/** Generation name shown in Langfuse. */
name: string
/** Extra observation metadata. */
metadata?: Record<string, unknown>
/** Billing/identity owner of the request. Lifted to trace-level `userId`. */
userId: string
/** Client-supplied conversation id (`x-airi-session-id`). Absent → user-only attribution. */
sessionId?: string
}
/** Parameters identifying the chat request a generation traces. */
export interface ChatGenerationInput extends Omit<GenerationInput, 'name' | 'metadata'> {
/** OpenAI chat `messages` array (the prompt), recorded verbatim as trace input. */
input: unknown
/** Whether the response is streamed (affects how output is captured). */
stream: boolean
}
/** Parameters identifying the TTS request a generation traces. */
export interface TtsGenerationInput extends Omit<GenerationInput, 'name' | 'metadata'> {
/** Adapter-neutral TTS request payload, recorded as trace input. */
input: {
text: string
voice?: string
speed?: number
responseFormat?: string
}
}
/** Terminal usage/cost figures recorded when a generation completes successfully. */
interface GenerationResult {
/**
* Explicit completion to record. Omit for streaming requests to use the
* assistant text assembled from the streamed SSE deltas.
*/
output?: unknown
/** Usage dimensions for Langfuse. For chat this is token counts; for TTS this is character count. */
usageDetails?: Record<string, number>
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
fluxConsumed?: number
/** Additional terminal metadata to merge with request metadata. */
metadata?: Record<string, unknown>
}
/** Terminal usage/cost figures recorded when a chat generation completes successfully. */
export interface ChatGenerationResult {
/**
* Explicit completion to record. Omit for streaming requests to use the
* assistant text assembled from the streamed SSE deltas.
*/
output?: unknown
promptTokens?: number
completionTokens?: number
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
fluxConsumed?: number
}
/** Terminal usage/cost figures recorded when a TTS generation completes successfully. */
export interface TtsGenerationResult {
/** Output metadata only; binary audio is not buffered into Langfuse. */
output?: unknown
/** Input character count charged by the TTS flux meter. */
inputChars: number
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
fluxConsumed?: number
/** Additional terminal metadata to merge with request metadata. */
metadata?: Record<string, unknown>
}
/**
* Lifecycle handle for one chat completion's Langfuse generation.
*
* Hides whether Langfuse is enabled (no-op when off), the SDK call shape, the
* trace field mapping, and the streamed-output assembly. The owning route only
* drives the domain lifecycle: feed stream chunks, then end with success or
* failure exactly once (subsequent calls are ignored, so every transport exit
* branch can call defensively without double-ending).
*/
export interface ChatGenerationTrace {
/**
* Feed one decoded chunk of streamed SSE text. Accumulates the assistant
* completion for the trace `output`, bounded by the char cap. No-op for
* non-streaming requests (which pass `output` to {@link ChatGenerationTrace.succeed}).
*/
appendStreamChunk: (decodedChunk: string) => void
/** Record a successful completion with usage/cost and end the generation. */
succeed: (result: ChatGenerationResult) => void
/** Record a failure (`level: ERROR` + message) and end the generation. */
fail: (statusMessage: string) => void
}
/** Lifecycle handle for one TTS Langfuse generation. */
export interface TtsGenerationTrace {
/** Record a successful speech generation with character usage/cost and end the generation. */
succeed: (result: TtsGenerationResult) => void
/** Record a failure (`level: ERROR` + message) and end the generation. */
fail: (statusMessage: string) => void
}
const NOOP_CHAT_TRACE: ChatGenerationTrace = {
appendStreamChunk() {},
succeed() {},
fail() {},
}
const NOOP_TTS_TRACE: TtsGenerationTrace = {
succeed() {},
fail() {},
}
function startGeneration(input: GenerationInput): {
succeed: (result: GenerationResult) => void
fail: (statusMessage: string) => void
} | null {
if (!tracingActive())
return null
const baseMetadata = { requestId: input.requestId, ...input.metadata }
const generation = startObservation(input.name, {
input: input.input,
model: input.model,
metadata: baseMetadata,
}, { asType: 'generation' })
// Trace-level identity via Langfuse compat attributes, lifted to the trace by
// the platform — enables per-user / per-session cost attribution.
generation.otelSpan.setAttribute('langfuse.user.id', input.userId)
if (input.sessionId)
generation.otelSpan.setAttribute('langfuse.session.id', input.sessionId)
let ended = false
return {
succeed(result) {
if (ended)
return
ended = true
generation.update({
output: result.output,
usageDetails: result.usageDetails,
metadata: { ...baseMetadata, ...result.metadata, fluxConsumed: result.fluxConsumed ?? 0 },
})
generation.end()
},
fail(statusMessage) {
if (ended)
return
ended = true
generation.update({ level: 'ERROR', statusMessage, metadata: baseMetadata })
generation.end()
},
}
}
/**
* Starts a Langfuse generation for a chat completion, or a no-op handle when
* Langfuse tracing is disabled.
*
* Use when:
* - Entering a chat completion handler that should be traced for prompt/eval/cost.
*
* Expects:
* - Called once per request. `instrumentation.ts` has already wired the isolated
* Langfuse TracerProvider when tracing is active.
*
* Returns:
* - A {@link ChatGenerationTrace} whose `succeed`/`fail` are idempotent; the
* first call ends the generation and later calls are ignored.
*/
export function startChatGeneration(input: ChatGenerationInput): ChatGenerationTrace {
const generation = startGeneration({
input: input.input,
model: input.model,
requestId: input.requestId,
name: 'chat.completion',
metadata: { stream: input.stream },
userId: input.userId,
sessionId: input.sessionId,
})
if (!generation)
return NOOP_CHAT_TRACE
let assistantText = ''
let sseLineBuffer = ''
return {
appendStreamChunk(decodedChunk) {
if (assistantText.length >= STREAM_OUTPUT_CHAR_CAP)
return
// Split on newlines, parse complete lines, keep the partial trailing line
// for the next chunk so a delta split across a chunk boundary still parses.
sseLineBuffer += decodedChunk
const lines = sseLineBuffer.split('\n')
sseLineBuffer = lines.pop() ?? ''
for (const line of lines) {
const deltaText = extractSseDeltaText(line)
const remainingChars = STREAM_OUTPUT_CHAR_CAP - assistantText.length
if (remainingChars <= 0)
break
assistantText += deltaText.slice(0, remainingChars)
if (assistantText.length >= STREAM_OUTPUT_CHAR_CAP)
break
}
},
succeed(result) {
generation.succeed({
output: result.output ?? assistantText,
usageDetails: { input: result.promptTokens ?? 0, output: result.completionTokens ?? 0 },
fluxConsumed: result.fluxConsumed,
})
},
fail(statusMessage) {
generation.fail(statusMessage)
},
}
}
/**
* Starts a Langfuse generation for a TTS request, or a no-op handle when
* Langfuse tracing is disabled.
*
* Use when:
* - Entering the OpenAI-compatible `/audio/speech` handler.
*
* Expects:
* - Binary audio is not buffered into Langfuse; callers pass output metadata
* such as content type instead.
*
* Returns:
* - A {@link TtsGenerationTrace} whose `succeed`/`fail` are idempotent.
*/
export function startTtsGeneration(input: TtsGenerationInput): TtsGenerationTrace {
const generation = startGeneration({
input: input.input,
model: input.model,
requestId: input.requestId,
name: 'tts.speech',
metadata: {
inputChars: input.input.text.length,
voice: input.input.voice,
speed: input.input.speed,
responseFormat: input.input.responseFormat,
},
userId: input.userId,
sessionId: input.sessionId,
})
if (!generation)
return NOOP_TTS_TRACE
return {
succeed(result) {
generation.succeed({
output: result.output,
usageDetails: { input: result.inputChars },
fluxConsumed: result.fluxConsumed,
metadata: { inputChars: result.inputChars, ...result.metadata },
})
},
fail(statusMessage) {
generation.fail(statusMessage)
},
}
}
@@ -0,0 +1,514 @@
import type { GenAiMetrics } from '../../../otel'
import type { ConfigKVService } from '../../adapters/config-kv'
import type { FluxMeter } from '../billing/flux-meter'
import type { FluxService } from '../flux'
import type { LlmRouterService } from '../llm-router'
import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing'
import type { ProductEventService } from '../product-events'
import type { ProviderCatalogService } from '../provider-catalog'
import type { RequestLogService } from '../request-log'
import type { VoicePackService } from '../voice-packs'
import { useLogger } from '@guiiai/logg'
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
import { ApiError, createBadRequestError, createPaymentRequiredError } from '../../../utils/error'
import { nanoid } from '../../../utils/id'
import {
AIRI_ATTR_BILLING_FLUX_CONSUMED,
AIRI_ATTR_GEN_AI_OPERATION_KIND,
GEN_AI_ATTR_REQUEST_MODEL,
} from '../../../utils/observability'
import { fluxBalanceBucket } from '../flux-balance'
const tracer = trace.getTracer('v1-completions')
const SAFE_RESPONSE_HEADERS = new Set([
'content-type',
'content-length',
'transfer-encoding',
'cache-control',
])
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value == null || Array.isArray(value))
return undefined
return value as Record<string, unknown>
}
function readOptionalNumber(record: Record<string, unknown> | undefined, key: string): number | undefined {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined
}
export interface OpenAiSpeechServiceDeps {
fluxService: FluxService
configKV: ConfigKVService
requestLogService: RequestLogService
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
providerCatalogService: ProviderCatalogService
productEventService: ProductEventService
genAi?: GenAiMetrics | null
llmTracing: {
startTtsGeneration: (input: Parameters<typeof startTtsGeneration>[0]) => TtsGenerationTrace
}
}
export interface OpenAiSpeechRequest {
userId: string
body: Record<string, unknown>
sessionId?: string
abortSignal?: AbortSignal
}
type TtsTrigger = 'auto' | 'manual'
type TtsVoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
interface TtsAnalyticsContext {
trigger: TtsTrigger
source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
voiceType: TtsVoiceType
}
/**
* Runs the OpenAI-shaped text-to-speech gateway flow.
*
* Use when:
* - The HTTP route has parsed an authenticated `/audio/speech` request and
* needs domain orchestration for billing, routing, tracing, and logging.
*
* Expects:
* - `body` is the parsed JSON request body.
* - Auth and route guards have already run.
*
* Returns:
* - A gateway `Response` with safe upstream headers and audio body.
*/
export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
const logger = useLogger('v1-completions').useGlobalConfig()
async function handleSpeechRequest(input: OpenAiSpeechRequest): Promise<Response> {
const requestId = nanoid()
const requestedModel = typeof input.body.model === 'string' ? input.body.model : 'auto'
let requestModel = requestedModel
const requestVoice = typeof input.body.voice === 'string' ? input.body.voice : undefined
const inputText = typeof input.body.input === 'string' ? input.body.input : ''
const analytics = ttsAnalyticsContext(input.body)
const voicePackRequest = await voicePackRequestOptions(input.body, {
requestedModel,
voice: requestVoice,
voicePackService: deps.voicePackService,
})
requestModel = voicePackRequest.model ?? requestModel
if (requestModel === 'auto')
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
const routedVoice = voicePackRequest.voice ?? requestVoice
await deps.providerCatalogService.assertTtsModelEnabled(requestModel)
if (!voicePackRequest.voicePackId && routedVoice)
await deps.providerCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice)
const voiceMetadata = ttsVoiceMetadata({
voice: requestVoice,
voicePackId: voicePackRequest.voicePackId,
voiceType: analytics.voiceType,
})
const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier)
logger.withFields({
requestId,
userId: input.userId,
model: requestModel,
inputChars: inputText.length,
voice: requestVoice,
}).log('tts speech request')
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_requested',
status: 'started',
source: analytics.source,
model: requestModel,
metadata: {
input_chars: inputText.length,
trigger: analytics.trigger,
...voiceMetadata,
},
})
const flux = await deps.fluxService.getFlux(input.userId)
try {
await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux)
}
catch (err) {
if (!(err instanceof ApiError) || err.statusCode !== 402)
throw err
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: analytics.source,
model: requestModel,
reason: 'insufficient_balance',
metadata: {
input_chars: inputText.length,
billing_units: billingUnits,
block_reason: 'insufficient_balance',
balance_state: 'insufficient',
flux_balance_bucket: fluxBalanceBucket(flux.flux),
trigger: analytics.trigger,
...voiceMetadata,
},
})
logger.withError(err).withFields({
requestId,
userId: input.userId,
model: requestModel,
trigger: analytics.trigger,
source: analytics.source,
}).warn('tts speech blocked by pre-flight balance check')
if (analytics.trigger === 'auto')
return new Response(null, { status: 204 })
throw createPaymentRequiredError('Insufficient flux')
}
const ttsInput = {
text: inputText,
voice: routedVoice,
speed: voicePackRequest.speed ?? (typeof input.body.speed === 'number' ? input.body.speed : undefined),
responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined,
extraOptions: voicePackRequest.extraOptions,
}
const generationTrace = deps.llmTracing.startTtsGeneration({
input: ttsInput,
model: requestModel,
requestId,
userId: input.userId,
sessionId: input.sessionId,
})
const span = tracer.startSpan('llm.gateway.tts', {
attributes: {
[GEN_AI_ATTR_REQUEST_MODEL]: requestModel,
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech',
},
})
const startedAt = Date.now()
const routeCtx = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null }
let response: Response
try {
response = await context.with(trace.setSpan(context.active(), span), () =>
deps.llmRouter.routeTts({
modelName: requestModel,
input: ttsInput,
abortSignal: input.abortSignal,
}, routeCtx))
}
catch (err) {
const failure = routerFailure(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: failure.message })
span.end()
generationTrace.fail(failure.message)
recordMetrics({
durationMs: Date.now() - startedAt,
fluxConsumed: 0,
model: requestModel,
provider: routeCtx.provider,
status: failure.status,
})
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: analytics.source,
model: requestModel,
provider: routeCtx.provider,
reason: failure.reason,
metadata: {
http_status: failure.status,
duration_ms: Date.now() - startedAt,
failure_reason: failure.reason,
trigger: analytics.trigger,
...voiceMetadata,
},
})
throw err
}
const durationMs = Date.now() - startedAt
span.setAttribute('http.response.status_code', response.status)
if (!response.ok) {
span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` })
span.end()
generationTrace.fail(`Gateway ${response.status}`)
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed: 0 })
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: analytics.source,
model: requestModel,
provider: routeCtx.provider,
reason: 'upstream_error',
metadata: {
http_status: response.status,
duration_ms: durationMs,
failure_reason: 'upstream_error',
trigger: analytics.trigger,
...voiceMetadata,
},
})
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
.warn('tts speech delivered with upstream error status')
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
let fluxConsumed = 0
try {
const result = await deps.ttsMeter.accumulate({
userId: input.userId,
units: billingUnits,
currentBalance: flux.flux,
requestId,
metadata: { model: requestModel, costMultiplier: voicePackRequest.costMultiplier },
})
fluxConsumed = result.fluxDebited
span.setAttribute(AIRI_ATTR_BILLING_FLUX_CONSUMED, fluxConsumed)
generationTrace.succeed({
inputChars: inputText.length,
fluxConsumed,
output: { contentType: response.headers.get('content-type') },
})
}
catch (err) {
generationTrace.fail('TTS billing failed')
throw err
}
finally {
span.end()
}
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed })
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: analytics.source,
model: requestModel,
provider: routeCtx.provider,
metadata: {
http_status: response.status,
input_chars: inputText.length,
billing_units: billingUnits,
cost_multiplier: voicePackRequest.costMultiplier,
duration_ms: durationMs,
flux_consumed: fluxConsumed,
trigger: analytics.trigger,
...voiceMetadata,
},
})
deps.requestLogService.logRequest({
userId: input.userId,
model: requestModel,
status: response.status,
durationMs,
fluxConsumed,
}).catch(err => logger.withError(err).warn('Failed to write llm_request_log row'))
logger.withFields({
requestId,
userId: input.userId,
model: requestModel,
status: response.status,
durationMs,
inputChars: inputText.length,
fluxConsumed,
}).log('tts speech delivered')
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
function recordMetrics(input: {
model: string
status: number
provider: string
durationMs: number
fluxConsumed: number
}): void {
const attrs = {
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'tts',
'http.response.status_code': input.status,
'provider': input.provider,
}
deps.genAi?.operationCount.add(1, attrs)
deps.genAi?.operationDuration.record(input.durationMs / 1000, attrs)
deps.genAi?.fluxConsumed.add(input.fluxConsumed, attrs)
}
return { handleSpeechRequest }
}
function ttsAnalyticsContext(body: Record<string, unknown>): TtsAnalyticsContext {
const extraBody = asRecord(body.extra_body)
const analytics = asRecord(extraBody?.airi_analytics)
const trigger = analytics?.trigger === 'auto' ? 'auto' : 'manual'
const rawSource = analytics?.source
const source = rawSource === 'chat_auto_tts'
|| rawSource === 'manual_preview'
|| rawSource === 'settings_test'
? rawSource
: 'audio.speech'
const voiceType = normalizeVoiceType(analytics?.voice_type)
return { trigger, source, voiceType }
}
/**
* Normalizes client-provided TTS voice type into bounded analytics values.
*/
function normalizeVoiceType(value: unknown): TtsVoiceType {
switch (value) {
case 'official_default':
case 'official_selected':
case 'custom_configured':
case 'voice_pack':
return value
default:
return 'unknown'
}
}
/**
* Builds reusable low-cardinality voice metadata for every TTS product event.
*/
function ttsVoiceMetadata(input: {
voice?: string
voicePackId?: string
voiceType: TtsVoiceType
}): Record<string, unknown> {
const voiceType = input.voicePackId ? 'voice_pack' : input.voiceType
return {
...(input.voice ? { voice_id: input.voice } : {}),
voice_type: voiceType,
...(input.voicePackId ? { voice_pack_id: input.voicePackId } : {}),
}
}
async function voicePackRequestOptions(
body: Record<string, unknown>,
context: {
requestedModel: string
voice?: string
voicePackService: VoicePackService
},
): Promise<{
extraOptions: Record<string, unknown> | undefined
costMultiplier: number
voicePackId?: string
model?: string
voice?: string
speed?: number
}> {
const extraBody = asRecord(body.extra_body)
const voicePackOptions = asRecord(extraBody?.voice_pack)
const pitch = readOptionalNumber(voicePackOptions, 'pitch')
const volume = readOptionalNumber(voicePackOptions, 'volume')
const voicePack = await resolveVoicePackRequest(voicePackOptions, context)
const extraOptions: Record<string, unknown> = {}
const resolvedPitch = voicePack?.params.pitch ?? pitch
const resolvedVolume = voicePack?.params.volume ?? volume
if (resolvedPitch != null)
extraOptions.pitch = resolvedPitch
if (resolvedVolume != null)
extraOptions.volume = resolvedVolume
return {
extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined,
costMultiplier: voicePack?.costMultiplier ?? 1,
voicePackId: voicePack?.id,
model: voicePack?.ttsModelId,
voice: voicePack?.upstreamVoiceId,
speed: voicePack?.params.rate,
}
}
async function resolveVoicePackRequest(
voicePackOptions: Record<string, unknown> | undefined,
context: {
requestedModel: string
voice?: string
voicePackService: VoicePackService
},
): Promise<Awaited<ReturnType<VoicePackService['findById']>> | null> {
const packId = voicePackOptions?.pack_id
const requestedVoice = context.voice?.trim()
if (voicePackOptions?.cost_multiplier != null) {
throw createBadRequestError('voice_pack.cost_multiplier is server-managed', 'INVALID_VOICE_PACK', {
field: 'voice_pack.cost_multiplier',
})
}
if (packId != null && (typeof packId !== 'string' || !packId.trim()))
throw createBadRequestError('voice_pack.pack_id is required when Voice Pack billing metadata is provided', 'INVALID_VOICE_PACK')
const pack = typeof packId === 'string'
? await context.voicePackService.findById(packId)
: requestedVoice
? await context.voicePackService.findEnabledByVoiceId(requestedVoice)
: null
if (!pack && packId == null)
return null
if (!pack)
throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId })
if (!pack.enabled)
throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId })
return pack
}
function routerFailure(error: unknown): { status: number, reason: string, message: string } {
if (error instanceof ApiError) {
return {
status: error.statusCode,
reason: error.errorCode,
message: error.message,
}
}
return {
status: 502,
reason: 'router_exhausted',
message: 'TTS router exhausted or unknown model',
}
}
function buildSafeResponseHeaders(response: Response): Headers {
const headers = new Headers()
response.headers.forEach((value, key) => {
if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase()))
headers.set(key, value)
})
return headers
}
@@ -0,0 +1,375 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import { sql } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createProductEventService } from './product-events'
import * as schema from '../../schemas'
describe('productEventService', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
beforeEach(async () => {
await db.delete(schema.productEvents)
})
it('writes first-party events and increments only low-cardinality metric labels', async () => {
const events = { add: vi.fn() }
const service = createProductEventService(db, { events } as unknown as ProductMetrics)
await service.track({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
source: 'openai.chat.completions',
model: 'openrouter/anthropic/claude-sonnet-4',
provider: 'openrouter',
metadata: {
stream: false,
flux_consumed: 3,
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
source: 'openai.chat.completions',
model: 'openrouter/anthropic/claude-sonnet-4',
provider: 'openrouter',
})
expect(events.add).toHaveBeenCalledWith(1, {
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
source: 'openai.chat.completions',
})
})
it('aggregates event volume and distinct users by feature/action/status', async () => {
const service = createProductEventService(db)
const createdAt = new Date('2026-06-03T00:00:00.000Z')
await service.track({
userId: 'user-1',
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech',
createdAt,
})
await service.track({
userId: 'user-1',
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech.ws',
createdAt,
})
await service.track({
userId: 'user-2',
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech',
createdAt,
})
const rows = await service.countDistinctUsersByFeature({
from: new Date('2026-06-02T00:00:00.000Z'),
to: new Date('2026-06-04T00:00:00.000Z'),
})
expect(rows).toEqual([{
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
eventCount: 3,
distinctUsers: 2,
}])
})
it('writes blocked TTS events for server-side preflight decisions', async () => {
const events = { add: vi.fn() }
const service = createProductEventService(db, { events } as unknown as ProductMetrics)
await service.track({
userId: 'user-1',
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
metadata: {
trigger: 'auto',
balance_state: 'insufficient',
flux_balance_bucket: 'zero',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
})
expect(events.add).toHaveBeenCalledWith(1, {
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
flux_balance_bucket: 'zero',
})
})
it('forwards allowlisted business facts to PostHog keyed by user id, mapping user_signed_up to signup_completed', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: { amount_minor_unit: 990, currency: 'usd' },
})
await service.track({
userId: 'user-2',
feature: 'auth',
action: 'user_signed_up',
status: 'succeeded',
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: 'payment_completed',
properties: {
app_surface: 'server',
airi_user_id: 'user-1',
feature: 'billing',
status: 'succeeded',
source: 'stripe.webhook',
amount_minor_unit: 990,
currency: 'usd',
},
})
expect(capture).toHaveBeenNthCalledWith(2, {
distinctId: 'user-2',
event: 'signup_completed',
properties: {
app_surface: 'server',
airi_user_id: 'user-2',
feature: 'auth',
status: 'succeeded',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
it('merges Stripe webhook conversions with the browser PostHog person when a distinct id is present', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
stripe_checkout_session_id: 'cs_1',
},
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: '$identify',
properties: {
$anon_distinct_id: 'anon-browser-1',
$session_id: 'ph-session-1',
airi_user_id: 'user-1',
},
})
expect(capture).toHaveBeenNthCalledWith(2, {
distinctId: 'user-1',
event: 'payment_completed',
properties: {
app_surface: 'server',
airi_user_id: 'user-1',
posthog_distinct_id: 'anon-browser-1',
$session_id: 'ph-session-1',
feature: 'billing',
status: 'succeeded',
source: 'stripe.webhook',
posthog_session_id: 'ph-session-1',
stripe_checkout_session_id: 'cs_1',
},
})
})
it('does not forward high-volume per-request actions to PostHog', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
})
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'checkout_started',
status: 'started',
})
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
it('captures an LLM generation as a PostHog AI fact without storing prompts or responses', async () => {
const capture = vi.fn(async () => {})
const captureQueued = vi.fn()
const sink = { capture, captureQueued, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
service.trackGeneration({
userId: 'user-1',
traceId: 'session-1',
generationId: 'round-1',
model: 'openai/gpt-5-mini',
provider: 'openai',
providerType: 'official',
usageSource: 'reported',
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
conversationId: 'session-1',
conversationIdSource: 'client_header',
roundId: 'round-1',
appSurface: 'electron',
captureSurface: 'server',
})
expect(capture).not.toHaveBeenCalled()
expect(captureQueued).toHaveBeenCalledWith({
distinctId: 'user-1',
event: '$ai_generation',
properties: {
$ai_trace_id: 'session-1',
$ai_session_id: 'session-1',
$ai_span_id: 'round-1',
$ai_model: 'openai/gpt-5-mini',
$ai_provider: 'openai',
$ai_input_tokens: 12,
$ai_output_tokens: 8,
$ai_total_tokens: 20,
$insert_id: 'ai-generation:round-1',
airi_user_id: 'user-1',
provider_type: 'official',
usage_source: 'reported',
token_usage_available: true,
cost_usd_source: 'unavailable',
cost_usd_known: false,
conversation_id: 'session-1',
conversation_id_source: 'client_header',
round_id: 'round-1',
app_surface: 'electron',
capture_surface: 'server',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(0)
})
it('does not forward to PostHog when the DB write fails', async () => {
// ROOT CAUSE:
//
// track() swallows DB insert errors to protect the caller, but the
// PostHog forwarding block ran unconditionally afterwards — a Postgres
// outage during a Stripe webhook would mint `payment_completed` in
// PostHog with no `product_events` row backing it, breaking the
// "Postgres is the fact of record" invariant and later reconciliation.
// Found by PR #2038 review.
//
// Fixed by gating forwarding on a `persisted` flag set only after the
// insert resolves.
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
// Simulate a DB outage by renaming the table out from under the insert.
await db.execute(sql`ALTER TABLE product_events RENAME TO product_events_outage`)
try {
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
}
finally {
await db.execute(sql`ALTER TABLE product_events_outage RENAME TO product_events`)
}
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(0)
})
it('persists the product event even when a misbehaving sink throws', async () => {
// ROOT CAUSE:
//
// The PosthogSink contract says implementations swallow transport
// errors, but the forwarding call sits on the Stripe webhook path —
// if a sink ever throws, an unguarded `await` would fail the webhook
// after the fact was already persisted, causing Stripe to retry and
// (before the idempotency guard) double-process the payment.
//
// track() therefore wraps forwarding in its own try/catch: the DB row
// must survive and track() must resolve regardless of sink behavior.
const capture = vi.fn(async () => {
throw new Error('posthog exploded')
})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ action: 'payment_completed', status: 'succeeded' })
})
})
@@ -0,0 +1,329 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import type { ProductEventMetadata } from '../../schemas/product-events'
import type { PosthogSink } from '../adapters/posthog'
import { useLogger } from '@guiiai/logg'
import { and, asc, count, gte, lt, sql } from 'drizzle-orm'
import * as schema from '../../schemas/product-events'
const logger = useLogger('product-events')
export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack'
export type ProductEventStatus = 'started' | 'succeeded' | 'failed' | 'blocked'
export type ProductAction
= | 'user_signed_up'
| 'session_started'
| 'message_pushed'
| 'completion_requested'
| 'completion_succeeded'
| 'completion_failed'
| 'speech_requested'
| 'speech_succeeded'
| 'speech_failed'
| 'speech_blocked'
| 'voice_pack_created'
| 'voice_pack_updated'
| 'voice_pack_disabled'
| 'checkout_started'
| 'payment_completed'
| 'subscription_started'
| 'subscription_renewed'
| 'subscription_cancelled'
| 'topic_classified'
/**
* Product event fact written to AIRI's own Postgres analytics table.
*/
export interface ProductEventInput {
/** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */
userId: string
/** Bounded product area used for product dashboards and funnels. */
feature: ProductFeature
/** Bounded user/business action within the feature. */
action: ProductAction
/** Lifecycle state for the action. */
status: ProductEventStatus
/** Optional bounded route/surface label such as `openai.chat.completions`. */
source?: string
/** Optional model alias for DB-side drilldown. Do not expose as a Prometheus label. */
model?: string
/** Optional provider name for DB-side drilldown. */
provider?: string
/** Optional bounded failure reason or business outcome. */
reason?: string
/** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */
metadata?: ProductEventMetadata
/** Override for tests/backfills. Defaults to database/server current time. */
createdAt?: Date
}
export interface ProductEventAggregateInput {
/** Inclusive lower time bound. */
from: Date
/** Exclusive upper time bound. Omit for open-ended queries. */
to?: Date
}
export interface ProductEventAggregateRow {
feature: string
action: string
status: string
eventCount: number
distinctUsers: number
}
/** Product runtime where the user initiated the AI generation. */
export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron'
/** Runtime that captured the `$ai_generation` fact. */
export type AiGenerationCaptureSurface = 'server' | 'client'
/** Explains whether `conversation_id` is an app conversation or a server fallback. */
export type AiGenerationConversationIdSource = 'client_header' | 'server_request'
/** Explains whether AIRI supplied a trustworthy USD cost for this generation. */
export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable'
/** Content-free PostHog AI generation fact keyed to the authenticated user. */
export interface AiGenerationEventInput {
userId: string
traceId: string
generationId: string
model: string
provider: string
providerType: 'official' | 'custom' | 'unknown'
usageSource: 'reported' | 'estimated' | 'unavailable'
inputTokens?: number
outputTokens?: number
totalTokens?: number
totalCostUsd?: number
costUsdSource?: AiGenerationCostUsdSource
/** Always present for joins; `conversationIdSource` tells whether it is request-level fallback. */
conversationId: string
/** Distinguishes real client conversation ids from server-generated request fallbacks. */
conversationIdSource: AiGenerationConversationIdSource
roundId?: string
/** Omitted when the server cannot determine the user's product runtime. */
appSurface?: AiGenerationAppSurface
/** Defaults to `server` because this service runs in the API process. */
captureSurface?: AiGenerationCaptureSurface
latencySeconds?: number
stream?: boolean
}
/**
* Server-side actions worth a PostHog copy, mapped to the event name the
* client-side funnels expect. Only business facts that terminate or anchor
* a funnel are forwarded per-request LLM/TTS volume stays in Postgres and
* Grafana where it belongs (see `docs/ai-context/metrics-ownership.md`).
*
* `user_signed_up` maps to `signup_completed` because the identified server
* hook is the canonical registration fact for every signup method. Anonymous
* auth UI progress uses `signup_form_completed` and never reuses this name.
*/
const POSTHOG_FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
user_signed_up: 'signup_completed',
payment_completed: 'payment_completed',
subscription_started: 'subscription_started',
subscription_renewed: 'subscription_renewed',
subscription_cancelled: 'subscription_cancelled',
}
/**
* Builds bounded Prometheus labels from product event inputs.
*/
function metricLabels(input: ProductEventInput): Record<string, string> {
const attrs: Record<string, string> = {
feature: input.feature,
action: input.action,
status: input.status,
}
if (input.source)
attrs.source = input.source
if (input.reason)
attrs.reason = input.reason
const fluxBalanceBucket = input.metadata?.flux_balance_bucket
if (typeof fluxBalanceBucket === 'string')
attrs.flux_balance_bucket = fluxBalanceBucket
return attrs
}
function stringMetadata(input: ProductEventInput, key: string): string | undefined {
const value = input.metadata?.[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/**
* Creates AIRI's first-party product analytics event writer.
*
* Use when:
* - Server-side product behavior has a user id and should be queryable by
* distinct users, funnels, or retention windows.
* - Grafana needs low-cardinality event volume while Postgres keeps user-level
* detail.
*
* Expects:
* - Callers pass only bounded `feature` / `action` / `status` values.
* - PII, prompts, request ids, sessions, and user ids are not written into
* Prometheus labels. User id is stored only in the DB row.
*
* Returns:
* - Best-effort event writer plus a DB aggregation helper for analytics jobs.
*/
export function createProductEventService(db: Database, metrics?: ProductMetrics | null, posthog?: PosthogSink | null) {
return {
trackGeneration(input: AiGenerationEventInput): void {
if (!posthog)
return
const event = {
distinctId: input.userId,
event: '$ai_generation',
properties: {
$ai_trace_id: input.traceId,
$ai_session_id: input.conversationId,
$ai_span_id: input.generationId,
$ai_model: input.model,
$ai_provider: input.provider,
...(input.inputTokens != null && { $ai_input_tokens: input.inputTokens }),
...(input.outputTokens != null && { $ai_output_tokens: input.outputTokens }),
...(input.totalTokens != null && { $ai_total_tokens: input.totalTokens }),
...(input.totalCostUsd != null && { $ai_total_cost_usd: input.totalCostUsd }),
...(input.latencySeconds != null && { $ai_latency: input.latencySeconds }),
...(input.stream != null && { $ai_stream: input.stream }),
$insert_id: `ai-generation:${input.generationId}`,
airi_user_id: input.userId,
provider_type: input.providerType,
usage_source: input.usageSource,
token_usage_available: input.usageSource !== 'unavailable',
cost_usd_source: input.costUsdSource ?? 'unavailable',
cost_usd_known: input.totalCostUsd != null,
conversation_id: input.conversationId,
conversation_id_source: input.conversationIdSource,
...(input.roundId && { round_id: input.roundId }),
...(input.appSurface && { app_surface: input.appSurface }),
capture_surface: input.captureSurface ?? 'server',
},
}
if (posthog.captureQueued) {
posthog.captureQueued(event)
return
}
void posthog.capture(event)
.catch(err => logger.withError(err).withFields({ generationId: input.generationId }).warn('Failed to capture PostHog AI generation'))
},
async track(input: ProductEventInput): Promise<void> {
// Postgres is the fact of record, so forwarding is gated both ways:
// the DB write comes first (a PostHog outage can't lose the row) and
// forwarding only runs when the row actually landed (a DB outage
// can't mint PostHog events with no DB backing).
let persisted = false
try {
await db.insert(schema.productEvents).values({
userId: input.userId,
feature: input.feature,
action: input.action,
status: input.status,
source: input.source,
model: input.model,
provider: input.provider,
reason: input.reason,
metadata: input.metadata,
createdAt: input.createdAt,
})
persisted = true
metrics?.events.add(1, metricLabels(input))
}
catch (err) {
logger.withError(err).withFields({
userId: input.userId,
feature: input.feature,
action: input.action,
status: input.status,
}).warn('Failed to write product event; swallowing to protect caller')
}
// PostHog copy so browser funnels (identified by the same Better Auth
// user id) get their server-side terminator events.
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action]
if (persisted && posthog && forwardedEvent) {
try {
const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id')
const posthogSessionId = stringMetadata(input, 'posthog_session_id')
if (posthogDistinctId && posthogDistinctId !== input.userId) {
await posthog.capture({
distinctId: input.userId,
event: '$identify',
properties: {
$anon_distinct_id: posthogDistinctId,
airi_user_id: input.userId,
...(posthogSessionId && { $session_id: posthogSessionId }),
},
})
}
await posthog.capture({
distinctId: input.userId,
event: forwardedEvent,
properties: {
app_surface: 'server',
airi_user_id: input.userId,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { $session_id: posthogSessionId }),
feature: input.feature,
status: input.status,
...(input.source && { source: input.source }),
...(input.reason && { reason: input.reason }),
...input.metadata,
},
})
}
catch (err) {
// The sink contract already swallows transport errors; this guard
// is the last line so a misbehaving sink can never fail the
// webhook/auth flow that produced the business fact.
logger.withError(err).withFields({ action: input.action }).warn('PostHog forwarding threw; product event already persisted')
}
}
},
async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise<ProductEventAggregateRow[]> {
const where = input.to
? and(gte(schema.productEvents.createdAt, input.from), lt(schema.productEvents.createdAt, input.to))
: gte(schema.productEvents.createdAt, input.from)
const rows = await db
.select({
feature: schema.productEvents.feature,
action: schema.productEvents.action,
status: schema.productEvents.status,
eventCount: count(),
distinctUsers: sql<number>`count(distinct ${schema.productEvents.userId})::int`,
})
.from(schema.productEvents)
.where(where)
.groupBy(schema.productEvents.feature, schema.productEvents.action, schema.productEvents.status)
.orderBy(asc(schema.productEvents.feature), asc(schema.productEvents.action), asc(schema.productEvents.status))
return rows.map(row => ({
feature: row.feature,
action: row.action,
status: row.status,
eventCount: Number(row.eventCount),
distinctUsers: Number(row.distinctUsers),
}))
},
}
}
export type ProductEventService = ReturnType<typeof createProductEventService>
@@ -0,0 +1,208 @@
import type { Database } from '../../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { createProviderCatalogService } from '.'
import { mockDB } from '../../../libs/mock-db'
import { capabilityAliases, capabilityAliasRoutes, providerCatalogTtsModels, providerCatalogTtsVoices } from '../../../schemas/provider-catalog'
import { ApiError } from '../../../utils/error'
import * as schema from '../../../schemas'
describe('providerCatalogService', () => {
let db: Database
let service: ReturnType<typeof createProviderCatalogService>
beforeAll(async () => {
db = await mockDB(schema)
service = createProviderCatalogService(db)
})
beforeEach(async () => {
await db.delete(capabilityAliasRoutes)
await db.delete(capabilityAliases)
await db.delete(providerCatalogTtsVoices)
await db.delete(providerCatalogTtsModels)
})
it('syncs the default LLM auto alias and runtime model routes as enabled', async () => {
const aliases = await service.syncAliasesFromRouterConfig({
surface: 'llm',
modelIds: ['chat-b', 'chat-a'],
})
expect(aliases).toHaveLength(1)
expect(aliases[0]).toMatchObject({
surface: 'llm',
aliasId: 'auto',
displayName: 'Auto',
enabled: true,
fallbackEnabled: true,
loadBalancingEnabled: false,
})
const resolved = await service.resolveEnabledAlias('llm', 'auto')
expect(resolved.routes.map(route => route.routerModelId)).toEqual(['chat-b', 'chat-a'])
expect(resolved.routes.every(route => route.enabled)).toBe(true)
expect(resolved.routes.every(route => route.pool === 'primary')).toBe(true)
})
it('preserves alias and route curation across repeated syncs', async () => {
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
const [alias] = await db.select().from(capabilityAliases)
const [route] = await db.select().from(capabilityAliasRoutes)
await db.update(capabilityAliases)
.set({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
.where(eq(capabilityAliases.id, alias.id))
await db.update(capabilityAliasRoutes)
.set({ enabled: false, displayOrder: 9 })
.where(eq(capabilityAliasRoutes.id, route.id))
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a', 'chat-b'] })
const aliases = await service.listAliases('llm')
const preservedRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-a')
const newRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-b')
expect(aliases[0]).toMatchObject({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
expect(preservedRoute).toMatchObject({ enabled: false, displayOrder: 9 })
expect(newRoute).toMatchObject({ enabled: true, displayOrder: 1 })
})
it('syncs runtime TTS models as enabled but preserves admin display fields', async () => {
const first = await service.syncTtsModelsFromRouterConfig({
models: {
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
},
})
await db.update(providerCatalogTtsModels)
.set({ enabled: false, displayName: 'Curated CosyVoice', displayOrder: 7 })
.where(eq(providerCatalogTtsModels.id, first[0].id))
await service.syncTtsModelsFromRouterConfig({
models: {
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
'microsoft/v1': { provider: 'azure' },
},
})
const models = await service.listTtsModels()
expect(models.map(model => model.routerModelId)).toEqual(['alibaba/cosyvoice-v2', 'microsoft/v1'])
expect(models.find(model => model.routerModelId === 'alibaba/cosyvoice-v2')).toMatchObject({
enabled: false,
displayName: 'Curated CosyVoice',
displayOrder: 7,
provider: 'dashscope-cosyvoice',
})
expect(models.find(model => model.routerModelId === 'microsoft/v1')).toMatchObject({
enabled: true,
displayName: 'microsoft/v1',
provider: 'azure',
})
})
it('syncs provider voices as disabled by default and preserves curation on resync', async () => {
await service.syncTtsModelsFromRouterConfig({
models: { 'microsoft/v1': { provider: 'azure' } },
})
const first = await service.syncTtsVoices({
routerModelId: 'microsoft/v1',
voices: [{
id: 'en-US-AvaMultilingualNeural',
name: 'Ava',
languages: [{ code: 'en-US', title: 'English' }],
labels: { gender: 'female' },
previewAudioUrl: 'https://example.com/ava.mp3',
}],
})
expect(first[0]).toMatchObject({
providerVoiceId: 'en-US-AvaMultilingualNeural',
displayName: 'Ava',
enabled: false,
previewAudioUrl: 'https://example.com/ava.mp3',
})
await db.update(providerCatalogTtsVoices)
.set({
enabled: true,
displayName: 'Curated Ava',
displayOrder: 3,
previewAudioUrl: 'https://example.com/manual.mp3',
})
.where(eq(providerCatalogTtsVoices.id, first[0].id))
await service.syncTtsVoices({
routerModelId: 'microsoft/v1',
voices: [{
id: 'en-US-AvaMultilingualNeural',
name: 'Ava from provider',
languages: [{ code: 'en-US', title: 'English US' }],
labels: { gender: 'Female' },
previewAudioUrl: 'https://example.com/provider-new.mp3',
}],
})
const voices = await service.listTtsVoices('microsoft/v1')
expect(voices[0]).toMatchObject({
enabled: true,
displayName: 'Curated Ava',
displayOrder: 3,
previewAudioUrl: 'https://example.com/manual.mp3',
labels: { gender: 'Female' },
languages: [{ code: 'en-US', title: 'English US' }],
})
})
it('lists and gates only enabled TTS models and voices', async () => {
const [model] = await service.syncTtsModelsFromRouterConfig({
models: { 'microsoft/v1': { provider: 'azure' } },
})
const [voice] = await service.syncTtsVoices({
routerModelId: 'microsoft/v1',
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
})
expect(await service.listEnabledTtsModels()).toHaveLength(1)
expect(await service.listEnabledTtsVoices('microsoft/v1')).toEqual([])
await db.update(providerCatalogTtsVoices)
.set({ enabled: true })
.where(eq(providerCatalogTtsVoices.id, voice.id))
expect((await service.listEnabledTtsVoices('microsoft/v1')).map(item => item.providerVoiceId)).toEqual(['en-US-AvaMultilingualNeural'])
await db.update(providerCatalogTtsModels)
.set({ enabled: false })
.where(eq(providerCatalogTtsModels.id, model.id))
await expect(service.assertTtsModelEnabled('microsoft/v1')).rejects.toMatchObject({
errorCode: 'PROVIDER_CATALOG_TTS_MODEL_DISABLED',
})
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'en-US-AvaMultilingualNeural')).rejects.toMatchObject({
errorCode: 'PROVIDER_CATALOG_TTS_MODEL_DISABLED',
})
})
it('throws structured errors for missing or disabled aliases and voices', async () => {
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
errorCode: 'CAPABILITY_ALIAS_NOT_FOUND',
})
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
const [alias] = await db.select().from(capabilityAliases)
await db.update(capabilityAliases)
.set({ enabled: false })
.where(eq(capabilityAliases.id, alias.id))
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
errorCode: 'CAPABILITY_ALIAS_DISABLED',
})
await service.syncTtsModelsFromRouterConfig({ models: { 'microsoft/v1': { provider: 'azure' } } })
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toBeInstanceOf(ApiError)
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toMatchObject({
errorCode: 'PROVIDER_CATALOG_TTS_VOICE_NOT_FOUND',
})
})
})
@@ -0,0 +1,444 @@
import type { Database } from '../../../libs/db'
import type {
CapabilityAlias,
CapabilityAliasRoute,
CapabilityAliasRoutePool,
CapabilityAliasSurface,
ProviderCatalogTtsModel,
ProviderCatalogTtsVoice,
ProviderCatalogTtsVoiceLabels,
ProviderCatalogTtsVoiceLanguage,
} from '../../../schemas/provider-catalog'
import { and, asc, eq, inArray } from 'drizzle-orm'
import {
capabilityAliases,
capabilityAliasRoutes,
providerCatalogTtsModels,
providerCatalogTtsVoices,
} from '../../../schemas/provider-catalog'
import { createBadRequestError } from '../../../utils/error'
const DEFAULT_ALIAS_ID = 'auto'
export interface ProviderCatalogTtsModelSyncInput {
provider: string
}
export interface ProviderCatalogTtsVoiceSyncInput {
id: string
name?: string
languages?: ProviderCatalogTtsVoiceLanguage[]
labels?: ProviderCatalogTtsVoiceLabels
previewAudioUrl?: string | null
}
export interface CapabilityAliasWithRoutes extends CapabilityAlias {
routes: CapabilityAliasRoute[]
}
export interface ProviderCatalogTtsVoiceWithModel {
model: ProviderCatalogTtsModel
voice: ProviderCatalogTtsVoice
}
export interface CapabilityAliasUpdateInput {
displayName?: string
enabled?: boolean
displayOrder?: number
fallbackEnabled?: boolean
loadBalancingEnabled?: boolean
}
export interface CapabilityAliasRouteUpdateInput {
enabled?: boolean
pool?: CapabilityAliasRoutePool
weight?: number
displayOrder?: number
}
export interface ProviderCatalogTtsModelUpdateInput {
displayName?: string
enabled?: boolean
displayOrder?: number
}
export interface ProviderCatalogTtsVoiceUpdateInput {
displayName?: string
enabled?: boolean
displayOrder?: number
languages?: ProviderCatalogTtsVoiceLanguage[]
labels?: ProviderCatalogTtsVoiceLabels
previewAudioUrl?: string | null
}
function defaultAliasDisplayName(surface: CapabilityAliasSurface, aliasId: string): string {
if (aliasId !== DEFAULT_ALIAS_ID)
return aliasId
return surface === 'llm' ? 'Auto' : 'Auto Transcription'
}
function nextOrder(rows: Array<{ displayOrder: number }>): number {
if (rows.length === 0)
return 0
return Math.max(...rows.map(row => row.displayOrder)) + 1
}
function catalogError(message: string, errorCode: string, details?: unknown) {
return createBadRequestError(message, errorCode, details)
}
/**
* Owns AIRI's provider catalog curation state.
*
* The router config still owns real provider URLs, keys, and fallback
* mechanics. Capability aliases and provider model or voice rows decide what
* users can see and what gateway requests may use. Public list endpoints and
* gateway request gates should both call this service so UI hiding and
* handwritten request validation cannot drift.
*/
export function createProviderCatalogService(db: Database) {
async function findAlias(surface: CapabilityAliasSurface, aliasId: string) {
return await db.query.capabilityAliases.findFirst({
where: and(
eq(capabilityAliases.surface, surface),
eq(capabilityAliases.aliasId, aliasId),
),
})
}
async function ensureAlias(surface: CapabilityAliasSurface, aliasId: string) {
const existing = await findAlias(surface, aliasId)
if (existing)
return existing
const existingAliases = await db.query.capabilityAliases.findMany({
where: eq(capabilityAliases.surface, surface),
})
const [created] = await db.insert(capabilityAliases).values({
surface,
aliasId,
displayName: defaultAliasDisplayName(surface, aliasId),
enabled: true,
displayOrder: nextOrder(existingAliases),
fallbackEnabled: true,
loadBalancingEnabled: false,
}).onConflictDoNothing({
target: [capabilityAliases.surface, capabilityAliases.aliasId],
}).returning()
const alias = created ?? await findAlias(surface, aliasId)
if (!alias)
throw catalogError('Capability alias could not be synced', 'CAPABILITY_ALIAS_SYNC_FAILED', { surface, aliasId })
return alias
}
async function syncAliasRoute(input: {
aliasRowId: string
routerModelId: string
pool: CapabilityAliasRoutePool
order: number
}) {
const existing = await db.query.capabilityAliasRoutes.findFirst({
where: and(
eq(capabilityAliasRoutes.aliasId, input.aliasRowId),
eq(capabilityAliasRoutes.routerModelId, input.routerModelId),
eq(capabilityAliasRoutes.pool, input.pool),
),
})
if (existing)
return existing
const [created] = await db.insert(capabilityAliasRoutes).values({
aliasId: input.aliasRowId,
routerModelId: input.routerModelId,
pool: input.pool,
enabled: true,
weight: 1,
displayOrder: input.order,
}).onConflictDoNothing({
target: [
capabilityAliasRoutes.aliasId,
capabilityAliasRoutes.routerModelId,
capabilityAliasRoutes.pool,
],
}).returning()
const route = created ?? await db.query.capabilityAliasRoutes.findFirst({
where: and(
eq(capabilityAliasRoutes.aliasId, input.aliasRowId),
eq(capabilityAliasRoutes.routerModelId, input.routerModelId),
eq(capabilityAliasRoutes.pool, input.pool),
),
})
if (!route) {
throw catalogError('Capability alias route could not be synced', 'CAPABILITY_ALIAS_ROUTE_SYNC_FAILED', {
routerModelId: input.routerModelId,
pool: input.pool,
})
}
return route
}
return {
async syncAliasesFromRouterConfig(input: {
surface: CapabilityAliasSurface
modelIds: string[]
}) {
const alias = await ensureAlias(input.surface, DEFAULT_ALIAS_ID)
const uniqueModelIds = Array.from(new Set(input.modelIds))
for (const [index, routerModelId] of uniqueModelIds.entries()) {
await syncAliasRoute({
aliasRowId: alias.id,
routerModelId,
pool: 'primary',
order: index,
})
}
return await db.query.capabilityAliases.findMany({
where: eq(capabilityAliases.surface, input.surface),
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
})
},
async listAliases(surface?: CapabilityAliasSurface): Promise<CapabilityAliasWithRoutes[]> {
const aliases = await db.query.capabilityAliases.findMany({
where: surface ? eq(capabilityAliases.surface, surface) : undefined,
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
})
if (aliases.length === 0)
return []
const routes = await db.query.capabilityAliasRoutes.findMany({
where: inArray(capabilityAliasRoutes.aliasId, aliases.map(alias => alias.id)),
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
})
return aliases.map(alias => ({
...alias,
routes: routes.filter(route => route.aliasId === alias.id),
}))
},
async updateAlias(id: string, input: CapabilityAliasUpdateInput): Promise<CapabilityAlias | null> {
const [updated] = await db.update(capabilityAliases)
.set({ ...input, updatedAt: new Date() })
.where(eq(capabilityAliases.id, id))
.returning()
return updated ?? null
},
async updateAliasRoute(id: string, input: CapabilityAliasRouteUpdateInput): Promise<CapabilityAliasRoute | null> {
const [updated] = await db.update(capabilityAliasRoutes)
.set({ ...input, updatedAt: new Date() })
.where(eq(capabilityAliasRoutes.id, id))
.returning()
return updated ?? null
},
async resolveEnabledAlias(surface: CapabilityAliasSurface, aliasId: string): Promise<CapabilityAliasWithRoutes> {
const alias = await findAlias(surface, aliasId)
if (!alias) {
throw catalogError('Capability alias is not configured', 'CAPABILITY_ALIAS_NOT_FOUND', { surface, aliasId })
}
if (!alias.enabled) {
throw catalogError('Capability alias is disabled', 'CAPABILITY_ALIAS_DISABLED', { surface, aliasId })
}
const routes = await db.query.capabilityAliasRoutes.findMany({
where: and(
eq(capabilityAliasRoutes.aliasId, alias.id),
eq(capabilityAliasRoutes.enabled, true),
),
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
})
if (routes.length === 0) {
throw catalogError('Capability alias has no enabled route', 'CAPABILITY_ALIAS_ROUTE_NOT_FOUND', { surface, aliasId })
}
return { ...alias, routes }
},
async syncTtsModelsFromRouterConfig(input: {
models: Record<string, ProviderCatalogTtsModelSyncInput>
}) {
const existingModels = await db.query.providerCatalogTtsModels.findMany()
const synced: ProviderCatalogTtsModel[] = []
const now = new Date()
for (const [routerModelId, model] of Object.entries(input.models).sort(([a], [b]) => a.localeCompare(b))) {
const [syncedModel] = await db.insert(providerCatalogTtsModels).values({
routerModelId,
provider: model.provider,
displayName: routerModelId,
enabled: true,
displayOrder: nextOrder([...existingModels, ...synced]),
lastSyncedAt: now,
}).onConflictDoUpdate({
target: providerCatalogTtsModels.routerModelId,
set: {
provider: model.provider,
lastSyncedAt: now,
updatedAt: now,
},
}).returning()
synced.push(syncedModel)
}
return synced
},
async listTtsModels(): Promise<ProviderCatalogTtsModel[]> {
return await db.query.providerCatalogTtsModels.findMany({
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
})
},
async updateTtsModel(id: string, input: ProviderCatalogTtsModelUpdateInput): Promise<ProviderCatalogTtsModel | null> {
const [updated] = await db.update(providerCatalogTtsModels)
.set({ ...input, updatedAt: new Date() })
.where(eq(providerCatalogTtsModels.id, id))
.returning()
return updated ?? null
},
async listEnabledTtsModels(): Promise<ProviderCatalogTtsModel[]> {
return await db.query.providerCatalogTtsModels.findMany({
where: eq(providerCatalogTtsModels.enabled, true),
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
})
},
async assertTtsModelEnabled(routerModelId: string): Promise<ProviderCatalogTtsModel> {
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.routerModelId, routerModelId),
})
if (!model) {
throw catalogError('Provider catalog TTS model is not configured', 'PROVIDER_CATALOG_TTS_MODEL_NOT_FOUND', { model: routerModelId })
}
if (!model.enabled) {
throw catalogError('Provider catalog TTS model is disabled', 'PROVIDER_CATALOG_TTS_MODEL_DISABLED', { model: routerModelId })
}
return model
},
async syncTtsVoices(input: {
routerModelId: string
voices: ProviderCatalogTtsVoiceSyncInput[]
}) {
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.routerModelId, input.routerModelId),
})
if (!model) {
throw catalogError('Provider catalog TTS model is not configured', 'PROVIDER_CATALOG_TTS_MODEL_NOT_FOUND', { model: input.routerModelId })
}
const existingVoices = await db.query.providerCatalogTtsVoices.findMany({
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
})
const existingByVoiceId = new Map(existingVoices.map(voice => [voice.providerVoiceId, voice]))
const synced: ProviderCatalogTtsVoice[] = []
const now = new Date()
for (const voice of input.voices) {
const existing = existingByVoiceId.get(voice.id)
const [syncedVoice] = await db.insert(providerCatalogTtsVoices).values({
ttsModelId: model.id,
providerVoiceId: voice.id,
displayName: voice.name ?? voice.id,
enabled: false,
displayOrder: nextOrder([...existingVoices, ...synced]),
languages: voice.languages ?? [],
labels: voice.labels ?? {},
previewAudioUrl: voice.previewAudioUrl ?? null,
source: 'provider-sync',
lastSyncedAt: now,
}).onConflictDoUpdate({
target: [providerCatalogTtsVoices.ttsModelId, providerCatalogTtsVoices.providerVoiceId],
set: {
languages: voice.languages ?? existing?.languages ?? [],
labels: voice.labels ?? existing?.labels ?? {},
lastSyncedAt: now,
updatedAt: now,
},
}).returning()
synced.push(syncedVoice)
}
return synced
},
async listTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.routerModelId, routerModelId),
})
if (!model)
return []
return await db.query.providerCatalogTtsVoices.findMany({
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
})
},
async getTtsVoiceWithModel(id: string): Promise<ProviderCatalogTtsVoiceWithModel | null> {
const voice = await db.query.providerCatalogTtsVoices.findFirst({
where: eq(providerCatalogTtsVoices.id, id),
})
if (!voice)
return null
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.id, voice.ttsModelId),
})
if (!model)
return null
return { model, voice }
},
async updateTtsVoice(id: string, input: ProviderCatalogTtsVoiceUpdateInput): Promise<ProviderCatalogTtsVoice | null> {
const [updated] = await db.update(providerCatalogTtsVoices)
.set({ ...input, updatedAt: new Date() })
.where(eq(providerCatalogTtsVoices.id, id))
.returning()
return updated ?? null
},
async listEnabledTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
const model = await this.assertTtsModelEnabled(routerModelId)
return await db.query.providerCatalogTtsVoices.findMany({
where: and(
eq(providerCatalogTtsVoices.ttsModelId, model.id),
eq(providerCatalogTtsVoices.enabled, true),
),
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
})
},
async assertTtsVoiceEnabled(routerModelId: string, providerVoiceId: string): Promise<ProviderCatalogTtsVoice> {
const model = await this.assertTtsModelEnabled(routerModelId)
const voice = await db.query.providerCatalogTtsVoices.findFirst({
where: and(
eq(providerCatalogTtsVoices.ttsModelId, model.id),
eq(providerCatalogTtsVoices.providerVoiceId, providerVoiceId),
),
})
if (!voice) {
throw catalogError('Provider catalog TTS voice is not configured for this model', 'PROVIDER_CATALOG_TTS_VOICE_NOT_FOUND', {
model: routerModelId,
voice: providerVoiceId,
})
}
if (!voice.enabled) {
throw catalogError('Provider catalog TTS voice is disabled', 'PROVIDER_CATALOG_TTS_VOICE_DISABLED', {
model: routerModelId,
voice: providerVoiceId,
})
}
return voice
},
}
}
export type ProviderCatalogService = ReturnType<typeof createProviderCatalogService>
@@ -0,0 +1,70 @@
import type { ProviderCatalogTtsVoice, ProviderCatalogTtsVoiceLabels, ProviderCatalogTtsVoiceLanguage } from '../../../schemas/provider-catalog'
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value == null || Array.isArray(value))
return undefined
return value as Record<string, unknown>
}
function asOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function asLanguageList(value: unknown): ProviderCatalogTtsVoiceLanguage[] | undefined {
if (!Array.isArray(value))
return undefined
const languages = value.flatMap((item) => {
const record = asRecord(item)
const code = asOptionalString(record?.code)
if (!code)
return []
const title = asOptionalString(record?.title)
return [{ code, ...(title ? { title } : {}) }]
})
return languages.length > 0 ? languages : undefined
}
function asLabels(value: unknown): ProviderCatalogTtsVoiceLabels | undefined {
const record = asRecord(value)
return record ? { ...record } : undefined
}
/**
* Normalizes a provider-specific voice object into the provider catalog sync shape.
*
* Before:
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewUrl: "https://..." }`
*
* After:
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewAudioUrl: "https://..." }`
*/
export function normalizeProviderVoiceForCatalog(value: unknown) {
const record = asRecord(value)
const id = asOptionalString(record?.id)
if (!id)
return null
return {
id,
name: asOptionalString(record?.name),
languages: asLanguageList(record?.languages),
labels: asLabels(record?.labels),
previewAudioUrl: asOptionalString(record?.previewAudioUrl) ?? asOptionalString(record?.previewUrl) ?? null,
}
}
export function catalogVoiceResponse(voice: ProviderCatalogTtsVoice) {
// NOTICE: Admin-generated previews may temporarily live as data URIs until
// object storage is wired. Public voice catalogs stay lightweight and only
// expose provider or storage URLs.
const previewAudioUrl = voice.previewAudioUrl?.startsWith('data:') ? undefined : voice.previewAudioUrl
return {
id: voice.providerVoiceId,
name: voice.displayName,
languages: voice.languages,
labels: voice.labels,
preview_audio_url: previewAudioUrl ?? undefined,
}
}
@@ -0,0 +1,94 @@
import type { Database } from '../../libs/db'
import { beforeAll, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createProviderService } from './providers'
import * as schema from '../../schemas'
describe('providerService', () => {
let db: Database
let service: ReturnType<typeof createProviderService>
let testUser: any
beforeAll(async () => {
db = await mockDB(schema)
service = createProviderService(db)
// Create a test user for foreign key constraints
const [user] = await db.insert(schema.user).values({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
it('createUserConfig should handle provider config creation', async () => {
const providerData = {
id: 'prov-1',
ownerId: testUser.id,
definitionId: 'openai',
name: 'My OpenAI',
config: { apiKey: 'sk-123' },
validated: true,
validationBypassed: false,
}
const result = await service.createUserConfig(providerData)
expect(result.id).toBe('prov-1')
expect(result.name).toBe('My OpenAI')
const found = await service.findUserConfigById('prov-1')
expect(found).toBeDefined()
expect(found!.definitionId).toBe('openai')
expect((found!.config as Record<string, string>).apiKey).toBe('sk-123')
})
it('findUserConfigsByOwnerId should return providers for the user', async () => {
const result = await service.findUserConfigsByOwnerId(testUser.id)
expect(result.length).toBe(1)
expect(result[0].ownerId).toBe(testUser.id)
})
it('findAll should return both user and system configs', async () => {
// Create a system config
await db.insert(schema.systemProviderConfigs).values({
id: 'sys-1',
definitionId: 'anthropic',
name: 'System Anthropic',
config: { apiKey: 'sys-sk' },
})
const result = await service.findAll(testUser.id)
expect(result.length).toBe(2)
const userConfig = result.find(r => r.id === 'prov-1')
const systemConfig = result.find(r => r.id === 'sys-1')
expect(userConfig?.isSystem).toBe(false)
expect(systemConfig?.isSystem).toBe(true)
expect(systemConfig?.name).toBe('System Anthropic')
})
it('findById should find both user and system configs', async () => {
const userFound = await service.findById('prov-1', testUser.id)
expect(userFound?.isSystem).toBe(false)
const sysFound = await service.findById('sys-1', testUser.id)
expect(sysFound?.isSystem).toBe(true)
})
it('updateUserConfig should update provider fields', async () => {
await service.updateUserConfig('prov-1', { name: 'Updated OpenAI' })
const prov = await service.findUserConfigById('prov-1')
expect(prov?.name).toBe('Updated OpenAI')
})
it('deleteUserConfig should soft delete provider', async () => {
await service.deleteUserConfig('prov-1')
const prov = await service.findUserConfigById('prov-1')
expect(prov).toBeUndefined()
})
})
@@ -0,0 +1,195 @@
import type { Database } from '../../libs/db'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull, sql } from 'drizzle-orm'
import * as schema from '../../schemas/providers'
const logger = useLogger('providers')
export function createProviderService(db: Database) {
return {
async findAll(ownerId: string) {
const userConfigs = db
.select({
id: schema.userProviderConfigs.id,
definitionId: schema.userProviderConfigs.definitionId,
name: schema.userProviderConfigs.name,
config: schema.userProviderConfigs.config,
validated: schema.userProviderConfigs.validated,
validationBypassed: schema.userProviderConfigs.validationBypassed,
createdAt: schema.userProviderConfigs.createdAt,
updatedAt: schema.userProviderConfigs.updatedAt,
isSystem: sql<boolean>`false`.as('is_system'),
})
.from(schema.userProviderConfigs)
.where(
and(
eq(schema.userProviderConfigs.ownerId, ownerId),
isNull(schema.userProviderConfigs.deletedAt),
),
)
const systemConfigs = db
.select({
id: schema.systemProviderConfigs.id,
definitionId: schema.systemProviderConfigs.definitionId,
name: schema.systemProviderConfigs.name,
config: schema.systemProviderConfigs.config,
validated: schema.systemProviderConfigs.validated,
validationBypassed: schema.systemProviderConfigs.validationBypassed,
createdAt: schema.systemProviderConfigs.createdAt,
updatedAt: schema.systemProviderConfigs.updatedAt,
isSystem: sql<boolean>`true`.as('is_system'),
})
.from(schema.systemProviderConfigs)
.where(isNull(schema.systemProviderConfigs.deletedAt))
return await userConfigs.unionAll(systemConfigs)
},
async findUserConfigsByOwnerId(ownerId: string) {
return await db.query.userProviderConfigs.findMany({
where: and(
eq(schema.userProviderConfigs.ownerId, ownerId),
isNull(schema.userProviderConfigs.deletedAt),
),
})
},
async findById(id: string, ownerId: string) {
const userConfig = await db.query.userProviderConfigs.findFirst({
where: and(
eq(schema.userProviderConfigs.id, id),
eq(schema.userProviderConfigs.ownerId, ownerId),
isNull(schema.userProviderConfigs.deletedAt),
),
})
if (userConfig) {
return { ...userConfig, isSystem: false }
}
const systemConfig = await db.query.systemProviderConfigs.findFirst({
where: and(
eq(schema.systemProviderConfigs.id, id),
isNull(schema.systemProviderConfigs.deletedAt),
),
})
if (systemConfig) {
return { ...systemConfig, isSystem: true }
}
return null
},
async findUserConfigById(id: string) {
return await db.query.userProviderConfigs.findFirst({
where: and(
eq(schema.userProviderConfigs.id, id),
isNull(schema.userProviderConfigs.deletedAt),
),
})
},
async createUserConfig(data: schema.NewUserProviderConfig) {
const [inserted] = await db.insert(schema.userProviderConfigs).values(data).returning()
logger.withFields({ id: inserted.id, ownerId: data.ownerId, definitionId: data.definitionId }).log('Created user provider config')
return inserted
},
async updateUserConfig(id: string, data: Partial<schema.NewUserProviderConfig>) {
const [updated] = await db.update(schema.userProviderConfigs)
.set({ ...data, updatedAt: new Date() })
.where(and(
eq(schema.userProviderConfigs.id, id),
isNull(schema.userProviderConfigs.deletedAt),
))
.returning()
logger.withFields({ id }).log('Updated user provider config')
return updated
},
async deleteUserConfig(id: string) {
const result = await db.update(schema.userProviderConfigs)
.set({ deletedAt: new Date() })
.where(and(
eq(schema.userProviderConfigs.id, id),
isNull(schema.userProviderConfigs.deletedAt),
))
.returning()
logger.withFields({ id }).log('Deleted user provider config')
return result
},
// System Provider Configs
async findSystemConfigs() {
return await db.query.systemProviderConfigs.findMany({
where: isNull(schema.systemProviderConfigs.deletedAt),
})
},
async findSystemConfigById(id: string) {
return await db.query.systemProviderConfigs.findFirst({
where: and(
eq(schema.systemProviderConfigs.id, id),
isNull(schema.systemProviderConfigs.deletedAt),
),
})
},
async createSystemConfig(data: schema.NewSystemProviderConfig) {
const [inserted] = await db.insert(schema.systemProviderConfigs).values(data).returning()
logger.withFields({ id: inserted.id, definitionId: data.definitionId }).log('Created system provider config')
return inserted
},
async updateSystemConfig(id: string, data: Partial<schema.NewSystemProviderConfig>) {
const [updated] = await db.update(schema.systemProviderConfigs)
.set({ ...data, updatedAt: new Date() })
.where(and(
eq(schema.systemProviderConfigs.id, id),
isNull(schema.systemProviderConfigs.deletedAt),
))
.returning()
logger.withFields({ id }).log('Updated system provider config')
return updated
},
async deleteSystemConfig(id: string) {
const result = await db.update(schema.systemProviderConfigs)
.set({ deletedAt: new Date() })
.where(and(
eq(schema.systemProviderConfigs.id, id),
isNull(schema.systemProviderConfigs.deletedAt),
))
.returning()
logger.withFields({ id }).log('Deleted system provider config')
return result
},
/**
* Soft-delete every `user_provider_configs` row owned by the user.
* Called from the user-deletion pipeline. System configs are not
* touched (they are not user-scoped).
*
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
const result = await db.update(schema.userProviderConfigs)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.userProviderConfigs.ownerId, userId),
isNull(schema.userProviderConfigs.deletedAt),
))
.returning({ id: schema.userProviderConfigs.id })
logger.withFields({ userId, count: result.length }).log('Provider configs soft-deleted for user')
},
}
}
export type ProviderService = ReturnType<typeof createProviderService>
@@ -0,0 +1,23 @@
import type { Database } from '../../libs/db'
import * as schema from '../../schemas/llm-request-log'
export interface RequestLogEntry {
userId: string
model: string
status: number
durationMs: number
fluxConsumed: number
promptTokens?: number
completionTokens?: number
}
export function createRequestLogService(db: Database) {
return {
async logRequest(entry: RequestLogEntry) {
await db.insert(schema.llmRequestLog).values(entry)
},
}
}
export type RequestLogService = ReturnType<typeof createRequestLogService>
@@ -0,0 +1,468 @@
import type { Database } from '../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createStripeService } from './stripe'
import * as schema from '../../schemas'
describe('stripeService', () => {
let db: Database
let stripeService: ReturnType<typeof createStripeService>
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values([
{ id: 'user-stripe-1', name: 'Stripe User 1', email: 'stripe1@example.com' },
{ id: 'user-stripe-2', name: 'Stripe User 2', email: 'stripe2@example.com' },
])
})
beforeEach(async () => {
stripeService = createStripeService(db, null)
// Clean all stripe tables between tests
await db.delete(schema.stripeInvoice)
await db.delete(schema.stripeSubscription)
await db.delete(schema.stripeCheckoutSession)
await db.delete(schema.stripeCustomer)
})
// ---- Customer ----
describe('upsertCustomer', () => {
it('inserts a new customer', async () => {
const result = await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_new_1',
email: 'stripe1@example.com',
})
expect(result.userId).toBe('user-stripe-1')
expect(result.stripeCustomerId).toBe('cus_new_1')
expect(result.email).toBe('stripe1@example.com')
})
it('updates an existing customer on conflict (atomic upsert)', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_dup_1',
email: 'old@example.com',
})
const updated = await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_dup_1',
email: 'new@example.com',
name: 'Updated Name',
})
expect(updated.email).toBe('new@example.com')
expect(updated.name).toBe('Updated Name')
// Verify only one record exists
const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_dup_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts for the same customer without error', async () => {
// Simulate two webhook events arriving at the same time for the same customer
const results = await Promise.all([
stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_race_1',
email: 'a@example.com',
}),
stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_race_1',
email: 'b@example.com',
}),
])
// Both should succeed (no unique constraint violation)
expect(results).toHaveLength(2)
results.forEach(r => expect(r.stripeCustomerId).toBe('cus_race_1'))
// Only one record should exist
const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getCustomerByUserId', () => {
it('returns the customer for a given userId', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_lookup_1',
})
const found = await stripeService.getCustomerByUserId('user-stripe-1')
expect(found?.stripeCustomerId).toBe('cus_lookup_1')
})
it('returns undefined when no customer exists', async () => {
const found = await stripeService.getCustomerByUserId('user-nonexistent')
expect(found).toBeUndefined()
})
})
describe('getCustomerByStripeId', () => {
it('returns the customer for a given stripeCustomerId', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_sid_1',
})
const found = await stripeService.getCustomerByStripeId('cus_sid_1')
expect(found?.userId).toBe('user-stripe-1')
})
it('returns undefined when no customer exists', async () => {
const found = await stripeService.getCustomerByStripeId('cus_nonexistent')
expect(found).toBeUndefined()
})
})
// ---- Checkout Session ----
describe('upsertCheckoutSession', () => {
it('inserts a new checkout session', async () => {
const result = await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_new_1',
mode: 'payment',
status: 'open',
paymentStatus: 'unpaid',
amountTotal: 1000,
currency: 'usd',
})
expect(result.stripeSessionId).toBe('cs_new_1')
expect(result.amountTotal).toBe(1000)
expect(result.fluxCredited).toBe(false)
})
it('updates an existing checkout session on conflict', async () => {
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_upd_1',
mode: 'payment',
status: 'open',
paymentStatus: 'unpaid',
amountTotal: 1000,
currency: 'usd',
})
const updated = await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_upd_1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 1000,
currency: 'usd',
})
expect(updated.status).toBe('complete')
expect(updated.paymentStatus).toBe('paid')
const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_upd_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_race_1',
mode: 'payment',
status: 'open',
paymentStatus: 'unpaid',
amountTotal: 500,
currency: 'usd',
}),
stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_race_1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 500,
currency: 'usd',
}),
])
expect(results).toHaveLength(2)
const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getCheckoutSessionsByUserId', () => {
it('returns all sessions for the user', async () => {
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_list_1',
mode: 'payment',
amountTotal: 100,
currency: 'usd',
})
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_list_2',
mode: 'payment',
amountTotal: 200,
currency: 'usd',
})
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
expect(sessions).toHaveLength(2)
const ids = sessions.map(s => s.stripeSessionId)
expect(ids).toContain('cs_list_1')
expect(ids).toContain('cs_list_2')
})
it('does not return sessions from other users', async () => {
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_iso_1',
mode: 'payment',
})
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-2',
stripeSessionId: 'cs_iso_2',
mode: 'payment',
})
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
expect(sessions).toHaveLength(1)
expect(sessions[0]?.stripeSessionId).toBe('cs_iso_1')
})
})
// ---- Subscription ----
describe('upsertSubscription', () => {
it('inserts a new subscription', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_sub_1',
})
const result = await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_new_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
expect(result.stripeSubscriptionId).toBe('sub_new_1')
expect(result.status).toBe('active')
})
it('updates an existing subscription on conflict', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_upd_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
const updated = await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_upd_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
expect(updated.status).toBe('canceled')
const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_upd_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_race_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
}),
stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_race_1',
stripeCustomerId: 'cus_sub_1',
status: 'past_due',
}),
])
expect(results).toHaveLength(2)
const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getActiveSubscription', () => {
it('returns only the active subscription', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_active_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_active_2',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
expect(active?.stripeSubscriptionId).toBe('sub_active_2')
expect(active?.status).toBe('active')
})
it('returns undefined when no active subscription exists', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_none_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
expect(active).toBeUndefined()
})
it('does not return subscriptions from other users', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-2',
stripeSubscriptionId: 'sub_other_1',
stripeCustomerId: 'cus_other_1',
status: 'active',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
expect(active).toBeUndefined()
})
})
// ---- Invoice ----
describe('upsertInvoice', () => {
it('inserts a new invoice', async () => {
const result = await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_new_1',
stripeCustomerId: 'cus_inv_1',
status: 'open',
amountDue: 2000,
amountPaid: 0,
currency: 'usd',
})
expect(result.stripeInvoiceId).toBe('inv_new_1')
expect(result.status).toBe('open')
expect(result.fluxCredited).toBe(false)
})
it('updates an existing invoice on conflict', async () => {
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_upd_1',
status: 'open',
amountDue: 2000,
amountPaid: 0,
currency: 'usd',
})
const updated = await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_upd_1',
status: 'paid',
amountDue: 2000,
amountPaid: 2000,
currency: 'usd',
})
expect(updated.status).toBe('paid')
expect(updated.amountPaid).toBe(2000)
const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_upd_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_race_1',
status: 'open',
amountDue: 1000,
currency: 'usd',
}),
stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_race_1',
status: 'paid',
amountPaid: 1000,
currency: 'usd',
}),
])
expect(results).toHaveLength(2)
const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getInvoicesByUserId', () => {
it('returns all invoices for the user', async () => {
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_list_1',
status: 'paid',
currency: 'usd',
})
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_list_2',
status: 'open',
currency: 'usd',
})
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
expect(invoices).toHaveLength(2)
const ids = invoices.map(i => i.stripeInvoiceId)
expect(ids).toContain('inv_list_1')
expect(ids).toContain('inv_list_2')
})
it('does not return invoices from other users', async () => {
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_iso_1',
status: 'paid',
currency: 'usd',
})
await stripeService.upsertInvoice({
userId: 'user-stripe-2',
stripeInvoiceId: 'inv_iso_2',
status: 'paid',
currency: 'usd',
})
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
expect(invoices).toHaveLength(1)
expect(invoices[0]?.stripeInvoiceId).toBe('inv_iso_1')
})
})
})
@@ -0,0 +1,212 @@
import type Stripe from 'stripe'
import type { Database } from '../../libs/db'
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../../schemas/stripe'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull, notInArray } from 'drizzle-orm'
import * as schema from '../../schemas/stripe'
const logger = useLogger('stripe-service')
// NOTICE:
// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose
// stripe_* rows persist for billing audit) are invisible to user-facing
// API. Webhooks that arrive after deletion still match by stripeCustomerId
// and re-upsert into the soft-deleted row — that's by design (the row
// remains deletedAt-set, but we capture the late event for accurate audit).
// See `server/apps/api/docs/ai-context/account-deletion.md`.
export function createStripeService(db: Database, stripe: Stripe | null) {
return {
// ---- Customer ----
async upsertCustomer(data: NewStripeCustomer) {
const [row] = await db.insert(schema.stripeCustomer)
.values(data)
.onConflictDoUpdate({
target: schema.stripeCustomer.stripeCustomerId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Upserted Stripe customer')
return row
},
async getCustomerByUserId(userId: string) {
return db.query.stripeCustomer.findFirst({
where: and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
),
})
},
async getCustomerByStripeId(stripeCustomerId: string) {
// NOTICE: NOT filtering by deletedAt — this lookup is by external
// Stripe id and is used by webhook handlers that need to reach
// soft-deleted archive rows for late events (cancellation receipts,
// final invoices arriving after account deletion). User-facing reads
// use getCustomerByUserId which DOES filter.
return db.query.stripeCustomer.findFirst({
where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId),
})
},
// ---- Checkout Session ----
async upsertCheckoutSession(data: NewStripeCheckoutSession) {
const [row] = await db.insert(schema.stripeCheckoutSession)
.values(data)
.onConflictDoUpdate({
target: schema.stripeCheckoutSession.stripeSessionId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Upserted checkout session')
return row
},
async getCheckoutSessionsByUserId(userId: string) {
return db.query.stripeCheckoutSession.findMany({
where: and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
// ---- Subscription ----
async upsertSubscription(data: NewStripeSubscription) {
const [row] = await db.insert(schema.stripeSubscription)
.values(data)
.onConflictDoUpdate({
target: schema.stripeSubscription.stripeSubscriptionId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Upserted subscription')
return row
},
async getActiveSubscription(userId: string) {
return db.query.stripeSubscription.findFirst({
where: and(
eq(schema.stripeSubscription.userId, userId),
eq(schema.stripeSubscription.status, 'active'),
isNull(schema.stripeSubscription.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
// ---- Invoice ----
async upsertInvoice(data: NewStripeInvoice) {
const [row] = await db.insert(schema.stripeInvoice)
.values(data)
.onConflictDoUpdate({
target: schema.stripeInvoice.stripeInvoiceId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Upserted invoice')
return row
},
async getInvoicesByUserId(userId: string) {
return db.query.stripeInvoice.findMany({
where: and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
/**
* Cancel the user's active Stripe subscription via the API and stamp every
* `stripe_*` row with `deletedAt`. Called from the user-deletion pipeline
* (priority 10 runs first because Stripe API cancellation has no
* rollback path).
*
* Idempotent on retry: subsequent calls find no `active` subs to cancel
* and the `WHERE deletedAt IS NULL` guard skips already-stamped rows.
* Stripe `subscriptions.cancel` itself is also idempotent per spec
* cancelling an already-canceled sub returns 200.
*
* Cancellation is immediate, no proration, no refund see
* `server/apps/api/docs/ai-context/account-deletion.md`.
*/
async deleteAllForUser(userId: string) {
// Cancel every subscription that is NOT already in a terminal state.
// Stripe's terminal statuses are `canceled` and `incomplete_expired`;
// anything else (`active`, `trialing`, `past_due`, `unpaid`,
// `incomplete`, `paused`) can still bill or transition into billing,
// so leaving them uncancelled would charge a deleted account.
// Stripe `subscriptions.cancel` is idempotent per spec — safe to
// call on any non-terminal status.
const cancellableSubs = await db.query.stripeSubscription.findMany({
where: and(
eq(schema.stripeSubscription.userId, userId),
notInArray(schema.stripeSubscription.status, ['canceled', 'incomplete_expired']),
isNull(schema.stripeSubscription.deletedAt),
),
})
if (stripe && cancellableSubs.length > 0) {
for (const sub of cancellableSubs) {
try {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId, {
prorate: false,
})
logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription')
}
catch (err) {
logger.withError(err).withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).error('Failed to cancel Stripe subscription')
throw err
}
}
}
else if (!stripe && cancellableSubs.length > 0) {
logger.withFields({ userId, cancellableSubCount: cancellableSubs.length }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted')
}
const now = new Date()
await db.update(schema.stripeSubscription)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeSubscription.userId, userId),
isNull(schema.stripeSubscription.deletedAt),
))
await db.update(schema.stripeCheckoutSession)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
))
await db.update(schema.stripeInvoice)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
))
await db.update(schema.stripeCustomer)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
))
logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user')
},
}
}
export type StripeService = ReturnType<typeof createStripeService>
@@ -0,0 +1,77 @@
import type { UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
import { useLogger } from '@guiiai/logg'
export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
/**
* Build an empty deletion-service registry.
*
* Use when:
* - Composing the server in `app.ts` wire one instance and `register()`
* each business handler at composition time.
*
* Returns:
* - A registry whose `softDeleteAll` walks handlers in ascending `priority`
* and aborts on the first throw. Successful handlers are NOT rolled back
* each handler's writes must be idempotent.
*
* Call stack:
*
* better-auth `/delete-user/callback`
* -> `user.deleteUser.beforeDelete` (libs/auth.ts)
* -> {@link UserDeletionService.softDeleteAll}
* -> handler.softDelete (per registered module)
*
* Failure model: a thrown error from any handler aborts before
* `internalAdapter.deleteUser`, leaving the user row intact. The next retry
* is expected to re-run already-completed handlers as no-ops.
*/
export function createUserDeletionService(): UserDeletionService {
const handlers: UserDeletionHandler[] = []
const names = new Set<string>()
const logger = useLogger('user-deletion').useGlobalConfig()
return {
register(handler) {
if (names.has(handler.name))
throw new Error(`Duplicate user-deletion handler name: ${handler.name}`)
names.add(handler.name)
handlers.push(handler)
// Resort on every insert so post-boot registrations stay ordered.
handlers.sort((a, b) => a.priority - b.priority)
},
async softDeleteAll({ userId, reason }) {
const ctx = {
userId,
reason: reason as UserDeletionReason,
logger,
}
logger.withFields({ userId, reason, handlerCount: handlers.length }).log('starting user deletion')
for (const handler of handlers) {
const startedAt = Date.now()
try {
await handler.softDelete(ctx)
logger
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
.log('handler completed')
}
catch (err) {
logger
.withError(err)
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
.error('handler failed; aborting deletion pipeline')
throw err
}
}
logger.withFields({ userId, reason }).log('user deletion handlers completed')
},
}
}
@@ -0,0 +1,121 @@
import type { UserDeletionHandler } from '../types'
import { describe, expect, it, vi } from 'vitest'
import { createUserDeletionService } from '../index'
function makeHandler(name: string, priority: number, body?: () => Promise<void> | void): UserDeletionHandler {
return {
name,
priority,
softDelete: vi.fn(async () => {
await body?.()
}),
}
}
describe('createUserDeletionService', () => {
describe('register', () => {
it('rejects duplicate handler names', () => {
const service = createUserDeletionService()
service.register(makeHandler('flux', 20))
expect(() => service.register(makeHandler('flux', 30))).toThrow(/Duplicate user-deletion handler name: flux/)
})
it('keeps handlers in ascending priority regardless of registration order', async () => {
const service = createUserDeletionService()
const calls: string[] = []
service.register(makeHandler('characters', 30, () => {
calls.push('characters')
}))
service.register(makeHandler('stripe', 10, () => {
calls.push('stripe')
}))
service.register(makeHandler('flux', 20, () => {
calls.push('flux')
}))
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// register order: characters(30) -> stripe(10) -> flux(20)
// execution order: stripe(10) -> flux(20) -> characters(30)
expect(calls).toEqual(['stripe', 'flux', 'characters'])
})
})
describe('softDeleteAll', () => {
it('passes the user id and reason to every handler', async () => {
const service = createUserDeletionService()
const a = makeHandler('a', 10)
const b = makeHandler('b', 20)
service.register(a)
service.register(b)
await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' })
expect(a.softDelete).toHaveBeenCalledTimes(1)
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
expect(b.softDelete).toHaveBeenCalledTimes(1)
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
})
it('aborts on first handler error and skips later handlers', async () => {
const service = createUserDeletionService()
const earlyOk = makeHandler('a', 10)
const failing = makeHandler('b', 20, () => {
throw new Error('stripe API down')
})
const lateNeverRuns = makeHandler('c', 30)
service.register(earlyOk)
service.register(failing)
service.register(lateNeverRuns)
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }))
.rejects
.toThrow('stripe API down')
expect(earlyOk.softDelete).toHaveBeenCalledTimes(1)
expect(failing.softDelete).toHaveBeenCalledTimes(1)
expect(lateNeverRuns.softDelete).not.toHaveBeenCalled()
})
it('runs handlers serially (next starts only after previous resolves)', async () => {
const service = createUserDeletionService()
const order: string[] = []
service.register({
name: 'slow',
priority: 10,
softDelete: async () => {
order.push('slow:start')
await new Promise(r => setTimeout(r, 5))
order.push('slow:end')
},
})
service.register({
name: 'fast',
priority: 20,
softDelete: async () => {
order.push('fast:start')
order.push('fast:end')
},
})
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// serial execution: slow:start -> slow:end -> fast:start -> fast:end
// (NOT slow:start -> fast:start -> slow:end -> fast:end which would
// indicate parallelism)
expect(order).toEqual(['slow:start', 'slow:end', 'fast:start', 'fast:end'])
})
it('runs no handlers gracefully when registry is empty', async () => {
const service = createUserDeletionService()
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,314 @@
import type { Database } from '../../../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../../../libs/mock-db'
import { createCharacterService } from '../../characters'
import { createChatService } from '../../chats'
import { createFluxService } from '../../flux'
import { createProviderService } from '../../providers'
import * as schema from '../../../../schemas'
function fakeRedis() {
const map = new Map<string, string>()
return {
get: vi.fn(async (k: string) => map.get(k) ?? null),
set: vi.fn(async (k: string, v: string) => {
map.set(k, v)
return 'OK'
}),
del: vi.fn(async (k: string) => {
const had = map.has(k)
map.delete(k)
return had ? 1 : 0
}),
} as any
}
function fakeConfigKV() {
return {
get: vi.fn(async () => undefined),
getOrThrow: vi.fn(async () => 0),
set: vi.fn(async () => {}),
} as any
}
describe('fluxService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('marks userFlux.deletedAt and invalidates Redis cache', async () => {
await db.insert(schema.user).values({ id: 'u-flux-1', name: 'A', email: 'a@example.com' })
await db.insert(schema.userFlux).values({ userId: 'u-flux-1', flux: 100 })
const redis = fakeRedis()
const service = createFluxService(db, redis, fakeConfigKV())
await service.deleteAllForUser('u-flux-1')
const row = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-1') })
expect(row?.deletedAt).toBeInstanceOf(Date)
expect(redis.del).toHaveBeenCalledTimes(1)
expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1'))
})
it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => {
await db.insert(schema.user).values({ id: 'u-flux-2', name: 'B', email: 'b@example.com' })
await db.insert(schema.userFlux).values({ userId: 'u-flux-2', flux: 50 })
const redis = fakeRedis()
const service = createFluxService(db, redis, fakeConfigKV())
await service.deleteAllForUser('u-flux-2')
const firstStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
// Second invocation: WHERE deletedAt IS NULL filters out the
// already-stamped row, so deletedAt does not change.
await service.deleteAllForUser('u-flux-2')
const secondStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
expect(secondStamp).toEqual(firstStamp)
})
})
describe('providerService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('marks every userProviderConfigs row owned by the user', async () => {
await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' })
await db.insert(schema.userProviderConfigs).values([
{ ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' },
{ ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' },
])
const service = createProviderService(db)
await service.deleteAllForUser('u-prov-1')
const rows = await db.query.userProviderConfigs.findMany({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-1') })
expect(rows).toHaveLength(2)
rows.forEach(r => expect(r.deletedAt).toBeInstanceOf(Date))
})
it('does not touch other users rows', async () => {
await db.insert(schema.user).values({ id: 'u-prov-other', name: 'O', email: 'o@example.com' })
await db.insert(schema.userProviderConfigs).values({ ownerId: 'u-prov-other', definitionId: 'openai', name: 'kept' })
const service = createProviderService(db)
await service.deleteAllForUser('u-prov-1')
const otherRow = await db.query.userProviderConfigs.findFirst({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-other') })
expect(otherRow?.deletedAt).toBeNull()
})
})
describe('characterService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('soft-deletes characters where the user is owner OR creator', async () => {
await db.insert(schema.user).values([
{ id: 'u-char-1', name: 'C1', email: 'c1@example.com' },
{ id: 'u-char-2', name: 'C2', email: 'c2@example.com' },
])
await db.insert(schema.character).values([
{ id: 'char-owner', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-1', characterId: 'cid-1' },
{ id: 'char-creator', version: '1', coverUrl: '', creatorId: 'u-char-1', ownerId: 'u-char-2', characterId: 'cid-2' },
{ id: 'char-other', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-2', characterId: 'cid-3' },
])
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-1')
const owner = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-owner') })
const creator = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-creator') })
const other = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-other') })
expect(owner?.deletedAt).toBeInstanceOf(Date)
expect(creator?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
it('decrements character engagement counters for soft-deleted likes and bookmarks', async () => {
await db.insert(schema.user).values([
{ id: 'u-char-counts', name: 'Counts', email: 'counts@example.com' },
{ id: 'u-char-owner', name: 'Owner', email: 'owner@example.com' },
])
await db.insert(schema.character).values({
id: 'char-counts',
version: '1',
coverUrl: '',
creatorId: 'u-char-owner',
ownerId: 'u-char-owner',
characterId: 'cid-counts',
likesCount: 1,
bookmarksCount: 1,
})
await db.insert(schema.characterLikes).values({ userId: 'u-char-counts', characterId: 'char-counts' })
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-counts', characterId: 'char-counts' })
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-counts')
const character = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-counts') })
expect(character?.likesCount).toBe(0)
expect(character?.bookmarksCount).toBe(0)
await service.deleteAllForUser('u-char-counts')
const afterRetry = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-counts') })
expect(afterRetry?.likesCount).toBe(0)
expect(afterRetry?.bookmarksCount).toBe(0)
})
it('soft-deletes the user likes and bookmarks', async () => {
await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' })
await db.insert(schema.character).values({
id: 'char-z',
version: '1',
coverUrl: '',
creatorId: 'u-char-3',
ownerId: 'u-char-3',
characterId: 'cid-z',
})
await db.insert(schema.characterLikes).values({ userId: 'u-char-3', characterId: 'char-z' })
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-3', characterId: 'char-z' })
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-3')
const like = await db.query.characterLikes.findFirst({ where: eq(schema.characterLikes.userId, 'u-char-3') })
const bookmark = await db.query.characterBookmarks.findFirst({ where: eq(schema.characterBookmarks.userId, 'u-char-3') })
expect(like?.deletedAt).toBeInstanceOf(Date)
expect(bookmark?.deletedAt).toBeInstanceOf(Date)
})
})
describe('chatService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('soft-deletes chats the user is a member of', async () => {
await db.insert(schema.user).values({ id: 'u-chat-1', name: 'C', email: 'chat@example.com' })
await db.insert(schema.chats).values([
{ id: 'chat-mine', type: 'private', title: 'mine' },
{ id: 'chat-other', type: 'private', title: 'other' },
])
await db.insert(schema.chatMembers).values({ chatId: 'chat-mine', memberType: 'user', userId: 'u-chat-1' })
const service = createChatService(db)
await service.deleteAllForUser('u-chat-1')
const mine = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-mine') })
const other = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-other') })
expect(mine?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
it('drops chat_members for shared (group/channel) chats but keeps the chat alive', async () => {
// Two users in a shared group chat. When user A is deleted, the chat
// row must survive for user B; only A's chat_members row goes.
await db.insert(schema.user).values([
{ id: 'u-grp-a', name: 'A', email: 'grpa@example.com' },
{ id: 'u-grp-b', name: 'B', email: 'grpb@example.com' },
])
await db.insert(schema.chats).values({ id: 'chat-grp', type: 'group', title: 'team' })
await db.insert(schema.chatMembers).values([
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-a' },
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-b' },
])
const service = createChatService(db)
await service.deleteAllForUser('u-grp-a')
const chatRow = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-grp') })
expect(chatRow?.deletedAt).toBeNull() // chat survives
const remainingMembers = await db.query.chatMembers.findMany({ where: eq(schema.chatMembers.chatId, 'chat-grp') })
expect(remainingMembers).toHaveLength(1)
expect(remainingMembers[0]?.userId).toBe('u-grp-b')
})
it('preserves the user messages inside group chats so other members keep conversation context', async () => {
// Anonymization-by-design: in a group chat, user A's messages must NOT
// be soft-deleted on account deletion — that would corrupt B's history.
// The senderId stays as the (now-orphan) user.id string; the UI renders
// it as "Deleted User" once it cannot resolve the id to a real user.
await db.insert(schema.user).values([
{ id: 'u-anon-a', name: 'A', email: 'anona@example.com' },
{ id: 'u-anon-b', name: 'B', email: 'anonb@example.com' },
])
await db.insert(schema.chats).values({ id: 'chat-anon-grp', type: 'group', title: 'team' })
await db.insert(schema.chatMembers).values([
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-a' },
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-b' },
])
await db.insert(schema.messages).values([
{ id: 'm-a-1', chatId: 'chat-anon-grp', senderId: 'u-anon-a', role: 'user', content: 'hi from A', mediaIds: [], stickerIds: [] },
{ id: 'm-b-1', chatId: 'chat-anon-grp', senderId: 'u-anon-b', role: 'user', content: 'hi from B', mediaIds: [], stickerIds: [] },
])
const service = createChatService(db)
await service.deleteAllForUser('u-anon-a')
// A's message stays alive; senderId still points at the now-orphan user.id string.
const aMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-a-1') })
expect(aMsg?.deletedAt).toBeNull()
expect(aMsg?.senderId).toBe('u-anon-a')
expect(aMsg?.content).toBe('hi from A')
// B's message obviously untouched.
const bMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-b-1') })
expect(bMsg?.deletedAt).toBeNull()
})
it('soft-deletes messages the user sent in private/bot chats', async () => {
await db.insert(schema.user).values({ id: 'u-chat-2', name: 'M', email: 'msg@example.com' })
await db.insert(schema.chats).values({ id: 'chat-msg', type: 'private', title: 't' })
await db.insert(schema.chatMembers).values({ chatId: 'chat-msg', memberType: 'user', userId: 'u-chat-2' })
await db.insert(schema.messages).values([
{
id: 'msg-mine',
chatId: 'chat-msg',
senderId: 'u-chat-2',
role: 'user',
content: 'hi',
mediaIds: [],
stickerIds: [],
},
{
id: 'msg-other',
chatId: 'chat-msg',
senderId: 'someone-else',
role: 'assistant',
content: 'hello',
mediaIds: [],
stickerIds: [],
},
])
const service = createChatService(db)
await service.deleteAllForUser('u-chat-2')
const mine = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-mine') })
const other = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-other') })
expect(mine?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
})
@@ -0,0 +1,89 @@
import type { Logger } from '@guiiai/logg'
/**
* Reason a user deletion is being requested. Recorded in logs and surfaced
* to handlers so they can branch (e.g. compliance erase vs. user-initiated).
*
* - `user-requested`: triggered by the user via better-auth `/delete-user/callback`.
* - `admin`: triggered by an admin tool (not yet implemented).
* - `compliance`: triggered by automated GDPR / data-retention workflow (not yet implemented).
*/
export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance'
/**
* Context passed to every {@link UserDeletionHandler} invocation.
*
* Use when:
* - Implementing a new business handler read `userId` to scope your soft-delete writes.
* - Logging within a handler use the provided `logger` so entries share the deletion correlation context.
*/
export interface UserDeletionContext {
/** The user being deleted. Handlers MUST scope their writes to this id. */
userId: string
/** Why the deletion was triggered. */
reason: UserDeletionReason
/** Pre-scoped logger for handler diagnostics. */
logger: Logger
}
/**
* A registered participant in the account-deletion pipeline.
*
* Each business module that owns user-scoped tables registers one of these
* with the {@link UserDeletionService}. Handlers run sequentially in
* ascending `priority` order; a thrown error aborts the whole pipeline so
* better-auth's hard-delete of the user row never runs (the user is left
* intact and the operation can be retried idempotently).
*
* @example
* createUserDeletionService().register({
* name: 'flux',
* priority: 20,
* async softDelete({ userId }) {
* await db.update(userFlux).set({ deletedAt: new Date() }).where(eq(userFlux.userId, userId))
* },
* })
*/
export interface UserDeletionHandler {
/** Stable identifier used for logs, metrics, and duplicate-registration checks. */
name: string
/**
* Lower runs first. Conventions:
* - 10: external side-effects without rollback (Stripe API cancel)
* - 20: financial / cache state (Flux balance, Redis invalidation)
* - 30: pure DB soft-delete (providers, characters, chats)
*
* @default 30
*/
priority: number
/**
* Mark business records as deleted. MUST be idempotent the deletion
* pipeline retries by re-issuing the entire request, and Stripe / Postgres
* already deduplicate on subsequent calls. Throw to abort the pipeline.
*/
softDelete: (ctx: UserDeletionContext) => Promise<void>
}
/**
* Coordinator for account deletion across business modules.
*
* Use when:
* - Wiring better-auth's `user.deleteUser.beforeDelete` hook in `libs/auth.ts`.
* - Implementing an admin-triggered deletion path (future).
*
* Expects:
* - All handlers are registered at app-composition time before the first
* request hits `beforeDelete`. Late registration is allowed but discouraged.
*/
export interface UserDeletionService {
/**
* Register a handler. Throws if `handler.name` is already registered
* names must be unique so logs and metrics can attribute work cleanly.
*/
register: (handler: UserDeletionHandler) => void
/**
* Run every registered handler in priority order. Returns when all
* handlers complete, or throws the first handler error and stops.
*/
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
}
@@ -0,0 +1,58 @@
import type { Database } from '../../../libs/db'
import { eq } from 'drizzle-orm'
import { createNotFoundError } from '../../../utils/error'
import * as accountsSchema from '../../../schemas/accounts'
export interface UserSelector {
/** Select by user id. Exactly one of `userId` / `email` must be set. */
userId?: string
/** Select by email (case-insensitive). Exactly one of `userId` / `email` must be set. */
email?: string
}
/**
* Resolve an admin-supplied selector to a concrete user row.
*
* Use when:
* - An admin operation targets a single user by id or email and needs the
* canonical `{ id, email }` before mutating balance, bans, etc.
*
* Expects:
* - Exactly one of `userId` / `email` is set. `email` is matched lowercased,
* which is safe because better-auth stores emails lowercased and the unique
* index is on the raw column (wrapping in `LOWER()` would seq-scan).
*
* Returns:
* - `{ id, email }` of the matching user.
*
* Throws:
* - 404 when no user matches the selector.
*/
export async function resolveUserByIdOrEmail(
db: Database,
selector: UserSelector,
): Promise<{ id: string, email: string }> {
if (selector.userId != null) {
const [row] = await db
.select({ id: accountsSchema.user.id, email: accountsSchema.user.email })
.from(accountsSchema.user)
.where(eq(accountsSchema.user.id, selector.userId))
.limit(1)
if (!row)
throw createNotFoundError(`No user with id ${selector.userId}`)
return row
}
const email = selector.email!.toLowerCase()
const [row] = await db
.select({ id: accountsSchema.user.id, email: accountsSchema.user.email })
.from(accountsSchema.user)
.where(eq(accountsSchema.user.email, email))
.limit(1)
if (!row)
throw createNotFoundError(`No user with email ${email}`)
return row
}
@@ -0,0 +1,163 @@
import type { Database } from '../../../libs/db'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { createVoicePackService } from '.'
import { mockDB } from '../../../libs/mock-db'
import * as schema from '../../../schemas'
describe('voicePackService', () => {
let db: Database
let service: ReturnType<typeof createVoicePackService>
beforeAll(async () => {
db = await mockDB(schema)
service = createVoicePackService(db)
})
beforeEach(async () => {
await db.delete(schema.voicePacks)
})
it('creates a Voice Pack with provider, model, voice, params, cost multiplier, and tts model pin', async () => {
// @example create one curated cloud voice -> row stores the resolved routing pin.
const pack = await service.create({
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
upstreamVoiceId: 'voice-neuro-upstream',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: 20, volume: 5 },
costMultiplier: 1.5,
enabled: true,
})
expect(pack.name).toBe('Neuro Sama')
expect(pack.provider).toBe('volcengine')
expect(pack.model).toBe('seed-tts-2.0')
expect(pack.voiceId).toBe('voice-neuro')
expect(pack.upstreamVoiceId).toBe('voice-neuro-upstream')
expect(pack.ttsModelId).toBe('volcengine/neuro-pool')
expect(pack.params).toEqual({ pitch: 20, volume: 5 })
expect(pack.costMultiplier).toBe(1.5)
expect(pack.enabled).toBe(true)
})
it('keeps parameter variants as separate packs', async () => {
// @example same provider/model/voice with different params -> two library entries.
await service.create({
name: 'Base',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
upstreamVoiceId: 'voice-a-upstream',
ttsModelId: 'volcengine/pool',
params: {},
costMultiplier: 1,
enabled: true,
})
await service.create({
name: 'Pitched',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
upstreamVoiceId: 'voice-a-upstream',
ttsModelId: 'volcengine/pool',
params: { pitch: 20 },
costMultiplier: 1,
enabled: true,
})
const packs = await service.list()
expect(packs).toHaveLength(2)
expect(packs.map(p => p.name).sort()).toEqual(['Base', 'Pitched'])
})
it('updates mutable fields without replacing the row', async () => {
// @example edit curation metadata/params -> same id, updated values.
const pack = await service.create({
name: 'Old',
provider: 'azure',
model: 'v1',
voiceId: 'en-US-AvaMultilingualNeural',
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
ttsModelId: 'microsoft/v1',
params: {},
costMultiplier: 1,
enabled: true,
})
const updated = await service.update(pack.id, {
name: 'New',
params: { rate: 1.1 },
costMultiplier: 2,
})
expect(updated?.id).toBe(pack.id)
expect(updated?.name).toBe('New')
expect(updated?.params).toEqual({ rate: 1.1 })
expect(updated?.costMultiplier).toBe(2)
})
it('soft-disables a pack and excludes it from listEnabled', async () => {
// @example disabled packs remain in admin list but disappear from user list.
const pack = await service.create({
name: 'Disable me',
provider: 'dashscope-cosyvoice',
model: 'cosyvoice-v2',
voiceId: 'longxiaochun_v2',
upstreamVoiceId: 'longxiaochun_v2',
ttsModelId: 'alibaba/cosyvoice-v2',
params: {},
costMultiplier: 1,
enabled: true,
})
const disabled = await service.disable(pack.id)
const all = await service.list()
const enabled = await service.listEnabled()
expect(disabled?.enabled).toBe(false)
expect(all).toHaveLength(1)
expect(enabled).toEqual([])
})
it('finds only enabled packs by product-facing voice alias', async () => {
// @example TTS request voice="narrator" -> enabled Voice Pack row resolves server-side.
await service.create({
name: 'Disabled narrator',
provider: 'azure',
model: 'v1',
voiceId: 'narrator',
upstreamVoiceId: 'disabled-upstream',
ttsModelId: 'microsoft/v1',
params: {},
costMultiplier: 1,
enabled: false,
})
const enabled = await service.create({
name: 'Enabled narrator',
provider: 'azure',
model: 'v1',
voiceId: 'narrator',
upstreamVoiceId: 'enabled-upstream',
ttsModelId: 'microsoft/v1',
params: {},
costMultiplier: 1,
enabled: true,
})
expect(await service.findEnabledByVoiceId('narrator')).toMatchObject({
id: enabled.id,
upstreamVoiceId: 'enabled-upstream',
})
})
it('returns null when updating or disabling a missing pack', async () => {
// @example unknown id -> null so routes can map to 404.
expect(await service.update('missing', { name: 'Nope' })).toBeNull()
expect(await service.disable('missing')).toBeNull()
})
})
@@ -0,0 +1,141 @@
import type { InferOutput } from 'valibot'
import type { Database } from '../../../libs/db'
import type { VoicePack } from '../../../schemas/voice-packs'
import { and, eq } from 'drizzle-orm'
import { boolean, maxLength, minValue, nonEmpty, number, object, optional, pipe, string } from 'valibot'
import * as schema from '../../../schemas/voice-packs'
export const VoicePackParamsSchema = object({
pitch: optional(number()),
volume: optional(number()),
rate: optional(pipe(number(), minValue(0.01, 'rate must be positive'))),
})
export const VoicePackCostMultiplierSchema = pipe(
number(),
minValue(0, 'costMultiplier must not be negative'),
)
export const CreateVoicePackInputSchema = object({
name: pipe(string(), nonEmpty('name is required'), maxLength(120)),
description: optional(pipe(string(), maxLength(500))),
provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)),
model: pipe(string(), nonEmpty('model is required'), maxLength(200)),
voiceId: pipe(string(), nonEmpty('voiceId is required'), maxLength(200)),
upstreamVoiceId: pipe(string(), nonEmpty('upstreamVoiceId is required'), maxLength(200)),
ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)),
params: optional(VoicePackParamsSchema, {}),
costMultiplier: VoicePackCostMultiplierSchema,
enabled: optional(boolean(), true),
})
export const UpdateVoicePackInputSchema = object({
name: optional(pipe(string(), nonEmpty('name must not be empty'), maxLength(120))),
description: optional(pipe(string(), maxLength(500))),
provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))),
model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))),
voiceId: optional(pipe(string(), nonEmpty('voiceId must not be empty'), maxLength(200))),
upstreamVoiceId: optional(pipe(string(), nonEmpty('upstreamVoiceId must not be empty'), maxLength(200))),
ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))),
params: optional(VoicePackParamsSchema),
costMultiplier: optional(VoicePackCostMultiplierSchema),
enabled: optional(boolean()),
})
/**
* Voice Pack creation input accepted by the admin service.
*/
export type CreateVoicePackInput = InferOutput<typeof CreateVoicePackInputSchema>
/**
* Voice Pack update input accepted by the admin service.
*/
export type UpdateVoicePackInput = InferOutput<typeof UpdateVoicePackInputSchema>
/**
* Handles the curated server-side Voice Pack library.
*
* Use when:
* - Admin routes create, update, disable, or list curated cloud-provider voices.
* - Client routes need the enabled-only market list for binding.
*
* Expects:
* - HTTP routes validate input with the exported Valibot schemas before calling.
*
* Returns:
* - CRUD methods that preserve rows and use `enabled=false` as soft disable.
*/
export function createVoicePackService(db: Database) {
return {
async create(input: CreateVoicePackInput) {
const [inserted] = await db.insert(schema.voicePacks).values({
name: input.name,
description: input.description,
provider: input.provider,
model: input.model,
voiceId: input.voiceId,
upstreamVoiceId: input.upstreamVoiceId,
ttsModelId: input.ttsModelId,
params: input.params,
costMultiplier: input.costMultiplier,
enabled: input.enabled,
}).returning()
return inserted
},
async list() {
return await db.query.voicePacks.findMany({
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
async listEnabled() {
return await db.query.voicePacks.findMany({
where: eq(schema.voicePacks.enabled, true),
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
async findById(id: string) {
return await db.query.voicePacks.findFirst({
where: eq(schema.voicePacks.id, id),
})
},
async findEnabledByVoiceId(voiceId: string) {
return await db.query.voicePacks.findFirst({
where: and(
eq(schema.voicePacks.voiceId, voiceId),
eq(schema.voicePacks.enabled, true),
),
})
},
async update(id: string, input: UpdateVoicePackInput): Promise<VoicePack | null> {
const [updated] = await db.update(schema.voicePacks)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.voicePacks.id, id))
.returning()
return updated ?? null
},
async disable(id: string): Promise<VoicePack | null> {
const [updated] = await db.update(schema.voicePacks)
.set({ enabled: false, updatedAt: new Date() })
.where(and(
eq(schema.voicePacks.id, id),
eq(schema.voicePacks.enabled, true),
))
.returning()
return updated ?? null
},
}
}
export type VoicePackService = ReturnType<typeof createVoicePackService>