Revert "style: lint"

This reverts commit 98f40d7d0b.
This commit is contained in:
Neko Ayaka
2026-08-26 20:13:10 +08:00
parent cfcfc513ef
commit 146b3da65a
1625 changed files with 75440 additions and 75453 deletions
@@ -6,16 +6,16 @@ describe('configKV invalidation contract', () => {
it('accepts a declared ConfigKV key', () => {
expect(parseConfigKVInvalidation(JSON.stringify({
key: 'FLUX_PER_REQUEST',
publishedAt: 1,
version: 1,
publishedAt: 1,
}))).toMatchObject({ key: 'FLUX_PER_REQUEST' })
})
it('rejects an unknown ConfigKV key', () => {
expect(() => parseConfigKVInvalidation(JSON.stringify({
key: 'UNKNOWN_CONFIG_KEY',
publishedAt: 1,
version: 1,
publishedAt: 1,
}))).toThrow('ConfigKV invalidation key is unknown')
})
@@ -14,14 +14,14 @@ const configKVInvalidationPayloadSchema = object({
object(configEntrySchemas),
'ConfigKV invalidation key is unknown',
),
publishedAt: pipe(
number('ConfigKV invalidation publishedAt must be a number'),
finite('ConfigKV invalidation publishedAt must be a number'),
),
version: pipe(
number('ConfigKV invalidation version must be a number'),
finite('ConfigKV invalidation version must be a number'),
),
publishedAt: pipe(
number('ConfigKV invalidation publishedAt must be a number'),
finite('ConfigKV invalidation publishedAt must be a number'),
),
})
const configKVInvalidationSchema = pipe(
@@ -39,36 +39,36 @@ export const routeFailureTriggersSchema = object({
})
export const keyEntrySchema = object({
ciphertext: pipe(string(), nonEmpty('keys[].ciphertext must not be empty')),
id: pipe(
string(),
nonEmpty('keys[].id must not be empty'),
regex(/^[^|]+$/, 'keys[].id must not contain "|" (reserved AAD separator)'),
),
ciphertext: pipe(string(), nonEmpty('keys[].ciphertext must not be empty')),
})
export const llmUpstreamSchema = object({
baseURL: pipe(string(), nonEmpty('llm.upstreams[].baseURL must not be empty')),
headerTemplate: optional(string(), 'Bearer {KEY}'),
id: optional(pipe(
string(),
nonEmpty('llm.upstreams[].id must not be empty'),
regex(/^[^|]+$/, 'llm.upstreams[].id must not contain "|"'),
)),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'llm.upstreams[].keys must contain at least 1 entry')),
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({
continueOn: optional(routeFailureTriggersSchema),
id: pipe(string(), nonEmpty('llm.routing.groups[].id must not be empty')),
retryOn: routeFailureTriggersSchema,
upstreamIds: pipe(
array(pipe(string(), nonEmpty('llm.routing.groups[].upstreamIds[] must not be empty'))),
check(v => v.length >= 1, 'llm.routing.groups[].upstreamIds must contain at least 1 entry'),
check(v => new Set(v).size === v.length, 'llm.routing.groups[].upstreamIds must be unique'),
),
retryOn: routeFailureTriggersSchema,
continueOn: optional(routeFailureTriggersSchema),
})
export const llmRoutingSchema = object({
@@ -81,9 +81,9 @@ export const llmRoutingSchema = object({
export const llmModelSchema = pipe(
object({
fallbackTriggers: fallbackTriggersSchema,
routing: optional(llmRoutingSchema),
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)
@@ -107,14 +107,14 @@ const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', '
const asrProviderSchema = picklist(['aliyun-nls'])
export const ttsUpstreamSchema = object({
adapterParams: optional(record(string(), any()), {}),
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
id: optional(pipe(
string(),
nonEmpty('tts.upstreams[].id must not be empty'),
regex(/^[^|]+$/, 'tts.upstreams[].id must not contain "|"'),
)),
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'tts.upstreams[].keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
// Per-app_id concurrency cap for the pool load balancer. One upstream maps to
// one app_id (Volcengine `adapterParams.appid`), capped by the provider at a
// small number (e.g. 10). When set on any upstream of a model, the router
@@ -125,15 +125,15 @@ export const ttsUpstreamSchema = object({
})
export const ttsRoutingGroupSchema = object({
continueOn: optional(routeFailureTriggersSchema),
id: pipe(string(), nonEmpty('tts.routing.groups[].id must not be empty')),
retryOn: routeFailureTriggersSchema,
strategy: optional(picklist(['ordered', 'least-inflight']), 'ordered'),
upstreamIds: pipe(
array(pipe(string(), nonEmpty('tts.routing.groups[].upstreamIds[] must not be empty'))),
check(v => v.length >= 1, 'tts.routing.groups[].upstreamIds must contain at least 1 entry'),
check(v => new Set(v).size === v.length, 'tts.routing.groups[].upstreamIds must be unique'),
),
strategy: optional(picklist(['ordered', 'least-inflight']), 'ordered'),
retryOn: routeFailureTriggersSchema,
continueOn: optional(routeFailureTriggersSchema),
})
export const ttsRoutingSchema = object({
@@ -145,18 +145,18 @@ export const ttsRoutingSchema = object({
})
export const streamingTtsUpstreamSchema = object({
adapterParams: optional(record(string(), any()), {}),
baseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.baseURL must not be empty')),
defaultModel: optional(string()),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'UNSPEECH_UPSTREAM.streaming.keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
models: optional(
array(object({
description: optional(string()),
id: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.models[].id must not be empty')),
name: optional(string()),
description: optional(string()),
})),
[],
),
defaultModel: optional(string()),
})
export const unspeechUpstreamSchema = object({
@@ -166,10 +166,10 @@ export const unspeechUpstreamSchema = object({
export const ttsModelSchema = pipe(
object({
fallbackTriggers: fallbackTriggersSchema,
provider: ttsProviderSchema,
routing: optional(ttsRoutingSchema),
upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')),
routing: optional(ttsRoutingSchema),
fallbackTriggers: fallbackTriggersSchema,
}),
check((model) => {
if (model.routing == null)
@@ -199,8 +199,8 @@ export const ttsModelSchema = pipe(
)
export const asrUpstreamSchema = object({
adapterParams: optional(record(string(), any()), {}),
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({
@@ -210,24 +210,24 @@ export const asrModelSchema = object({
export const llmRouterDefaultsSchema = optional(
object({
fallbackHttpCodes: optional(array(number()), [401, 402, 403, 429, 500, 502, 503, 504]),
fullChainTimeoutMs: optional(number(), 60000),
perAttemptTimeoutMs: optional(number(), 30000),
fullChainTimeoutMs: optional(number(), 60000),
fallbackHttpCodes: optional(array(number()), [401, 402, 403, 429, 500, 502, 503, 504]),
}),
{ fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504], fullChainTimeoutMs: 60000, perAttemptTimeoutMs: 30000 },
{ perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] },
)
export const llmRouterConfigSchema = object({
asr: optional(object({
models: record(string(), asrModelSchema),
})),
defaults: llmRouterDefaultsSchema,
llm: object({
models: record(string(), llmModelSchema),
}),
tts: object({
models: record(string(), ttsModelSchema),
}),
asr: optional(object({
models: record(string(), asrModelSchema),
})),
defaults: llmRouterDefaultsSchema,
})
/**
@@ -237,6 +237,24 @@ export const llmRouterConfigSchema = object({
* - stored JSON shape
*/
export const configEntrySchemas = {
FLUX_PER_REQUEST: optional(number(), 5),
INITIAL_USER_FLUX: optional(number(), 0),
FLUX_PER_1K_TOKENS: optional(number(), 1),
FLUX_PER_1K_CHARS_TTS: number(),
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
// model id → (BCP-47 locale → recommended voice id). Outer key is either a
// router TTS model id (LLM_ROUTER_CONFIG.tts.models key) for REST or a
// streaming api_resource_id (e.g. `seed-tts-2.0`) for the streaming surface.
// The two key spaces do not overlap. Consumed by the client to preselect a
// voice matching UI locale per active model.
DEFAULT_TTS_VOICES: optional(record(string(), record(string(), string())), {}),
// Server-side alias resolution for `model: 'auto'` in /chat/completions and
// /audio/speech. The modelName written here must exist as a key in
// LLM_ROUTER_CONFIG.{llm,tts}.models — the router itself doesn't understand
@@ -247,27 +265,9 @@ export const configEntrySchemas = {
// type tight (`string` rather than `string | undefined`) for call sites.
DEFAULT_CHAT_MODEL: pipe(string(), nonEmpty('DEFAULT_CHAT_MODEL must not be empty')),
DEFAULT_TTS_MODEL: pipe(string(), nonEmpty('DEFAULT_TTS_MODEL must not be empty')),
// model id → (BCP-47 locale → recommended voice id). Outer key is either a
// router TTS model id (LLM_ROUTER_CONFIG.tts.models key) for REST or a
// streaming api_resource_id (e.g. `seed-tts-2.0`) for the streaming surface.
// The two key spaces do not overlap. Consumed by the client to preselect a
// voice matching UI locale per active model.
DEFAULT_TTS_VOICES: optional(record(string(), record(string(), string())), {}),
FLUX_PER_1K_CHARS_TTS: number(),
FLUX_PER_1K_TOKENS: optional(number(), 1),
FLUX_PER_REQUEST: optional(number(), 5),
INITIAL_USER_FLUX: optional(number(), 0),
// No default — the router throws CONFIG_NOT_SET when this entry is absent
// so deployment configuration must populate it before traffic flows.
LLM_ROUTER_CONFIG: optional(llmRouterConfigSchema),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
// Single unspeech deployment used for every TTS surface: REST audio/speech,
// REST voices catalog, ws audio/speech/stream. `streaming` is optional —
// operator may run REST-only without the ws upstream. `streaming.keys`
@@ -5,10 +5,10 @@ import { createConfigKVService } from './index'
function createMockStore() {
const store = new Map<string, string>()
return {
_store: store,
getFreshRaw: vi.fn(async (key: string) => store.get(key) ?? null),
getRaw: vi.fn(async (key: string) => store.get(key) ?? null),
getFreshRaw: vi.fn(async (key: string) => store.get(key) ?? null),
invalidateCache: vi.fn(async () => {}),
_store: store,
}
}
@@ -76,8 +76,8 @@ describe('configKVService', () => {
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
errorCode: 'CONFIG_INVALID',
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
@@ -87,8 +87,8 @@ describe('configKVService', () => {
await expect(service.getOptional('FLUX_PER_REQUEST'))
.rejects
.toMatchObject({
errorCode: 'CONFIG_INVALID',
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
@@ -98,8 +98,8 @@ describe('configKVService', () => {
await expect(service.getOrThrow('FLUX_PER_REQUEST'))
.rejects
.toMatchObject({
errorCode: 'CONFIG_UNAVAILABLE',
statusCode: 503,
errorCode: 'CONFIG_UNAVAILABLE',
})
})
@@ -109,28 +109,28 @@ describe('configKVService', () => {
*/
it('llm router config should preserve official ASR model config', async () => {
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
llm: { models: {} },
tts: { models: {} },
asr: {
models: {
auto: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext: 'ciphertext' }],
adapterParams: {
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
},
keys: [{ ciphertext: 'ciphertext', id: 'aliyun-nls-asr-prod-1' }],
}],
},
},
},
defaults: {
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
fullChainTimeoutMs: 60000,
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
llm: { models: {} },
tts: { models: {} },
}))
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
@@ -148,93 +148,93 @@ describe('configKVService', () => {
it('llm router config should preserve explicit LLM and TTS provider groups', async () => {
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
defaults: {
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
fullChainTimeoutMs: 60000,
perAttemptTimeoutMs: 30000,
},
llm: {
models: {
'step-3.5-flash': {
upstreams: [
{
id: 'plan',
baseURL: 'https://api.stepfun.com/step_plan/v1',
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
},
{
id: 'paygo',
baseURL: 'https://api.stepfun.com/v1',
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
},
],
routing: {
groups: [
{
id: 'plan',
upstreamIds: ['plan'],
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
continueOn: { httpCodes: [402], onTimeout: false },
},
{
id: 'paygo',
upstreamIds: ['paygo'],
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
},
],
},
fallbackTriggers: {
httpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
onTimeout: true,
},
routing: {
groups: [
{
continueOn: { httpCodes: [402], onTimeout: false },
id: 'plan',
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
upstreamIds: ['plan'],
},
{
id: 'paygo',
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
upstreamIds: ['paygo'],
},
],
},
upstreams: [
{
baseURL: 'https://api.stepfun.com/step_plan/v1',
headerTemplate: 'Bearer {KEY}',
id: 'plan',
keys: [{ ciphertext: 'plan-ciphertext', id: 'plan-key' }],
},
{
baseURL: 'https://api.stepfun.com/v1',
headerTemplate: 'Bearer {KEY}',
id: 'paygo',
keys: [{ ciphertext: 'paygo-ciphertext', id: 'paygo-key' }],
},
],
},
},
},
tts: {
models: {
'stepfun/stepaudio-2.5-tts': {
provider: 'stepfun',
upstreams: [
{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
adapterParams: { endpointProfile: 'step-plan' },
maxConcurrency: 1,
},
{
id: 'paygo',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
adapterParams: { endpointProfile: 'default' },
},
],
routing: {
groups: [
{
id: 'plan',
upstreamIds: ['plan'],
strategy: 'least-inflight',
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
continueOn: { httpCodes: [402], onTimeout: false },
},
{
id: 'paygo',
upstreamIds: ['paygo'],
strategy: 'ordered',
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
},
],
},
fallbackTriggers: {
httpCodes: [401, 402, 429, 500, 502, 503, 504],
onTimeout: true,
},
provider: 'stepfun',
routing: {
groups: [
{
continueOn: { httpCodes: [402], onTimeout: false },
id: 'plan',
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
strategy: 'least-inflight',
upstreamIds: ['plan'],
},
{
id: 'paygo',
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
strategy: 'ordered',
upstreamIds: ['paygo'],
},
],
},
upstreams: [
{
adapterParams: { endpointProfile: 'step-plan' },
baseURL: 'https://api.stepfun.com',
id: 'plan',
keys: [{ ciphertext: 'plan-ciphertext', id: 'plan-key' }],
maxConcurrency: 1,
},
{
adapterParams: { endpointProfile: 'default' },
baseURL: 'https://api.stepfun.com',
id: 'paygo',
keys: [{ ciphertext: 'paygo-ciphertext', id: 'paygo-key' }],
},
],
},
},
},
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
}))
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
@@ -255,19 +255,19 @@ describe('configKVService', () => {
models: {
tts: {
provider: 'stepfun',
upstreams: [{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }],
}],
routing: {
groups: [{
id: 'plan',
retryOn: { httpCodes: [402], onTimeout: false },
strategy: 'ordered',
upstreamIds: ['missing'],
strategy: 'ordered',
retryOn: { httpCodes: [402], onTimeout: false },
}],
},
upstreams: [{
baseURL: 'https://api.stepfun.com',
id: 'plan',
keys: [{ ciphertext: 'ciphertext', id: 'plan-key' }],
}],
},
},
},
@@ -276,8 +276,8 @@ describe('configKVService', () => {
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
errorCode: 'CONFIG_INVALID',
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
@@ -288,19 +288,19 @@ describe('configKVService', () => {
models: {
tts: {
provider: 'stepfun',
upstreams: [{
id: 'plan',
baseURL: 'https://api.stepfun.com',
keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }],
}],
routing: {
groups: [{
id: 'plan',
retryOn: { httpCodes: [402], onTimeout: false },
strategy: 'least-inflight',
upstreamIds: ['plan'],
strategy: 'least-inflight',
retryOn: { httpCodes: [402], onTimeout: false },
}],
},
upstreams: [{
baseURL: 'https://api.stepfun.com',
id: 'plan',
keys: [{ ciphertext: 'ciphertext', id: 'plan-key' }],
}],
},
},
},
@@ -309,8 +309,8 @@ describe('configKVService', () => {
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
.toMatchObject({
errorCode: 'CONFIG_INVALID',
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
@@ -9,63 +9,6 @@ import { configEntrySchemas } from './definitions'
export * from './definitions'
export type ConfigKVService = ReturnType<typeof createConfigKVService>
/**
* Creates the API's typed, read-only ConfigKV boundary.
*
* PostgreSQL owns persisted values. Redis must be available for every store
* operation. This layer preserves validation, defaults, and API errors.
*/
export function createConfigKVService(store: ConfigKVStore) {
async function loadRaw(key: ConfigKey, fresh = false): Promise<null | string> {
try {
return fresh ? await store.getFreshRaw(key) : await store.getRaw(key)
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is unavailable',
'CONFIG_UNAVAILABLE',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config store error',
},
)
}
}
return {
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value as Exclude<ConfigDefinitions[K], undefined>
},
async invalidateCache<K extends ConfigKey>(key: K): Promise<void> {
await store.invalidateCache(key)
},
async refresh<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key, true)
const value = resolveWithDefault(key, raw)
return value ?? null
},
}
}
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
try {
return parse(configEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
@@ -83,7 +26,7 @@ function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions
}
/** Resolves a config value and applies the Valibot default when the row is missing. */
function resolveWithDefault<K extends ConfigKey>(key: K, raw: null | string): ConfigDefinitions[K] | undefined {
function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): ConfigDefinitions[K] | undefined {
if (raw !== null)
return parseValue(key, raw)
@@ -94,3 +37,60 @@ function resolveWithDefault<K extends ConfigKey>(key: K, raw: null | string): Co
return undefined
}
}
/**
* Creates the API's typed, read-only ConfigKV boundary.
*
* PostgreSQL owns persisted values. Redis must be available for every store
* operation. This layer preserves validation, defaults, and API errors.
*/
export function createConfigKVService(store: ConfigKVStore) {
async function loadRaw(key: ConfigKey, fresh = false): Promise<string | null> {
try {
return fresh ? await store.getFreshRaw(key) : await store.getRaw(key)
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is unavailable',
'CONFIG_UNAVAILABLE',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config store error',
},
)
}
}
return {
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value as Exclude<ConfigDefinitions[K], undefined>
},
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
async refresh<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key, true)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async invalidateCache<K extends ConfigKey>(key: K): Promise<void> {
await store.invalidateCache(key)
},
}
}
export type ConfigKVService = ReturnType<typeof createConfigKVService>
@@ -8,8 +8,6 @@ import { eq } from 'drizzle-orm'
import { configKV } from '../../../schemas/config-kv'
import { CONFIG_KV_CACHE_TTL_SECONDS, configKVCacheKey } from './contracts'
export type ConfigKVStore = ReturnType<typeof createConfigKVStore>
export interface ConfigKVStoreOptions {
/**
* Maximum lifetime of one derived Redis entry.
@@ -31,7 +29,7 @@ export function createConfigKVStore<TSchema extends Record<string, unknown>>(
) {
const cacheTtlSeconds = options.cacheTtlSeconds ?? CONFIG_KV_CACHE_TTL_SECONDS
async function readDatabase(key: ConfigKey): Promise<null | string> {
async function readDatabase(key: ConfigKey): Promise<string | null> {
const rows = await db
.select({ value: configKV.value })
.from(configKV)
@@ -49,18 +47,7 @@ export function createConfigKVStore<TSchema extends Record<string, unknown>>(
}
return {
async getFreshRaw(key: ConfigKey): Promise<null | string> {
const value = await readDatabase(key)
if (value !== null) {
await cacheValue(key, value)
}
else {
await deleteCachedValue(key)
}
return value
},
async getRaw(key: ConfigKey): Promise<null | string> {
async getRaw(key: ConfigKey): Promise<string | null> {
const cached = await redis.get(configKVCacheKey(key))
if (cached !== null)
return cached
@@ -71,8 +58,21 @@ export function createConfigKVStore<TSchema extends Record<string, unknown>>(
return value
},
async getFreshRaw(key: ConfigKey): Promise<string | null> {
const value = await readDatabase(key)
if (value !== null) {
await cacheValue(key, value)
}
else {
await deleteCachedValue(key)
}
return value
},
async invalidateCache(key: ConfigKey): Promise<void> {
await deleteCachedValue(key)
},
}
}
export type ConfigKVStore = ReturnType<typeof createConfigKVStore>
@@ -20,13 +20,13 @@ export interface PosthogCaptureInput {
* an interface so tests inject a fake instead of mocking the SDK.
*/
export interface PosthogSink {
capture: (input: PosthogCaptureInput) => Promise<void>
/**
* Queue a high-volume analytics event without waiting for a network
* roundtrip. Use on request hot paths where occasional process-exit loss is
* preferable to user-visible latency.
*/
captureQueued?: (input: PosthogCaptureInput) => void
capture: (input: PosthogCaptureInput) => Promise<void>
/** Flush and close the underlying client. Call on server shutdown. */
shutdown: () => Promise<void>
}
@@ -42,24 +42,10 @@ export interface PosthogSink {
* Capture failures are logged and swallowed. Analytics forwarding never
* fails the Stripe webhook or auth flow that produced the business fact.
*/
export function createPosthogSink(options: { host: string, projectKey: string }): PosthogSink {
export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink {
const client = new PostHog(options.projectKey, { host: options.host })
return {
async capture(input: PosthogCaptureInput): Promise<void> {
try {
await client.captureImmediate({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
...(input.uuid && { uuid: input.uuid }),
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
}
},
captureQueued(input: PosthogCaptureInput): void {
try {
client.capture({
@@ -74,6 +60,20 @@ export function createPosthogSink(options: { host: string, projectKey: string })
}
},
async capture(input: PosthogCaptureInput): Promise<void> {
try {
await client.captureImmediate({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
...(input.uuid && { uuid: input.uuid }),
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
}
},
async shutdown(): Promise<void> {
await client.shutdown()
},
@@ -25,25 +25,6 @@ import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
* present, otherwise inferred from the requested format.
*/
export const azureAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// Azure has no static catalog. Voices live at Microsoft's `voices/list`
// REST endpoint, which we reach via the unspeech `microsoft` backend
// because unspeech already maps the proprietary response shape to
// `types.Voice` (full formats table, masterpiece preview URLs, locale
// metadata). Calling unspeech also keeps a single integration point for
// every other provider that could grow this way later.
if (!ctx.region)
throw createServiceUnavailableError('azure tts region not configured', 'AZURE_TTS_NOT_CONFIGURED')
if (!ctx.keyPlaintext)
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'azure',
query: `provider=microsoft&region=${encodeURIComponent(ctx.region)}`,
})
},
id: 'azure',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -71,13 +52,32 @@ export const azureAdapter: TtsAdapter = {
return sendSpeechViaUnSpeech({
ctx,
extraBody: { disable_ssml: true, region },
fallbackContentType: inferMicrosoftContentType(outputFormat),
input: ssml,
model: 'microsoft/v1',
providerLabel: 'azure',
responseFormat: outputFormat,
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',
})
},
}
@@ -120,13 +120,13 @@ function buildAzureSsml(
return `<speak version='1.0' xml:lang='en-US'><voice name='${voice}'>${inner}</voice></speak>`
}
function escapeForSsml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
function speedToProsodyRate(speed: number | undefined): string {
if (speed == null || speed === 1)
return ''
const delta = Math.round((speed - 1) * 100)
if (delta === 0)
return ''
return delta > 0 ? `+${delta}%` : `${delta}%`
}
function percentToProsodyValue(value: number | undefined): string {
@@ -139,11 +139,11 @@ function percentToProsodyValue(value: number | undefined): string {
return '0%'
}
function speedToProsodyRate(speed: number | undefined): string {
if (speed == null || speed === 1)
return ''
const delta = Math.round((speed - 1) * 100)
if (delta === 0)
return ''
return delta > 0 ? `+${delta}%` : `${delta}%`
function escapeForSsml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
}
@@ -9,8 +9,8 @@ const SPEECH_URL = `${UNSPEECH}/v1/audio/speech`
function binaryResponse(bytes: Uint8Array, status = 200) {
return new Response(bytes, {
headers: { 'content-type': 'audio/mpeg' },
status,
headers: { 'content-type': 'audio/mpeg' },
})
}
@@ -20,13 +20,13 @@ describe('dashscopeCosyvoiceAdapter', () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(audioBytes))
const result = await dashscopeCosyvoiceAdapter.send(
{ responseFormat: 'mp3', text: 'hi there', voice: 'longxiaochun_v2' },
{ text: 'hi there', voice: 'longxiaochun_v2', responseFormat: 'mp3' },
{
adapterParams: { model: 'cosyvoice-v2' },
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
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,
},
)
@@ -37,10 +37,10 @@ describe('dashscopeCosyvoiceAdapter', () => {
const body = JSON.parse(init.body as string)
expect(body).toEqual({
input: 'hi there',
model: 'alibaba/cosyvoice-v2',
response_format: 'mp3',
input: 'hi there',
voice: 'longxiaochun_v2',
response_format: 'mp3',
})
const headers = init.headers as Record<string, string>
@@ -59,14 +59,14 @@ describe('dashscopeCosyvoiceAdapter', () => {
dashscopeCosyvoiceAdapter.send(
{ text: 'hi', voice: 'longxiaochun_v2' },
{
adapterParams: {},
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: {},
fetchImpl: fetchImpl as unknown as typeof fetch,
},
),
).rejects.toMatchObject({ message: expect.stringContaining('401'), status: 401 })
).rejects.toMatchObject({ status: 401, message: expect.stringContaining('401') })
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
@@ -77,11 +77,11 @@ describe('dashscopeCosyvoiceAdapter', () => {
await expect(dashscopeCosyvoiceAdapter.send(
{ text: 'hi' },
{
adapterParams: {},
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
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 })
@@ -97,18 +97,18 @@ describe('dashscopeCosyvoiceAdapter', () => {
await expect(dashscopeCosyvoiceAdapter.send(
{
text: 'hi',
voice: 'longxiaochun_v2',
extraOptions: {
volume: 5,
},
text: 'hi',
voice: 'longxiaochun_v2',
},
{
adapterParams: {},
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
fetchImpl: fetchImpl as unknown as typeof fetch,
keyPlaintext: Buffer.from('sk-test', 'utf8'),
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 })
@@ -125,15 +125,15 @@ describe('dashscopeCosyvoiceAdapter', () => {
}), { status: 200 })) as unknown as typeof fetch
const catalog = await dashscopeCosyvoiceAdapter.getVoiceCatalog({
adapterParams: { model: 'cosyvoice-v2' },
fetchImpl,
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({
headers: { Accept: 'application/json' },
method: 'GET',
headers: { Accept: 'application/json' },
}),
)
})
@@ -47,21 +47,6 @@ const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v2'
* contract as the Azure / Volcengine paths.
*/
export const dashscopeCosyvoiceAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// unspeech's alibaba backend embeds the catalog at build time
// (unspeech/pkg/backend/alibaba/voices.go `//go:embed voices.json`),
// so this call is in-memory on unspeech's side and only crosses a TCP
// hop. No upstream credential is required.
const params = new URLSearchParams({ provider: 'alibaba' })
if (typeof ctx.adapterParams.model === 'string')
params.set('model', ctx.adapterParams.model)
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'cosyvoice',
query: params.toString(),
})
},
id: 'dashscope-cosyvoice',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -81,12 +66,27 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
return sendSpeechViaUnSpeech({
ctx,
fallbackContentType: audioMimeFromFormat(format),
input: input.text,
model: `alibaba/${model}`,
providerLabel: 'dashscope-cosyvoice',
responseFormat: format,
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',
})
},
}
@@ -38,8 +38,8 @@ describe('getAdapter', () => {
expect(apiErr.errorCode).toBe('BAD_REQUEST')
expect(apiErr.details).toEqual(
expect.objectContaining({
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']),
id: 'unknown-provider',
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']),
}),
)
}
@@ -56,8 +56,8 @@ describe('getAdapter', () => {
const voices = await adapter.getVoiceCatalog({
adapterParams: {},
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual([{ id: 'v1', name: 'v1' }])
expect(fetchImpl).toHaveBeenCalledTimes(1)
@@ -74,8 +74,8 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
const voices = await adapter.getVoiceCatalog({
adapterParams: { model: 'cosyvoice-v2' },
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
@@ -90,8 +90,8 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
const fetchImpl = vi.fn(async () => new Response('boom', { status: 502 })) as unknown as typeof fetch
await expect(adapter.getVoiceCatalog({
adapterParams: {},
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
})
@@ -105,8 +105,8 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
const voices = await adapter.getVoiceCatalog({
adapterParams: { model: 'seed-tts-2.0' },
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual([{ id: 'zh_female_x', name: 'X' }])
@@ -119,8 +119,8 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ voices: [] }), { status: 200 })) as unknown as typeof fetch
await adapter.getVoiceCatalog({
adapterParams: {},
fetchImpl,
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')
@@ -132,14 +132,14 @@ describe('azureAdapter.getVoiceCatalog', () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
}), { headers: { 'Content-Type': 'application/json' }, status: 200 })) as unknown as typeof fetch
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: { region: 'eastasia' },
fetchImpl,
keyPlaintext: Buffer.from('subscription-key-XYZ', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local:5933',
fetchImpl,
})
expect(voices).toEqual([{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }])
@@ -153,32 +153,32 @@ describe('azureAdapter.getVoiceCatalog', () => {
it('throws 503 AZURE_TTS_NOT_CONFIGURED when region is missing', async () => {
const adapter = getAdapter('azure')
await expect(adapter.getVoiceCatalog({
adapterParams: {},
fetchImpl: vi.fn() as unknown as typeof fetch,
keyPlaintext: Buffer.from('k', 'utf8'),
adapterParams: {},
unspeechBaseURL: 'http://unspeech.local',
})).rejects.toMatchObject({ errorCode: 'AZURE_TTS_NOT_CONFIGURED', statusCode: 503 })
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({
adapterParams: { region: 'eastasia' },
fetchImpl: vi.fn() as unknown as typeof fetch,
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
})).rejects.toMatchObject({ errorCode: 'AZURE_TTS_NOT_CONFIGURED', statusCode: 503 })
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({
adapterParams: { region: 'eastasia' },
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
@@ -188,11 +188,11 @@ describe('azureAdapter.getVoiceCatalog', () => {
throw new Error('ECONNREFUSED')
}) as unknown as typeof fetch
await expect(adapter.getVoiceCatalog({
adapterParams: { region: 'eastasia' },
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
region: 'eastasia',
adapterParams: { region: 'eastasia' },
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})).rejects.toMatchObject({ statusCode: 502 })
})
})
@@ -201,26 +201,26 @@ describe('azureAdapter.send', () => {
it('posts SSML to unspeech /v1/audio/speech with model=microsoft/v1 + region extra_body', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'audio/mpeg' },
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,
},
speed: 1.2,
text: 'hi there',
voice: 'en-US-AvaMultilingualNeural',
},
{
adapterParams: { region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
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,
},
)
@@ -242,18 +242,18 @@ describe('azureAdapter.send', () => {
it('uses adapterParams.defaultVoice when the request omits voice', async () => {
const adapter = getAdapter('azure')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'audio/mpeg' },
status: 200,
headers: { 'content-type': 'audio/mpeg' },
})) as unknown as typeof fetch
await adapter.send(
{ text: 'hi there' },
{
adapterParams: { defaultVoice: 'en-US-AvaMultilingualNeural', region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
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,
},
)
@@ -269,11 +269,11 @@ describe('azureAdapter.send', () => {
await expect(adapter.send(
{ text: 'hi' },
{
adapterParams: { region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
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 })
@@ -287,11 +287,11 @@ describe('azureAdapter.send', () => {
await expect(adapter.send(
{ text: 'hi', voice: 'en-US-AvaMultilingualNeural' },
{
adapterParams: { region: 'eastasia' },
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
fetchImpl,
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 })
})
@@ -302,24 +302,24 @@ describe('stepfunAdapter', () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
voices: [{
compatible_models: ['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'],
id: 'cixingnansheng',
name: '磁性男声',
compatible_models: ['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'],
}],
}), { status: 200 })) as unknown as typeof fetch
const voices = await adapter.getVoiceCatalog({
adapterParams: {},
fetchImpl,
unspeechBaseURL: 'http://unspeech.local',
fetchImpl,
})
expect(voices).toEqual(
expect.arrayContaining([
expect.objectContaining({
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
id: 'cixingnansheng',
name: '磁性男声',
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
}),
]),
)
@@ -331,28 +331,28 @@ describe('stepfunAdapter', () => {
it('posts OpenAI-compatible speech JSON to unspeech with model=stepfun/<model>', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'audio/mpeg' },
status: 200,
headers: { 'content-type': 'audio/mpeg' },
})) as unknown as typeof fetch
const result = await adapter.send(
{
extraOptions: {
instruction: '温柔、克制、有一点笑意',
sampleRate: 24000,
volume: 1.1,
},
responseFormat: 'mp3',
speed: 1.2,
text: '(轻声)你好',
voice: 'cixingnansheng',
responseFormat: 'mp3',
speed: 1.2,
extraOptions: {
instruction: '温柔、克制、有一点笑意',
volume: 1.1,
sampleRate: 24000,
},
},
{
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { model: 'stepaudio-2.5-tts' },
fetchImpl,
},
)
@@ -365,16 +365,16 @@ describe('stepfunAdapter', () => {
})
const body = JSON.parse(init.body as string) as Record<string, unknown>
expect(body).toEqual({
extra_body: {
instruction: '温柔、克制、有一点笑意',
sample_rate: 24000,
volume: 1.1,
},
input: '(轻声)你好',
model: 'stepfun/stepaudio-2.5-tts',
input: '(轻声)你好',
voice: 'cixingnansheng',
response_format: 'mp3',
speed: 1.2,
voice: 'cixingnansheng',
extra_body: {
volume: 1.1,
sample_rate: 24000,
instruction: '温柔、克制、有一点笑意',
},
})
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
@@ -383,29 +383,29 @@ describe('stepfunAdapter', () => {
it('passes the Step Plan endpoint profile to unspeech', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'audio/mpeg' },
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: '温柔、克制',
},
responseFormat: 'mp3',
speed: 1.1,
text: '你好',
voice: 'cixingnansheng',
},
{
keyPlaintext: Buffer.from('step-plan-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: {
endpointProfile: 'step-plan',
model: 'stepaudio-2.5-tts',
},
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-plan-key', 'utf8'),
unspeechBaseURL: 'http://unspeech.local:5933',
},
)
@@ -417,15 +417,15 @@ describe('stepfunAdapter', () => {
'Content-Type': 'application/json',
})
expect(JSON.parse(init.body as string)).toEqual({
model: 'stepfun/stepaudio-2.5-tts',
input: '你好',
voice: 'cixingnansheng',
response_format: 'mp3',
speed: 1.1,
extra_body: {
endpoint_profile: 'step-plan',
instruction: '温柔、克制',
},
input: '你好',
model: 'stepfun/stepaudio-2.5-tts',
response_format: 'mp3',
speed: 1.1,
voice: 'cixingnansheng',
})
expect(result.contentType).toBe('audio/mpeg')
expect(result.body).toBeInstanceOf(ArrayBuffer)
@@ -434,23 +434,23 @@ describe('stepfunAdapter', () => {
it('passes voice_label through to unspeech for provider-level validation', async () => {
const adapter = getAdapter('stepfun')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]), {
headers: { 'content-type': 'audio/mpeg' },
status: 200,
headers: { 'content-type': 'audio/mpeg' },
})) as unknown as typeof fetch
await adapter.send(
{
text: 'hi',
extraOptions: {
voice_label: { emotion: '高兴' },
},
text: 'hi',
},
{
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
keyPlaintext: Buffer.from('step-key', 'utf8'),
baseURL: 'https://api.stepfun.com',
unspeechBaseURL: 'http://unspeech.local',
adapterParams: { model: 'stepaudio-2.5-tts' },
fetchImpl,
},
)
@@ -466,11 +466,11 @@ describe('stepfunAdapter', () => {
await expect(adapter.send(
{ text: 'hi', voice: 'cixingnansheng' },
{
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
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 })
})
@@ -480,19 +480,19 @@ describe('stepfunAdapter', () => {
const abortController = new AbortController()
const abortError = new Error('attempt-timeout')
abortController.abort(abortError)
const fetchImpl = vi.fn(async (_input: Request | string | URL, init?: RequestInit) => {
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' },
{
abortSignal: abortController.signal,
adapterParams: { model: 'stepaudio-2.5-tts' },
baseURL: 'https://api.stepfun.com',
fetchImpl,
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)
})
@@ -502,18 +502,18 @@ describe('volcengineAdapter.send', () => {
it('posts to unspeech with model=volcengine/<api_resource_id> and app/cluster in extra_body', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49, 0x44, 0x33]), {
headers: { 'content-type': 'audio/mpeg' },
status: 200,
headers: { 'content-type': 'audio/mpeg' },
})) as unknown as typeof fetch
const result = await adapter.send(
{ responseFormat: 'mp3', speed: 1.0, text: 'hi', voice: 'BV001_streaming' },
{ text: 'hi', voice: 'BV001_streaming', responseFormat: 'mp3', speed: 1.0 },
{
adapterParams: { appid: 'APP-123', cluster: 'volcano_tts', model: 'seed-tts-2.0' },
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
fetchImpl,
keyPlaintext: Buffer.from('volc-token', 'utf8'),
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,
},
)
@@ -546,18 +546,18 @@ describe('volcengineAdapter.send', () => {
await expect(adapter.send(
{
text: 'hi',
voice: 'BV001_streaming',
extraOptions: {
pitch: 20,
},
text: 'hi',
voice: 'BV001_streaming',
},
{
adapterParams: { appid: 'APP-123' },
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
fetchImpl,
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 })
@@ -570,11 +570,11 @@ describe('volcengineAdapter.send', () => {
await expect(adapter.send(
{ text: 'hi' },
{
adapterParams: {},
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
fetchImpl,
keyPlaintext: Buffer.from('k', 'utf8'),
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: {},
fetchImpl,
},
)).rejects.toMatchObject({ statusCode: 500 })
})
@@ -36,7 +36,7 @@ export function getAdapter(id: string): TtsAdapter {
throw createBadRequestError(
`unknown_tts_provider: ${id}`,
'BAD_REQUEST',
{ available: Object.keys(ADAPTERS), id },
{ id, available: Object.keys(ADAPTERS) },
)
}
@@ -31,14 +31,6 @@ const STEPFUN_DEFAULT_VOICE = 'cixingnansheng'
* - {@link TtsResult} with the upstream audio body and content type.
*/
export const stepfunAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'stepfun',
query: 'provider=stepfun',
})
},
id: 'stepfun',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -55,14 +47,22 @@ export const stepfunAdapter: TtsAdapter = {
return sendSpeechViaUnSpeech({
ctx,
model: `stepfun/${model}`,
input: input.text,
voice,
speed: input.speed,
responseFormat,
extraBody,
fallbackContentType: audioMimeFromFormat(responseFormat),
input: input.text,
model: `stepfun/${model}`,
providerLabel: 'stepfun',
responseFormat,
speed: input.speed,
voice,
})
},
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
return listVoicesViaUnSpeech({
ctx,
query: 'provider=stepfun',
providerLabel: 'stepfun',
})
},
}
+107 -107
View File
@@ -2,6 +2,109 @@ 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.
@@ -22,6 +125,10 @@ import type { Voice } from 'unspeech'
* 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.
*
@@ -30,111 +137,4 @@ export interface TtsAdapter {
* by unspeech. Adapters MUST throw on upstream failure — no empty fallback.
*/
getVoiceCatalog: (ctx: TtsVoiceCatalogContext) => Promise<Voice[]>
/** Stable id used by the registry and config (`tts.upstreams[i].adapter`). */
id: TtsAdapterId
/** Dispatches one TTS request and resolves with the audio payload. */
send: (input: TtsInput, ctx: TtsAdapterContext) => Promise<TtsResult>
}
/**
* Per-call context carrying the resolved key, upstream wiring, and abort
* plumbing. The router builds this before delegating to {@link TtsAdapter.send}.
*
* The plaintext key is held in a Node Buffer so callers can zero/scrub it on
* exit; adapters MUST NOT log or persist it.
*/
export interface TtsAdapterContext {
/** Caller-side abort signal — propagated to the upstream fetch. */
abortSignal?: AbortSignal
/** Free-form adapter-specific params from `tts.upstreams[i].adapterParams` (e.g. Volcengine `appid` / `cluster`). */
adapterParams: Record<string, unknown>
/**
* Per-upstream baseURL from `LLM_ROUTER_CONFIG.tts.upstreams[i].baseURL`.
*
* Adapters forward through unspeech and may use this as provider metadata.
* Provider endpoint selection belongs to unspeech, not this URL.
*/
baseURL: string
/** Fetch implementation. Tests inject a `vi.fn()`; production passes `globalThis.fetch`. */
fetchImpl: typeof fetch
/** Decrypted upstream credential. Plain text — keep in-memory only. */
keyPlaintext: Buffer
/** unspeech REST base URL (no trailing slash). */
unspeechBaseURL: string
}
/**
* Stable provider identifier for the v1 adapter registry.
*
* Adding a new adapter means adding a new id here AND registering it in
* `./index.ts` — the union is intentionally tight so unknown ids fail at the
* type level (router config validation handles runtime).
*/
export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'stepfun' | 'volcengine'
/**
* Inbound TTS request shape passed to every adapter.
*
* Adapters translate this provider-neutral payload into the
* provider's native protocol body (Azure SSML, DashScope JSON,
* Volcengine JSON, etc.).
*/
export interface TtsInput {
/**
* Adapter-specific escape hatch for niche flags that aren't worth promoting
* to the canonical shape (e.g. Azure's `disableSsml`, future per-call quirks).
*/
extraOptions?: Record<string, unknown>
/** Provider format key (e.g. `mp3`, `wav`, Azure-specific `audio-24khz-48kbitrate-mono-mp3`). */
responseFormat?: string
/**
* Speech rate multiplier. `1.0` = native rate, `1.2` = 20% faster, `0.8` = 20% slower.
*
* @default 1
*/
speed?: number
/** Caller-supplied speech text (raw text or SSML when {@link extraOptions} signals so). */
text: string
/** Provider voice id (e.g. `en-US-AvaMultilingualNeural`, `longxiaochun`, `BV001_streaming`). */
voice?: string
}
/**
* Result of a successful upstream call.
*
* `body` is either a fully-buffered `ArrayBuffer` (current v1 behavior — Azure
* REST + DashScope JSON + Volcengine JSON are all one-shot) or a streaming
* body for future streaming adapters.
*/
export interface TtsResult {
/** Audio payload (buffered or streamed). */
body: ArrayBuffer | ReadableStream<Uint8Array>
/** MIME type to forward to the caller (e.g. `audio/mpeg`, `audio/wav`). */
contentType: string
}
/**
* Per-call context for {@link TtsAdapter.getVoiceCatalog}.
*
* `keyPlaintext` and `region` are mandatory for live providers (Azure) that
* proxy through unspeech and call the upstream provider with a subscription
* key; the router decrypts the envelope key and forwards `adapterParams.region`
* verbatim. Unspeech-backed static catalogs ignore both fields.
*
* `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the router.
* Passing it through the context keeps adapters free of configKV coupling.
*/
export interface TtsVoiceCatalogContext {
/** Caller-side abort signal — propagated to the upstream fetch. */
abortSignal?: AbortSignal
/** Free-form adapter-specific params (mirrors `tts.upstreams[i].adapterParams`). */
adapterParams: Record<string, unknown>
/** Fetch implementation. Tests inject `vi.fn()`; production passes `globalThis.fetch`. */
fetchImpl: typeof fetch
/** Decrypted upstream credential (live providers only). */
keyPlaintext?: Buffer
/** Provider region (live providers only). */
region?: string
/** unspeech REST base URL, no trailing slash. */
unspeechBaseURL: string
}
@@ -7,60 +7,16 @@ import { generateSpeechResponse, listVoices, UnSpeechAPIError } from 'unspeech'
import { createBadGatewayError, createInternalError } from '../../../utils/error'
interface ListVoicesOptions {
ctx: TtsVoiceCatalogContext
providerLabel: string
query: string
}
interface SendSpeechOptions {
ctx: TtsAdapterContext
model: string
input: string
voice: string
speed?: number
responseFormat: string
extraBody?: Record<string, unknown>
fallbackContentType: string
input: string
model: string
providerLabel: string
responseFormat: string
speed?: number
voice: string
}
/**
* Lists unspeech voices and maps SDK failures into AIRI gateway errors.
*
* Use when:
* - A TTS adapter needs unspeech's normalized `Voice[]` catalog.
*
* Expects:
* - `query` is an unspeech `/api/voices` query string such as
* `provider=microsoft&region=eastasia`.
*
* Returns:
* - The parsed voice catalog.
*/
export async function listVoicesViaUnSpeech(options: ListVoicesOptions): Promise<Voice[]> {
const { ctx, providerLabel, query } = options
try {
return await listVoices({
abortSignal: ctx.abortSignal,
apiKey: ctx.keyPlaintext?.toString('utf8'),
baseURL: ctx.unspeechBaseURL.replace(/\/+$/, ''),
fetch: ctx.fetchImpl,
headers: { Accept: 'application/json' },
query,
})
}
catch (error) {
if (error instanceof UnSpeechAPIError) {
throw createBadGatewayError(
`${providerLabel} voices upstream ${error.status}: ${error.responseBody.slice(0, 256)}`,
{ lastStatusCode: error.status },
)
}
throw createBadGatewayError(`${providerLabel} voices fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
/**
@@ -92,21 +48,21 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise
try {
const result = await generateSpeechResponse({
abortSignal: ctx.abortSignal,
apiKey: ctx.keyPlaintext.toString('utf8'),
baseURL: `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/v1/`,
extraBody,
fetch: ctx.fetchImpl,
input,
model,
responseFormat,
speed,
voice,
abortSignal: ctx.abortSignal,
extraBody,
})
return {
body: result.body,
contentType: result.contentType ?? fallbackContentType,
body: result.body,
}
}
catch (error) {
@@ -124,3 +80,47 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise
throw createInternalError(`${providerLabel} tts fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
interface ListVoicesOptions {
ctx: TtsVoiceCatalogContext
query: string
providerLabel: string
}
/**
* Lists unspeech voices and maps SDK failures into AIRI gateway errors.
*
* Use when:
* - A TTS adapter needs unspeech's normalized `Voice[]` catalog.
*
* Expects:
* - `query` is an unspeech `/api/voices` query string such as
* `provider=microsoft&region=eastasia`.
*
* Returns:
* - The parsed voice catalog.
*/
export async function listVoicesViaUnSpeech(options: ListVoicesOptions): Promise<Voice[]> {
const { ctx, providerLabel, query } = options
try {
return await listVoices({
apiKey: ctx.keyPlaintext?.toString('utf8'),
baseURL: ctx.unspeechBaseURL.replace(/\/+$/, ''),
fetch: ctx.fetchImpl,
query,
abortSignal: ctx.abortSignal,
headers: { Accept: 'application/json' },
})
}
catch (error) {
if (error instanceof UnSpeechAPIError) {
throw createBadGatewayError(
`${providerLabel} voices upstream ${error.status}: ${error.responseBody.slice(0, 256)}`,
{ lastStatusCode: error.status },
)
}
throw createBadGatewayError(`${providerLabel} voices fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
}
@@ -43,26 +43,6 @@ const DEFAULT_VOLCENGINE_CLUSTER = 'volcano_tts'
* decoded from the upstream JSON `data` base64 field.
*/
export const volcengineAdapter: TtsAdapter = {
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
// unspeech embeds the Volcengine catalog at build time
// (unspeech/pkg/backend/volcengine/voices.go), filtered server-side to
// streaming-compatible voices. Passing `model=<api_resource_id>` narrows
// further by `compatible_models` — adapterParams.model is the operator-
// configured resource id (e.g. `seed-tts-2.0`).
const params = new URLSearchParams({ provider: 'volcengine' })
const apiResourceId = typeof ctx.adapterParams?.model === 'string'
? ctx.adapterParams.model
: undefined
if (apiResourceId)
params.set('model', apiResourceId)
return listVoicesViaUnSpeech({
ctx,
providerLabel: 'volcengine',
query: params.toString(),
})
},
id: 'volcengine',
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
@@ -97,18 +77,38 @@ export const volcengineAdapter: TtsAdapter = {
// - decodes the upstream base64 audio frame itself and returns binary.
return sendSpeechViaUnSpeech({
ctx,
model: apiResourceId ? `volcengine/${apiResourceId}` : 'volcengine',
input: input.text,
voice,
responseFormat: encoding,
extraBody: {
app: { appid, cluster },
audio: { speed_ratio: speed },
request: { operation: 'query', reqid: nanoid() },
user: { uid: 'airi-server' },
audio: { speed_ratio: speed },
request: { reqid: nanoid(), operation: 'query' },
},
fallbackContentType: audioMimeFromFormat(encoding),
input: input.text,
model: apiResourceId ? `volcengine/${apiResourceId}` : 'volcengine',
providerLabel: 'volcengine',
responseFormat: encoding,
voice,
})
},
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',
})
},
}
@@ -16,13 +16,11 @@ import * as stripeSchema from '../../../schemas/stripe'
const logger = useLogger('billing-service')
export type BillingService = ReturnType<typeof createBillingService>
export function createBillingService(
db: Database,
redis: Redis,
_configKV: ConfigKVService,
metrics?: null | RevenueMetrics,
metrics?: RevenueMetrics | null,
) {
/**
* Update Redis cache after a successful DB transaction.
@@ -58,13 +56,13 @@ export function createBillingService(
* Private — call domain-specific wrappers (e.g. consumeFluxForLLM) instead.
*/
async function debitFlux(input: {
amount: number
description?: string
metadata?: Record<string, unknown>
requestId?: string
source: string
userId: string
}): Promise<{ charged: number, flux: number, requested: number, 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.
@@ -89,11 +87,11 @@ export function createBillingService(
// current `amount`, so the caller doesn't double-fire unbilled
// counters on retries.
return {
charged: existing.amount,
flux: existing.balanceAfter,
idempotent: true as const,
requested: existing.amount,
userId: input.userId,
flux: existing.balanceAfter,
charged: existing.amount,
requested: existing.amount,
idempotent: true as const,
}
}
}
@@ -130,9 +128,12 @@ export function createBillingService(
.where(eq(fluxSchema.userFlux.userId, input.userId))
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'debit',
amount: chargedAmount,
balanceAfter,
balanceBefore,
balanceAfter,
requestId: input.requestId,
description: input.description ?? input.source,
metadata: {
...input.metadata,
@@ -142,17 +143,14 @@ export function createBillingService(
unbilled: input.amount - chargedAmount,
}),
},
requestId: input.requestId,
type: 'debit',
userId: input.userId,
})
return {
charged: chargedAmount,
flux: balanceAfter,
idempotent: false as const,
requested: input.amount,
userId: input.userId,
flux: balanceAfter,
charged: chargedAmount,
requested: input.amount,
idempotent: false as const,
}
})
@@ -161,17 +159,17 @@ export function createBillingService(
}
logger.withFields({
amount: input.amount,
balance: result.flux,
charged: result.charged,
idempotent: result.idempotent,
userId: input.userId,
amount: input.amount,
charged: result.charged,
balance: result.flux,
idempotent: result.idempotent,
}).log('Debited flux')
return {
charged: result.charged,
flux: result.flux,
requested: result.requested,
userId: result.userId,
flux: result.flux,
charged: result.charged,
requested: result.requested,
}
}
@@ -182,25 +180,25 @@ export function createBillingService(
* the existing transaction-history UI can render per-request token counts.
*/
async consumeFluxForLLM(input: {
userId: string
amount: number
completionTokens?: number
requestId?: string
description?: string
model?: string
promptTokens?: number
requestId?: string
userId: string
}): Promise<{ charged: number, flux: number, requested: number, userId: string }> {
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 }),
},
requestId: input.requestId,
source: 'llm.request',
userId: input.userId,
})
},
@@ -225,10 +223,10 @@ export function createBillingService(
* even though the user was already credited.
*/
async creditFlux(input: {
userId: string
amount: number
auditMetadata?: Record<string, unknown>
description: string
requestId?: string
description: string
source: string
/**
* Ledger row `type`. Defaults to `'credit'` for backward compatibility
@@ -236,17 +234,17 @@ export function createBillingService(
* `'promo'` so reports / dashboards can distinguish them.
*/
type?: 'credit' | 'promo'
userId: string
}): Promise<{ balanceAfter: number, balanceBefore: number, fluxTransactionId: string, idempotent: boolean }> {
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({
balanceAfter: fluxTxSchema.fluxTransaction.balanceAfter,
balanceBefore: fluxTxSchema.fluxTransaction.balanceBefore,
id: fluxTxSchema.fluxTransaction.id,
balanceBefore: fluxTxSchema.fluxTransaction.balanceBefore,
balanceAfter: fluxTxSchema.fluxTransaction.balanceAfter,
})
.from(fluxTxSchema.fluxTransaction)
.where(and(
@@ -257,8 +255,8 @@ export function createBillingService(
if (existing) {
return {
balanceAfter: existing.balanceAfter,
balanceBefore: existing.balanceBefore,
balanceAfter: existing.balanceAfter,
fluxTransactionId: existing.id,
idempotent: true,
}
@@ -266,7 +264,7 @@ export function createBillingService(
}
await tx.insert(fluxSchema.userFlux)
.values({ flux: 0, userId: input.userId })
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [row] = await tx
@@ -283,19 +281,19 @@ export function createBillingService(
.where(eq(fluxSchema.userFlux.userId, input.userId))
const [insertedTx] = await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: ledgerType,
amount: input.amount,
balanceAfter,
balanceBefore,
balanceAfter,
requestId: input.requestId,
description: input.description,
metadata: input.auditMetadata,
requestId: input.requestId,
type: ledgerType,
userId: input.userId,
}).returning({ id: fluxTxSchema.fluxTransaction.id })
return {
balanceAfter,
balanceBefore,
balanceAfter,
fluxTransactionId: insertedTx!.id,
idempotent: false,
}
@@ -303,9 +301,9 @@ export function createBillingService(
if (txResult.idempotent) {
logger.withFields({
fluxTransactionId: txResult.fluxTransactionId,
requestId: input.requestId,
userId: input.userId,
requestId: input.requestId,
fluxTransactionId: txResult.fluxTransactionId,
}).log('Credited flux (idempotent replay — no side effects emitted)')
return txResult
}
@@ -313,159 +311,7 @@ export function createBillingService(
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.amount, { source: input.source, type: ledgerType })
logger.withFields({ amount: input.amount, balance: txResult.balanceAfter, userId: input.userId }).log('Credited flux')
return txResult
},
/**
* Credit flux from a Stripe invoice payment (subscription).
* Idempotent: claims the invoice row by flipping `fluxCredited`
* from false to true; replays observe it already claimed and apply nothing.
*/
async creditFluxFromInvoice(input: {
amountPaid: number
currency: string
fluxAmount: number
stripeEventId: string
stripeInvoiceId: string
userId: string
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Invoice webhook idempotency follows the same object-level claim model
// as checkout sessions. We intentionally dedupe on the invoice record instead of
// only on Stripe `event.id`, because Stripe may emit multiple events that map to
// the same paid invoice while the balance must only be credited once.
const [claimed] = await tx.update(stripeSchema.stripeInvoice)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId),
eq(stripeSchema.stripeInvoice.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ flux: 0, userId: input.userId })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
amount: input.fluxAmount,
balanceAfter,
balanceBefore,
description,
metadata: {
source: 'invoice.paid',
stripeEventId: input.stripeEventId,
stripeInvoiceId: input.stripeInvoiceId,
},
requestId: input.stripeEventId,
type: 'credit',
userId: input.userId,
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.invoice', type: 'credit' })
}
return txResult
},
/**
* Credit flux from a Stripe checkout session (one-time payment).
* Idempotent: claims the checkout session row by flipping `fluxCredited`
* from false to true; replays of the same Stripe event observe the row
* already claimed and apply nothing.
*/
async creditFluxFromStripeCheckout(input: {
amountTotal: number
currency: null | string
fluxAmount: number
stripeEventId: string
stripeSessionId: string
userId: string
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Webhook idempotency is enforced at the business-object level, not by a
// dedicated processed-events table keyed on Stripe `event.id`. We claim the
// checkout session row exactly once via `fluxCredited = false -> true`, which
// covers both Stripe retries of the same event and distinct Event objects that
// still refer to the same checkout session.
const [claimed] = await tx.update(stripeSchema.stripeCheckoutSession)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId),
eq(stripeSchema.stripeCheckoutSession.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ flux: 0, userId: input.userId })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
amount: input.fluxAmount,
balanceAfter,
balanceBefore,
description,
metadata: {
source: 'stripe.checkout.completed',
stripeEventId: input.stripeEventId,
stripeSessionId: input.stripeSessionId,
},
requestId: input.stripeEventId,
type: 'credit',
userId: input.userId,
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.checkout', type: 'credit' })
}
logger.withFields({ userId: input.userId, amount: input.amount, balance: txResult.balanceAfter }).log('Credited flux')
return txResult
},
@@ -487,14 +333,14 @@ export function createBillingService(
* `metadata.direction` since a set can move the balance either way.
*/
async setFlux(input: {
userId: string
balance: number
description: string
issuedByUserId: string
userId: string
}): Promise<{ balanceAfter: number, balanceBefore: number, fluxTransactionId: string }> {
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string }> {
const txResult = await db.transaction(async (tx) => {
await tx.insert(fluxSchema.userFlux)
.values({ flux: 0, userId: input.userId })
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [row] = await tx
@@ -512,21 +358,21 @@ export function createBillingService(
.where(eq(fluxSchema.userFlux.userId, input.userId))
const [insertedTx] = await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'admin_set',
amount: Math.abs(delta),
balanceAfter,
balanceBefore,
balanceAfter,
description: input.description,
metadata: {
source: 'admin_set',
requestedBalance: input.balance,
direction: delta >= 0 ? 'credit' : 'debit',
issuedByUserId: input.issuedByUserId,
requestedBalance: input.balance,
source: 'admin_set',
},
type: 'admin_set',
userId: input.userId,
}).returning({ id: fluxTxSchema.fluxTransaction.id })
return { balanceAfter, balanceBefore, fluxTransactionId: insertedTx!.id }
return { balanceBefore, balanceAfter, fluxTransactionId: insertedTx!.id }
})
// NOTICE:
@@ -547,13 +393,167 @@ export function createBillingService(
}
logger.withFields({
balanceAfter: txResult.balanceAfter,
balanceBefore: txResult.balanceBefore,
issuedByUserId: input.issuedByUserId,
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>
@@ -1,15 +1,6 @@
export interface UsageInfo {
completionTokens?: number
promptTokens?: number
}
export function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number {
const { completionTokens, promptTokens } = usage
if (promptTokens != null && completionTokens != null) {
const totalTokens = promptTokens + completionTokens
return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens))
}
return fallbackRate
completionTokens?: number
}
export function extractUsageFromBody(body: any): UsageInfo {
@@ -17,7 +8,16 @@ export function extractUsageFromBody(body: any): UsageInfo {
if (!usage)
return {}
return {
completionTokens: usage.completion_tokens ?? undefined,
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
}
@@ -35,30 +35,11 @@ end
return {0, debt}
`
export type FluxMeter = ReturnType<typeof createFluxMeter>
interface AccumulateInput {
currentBalance: number
metadata?: Record<string, unknown>
requestId: string
units: number
userId: string
}
interface AccumulateResult {
/** User's flux balance after this call. */
balanceAfter: number
/** Residual debt left in Redis after this call. Includes unbilled units restored on partial drain. */
debtAfter: number
/** Actual flux charged to the user (== amount we are sure was billed). */
fluxDebited: number
/**
* Flux that crossed the meter threshold but couldn't be charged because the
* user's balance was lower than what the request required. > 0 means the
* user received service they only partially paid for. Reflects the gap
* between `requested` and `charged` returned by `billingService.consumeFluxForLLM`.
*/
unbilledFlux: number
interface 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 {
@@ -76,11 +57,28 @@ interface FluxMeterConfig {
resolveRuntime: () => Promise<FluxMeterRuntime>
}
interface FluxMeterRuntime {
/** Debt key TTL. Residual debt below unitsPerFlux is forgiven on expiry. */
debtTtlSeconds: number
/** How many small units equal one Flux. */
unitsPerFlux: number
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
}
/**
@@ -94,7 +92,7 @@ export function createFluxMeter(
redis: Redis,
billingService: BillingService,
config: FluxMeterConfig,
metrics?: null | RevenueMetrics,
metrics?: RevenueMetrics | null,
) {
async function getRuntime(): Promise<FluxMeterRuntime> {
const runtime = await config.resolveRuntime()
@@ -147,7 +145,7 @@ export function createFluxMeter(
*/
async function accumulate(input: AccumulateInput): Promise<AccumulateResult> {
if (!Number.isFinite(input.units) || input.units <= 0)
return { balanceAfter: input.currentBalance, debtAfter: await readDebt(input.userId), fluxDebited: 0, unbilledFlux: 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 })
@@ -158,21 +156,21 @@ export function createFluxMeter(
if (fluxRequested === 0) {
logger.withFields({
debtAfter: debtAfterSettlement,
userId: input.userId,
meter: config.name,
units: input.units,
userId: input.userId,
debtAfter: debtAfterSettlement,
}).debug('Accumulated units below flux threshold')
return { balanceAfter: input.currentBalance, debtAfter: debtAfterSettlement, fluxDebited: 0, unbilledFlux: 0 }
return { fluxDebited: 0, debtAfter: debtAfterSettlement, balanceAfter: input.currentBalance, unbilledFlux: 0 }
}
let result: Awaited<ReturnType<typeof billingService.consumeFluxForLLM>>
try {
result = await billingService.consumeFluxForLLM({
amount: fluxRequested,
description: `${config.name}_request`,
requestId: input.requestId,
userId: input.userId,
amount: fluxRequested,
requestId: input.requestId,
description: `${config.name}_request`,
...(typeof input.metadata?.model === 'string' && { model: input.metadata.model }),
})
}
@@ -188,10 +186,10 @@ export function createFluxMeter(
}
catch (rollbackError) {
logger.withError(rollbackError).withFields({
meter: config.name,
requestId: input.requestId,
restoreUnits,
userId: input.userId,
meter: config.name,
restoreUnits,
requestId: input.requestId,
}).error('Failed to roll back meter debt after billing failure')
}
throw error
@@ -205,11 +203,11 @@ export function createFluxMeter(
// same usage). Surface loud, but don't compensate.
if (!Number.isInteger(result.charged) || result.charged < 0 || result.charged > result.requested) {
logger.withFields({
charged: result.charged,
meter: config.name,
requested: result.requested,
requestId: input.requestId,
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}`)
}
@@ -236,9 +234,9 @@ export function createFluxMeter(
const restoreUnits = unbilledFlux * runtime.unitsPerFlux
metrics?.fluxUnbilled.add(unbilledFlux, {
source: 'tts_meter',
meter: config.name,
reason: 'partial_debit_drained',
source: 'tts_meter',
...(typeof input.metadata?.model === 'string' && { [GEN_AI_ATTR_REQUEST_MODEL]: input.metadata.model }),
})
@@ -251,38 +249,40 @@ export function createFluxMeter(
// Log loudly so on-call can reconcile manually; don't shadow the
// partial-debit signal by re-throwing.
logger.withError(rollbackError).withFields({
meter: config.name,
requestId: input.requestId,
restoreUnits,
userId: input.userId,
meter: config.name,
restoreUnits,
requestId: input.requestId,
}).error('Failed to restore meter debt after partial-debit drain')
}
logger.withFields({
charged: result.charged,
meter: config.name,
requested: result.requested,
requestId: input.requestId,
restoreUnits,
unbilledFlux,
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 {
balanceAfter: result.flux,
debtAfter: debtAfterRestore,
fluxDebited: result.charged,
debtAfter: debtAfterRestore,
balanceAfter: result.flux,
unbilledFlux,
}
}
return { balanceAfter: result.flux, debtAfter: debtAfterSettlement, fluxDebited: result.charged, unbilledFlux: 0 }
return { fluxDebited: result.charged, debtAfter: debtAfterSettlement, balanceAfter: result.flux, unbilledFlux: 0 }
}
return {
accumulate,
assertCanAfford,
config,
accumulate,
peekDebt: readDebt,
config,
}
}
export type FluxMeter = ReturnType<typeof createFluxMeter>
@@ -12,11 +12,11 @@ import { createBillingService } from '../billing-service'
import * as schema from '../../../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { FLUX_PER_REQUEST: 1, INITIAL_USER_FLUX: 100, ...overrides }
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
set: vi.fn(),
} as any
}
@@ -31,9 +31,9 @@ describe('billingService', () => {
db = await mockDB(schema)
await db.insert(schema.user).values({
email: 'billing@example.com',
id: 'user-billing-1',
name: 'Billing User',
email: 'billing@example.com',
})
})
@@ -47,26 +47,26 @@ describe('billingService', () => {
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,
mode: 'payment',
paymentStatus: 'paid',
status: 'complete',
stripeSessionId: 'sess-billing-1',
userId: 'user-billing-1',
})
})
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,
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
userId: 'user-billing-1',
})
expect(result).toEqual({ applied: true, balanceAfter: 50 })
@@ -84,9 +84,9 @@ describe('billingService', () => {
// Verify metadata on transaction entry
expect(txRecords[0]?.metadata).toMatchObject({
source: 'stripe.checkout.completed',
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
source: 'stripe.checkout.completed',
})
// Verify stripe session marked as credited
@@ -99,21 +99,21 @@ describe('billingService', () => {
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,
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
userId: 'user-billing-1',
})
const second = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
userId: 'user-billing-1',
})
expect(second).toEqual({ applied: false })
@@ -127,18 +127,18 @@ describe('billingService', () => {
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({ flux: 100, userId: 'user-billing-1' })
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 100 })
const result = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 30,
completionTokens: 80,
requestId: 'req-1',
description: 'gpt-4',
promptTokens: 120,
requestId: 'req-1',
userId: 'user-billing-1',
completionTokens: 80,
})
expect(result).toEqual({ charged: 30, flux: 70, requested: 30, userId: 'user-billing-1' })
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'))
@@ -150,17 +150,17 @@ describe('billingService', () => {
eq(schema.fluxTransaction.requestId, 'req-1'),
))
expect(txRecord).toMatchObject({
amount: 30,
balanceAfter: 70,
balanceBefore: 100,
description: 'gpt-4',
requestId: 'req-1',
type: 'debit',
userId: 'user-billing-1',
type: 'debit',
amount: 30,
balanceBefore: 100,
balanceAfter: 70,
requestId: 'req-1',
description: 'gpt-4',
})
expect(txRecord?.metadata).toMatchObject({
completionTokens: 80,
promptTokens: 120,
completionTokens: 80,
source: 'llm.request',
})
@@ -184,16 +184,16 @@ describe('billingService', () => {
// `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({ flux: 5, userId: 'user-billing-1' })
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
const result = await billingService.consumeFluxForLLM({
amount: 38,
description: 'gpt-4',
requestId: 'req-partial',
userId: 'user-billing-1',
amount: 38,
requestId: 'req-partial',
description: 'gpt-4',
})
expect(result).toEqual({ charged: 5, flux: 0, requested: 38, userId: 'user-billing-1' })
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)
@@ -203,14 +203,14 @@ describe('billingService', () => {
eq(schema.fluxTransaction.requestId, 'req-partial'),
))
expect(txRecord).toMatchObject({
amount: 5,
balanceAfter: 0,
balanceBefore: 5,
type: 'debit',
amount: 5,
balanceBefore: 5,
balanceAfter: 0,
})
expect(txRecord?.metadata).toMatchObject({
requestedAmount: 38,
source: 'llm.request',
requestedAmount: 38,
unbilled: 33,
})
@@ -220,11 +220,11 @@ describe('billingService', () => {
})
it('throws 402 when balance is already zero (no ledger row, no balance change)', async () => {
await db.insert(schema.userFlux).values({ flux: 0, userId: 'user-billing-1' })
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 0 })
await expect(billingService.consumeFluxForLLM({
amount: 10,
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'))
@@ -235,17 +235,17 @@ describe('billingService', () => {
})
it('idempotent replay returns the historical charge without re-debiting (partial debits stay partial on retry)', async () => {
await db.insert(schema.userFlux).values({ flux: 5, userId: 'user-billing-1' })
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',
userId: 'user-billing-1',
})
const second = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-replay',
userId: 'user-billing-1',
})
expect(first.charged).toBe(5)
@@ -269,10 +269,10 @@ describe('billingService', () => {
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',
userId: 'user-billing-1',
})
expect(result.balanceAfter).toBe(50)
@@ -283,10 +283,10 @@ describe('billingService', () => {
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]).toMatchObject({
amount: 50,
balanceAfter: 50,
balanceBefore: 0,
type: 'credit',
amount: 50,
balanceBefore: 0,
balanceAfter: 50,
})
})
@@ -310,22 +310,22 @@ describe('billingService', () => {
const requestId = 'campaign-replay-test'
const first = await billingService.creditFlux({
amount: 100,
description: 'Replay test',
requestId,
source: 'admin',
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({
amount: 100,
description: 'Replay test',
requestId,
source: 'admin',
userId: 'user-billing-1',
amount: 100,
requestId,
description: 'Replay test',
source: 'admin',
})
expect(second.idempotent).toBe(true)
@@ -350,13 +350,13 @@ describe('billingService', () => {
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({ amount: 100, description: 'seed', source: 'test', userId: 'user-billing-1' })
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',
userId: 'user-billing-1',
})
expect(result.balanceBefore).toBe(100)
@@ -370,17 +370,17 @@ describe('billingService', () => {
expect(tx!.amount).toBe(150)
expect(tx!.balanceBefore).toBe(100)
expect(tx!.balanceAfter).toBe(250)
expect(tx!.metadata).toMatchObject({ direction: 'credit', issuedByUserId: 'admin-1', requestedBalance: 250, source: 'admin_set' })
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({ amount: 500, description: 'seed', source: 'test', userId: 'user-billing-1' })
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',
userId: 'user-billing-1',
})
expect(result.balanceBefore).toBe(500)
@@ -401,10 +401,10 @@ describe('billingService', () => {
const del = vi.spyOn(redis, 'del')
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 42,
description: 'admin set from zero',
issuedByUserId: 'admin-1',
userId: 'user-billing-1',
})
expect(result.balanceBefore).toBe(0)
@@ -4,8 +4,8 @@ import { calculateFluxFromUsage, extractUsageFromBody } from '../billing'
describe('extractUsageFromBody', () => {
it('returns promptTokens and completionTokens from a normal body', () => {
const body = { usage: { completion_tokens: 200, prompt_tokens: 100 } }
expect(extractUsageFromBody(body)).toEqual({ completionTokens: 200, promptTokens: 100 })
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', () => {
@@ -43,14 +43,14 @@ describe('extractUsageFromBody', () => {
})
it('treats explicit null fields in usage as undefined', () => {
const body = { usage: { completion_tokens: null, prompt_tokens: null } }
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: { completion_tokens: 0, prompt_tokens: 0 } }
const body = { usage: { prompt_tokens: 0, completion_tokens: 0 } }
const result = extractUsageFromBody(body)
expect(result.promptTokens).toBe(0)
expect(result.completionTokens).toBe(0)
@@ -59,25 +59,25 @@ describe('extractUsageFromBody', () => {
describe('calculateFluxFromUsage', () => {
it('calculates flux based on total tokens and rate', () => {
const usage = { completionTokens: 500, promptTokens: 500 }
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 = { completionTokens: 501, promptTokens: 500 }
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 = { completionTokens: 1, promptTokens: 1 }
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 = { completionTokens: 0, promptTokens: 0 }
const usage = { promptTokens: 0, completionTokens: 0 }
// 0 tokens * anything = 0 → ceil → 0, max(1, 0) = 1
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
})
@@ -97,31 +97,31 @@ describe('calculateFluxFromUsage', () => {
})
it('uses a higher fluxPer1kTokens multiplier correctly', () => {
const usage = { completionTokens: 1000, promptTokens: 1000 }
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 = { completionTokens: 200, promptTokens: 200 }
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 = { completionTokens: 1_000_000, promptTokens: 1_000_000 }
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 = { completionTokens: 500, promptTokens: 500 }
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 = { completionTokens: undefined, promptTokens: undefined }
const usage = { promptTokens: undefined, completionTokens: undefined }
expect(calculateFluxFromUsage(usage, 1, 99)).toBe(99)
})
})
@@ -5,17 +5,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestRedis } from '../../../../libs/tests/redis'
import { createFluxMeter } from '../flux-meter'
function createMockBilling(opts: { partialChargeOn?: { amount: number, charged: number }, throwOn?: number } = {}): BillingService {
function createMockBilling(opts: { throwOn?: number, partialChargeOn?: { amount: number, charged: number } } = {}): BillingService {
return {
consumeFluxForLLM: vi.fn(async ({ amount, userId }: { amount: number, userId: string }) => {
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 { charged: opts.partialChargeOn.charged, flux: 0, requested: amount, userId }
return { userId, flux: 0, charged: opts.partialChargeOn.charged, requested: amount }
}
return { charged: amount, flux: 100 - amount, requested: amount, userId }
return { userId, flux: 100 - amount, charged: amount, requested: amount }
}),
} as unknown as BillingService
}
@@ -25,13 +25,13 @@ function createMockMetrics() {
const ttsChars = { add: vi.fn() }
const ttsPreflightRejections = { add: vi.fn() }
return {
fluxUnbilled,
metrics: { fluxUnbilled, ttsChars, ttsPreflightRejections } as any,
fluxUnbilled,
}
}
function staticRuntime(unitsPerFlux = 1000, debtTtlSeconds = 60) {
return vi.fn(async () => ({ debtTtlSeconds, unitsPerFlux }))
return vi.fn(async () => ({ unitsPerFlux, debtTtlSeconds }))
}
describe('fluxMeter', () => {
@@ -49,36 +49,36 @@ describe('fluxMeter', () => {
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
const result = await meter.accumulate({
userId: 'u1',
units: 500,
currentBalance: 10,
requestId: 'req-1',
units: 500,
userId: 'u1',
})
expect(result).toEqual({ balanceAfter: 10, debtAfter: 500, fluxDebited: 0, unbilledFlux: 0 })
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(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await meter.accumulate({ currentBalance: 10, requestId: 'a', units: 700, userId: 'u1' })
const result = await meter.accumulate({ currentBalance: 10, requestId: 'b', units: 400, userId: 'u1' })
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,
description: 'tts_request',
requestId: 'b',
description: 'tts_request',
}))
})
it('debits multiple flux when one request crosses several thresholds', async () => {
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
const result = await meter.accumulate({ currentBalance: 10, requestId: 'big', units: 3500, userId: 'u1' })
const result = await meter.accumulate({ userId: 'u1', units: 3500, currentBalance: 10, requestId: 'big' })
expect(result.fluxDebited).toBe(3)
expect(result.debtAfter).toBe(500)
@@ -89,7 +89,7 @@ describe('fluxMeter', () => {
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
const result = await meter.accumulate({ currentBalance: 10, requestId: 'x', units: bad, userId: 'u1' })
const result = await meter.accumulate({ userId: 'u1', units: bad, currentBalance: 10, requestId: 'x' })
expect(result.fluxDebited).toBe(0)
}
expect(billing.consumeFluxForLLM).not.toHaveBeenCalled()
@@ -114,15 +114,15 @@ describe('fluxMeter', () => {
it('throws from runtime resolver when unitsPerFlux is invalid', async () => {
const meter = createFluxMeter(redis, billing, {
name: 'bad',
resolveRuntime: async () => ({ debtTtlSeconds: 60, unitsPerFlux: 0 }),
resolveRuntime: async () => ({ unitsPerFlux: 0, debtTtlSeconds: 60 }),
})
await expect(meter.accumulate({ currentBalance: 10, requestId: 'r', units: 10, userId: 'u1' })).rejects.toThrow()
await expect(meter.accumulate({ userId: 'u1', units: 10, currentBalance: 10, requestId: 'r' })).rejects.toThrow()
})
it('peekDebt reflects current accumulated units', async () => {
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
await meter.accumulate({ currentBalance: 10, requestId: 'p', units: 250, userId: 'u1' })
await meter.accumulate({ userId: 'u1', units: 250, currentBalance: 10, requestId: 'p' })
expect(await meter.peekDebt('u1')).toBe(250)
})
@@ -138,8 +138,8 @@ describe('fluxMeter', () => {
const resolver = staticRuntime()
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: resolver })
await meter.accumulate({ currentBalance: 10, requestId: 'a', units: 100, userId: 'u1' })
await meter.accumulate({ currentBalance: 10, requestId: 'b', units: 100, userId: 'u1' })
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)
@@ -151,7 +151,7 @@ describe('fluxMeter', () => {
const meter = createFluxMeter(redis, failingBilling, { name: 'tts', resolveRuntime: staticRuntime() })
await expect(
meter.accumulate({ currentBalance: 10, requestId: 'fail', units: 2500, userId: 'u1' }),
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
@@ -186,15 +186,15 @@ describe('fluxMeter', () => {
// - 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 { fluxUnbilled, metrics } = createMockMetrics()
const { metrics, fluxUnbilled } = createMockMetrics()
const meter = createFluxMeter(redis, partialBilling, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
const result = await meter.accumulate({
currentBalance: 1,
metadata: { model: 'eleven_multilingual_v2' },
requestId: 'partial',
units: 3500,
userId: 'u1',
units: 3500,
currentBalance: 1,
requestId: 'partial',
metadata: { model: 'eleven_multilingual_v2' },
})
expect(result.fluxDebited).toBe(1)
@@ -204,18 +204,18 @@ describe('fluxMeter', () => {
expect(await meter.peekDebt('u1')).toBe(2500)
expect(incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
expect(fluxUnbilled.add).toHaveBeenCalledWith(2, expect.objectContaining({
'gen_ai.request.model': 'eleven_multilingual_v2',
'source': 'tts_meter',
'meter': 'tts',
'reason': 'partial_debit_drained',
'source': 'tts_meter',
'gen_ai.request.model': 'eleven_multilingual_v2',
}))
})
it('does not report fluxUnbilled when billing fully charges', async () => {
const { fluxUnbilled, metrics } = createMockMetrics()
const { metrics, fluxUnbilled } = createMockMetrics()
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
const result = await meter.accumulate({ currentBalance: 10, requestId: 'full', units: 1500, userId: 'u1' })
const result = await meter.accumulate({ userId: 'u1', units: 1500, currentBalance: 10, requestId: 'full' })
expect(result.fluxDebited).toBe(1)
expect(result.unbilledFlux).toBe(0)
@@ -18,27 +18,27 @@ describe('characterService', () => {
// Create a test user for foreign key constraints
const [user] = await db.insert(schema.user).values({
email: 'test@example.com',
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
it('create should handle full character creation', async () => {
const characterData = {
characterId: 'cid',
coverUrl: 'url',
creatorId: testUser.id,
id: 'char-1',
ownerId: testUser.id,
version: '1.0',
coverUrl: 'url',
characterId: 'cid',
ownerId: testUser.id,
creatorId: testUser.id,
}
const result = await service.create({
character: characterData,
cover: { backgroundUrl: 'bg', foregroundUrl: 'fg' },
i18n: [{ description: 'desc', language: 'en', name: 'Aster', tags: [] }],
i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }],
cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' },
})
expect(result.id).toBe('char-1')
+110 -110
View File
@@ -9,10 +9,96 @@ import * as userCharacterSchema from '../../schemas/user-character'
const logger = useLogger('characters')
export type CharacterService = ReturnType<typeof createCharacterService>
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({
@@ -38,7 +124,7 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics
return { bookmarked: false }
}
else {
await tx.insert(userCharacterSchema.characterBookmarks).values({ characterId, userId })
await tx.insert(userCharacterSchema.characterBookmarks).values({ userId, characterId })
await tx.update(schema.character)
.set({
@@ -55,10 +141,10 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics
},
async create(data: {
avatarModels?: Omit<schema.NewAvatarModel, 'characterId'>[]
capabilities?: Omit<schema.NewCharacterCapability, 'characterId'>[]
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'>[]
}) {
@@ -104,6 +190,20 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics
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() })
@@ -179,119 +279,19 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics
.where(eq(schema.character.id, characterId))
}
return { bookmarkRows, charRows, likeRows }
return { charRows, likeRows, bookmarkRows }
})
logger
.withFields({
bookmarks: result.bookmarkRows.length,
userId,
characters: result.charRows.length,
likes: result.likeRows.length,
userId,
bookmarks: result.bookmarkRows.length,
})
.log('Characters / likes / bookmarks soft-deleted for user')
},
async findAll() {
return await db.query.character.findMany({
where: isNull(schema.character.deletedAt),
with: {
bookmarks: true,
capabilities: true,
cover: true,
i18n: true,
likes: true,
},
})
},
async findById(id: string) {
return await db.query.character.findFirst({
where: and(
eq(schema.character.id, id),
isNull(schema.character.deletedAt),
),
with: {
avatarModels: true,
bookmarks: true,
capabilities: true,
cover: true,
i18n: true,
likes: true,
prompts: true,
},
})
},
async findByOwnerId(ownerId: string) {
return await db.query.character.findMany({
where: and(
eq(schema.character.ownerId, ownerId),
isNull(schema.character.deletedAt),
),
with: {
bookmarks: true,
capabilities: true,
cover: true,
i18n: true,
likes: 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({ characterId, userId })
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 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
},
}
}
export type CharacterService = ReturnType<typeof createCharacterService>
@@ -51,21 +51,21 @@ describe('pushMessages', () => {
{ chatId: 'group', memberType: 'user', userId: 'member' },
])
await db.insert(schema.messages).values({
chatId: 'group',
content: 'original',
id: 'message',
mediaIds: [],
role: 'user',
chatId: 'group',
senderId: 'author',
role: 'user',
seq: 1,
content: 'original',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ content: 'forged', id: 'message', role: 'user' }]))
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'user', content: 'forged' }]))
.rejects
.toMatchObject({ errorCode: 'FORBIDDEN', message: 'Forbidden', statusCode: 403 })
.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')
@@ -83,21 +83,21 @@ describe('pushMessages', () => {
{ chatId: 'target', memberType: 'user', userId: 'member' },
])
await db.insert(schema.messages).values({
chatId: 'source',
content: 'source message',
id: 'message',
mediaIds: [],
role: 'user',
chatId: 'source',
senderId: 'member',
role: 'user',
seq: 1,
content: 'source message',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'target', [{ content: 'target message', id: 'message', role: 'user' }]))
await expect(service.pushMessages('member', 'target', [{ id: 'message', role: 'user', content: 'target message' }]))
.rejects
.toMatchObject({ errorCode: 'CONFLICT', message: 'Message already belongs to another chat', statusCode: 409 })
.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') })
@@ -109,21 +109,21 @@ describe('pushMessages', () => {
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({
chatId: 'group',
content: 'original',
id: 'message',
mediaIds: [],
role: 'user',
chatId: 'group',
senderId: 'author',
role: 'user',
seq: 1,
content: 'original',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('author', 'group', [{ content: 'updated', id: 'message', role: 'user' }]))
await expect(service.pushMessages('author', 'group', [{ id: 'message', role: 'user', content: 'updated' }]))
.resolves
.toMatchObject({ fromSeq: 2, seq: 2, toSeq: 2 })
.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')
@@ -137,21 +137,21 @@ describe('pushMessages', () => {
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({
chatId: 'group',
content: 'original response',
id: 'message',
mediaIds: [],
role: 'assistant',
chatId: 'group',
senderId: null,
role: 'assistant',
seq: 1,
content: 'original response',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ content: 'original response', id: 'message', role: 'assistant' }]))
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'original response' }]))
.resolves
.toMatchObject({ fromSeq: 2, seq: 1, toSeq: 1 })
.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')
@@ -164,28 +164,28 @@ describe('pushMessages', () => {
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({
chatId: 'group',
content: 'original response',
id: 'legacy-assistant',
mediaIds: [],
role: 'assistant',
chatId: 'group',
senderId: null,
role: 'assistant',
seq: 1,
content: 'original response',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [
{ content: 'original response', id: 'legacy-assistant', role: 'assistant' },
{ content: 'next turn', id: 'new-user-message', role: 'user' },
{ id: 'legacy-assistant', role: 'assistant', content: 'original response' },
{ id: 'new-user-message', role: 'user', content: 'next turn' },
]))
.resolves
.toMatchObject({ fromSeq: 2, seq: 2, toSeq: 2 })
.toMatchObject({ seq: 2, fromSeq: 2, toSeq: 2 })
const messages = await db.query.messages.findMany({
orderBy: schema.messages.seq,
where: eq(schema.messages.chatId, 'group'),
orderBy: schema.messages.seq,
})
expect(messages).toHaveLength(2)
expect(messages[0]?.id).toBe('legacy-assistant')
@@ -201,9 +201,9 @@ describe('pushMessages', () => {
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ content: 'response', id: 'message', role: 'assistant' }]))
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'response' }]))
.resolves
.toMatchObject({ fromSeq: 1, seq: 1, toSeq: 1 })
.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')
@@ -218,21 +218,21 @@ describe('pushMessages', () => {
{ chatId: 'group', memberType: 'user', userId: 'member' },
])
await db.insert(schema.messages).values({
chatId: 'group',
content: 'original response',
id: 'message',
mediaIds: [],
role: 'assistant',
chatId: 'group',
senderId: null,
role: 'assistant',
seq: 1,
content: 'original response',
mediaIds: [],
stickerIds: [],
})
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ content: 'forged response', id: 'message', role: 'assistant' }]))
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'forged response' }]))
.rejects
.toMatchObject({ errorCode: 'FORBIDDEN', message: 'Forbidden', statusCode: 403 })
.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')
@@ -245,9 +245,9 @@ describe('pushMessages', () => {
const service = createChatService(db)
await expect(service.pushMessages('member', 'group', [{ content: 'local prompt', id: 'message', role: 'system' }]))
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'system', content: 'local prompt' }]))
.rejects
.toMatchObject({ errorCode: 'BAD_REQUEST', message: 'Only user and assistant messages can be synchronized', statusCode: 400 })
.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)
+259 -259
View File
@@ -13,34 +13,38 @@ import * as schema from '../../schemas/chats'
const logger = useLogger('chats')
export type ChatService = ReturnType<typeof createChatService>
type ChatMemberType = 'bot' | 'character' | 'user'
type ChatType = 'bot' | 'channel' | 'group' | 'private'
type ChatType = 'private' | 'bot' | 'group' | 'channel'
type ChatMemberType = 'user' | 'character' | 'bot'
interface CreateChatPayload {
id?: string
members?: { characterId?: string, type: ChatMemberType, userId?: string }[]
title?: 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)
// ---------------------------------------------------------------------------
interface PushMessage {
content: string
id: string
role: string
}
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
// ---------------------------------------------------------------------------
@@ -63,7 +67,7 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
),
})
if (!member) {
logger.withFields({ chatId, userId }).warn('User not a member of chat, forbidden')
logger.withFields({ userId, chatId }).warn('User not a member of chat, forbidden')
throw createForbiddenError()
}
@@ -75,7 +79,100 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
return {
// -- Chat management (REST) ---------------------------------------------
async addMember(userId: string, chatId: string, member: { characterId?: string, type: ChatMemberType, userId?: string }) {
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) {
@@ -89,55 +186,164 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
await verifyMembership(tx, chatId, userId)
const [added] = await tx.insert(schema.chatMembers).values({
characterId: member.type !== 'user' ? (member.characterId ?? null) : null,
chatId,
memberType: member.type,
userId: member.type === 'user' ? (member.userId ?? null) : null,
characterId: member.type !== 'user' ? (member.characterId ?? null) : null,
}).returning()
return added
})
},
async createChat(userId: string, payload: CreateChatPayload) {
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) => {
const chatId = payload.id ?? nanoid()
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()
await tx.insert(schema.chats).values({
createdAt: now,
id: chatId,
title: payload.title ?? null,
type: payload.type ?? 'group',
updatedAt: now,
})
// 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))
: []
// Always add creator as a user member
await tx.insert(schema.chatMembers).values({
characterId: null,
chatId,
memberType: 'user',
userId,
})
if (existingMessages.some(message => message.chatId !== chatId))
throw createConflictError('Message already belongs to another chat')
// 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 => ({
characterId: m.type !== 'user' ? (m.characterId ?? null) : null,
chatId,
memberType: m.type,
userId: m.type === 'user' ? (m.userId ?? null) : null,
}))
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 (extra.length > 0) {
await tx.insert(schema.chatMembers).values(extra)
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()
}
return { createdAt: now, id: chatId, title: payload.title ?? null, type: payload.type ?? 'group', updatedAt: now }
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)
}
metrics?.wsMessagesReceived.add(result.totalCount)
return { seq: result.seq, fromSeq: result.fromSeq, toSeq: result.toSeq }
},
/**
@@ -237,58 +443,14 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
}
logger.withFields({
preservedSharedMessages,
sharedChatMembershipsDropped: droppedMemberships,
soloChats: soloChatCount,
soloMessages: soloMessageCount,
userId,
soloChats: soloChatCount,
sharedChatMembershipsDropped: droppedMemberships,
soloMessages: soloMessageCount,
preservedSharedMessages,
}).log('Chats footprint processed for user (solo soft-deleted, shared anonymized)')
},
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 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 getMembers(chatId: string) {
return db.query.chatMembers.findMany({
where: eq(schema.chatMembers.chatId, chatId),
})
},
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 pullMessages(userId: string, chatId: string, afterSeq: number, limit?: number) {
return db.transaction(async (tx) => {
await verifyMembership(tx, chatId, userId)
@@ -312,182 +474,20 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
.where(eq(schema.messages.chatId, chatId))
const wireMessages: WireMessage[] = rows.map(r => ({
chatId: r.chatId,
content: r.content,
createdAt: r.createdAt.getTime(),
id: r.id,
role: r.role as MessageRole,
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 }
})
},
// -- 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({
chatId: schema.messages.chatId,
content: schema.messages.content,
id: schema.messages.id,
role: schema.messages.role,
senderId: schema.messages.senderId,
}).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 {
chatId,
content: m.content,
createdAt: now,
id: m.id,
mediaIds: [] as string[],
role: m.role,
senderId: resolveSenderId(m.role, userId),
seq: currentSeq,
stickerIds: [] as string[],
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 {
fromSeq: maxSeq + 1,
newCount: newMsgs.length,
seq: currentSeq,
toSeq: currentSeq,
totalCount: messages.length,
}
})
if (result.totalCount > 0) {
metrics?.chatMessages.add(result.totalCount)
}
metrics?.wsMessagesReceived.add(result.totalCount)
return { fromSeq: result.fromSeq, seq: result.seq, toSeq: result.toSeq }
},
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
})
},
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
})
},
}
}
export function resolveSenderId(role: string, userId: string): null | string {
if (role === 'user' || role === 'assistant')
return userId
return null
}
export type ChatService = ReturnType<typeof createChatService>
@@ -1,4 +1,4 @@
export type FluxBalanceBucket = '1_100' | '101_1000' | '1001_10000' | '10000_plus' | 'unknown' | 'zero'
export type FluxBalanceBucket = 'zero' | '1_100' | '101_1000' | '1001_10000' | '10000_plus' | 'unknown'
/**
* Normalizes exact Flux balance values into analytics-safe buckets.
@@ -13,7 +13,7 @@ export type FluxBalanceBucket = '1_100' | '101_1000' | '1001_10000' | '10000_plu
* - "1_100"
* - "1001_10000"
*/
export function fluxBalanceBucket(balance: null | number | undefined): FluxBalanceBucket {
export function fluxBalanceBucket(balance: number | null | undefined): FluxBalanceBucket {
if (balance == null || Number.isNaN(balance))
return 'unknown'
if (balance <= 0)
@@ -12,22 +12,22 @@ describe('fluxTransactionService', () => {
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values({
email: 'tx@example.com',
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,
balanceAfter: 500,
balanceBefore: 0,
balanceAfter: 500,
description: 'Stripe payment',
metadata: { stripeSessionId: 'sess_123' },
type: 'credit',
userId: 'user-tx',
})
const { records } = await service.getHistory('user-tx', 10, 0)
@@ -38,8 +38,8 @@ describe('fluxTransactionService', () => {
it('logBatch should insert multiple entries', async () => {
await service.logBatch([
{ amount: 10, balanceAfter: 490, balanceBefore: 500, description: 'gpt-4o', type: 'debit', userId: 'user-tx' },
{ amount: 5, balanceAfter: 485, balanceBefore: 490, description: 'gpt-4o-mini', type: 'debit', userId: 'user-tx' },
{ 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)
@@ -53,13 +53,13 @@ describe('fluxTransactionService', () => {
})
it('getHistory should paginate correctly with hasMore', async () => {
const { hasMore, records } = await service.getHistory('user-tx', 2, 0)
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 { hasMore, records } = await service.getHistory('user-tx', 10, 0)
const { records, hasMore } = await service.getHistory('user-tx', 10, 0)
expect(records).toHaveLength(3)
expect(hasMore).toBe(false)
})
@@ -7,34 +7,44 @@ import * as schema from '../../schemas/flux-transaction'
const logger = useLogger('flux-transaction')
export type FluxTransactionService = ReturnType<typeof createFluxTransactionService>
export interface TransactionEntry {
userId: string
type: 'credit' | 'debit' | 'initial' | 'promo'
amount: number
balanceAfter: number
balanceBefore: number
balanceAfter: number
requestId?: string
description: string
metadata?: Record<string, unknown>
requestId?: string
type: 'credit' | 'debit' | 'initial' | 'promo'
userId: string
}
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,
orderBy: [desc(schema.fluxTransaction.createdAt)],
where: eq(schema.fluxTransaction.userId, userId),
})
const hasMore = records.length > limit
if (hasMore)
records.pop()
return { hasMore, records }
return { records, hasMore }
},
async getStats(userId: string) {
@@ -56,17 +66,7 @@ export function createFluxTransactionService(db: Database) {
return { capacity: latestCredit?.balanceAfter ?? 0 }
},
async log(entry: TransactionEntry) {
await db.insert(schema.fluxTransaction).values(entry)
logger.withFields({ amount: entry.amount, type: entry.type, userId: entry.userId }).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')
},
}
}
export type FluxTransactionService = ReturnType<typeof createFluxTransactionService>
@@ -12,11 +12,11 @@ import { createFluxService } from './flux'
import * as schema from '../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { FLUX_PER_REQUEST: 1, INITIAL_USER_FLUX: 100, ...overrides }
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
set: vi.fn(),
} as any
}
@@ -33,9 +33,9 @@ describe('fluxService (DB-backed)', () => {
db = await mockDB(schema)
const [user] = await db.insert(schema.user).values({
email: 'test@example.com',
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
@@ -63,10 +63,10 @@ describe('fluxService (DB-backed)', () => {
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]).toMatchObject({
amount: 100,
balanceAfter: 100,
balanceBefore: 0,
type: 'initial',
amount: 100,
balanceBefore: 0,
balanceAfter: 100,
})
})
@@ -79,7 +79,7 @@ describe('fluxService (DB-backed)', () => {
it('getFlux should load from DB when Redis cache misses', async () => {
// Pre-insert user flux directly
await db.insert(schema.userFlux).values({ flux: 42, userId: testUser.id })
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 42 })
const record = await service.getFlux(testUser.id)
expect(record.flux).toBe(42)
@@ -87,7 +87,7 @@ describe('fluxService (DB-backed)', () => {
})
it('updateStripeCustomerId should update DB only', async () => {
await db.insert(schema.userFlux).values({ flux: 100, userId: testUser.id })
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')
+77 -77
View File
@@ -13,8 +13,6 @@ import * as fluxTxSchema from '../../schemas/flux-transaction'
const logger = useLogger('flux-service')
export type FluxService = ReturnType<typeof createFluxService>
// 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
@@ -22,6 +20,80 @@ export type FluxService = ReturnType<typeof createFluxService>
// `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
@@ -47,82 +119,10 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
await redis.del(userFluxRedisKey(userId))
logger
.withFields({ clearedFlux: result[0]?.flux ?? 0, userId })
.withFields({ userId, clearedFlux: result[0]?.flux ?? 0 })
.log('Flux balance soft-deleted and cache invalidated')
},
async getFlux(userId: string) {
// 1. Try Redis cache
const cached = await redis.get(userFluxRedisKey(userId))
if (cached !== null) {
return { flux: Number.parseInt(cached, 10), userId }
}
// 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({ flux: initialFlux, userId })
.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({
amount: initialFlux,
balanceAfter: initialFlux,
balanceBefore: 0,
description: 'Initial grant',
type: 'initial',
userId,
})
}
})
// 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({ initialFlux, userId }).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
},
}
}
export type FluxService = ReturnType<typeof createFluxService>
@@ -48,8 +48,6 @@ end
return 0
`
export type ConcurrencyLedger = ReturnType<typeof createConcurrencyLedger>
/**
* 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.
@@ -133,17 +131,19 @@ export function createConcurrencyLedger(redis: Redis, options?: {
* 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<{ inflight: number, poolId: string }>> {
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) => ({
inflight: values[i] == null ? 0 : Number(values[i]),
poolId,
inflight: values[i] == null ? 0 : Number(values[i]),
}))
}
return { currentInflight, isSaturated, markSaturated, release, snapshot, tryAcquire }
return { tryAcquire, release, markSaturated, isSaturated, currentInflight, snapshot }
}
export type ConcurrencyLedger = ReturnType<typeof createConcurrencyLedger>
@@ -11,22 +11,20 @@ import { createBadRequestError, createServiceUnavailableError } from '../../../u
*/
const DEFAULT_CACHE_TTL_MS = 5_000
export type ConfigLoader = ReturnType<typeof createConfigLoader>
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
/**
* Cache TTL in milliseconds.
* @default 5_000
*/
ttlMs?: number
}
/**
@@ -34,8 +32,8 @@ export interface ConfigLoaderOptions {
* union so callers handle `llm` and `tts` shapes explicitly.
*/
export type ModelConfigSlice
= | { defaults: RouterConfig['defaults'], kind: 'llm', model: LlmModel }
| { defaults: RouterConfig['defaults'], kind: 'tts', model: TtsModel }
= | { kind: 'llm', model: LlmModel, defaults: RouterConfig['defaults'] }
| { kind: 'tts', model: TtsModel, defaults: RouterConfig['defaults'] }
/**
* Build the in-process config loader for the router.
@@ -59,7 +57,7 @@ export function createConfigLoader(options: ConfigLoaderOptions) {
const ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS
const now = options.now ?? Date.now
let cached: null | { loadedAt: number, value: RouterConfig } = null
let cached: { value: RouterConfig, loadedAt: number } | null = null
async function loadFresh(): Promise<RouterConfig> {
const value = await options.configKV.getOptional('LLM_ROUTER_CONFIG')
@@ -69,7 +67,7 @@ export function createConfigLoader(options: ConfigLoaderOptions) {
'CONFIG_NOT_SET',
)
}
cached = { loadedAt: now(), value }
cached = { value, loadedAt: now() }
return value
}
@@ -87,20 +85,20 @@ export function createConfigLoader(options: ConfigLoaderOptions) {
throw createBadRequestError(
'unknown_model',
'BAD_REQUEST',
{ available: Object.keys(config.llm.models), requested: modelName },
{ requested: modelName, available: Object.keys(config.llm.models) },
)
}
return { defaults: config.defaults, kind: 'llm', model }
return { kind: 'llm', model, defaults: config.defaults }
}
const model = config.tts.models[modelName]
if (model == null) {
throw createBadRequestError(
'unknown_model',
'BAD_REQUEST',
{ available: Object.keys(config.tts.models), requested: modelName },
{ requested: modelName, available: Object.keys(config.tts.models) },
)
}
return { defaults: config.defaults, kind: 'tts', model }
return { kind: 'tts', model, defaults: config.defaults }
}
function invalidate(): void {
@@ -109,3 +107,5 @@ export function createConfigLoader(options: ConfigLoaderOptions) {
return { getModelConfig, invalidate }
}
export type ConfigLoader = ReturnType<typeof createConfigLoader>
@@ -12,24 +12,31 @@ function createHarness() {
invalidateTtsVoicesCache: vi.fn(async () => {}),
}
const logger = {
warn: vi.fn(),
withError: vi.fn(() => logger),
warn: vi.fn(),
}
const { subscriber } = createConfigSyncSubscriber({
redis,
configKV,
llmRouter: llmRouter as never,
gatewayMetrics: null,
instanceId: 'api-test',
llmRouter: llmRouter as never,
logger: logger as never,
redis,
})
return { configKV, llmRouter, redis, subscriber }
}
function message(key: string) {
return JSON.stringify({ key, publishedAt: Date.now(), version: 1 })
return JSON.stringify({ key, version: 1, publishedAt: Date.now() })
}
async function settleInitialReconnect(harness: ReturnType<typeof createHarness>): Promise<void> {
await vi.waitFor(() => expect(harness.configKV.invalidateCache).toHaveBeenCalledTimes(2))
harness.configKV.invalidateCache.mockClear()
harness.llmRouter.invalidateConfig.mockClear()
harness.llmRouter.invalidateTtsVoicesCache.mockClear()
}
async function publishInvalidation(harness: ReturnType<typeof createHarness>, key: string): Promise<void> {
@@ -41,13 +48,6 @@ async function publishInvalidation(harness: ReturnType<typeof createHarness>, ke
await received
}
async function settleInitialReconnect(harness: ReturnType<typeof createHarness>): Promise<void> {
await vi.waitFor(() => expect(harness.configKV.invalidateCache).toHaveBeenCalledTimes(2))
harness.configKV.invalidateCache.mockClear()
harness.llmRouter.invalidateConfig.mockClear()
harness.llmRouter.invalidateTtsVoicesCache.mockClear()
}
describe('configKV sync subscriber', () => {
it('invalidates router and voice state for LLM_ROUTER_CONFIG', async () => {
const harness = createHarness()
@@ -7,23 +7,21 @@ import type { LlmRouterService } from './router'
import { CONFIG_KV_INVALIDATION_CHANNEL, parseConfigKVInvalidation } from '../../adapters/config-kv/contracts'
/**
* 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
}
/**
* 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
/** Typed ConfigKV reader whose Redis cache is cleared after reconnects. */
configKV: Pick<ConfigKVService, 'invalidateCache'>
/** 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.
@@ -31,16 +29,18 @@ export interface ConfigSyncSubscriberOptions {
gatewayMetrics: GatewayMetrics | null
/** Value attached to the `service_instance_id` label on emitted metrics. */
instanceId: string
/** Router service whose in-memory `LLM_ROUTER_CONFIG` cache we invalidate. */
llmRouter: LlmRouterService
/** Logger handle. Caller supplies a scoped logger so namespacing is theirs. */
logger: ReturnType<typeof useLogger>
/**
* 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
}
/**
* 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
}
/**
@@ -81,15 +81,15 @@ export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): C
opts.llmRouter.invalidateConfig()
await opts.llmRouter.invalidateTtsVoicesCache()
opts.gatewayMetrics?.configReload.add(1, {
service_instance_id: opts.instanceId,
source,
service_instance_id: opts.instanceId,
})
}
function recordSubscriberState(state: 'connected' | 'error' | 'reconnecting') {
opts.gatewayMetrics?.subscriberState.add(1, {
service_instance_id: opts.instanceId,
state,
service_instance_id: opts.instanceId,
})
}
@@ -108,8 +108,8 @@ export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): C
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on LLM_ROUTER_CONFIG change')
})
opts.gatewayMetrics?.configReload.add(1, {
service_instance_id: opts.instanceId,
source: 'pubsub',
service_instance_id: opts.instanceId,
})
return
}
@@ -3,12 +3,20 @@ import type { ApiError } from '../../../utils/error'
import { createBadGatewayError, createGatewayTimeoutError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
/**
* Server-only cause attached to the {@link ApiError} that
* {@link mapUpstreamError} produces. Surfaced through logger + OTel
* span attributes, never through the HTTP response body.
* 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 RouterErrorCause {
attempts: UpstreamAttempt[]
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'
}
/**
@@ -18,6 +26,9 @@ export interface RouterErrorCause {
* 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
@@ -32,26 +43,15 @@ export interface UpstreamAttempt {
* upstreams populate `errorMessage`.
*/
errorMessage?: string
keyId: string
provider: string
status: 'timeout' | number
}
/**
* 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.
* 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 UpstreamErrorContext {
/** The status of the **last** attempt — drives the 502/503/504 selection. */
lastStatusCode: 'timeout' | number
/** How many distinct keys were attempted across all upstreams. */
triedKeys: number
/** How many distinct upstreams were attempted. */
triedUpstreams: number
export interface RouterErrorCause {
attempts: UpstreamAttempt[]
}
/**
@@ -78,14 +78,14 @@ export interface UpstreamErrorContext {
* 5xx, anything else).
*/
export function mapUpstreamError(
status: 'timeout' | number,
status: number | 'timeout',
context: UpstreamErrorContext,
attempts?: UpstreamAttempt[],
): ApiError {
const details = {
lastStatusCode: context.lastStatusCode,
triedKeys: context.triedKeys,
triedUpstreams: context.triedUpstreams,
lastStatusCode: context.lastStatusCode,
}
const apiErr = buildApiError(status, details)
@@ -98,7 +98,7 @@ export function mapUpstreamError(
return apiErr
}
function buildApiError(status: 'timeout' | number, details: UpstreamErrorContext): ApiError {
function buildApiError(status: number | 'timeout', details: UpstreamErrorContext): ApiError {
if (status === 'timeout')
return createGatewayTimeoutError('Upstream timeout', details)
@@ -14,7 +14,7 @@ import { createServiceUnavailableError } from '../../../utils/error'
* carry `keys` + each key has `id` + `ciphertext`.
*/
export interface RotatableUpstream {
keys: ReadonlyArray<{ ciphertext: string, id: string }>
keys: ReadonlyArray<{ id: string, ciphertext: string }>
}
/**
@@ -63,14 +63,14 @@ export function createKeyRotator(
let plaintext: Buffer
try {
plaintext = envelopeCrypto.decryptKey(entry.ciphertext, {
keyEntryId: entry.id,
modelName,
keyEntryId: entry.id,
})
}
catch (err) {
gatewayMetrics?.decryptFailures.add(1, {
key_entry_id: entry.id,
provider,
key_entry_id: entry.id,
})
// NOTICE:
// Surfacing decrypt failure as CONFIG_NOT_SET (503) rather than
@@ -30,80 +30,6 @@ import { createKeyRotator } from './key-rotator'
const UPSTREAM_BODY_SNIPPET_MAX = 256
export interface CreateLlmRouterServiceOptions {
/**
* Per-pool concurrency ledger backing capacity-aware TTS routing. When a TTS
* model has any upstream with `maxConcurrency` set, the router acquires a slot
* here before dispatching and releases it after, spreading load across app_ids
* instead of hammering the first upstream.
*/
concurrencyLedger: ConcurrencyLedger
/**
* Config cache TTL in milliseconds.
* @default 5_000
*/
configCacheTtlMs?: number
/** ConfigKV used to read `LLM_ROUTER_CONFIG`. */
configKV: ConfigKVService
/** Envelope crypto used to decrypt at-rest keys. */
envelopeCrypto: EnvelopeCrypto
/**
* Fetch implementation. Defaults to `globalThis.fetch`. Tests inject a
* `vi.fn` so we never touch the real network.
* @default globalThis.fetch
*/
fetchImpl?: typeof fetch
/** OTel gateway metric bundle. `null` when OTel is disabled. */
gatewayMetrics: GatewayMetrics | null
/**
* Redis client used as the TTS voice catalog cache. Live catalogs (Azure)
* are stable but heavy; caching avoids hammering Microsoft on every voice
* picker open while keeping freshness within {@link TTS_VOICES_CACHE_TTL_S}.
*/
redis: Redis
/**
* Cool-down (seconds) a pool is skipped after exhausting with a 429 (app_id
* concurrency exceeded upstream-side). Separate from the ledger's in-flight
* TTL: this is a reactive circuit-breaker window, not a leak bound.
* @default 15
*/
ttsPoolSaturationTtlSeconds?: number
/**
* TTL for the Redis voice catalog cache in seconds.
* @default 21_600 (6h)
*/
ttsVoiceCacheTtlSeconds?: number
}
/**
* Best-effort provider tag derived from `baseURL` host for OTel labels. We
* keep this loose every label below is just a dimension, not a domain
* identity. The admin-controlled `LLM_ROUTER_CONFIG` is the source of truth
* for which upstream serves a model.
*/
function deriveProviderTag(baseURL: string): string {
try {
return new URL(baseURL).hostname
}
catch {
return 'unknown'
}
}
function failuresMatch(
statuses: ReadonlyArray<'timeout' | number>,
triggers: RouteFailureTriggers | undefined,
): boolean {
if (statuses.length === 0 || triggers == null)
return false
return statuses.every((status) => {
if (status === 'timeout')
return triggers.onTimeout
return triggers.httpCodes.includes(status)
})
}
/**
* Read at most `maxBytes` from an upstream non-2xx response body for
* diagnostic logging, then cancel the rest so the socket can return to
@@ -125,7 +51,7 @@ async function readUpstreamBodySnippet(response: Response, maxBytes = UPSTREAM_B
const chunks: Uint8Array[] = []
let total = 0
while (total < maxBytes) {
const { done, value } = await reader.read()
const { value, done } = await reader.read()
if (done)
break
chunks.push(value)
@@ -151,6 +77,21 @@ function renderAuthHeader(headerTemplate: string, plaintext: Buffer): string {
return headerTemplate.replace('{KEY}', plaintext.toString('utf8'))
}
/**
* Best-effort provider tag derived from `baseURL` host for OTel labels. We
* keep this loose every label below is just a dimension, not a domain
* identity. The admin-controlled `LLM_ROUTER_CONFIG` is the source of truth
* for which upstream serves a model.
*/
function deriveProviderTag(baseURL: string): string {
try {
return new URL(baseURL).hostname
}
catch {
return 'unknown'
}
}
/**
* Identity of the pool (concurrency pool) one TTS upstream belongs to. One
* upstream == one app_id, so the Volcengine `adapterParams.appid` is the pool
@@ -170,6 +111,65 @@ function ttsPoolId(upstream: TtsUpstream, modelName: string): string {
])}`
}
function failuresMatch(
statuses: ReadonlyArray<number | 'timeout'>,
triggers: RouteFailureTriggers | undefined,
): boolean {
if (statuses.length === 0 || triggers == null)
return false
return statuses.every((status) => {
if (status === 'timeout')
return triggers.onTimeout
return triggers.httpCodes.includes(status)
})
}
export interface CreateLlmRouterServiceOptions {
/** ConfigKV used to read `LLM_ROUTER_CONFIG`. */
configKV: ConfigKVService
/** Envelope crypto used to decrypt at-rest keys. */
envelopeCrypto: EnvelopeCrypto
/** OTel gateway metric bundle. `null` when OTel is disabled. */
gatewayMetrics: GatewayMetrics | null
/**
* Redis client used as the TTS voice catalog cache. Live catalogs (Azure)
* are stable but heavy; caching avoids hammering Microsoft on every voice
* picker open while keeping freshness within {@link TTS_VOICES_CACHE_TTL_S}.
*/
redis: Redis
/**
* Per-pool concurrency ledger backing capacity-aware TTS routing. When a TTS
* model has any upstream with `maxConcurrency` set, the router acquires a slot
* here before dispatching and releases it after, spreading load across app_ids
* instead of hammering the first upstream.
*/
concurrencyLedger: ConcurrencyLedger
/**
* Cool-down (seconds) a pool is skipped after exhausting with a 429 (app_id
* concurrency exceeded upstream-side). Separate from the ledger's in-flight
* TTL: this is a reactive circuit-breaker window, not a leak bound.
* @default 15
*/
ttsPoolSaturationTtlSeconds?: number
/**
* Fetch implementation. Defaults to `globalThis.fetch`. Tests inject a
* `vi.fn` so we never touch the real network.
* @default globalThis.fetch
*/
fetchImpl?: typeof fetch
/**
* Config cache TTL in milliseconds.
* @default 5_000
*/
configCacheTtlMs?: number
/**
* TTL for the Redis voice catalog cache in seconds.
* @default 21_600 (6h)
*/
ttsVoiceCacheTtlSeconds?: number
}
/**
* Default TTL for the TTS voice catalog Redis cache, per provider.
*
@@ -190,7 +190,13 @@ const TTS_VOICES_CACHE_TTL_S_BY_PROVIDER: Record<string, number> = {
'volcengine': 86_400,
}
export type LlmRouterService = ReturnType<typeof createLlmRouterService>
function ttsVoicesCacheTtl(provider: string): number {
return TTS_VOICES_CACHE_TTL_S_BY_PROVIDER[provider] ?? 21_600
}
function ttsVoicesCacheKey(provider: string, modelName: string): string {
return `tts:voices:${provider}:${modelName}`
}
/**
* Build the in-process LLM router service.
@@ -237,15 +243,15 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
req: LlmRouteRequest,
perAttemptTimeoutMs: number,
fallbackHttpCodes: number[],
onAttemptFailure: (failure: { bodySnippet?: string, errorMessage?: string, keyId: string, status: 'timeout' | number }) => void,
onAttemptFailure: (failure: { keyId: string, status: number | 'timeout', bodySnippet?: string, errorMessage?: string }) => void,
): Promise<
| { attemptIndex: number, kind: 'ok', response: Response, upstreamModel: string }
| { failures: Array<{ bodySnippet?: string, errorMessage?: string, keyId: string, status: 'timeout' | number }>, kind: 'exhausted' }
| { kind: 'ok', response: Response, attemptIndex: number, upstreamModel: string }
| { kind: 'exhausted', failures: Array<{ keyId: string, status: number | 'timeout', bodySnippet?: string, errorMessage?: string }> }
> {
const provider = deriveProviderTag(upstream.baseURL)
const rotator = createKeyRotator(upstream, options.envelopeCrypto, req.modelName, options.gatewayMetrics, provider)
const failures: Array<{ bodySnippet?: string, errorMessage?: string, keyId: string, status: 'timeout' | number }> = []
const failures: Array<{ keyId: string, status: number | 'timeout', bodySnippet?: string, errorMessage?: string }> = []
let attemptIndex = 0
for (const key of rotator) {
@@ -280,9 +286,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
let response: Response
try {
response = await fetchImpl(`${upstream.baseURL.replace(/\/+$/, '')}/chat/completions`, {
body,
headers,
method: 'POST',
headers,
body,
signal: attemptCtrl.signal,
})
}
@@ -295,12 +301,12 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
if (response.ok) {
// First 2xx wins. Enrich the active span and return.
trace.getActiveSpan()?.setAttributes({
[AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH]: attemptIndex,
[AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID]: key.id,
[AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_INDEX]: upstreamIndex,
[AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_URL]: upstream.baseURL,
[AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_INDEX]: upstreamIndex,
[AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID]: key.id,
[AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH]: attemptIndex,
})
return { attemptIndex, kind: 'ok', response, upstreamModel: effectiveModel }
return { kind: 'ok', response, attemptIndex, upstreamModel: effectiveModel }
}
const status = response.status
@@ -314,11 +320,11 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
// Source: codex review 2026-05-15 HIGH #2 (cancel) + cause-propagation
// follow-up 2026-05-16 (snippet).
const bodySnippet = await readUpstreamBodySnippet(response)
failures.push({ bodySnippet, keyId: key.id, status })
onAttemptFailure({ bodySnippet, keyId: key.id, status })
failures.push({ keyId: key.id, status, bodySnippet })
onAttemptFailure({ keyId: key.id, status, bodySnippet })
options.gatewayMetrics?.fallbackCount.add(1, {
from_key: key.id,
provider,
from_key: key.id,
reason: String(status),
})
options.gatewayMetrics?.upstreamErrors.add(1, {
@@ -349,11 +355,11 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
// ApiError.cause so operators can tell apart "DNS failed" from
// "attempt-timeout" without re-running the request.
const errorMessage = errorMessageFromUnknown(err)
failures.push({ errorMessage, keyId: key.id, status: 'timeout' })
onAttemptFailure({ errorMessage, keyId: key.id, status: 'timeout' })
failures.push({ keyId: key.id, status: 'timeout', errorMessage })
onAttemptFailure({ keyId: key.id, status: 'timeout', errorMessage })
options.gatewayMetrics?.fallbackCount.add(1, {
from_key: key.id,
provider,
from_key: key.id,
reason: 'timeout',
})
logger.withError(err).withFields({ keyId: key.id, upstream: upstream.baseURL }).warn('Upstream attempt failed (timeout / network)')
@@ -366,7 +372,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
attemptIndex += 1
}
return { failures, kind: 'exhausted' }
return { kind: 'exhausted', failures }
}
async function route(req: LlmRouteRequest, ctx?: LlmRouteContext): Promise<Response> {
@@ -383,10 +389,10 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
const llmModel = slice.model
const defaults = slice.defaults ?? { fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504], fullChainTimeoutMs: 60000, perAttemptTimeoutMs: 30000 }
const defaults = slice.defaults ?? { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] }
const fallbackHttpCodes = llmModel.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504]
const allFailures: Array<{ bodySnippet?: string, errorMessage?: string, keyId: string, provider: string, status: 'timeout' | number }> = []
const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', bodySnippet?: string, errorMessage?: string }> = []
let triedUpstreams = 0
async function attemptUpstream(upstream: LlmUpstream, index: number) {
@@ -422,10 +428,10 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
async function routeGroup(group: LlmRoutingGroup): Promise<
| { kind: 'exhausted', statuses: Array<'timeout' | number>, transitionBlocked: boolean }
| { kind: 'ok', response: Response }
| { kind: 'exhausted', statuses: Array<number | 'timeout'>, transitionBlocked: boolean }
> {
const statuses: Array<'timeout' | number> = []
const statuses: Array<number | 'timeout'> = []
for (let groupCandidateIndex = 0; groupCandidateIndex < group.upstreamIds.length; groupCandidateIndex += 1) {
const upstreamId = group.upstreamIds[groupCandidateIndex]
const index = llmModel.upstreams.findIndex(upstream => upstream.id === upstreamId)
@@ -509,9 +515,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
throw mapUpstreamError(
lastFailure.status,
{
lastStatusCode: lastFailure.status,
triedKeys: allFailures.length,
triedUpstreams,
lastStatusCode: lastFailure.status,
},
allFailures,
)
@@ -533,15 +539,15 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
perAttemptTimeoutMs: number,
fallbackHttpCodes: number[],
unspeechBaseURL: string,
onAttemptFailure: (failure: { errorMessage?: string, keyId: string, status: 'timeout' | number }) => void,
onAttemptFailure: (failure: { keyId: string, status: number | 'timeout', errorMessage?: string }) => void,
): Promise<
| { attemptIndex: number, body: ArrayBuffer | ReadableStream<Uint8Array>, contentType: string, kind: 'ok' }
| { failures: Array<{ errorMessage?: string, keyId: string, status: 'timeout' | number }>, kind: 'exhausted' }
| { kind: 'ok', contentType: string, body: ArrayBuffer | ReadableStream<Uint8Array>, attemptIndex: number }
| { kind: 'exhausted', failures: Array<{ keyId: string, status: number | 'timeout', errorMessage?: string }> }
> {
const providerTag = deriveProviderTag(upstream.baseURL)
const rotator = createKeyRotator(upstream, options.envelopeCrypto, modelName, options.gatewayMetrics, providerTag)
const adapter = getAdapter(providerId)
const failures: Array<{ errorMessage?: string, keyId: string, status: 'timeout' | number }> = []
const failures: Array<{ keyId: string, status: number | 'timeout', errorMessage?: string }> = []
let attemptIndex = 0
for (const key of rotator) {
@@ -561,12 +567,12 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
let result
try {
result = await adapter.send(input, {
abortSignal: attemptCtrl.signal,
adapterParams: upstream.adapterParams ?? {},
baseURL: upstream.baseURL.replace(/\/+$/, ''),
fetchImpl,
keyPlaintext: key.plaintext,
baseURL: upstream.baseURL.replace(/\/+$/, ''),
unspeechBaseURL,
adapterParams: upstream.adapterParams ?? {},
fetchImpl,
abortSignal: attemptCtrl.signal,
})
}
finally {
@@ -576,12 +582,12 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
trace.getActiveSpan()?.setAttributes({
[AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH]: attemptIndex,
[AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID]: key.id,
[AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_INDEX]: upstreamIndex,
[AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_URL]: upstream.baseURL,
[AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_INDEX]: upstreamIndex,
[AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID]: key.id,
[AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH]: attemptIndex,
})
return { attemptIndex, body: result.body, contentType: result.contentType, kind: 'ok' }
return { kind: 'ok', contentType: result.contentType, body: result.body, attemptIndex }
}
catch (err) {
if (abortSignal?.aborted) {
@@ -610,17 +616,17 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
const rawStatus
= (err as { status?: unknown }).status
?? (err instanceof ApiError ? err.statusCode : undefined)
const failureStatus: 'timeout' | number = typeof rawStatus === 'number' ? rawStatus : 'timeout'
const failureStatus: number | 'timeout' = typeof rawStatus === 'number' ? rawStatus : 'timeout'
// TTS adapters bake the upstream body snippet into err.message
// (azure: `azure tts upstream 403: <body>`, cosyvoice / volcengine
// analogous), so a single errorMessage carries both the status
// and the upstream payload diagnostics.
const errorMessage = errorMessageFromUnknown(err)
failures.push({ errorMessage, keyId: key.id, status: failureStatus })
onAttemptFailure({ errorMessage, keyId: key.id, status: failureStatus })
failures.push({ keyId: key.id, status: failureStatus, errorMessage })
onAttemptFailure({ keyId: key.id, status: failureStatus, errorMessage })
options.gatewayMetrics?.fallbackCount.add(1, {
from_key: key.id,
provider: providerTag,
from_key: key.id,
reason: String(failureStatus),
})
if (typeof rawStatus === 'number') {
@@ -645,7 +651,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
attemptIndex += 1
}
return { failures, kind: 'exhausted' }
return { kind: 'exhausted', failures }
}
/**
@@ -665,20 +671,20 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
upstreams: readonly TtsUpstream[],
modelName: string,
attemptUpstream: (upstream: TtsUpstream, index: number) => Promise<
| { kind: 'exhausted', sawTooManyRequests: boolean, statuses: Array<'timeout' | number> }
| { kind: 'ok', response: Response }
| { kind: 'exhausted', sawTooManyRequests: boolean, statuses: Array<number | 'timeout'> }
>,
retryOn?: RouteFailureTriggers,
strategy: 'least-inflight' | 'ordered' = 'least-inflight',
): Promise<
| { kind: 'exhausted', statuses: Array<'timeout' | number>, transitionBlocked: boolean }
| { kind: 'ok', response: Response }
| { kind: 'exhausted', statuses: Array<number | 'timeout'>, transitionBlocked: boolean }
> {
async function markSaturated(upstream: TtsUpstream, poolId: string): Promise<void> {
await ledger.markSaturated(poolId, ttsPoolSaturationTtlSeconds)
options.gatewayMetrics?.poolSaturationMarked.add(1, {
app_id: poolId,
provider: deriveProviderTag(upstream.baseURL),
app_id: poolId,
})
}
@@ -691,19 +697,19 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
const saturated = await ledger.isSaturated(poolId)
if (saturated) {
return {
eligible: false,
index,
inflight: Number.POSITIVE_INFINITY,
maxConcurrency,
poolId,
upstream,
index,
poolId,
maxConcurrency,
inflight: Number.POSITIVE_INFINITY,
eligible: false,
}
}
if (maxConcurrency == null)
return { eligible: true, index, inflight: 0, maxConcurrency, poolId, upstream }
return { upstream, index, poolId, maxConcurrency, inflight: 0, eligible: true }
const inflight = await ledger.currentInflight(poolId)
return { eligible: inflight < maxConcurrency, index, inflight, maxConcurrency, poolId, upstream }
return { upstream, index, poolId, maxConcurrency, inflight, eligible: inflight < maxConcurrency }
}))
const eligible = candidates.filter(c => c.eligible)
const ranked = strategy === 'least-inflight'
@@ -712,9 +718,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
let dispatchedAny = false
let attemptedPools = 0
const statuses: Array<'timeout' | number> = []
const statuses: Array<number | 'timeout'> = []
for (let rankedIndex = 0; rankedIndex < ranked.length; rankedIndex += 1) {
const { index, maxConcurrency, poolId, upstream } = ranked[rankedIndex]
const { upstream, index, poolId, maxConcurrency } = ranked[rankedIndex]
const hasNextCandidate = rankedIndex < ranked.length - 1
if (maxConcurrency == null) {
// Unlimited pool — dispatch without occupying a slot.
@@ -735,8 +741,8 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
if (!acquired) {
// Pool filled between the snapshot and now — skip without dispatching.
options.gatewayMetrics?.poolSlotRejected.add(1, {
app_id: poolId,
provider: deriveProviderTag(upstream.baseURL),
app_id: poolId,
})
continue
}
@@ -777,7 +783,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
}
async function routeTts(req: { abortSignal?: AbortSignal, input: TtsInput, modelName: string }, ctx?: LlmRouteContext): Promise<Response> {
async function routeTts(req: { modelName: string, input: TtsInput, abortSignal?: AbortSignal }, ctx?: LlmRouteContext): Promise<Response> {
if (req.abortSignal?.aborted)
throw req.abortSignal.reason ?? new Error('aborted')
@@ -793,12 +799,12 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
// local instead of `slice.model` to keep `provider`/`upstreams` typed.
const ttsModel = slice.model
const defaults = slice.defaults ?? { fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504], fullChainTimeoutMs: 60000, perAttemptTimeoutMs: 30000 }
const defaults = slice.defaults ?? { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] }
const fallbackHttpCodes = ttsModel.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504]
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
const allFailures: Array<{ errorMessage?: string, keyId: string, provider: string, status: 'timeout' | number }> = []
const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', errorMessage?: string }> = []
let triedUpstreams = 0
// tts upstream schema has no per-upstream timeoutMs (see ttsUpstreamSchema);
@@ -810,8 +816,8 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
// whether the upstream saw a 429 (app_id concurrency exceeded upstream-side)
// so the caller can circuit-break thatpool.
async function attemptUpstream(upstream: TtsUpstream, index: number): Promise<
| { kind: 'exhausted', sawTooManyRequests: boolean, statuses: Array<'timeout' | number> }
| { kind: 'ok', response: Response }
| { kind: 'exhausted', sawTooManyRequests: boolean, statuses: Array<number | 'timeout'> }
> {
const providerTag = deriveProviderTag(upstream.baseURL)
triedUpstreams += 1
@@ -835,7 +841,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
if (result.kind === 'ok') {
return {
kind: 'ok',
response: new Response(result.body, { headers: { 'content-type': result.contentType }, status: 200 }),
response: new Response(result.body, { status: 200, headers: { 'content-type': result.contentType } }),
}
}
@@ -847,8 +853,8 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
async function routeGroup(group: TtsRoutingGroup): Promise<
| { kind: 'exhausted', statuses: Array<'timeout' | number>, transitionBlocked: boolean }
| { kind: 'ok', response: Response }
| { kind: 'exhausted', statuses: Array<number | 'timeout'>, transitionBlocked: boolean }
> {
const indexedUpstreams = group.upstreamIds.map((upstreamId) => {
const index = ttsModel.upstreams.findIndex(upstream => upstream.id === upstreamId)
@@ -857,12 +863,12 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
`TTS routing group ${group.id} references unknown upstream ${upstreamId} for model ${req.modelName}`,
)
}
return { index, upstream: ttsModel.upstreams[index] }
return { upstream: ttsModel.upstreams[index], index }
})
const capacityManaged = indexedUpstreams.some(({ upstream }) => upstream.maxConcurrency != null)
if (group.strategy === 'least-inflight' || capacityManaged) {
const indexByUpstream = new Map(indexedUpstreams.map(({ index, upstream }) => [upstream, index]))
const indexByUpstream = new Map(indexedUpstreams.map(({ upstream, index }) => [upstream, index]))
return routeTtsAcrossPools(
indexedUpstreams.map(({ upstream }) => upstream),
req.modelName,
@@ -877,9 +883,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
)
}
const statuses: Array<'timeout' | number> = []
const statuses: Array<number | 'timeout'> = []
for (let groupCandidateIndex = 0; groupCandidateIndex < indexedUpstreams.length; groupCandidateIndex += 1) {
const { index, upstream } = indexedUpstreams[groupCandidateIndex]
const { upstream, index } = indexedUpstreams[groupCandidateIndex]
const result = await attemptUpstream(upstream, index)
if (result.kind === 'ok')
return result
@@ -954,9 +960,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
throw mapUpstreamError(
lastFailure.status,
{
lastStatusCode: lastFailure.status,
triedKeys: allFailures.length,
triedUpstreams,
lastStatusCode: lastFailure.status,
},
allFailures,
)
@@ -1017,16 +1023,16 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
const keyEntry = upstream.keys[0]
const plaintext = slice.model.provider === 'azure'
? options.envelopeCrypto.decryptKey(keyEntry.ciphertext, { keyEntryId: keyEntry.id, modelName })
? options.envelopeCrypto.decryptKey(keyEntry.ciphertext, { modelName, keyEntryId: keyEntry.id })
: undefined
try {
const voices = await adapter.getVoiceCatalog({
adapterParams: upstream.adapterParams ?? {},
fetchImpl,
keyPlaintext: plaintext,
region,
adapterParams: upstream.adapterParams ?? {},
unspeechBaseURL,
fetchImpl,
})
// Cache only on success — failure responses must NOT be persisted or
@@ -1061,7 +1067,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
// SCAN avoids blocking redis on a large keyspace; production deployments
// can have voice catalogs from many models. Using a stream keeps memory
// bounded.
const stream = options.redis.scanStream({ count: 100, match: 'tts:voices:*' })
const stream = options.redis.scanStream({ match: 'tts:voices:*', count: 100 })
const pipeline = options.redis.pipeline()
let queued = 0
for await (const keys of stream as AsyncIterable<string[]>) {
@@ -1078,6 +1084,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
return {
route,
routeTts,
listTtsVoices,
/**
* Expose the loader's invalidate hook so U7's Pub/Sub subscriber and
* a configuration writer can flush the cache without a separate
@@ -1090,16 +1099,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
* writes invalidate it directly so the next voice-picker fetch repopulates.
*/
invalidateTtsVoicesCache,
listTtsVoices,
route,
routeTts,
}
}
function ttsVoicesCacheKey(provider: string, modelName: string): string {
return `tts:voices:${provider}:${modelName}`
}
function ttsVoicesCacheTtl(provider: string): number {
return TTS_VOICES_CACHE_TTL_S_BY_PROVIDER[provider] ?? 21_600
}
export type LlmRouterService = ReturnType<typeof createLlmRouterService>
@@ -62,8 +62,8 @@ describe('concurrencyLedger', () => {
await ledger.tryAcquire('app-1', 10)
await ledger.tryAcquire('app-2', 10)
const snap = await ledger.snapshot()
expect(snap).toContainEqual({ inflight: 2, poolId: 'app-1' })
expect(snap).toContainEqual({ inflight: 1, poolId: 'app-2' })
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 () => {
@@ -8,44 +8,44 @@ import { createConfigLoader } from '../config-loader'
function makeConfig(): RouterConfig {
return {
defaults: { fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504], fullChainTimeoutMs: 60000, perAttemptTimeoutMs: 30000 },
llm: {
models: {
'openai/gpt-5-mini': {
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
upstreams: [
{
baseURL: 'https://openrouter.example/v1',
keys: [{ id: 'k1', ciphertext: 'v1.aa.bb.cc' }],
headerTemplate: 'Bearer {KEY}',
keys: [{ ciphertext: 'v1.aa.bb.cc', id: 'k1' }],
},
],
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
},
},
},
tts: {
models: {
'tts-1': {
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
provider: 'azure',
upstreams: [
{
adapterParams: {},
baseURL: 'https://azure.example/tts',
keys: [{ ciphertext: 'v1.aa.bb.cc', id: 'tk1' }],
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: null | RouterConfig): ConfigKVService {
function makeMockConfigKV(value: RouterConfig | null): ConfigKVService {
return {
get: vi.fn(),
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
}
@@ -57,7 +57,7 @@ describe('createConfigLoader', () => {
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, now: () => nowValue, ttlMs: 5000 })
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
nowValue = 2000
@@ -71,7 +71,7 @@ describe('createConfigLoader', () => {
it('invalidate() clears cache; next call re-reads from configKV', async () => {
const configKV = makeMockConfigKV(makeConfig())
let nowValue = 1000
const loader = createConfigLoader({ configKV, now: () => nowValue, ttlMs: 5000 })
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
loader.invalidate()
@@ -84,7 +84,7 @@ describe('createConfigLoader', () => {
it('tTL expiry triggers fresh read on next call', async () => {
const configKV = makeMockConfigKV(makeConfig())
let nowValue = 1000
const loader = createConfigLoader({ configKV, now: () => nowValue, ttlMs: 5000 })
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
nowValue = 1000 + 5001
@@ -120,8 +120,8 @@ describe('createConfigLoader', () => {
expect((err as ApiError).statusCode).toBe(400)
expect((err as ApiError).errorCode).toBe('BAD_REQUEST')
expect((err as ApiError).details).toEqual({
available: ['openai/gpt-5-mini'],
requested: 'nope/does-not-exist',
available: ['openai/gpt-5-mini'],
})
}
})
@@ -137,8 +137,8 @@ describe('createConfigLoader', () => {
catch (err) {
expect((err as ApiError).statusCode).toBe(400)
expect((err as ApiError).details).toEqual({
available: ['tts-1'],
requested: 'nope-tts',
available: ['tts-1'],
})
}
})
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { ApiError } from '../../../../utils/error'
import { mapUpstreamError } from '../error-mapping'
const exampleContext = { lastStatusCode: 401 as const, triedKeys: 2, triedUpstreams: 1 }
const exampleContext = { triedKeys: 2, triedUpstreams: 1, lastStatusCode: 401 as const }
describe('mapUpstreamError', () => {
/**
@@ -65,8 +65,8 @@ describe('mapUpstreamError', () => {
})
it('attaches sanitized details (triedKeys / triedUpstreams / lastStatusCode) — no upstream body', () => {
const err = mapUpstreamError(500, { lastStatusCode: 500, triedKeys: 4, triedUpstreams: 2 })
expect(err.details).toEqual({ lastStatusCode: 500, triedKeys: 4, triedUpstreams: 2 })
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)', () => {
@@ -20,20 +20,20 @@ function makeCounter(): Counter {
return { add: vi.fn() } as unknown as Counter
}
function makeMetrics(): { decryptFailures: Counter, metrics: GatewayMetrics } {
function makeMetrics(): { metrics: GatewayMetrics, decryptFailures: Counter } {
const decryptFailures = makeCounter()
// We only exercise decryptFailures here; the rest are unused stubs.
const metrics = {
configInvalidHmac: makeCounter(),
configReload: makeCounter(),
decryptFailures,
fallbackCount: makeCounter(),
upstreamErrors: makeCounter(),
keyExhaustedCount: makeCounter(),
sameStatusExhaustion: makeCounter(),
configReload: makeCounter(),
decryptFailures,
subscriberState: makeCounter(),
upstreamErrors: makeCounter(),
configInvalidHmac: makeCounter(),
} as GatewayMetrics
return { decryptFailures, metrics }
return { metrics, decryptFailures }
}
describe('createKeyRotator', () => {
@@ -45,9 +45,9 @@ describe('createKeyRotator', () => {
const modelName = 'openai/gpt-5-mini'
const upstream = {
keys: [
{ ciphertext: crypto.encryptKey('sk-key-one', { keyEntryId: 'k1', modelName }), id: 'k1' },
{ ciphertext: crypto.encryptKey('sk-key-two', { keyEntryId: 'k2', modelName }), id: 'k2' },
{ ciphertext: crypto.encryptKey('sk-key-three', { keyEntryId: 'k3', modelName }), id: 'k3' },
{ 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()
@@ -69,7 +69,7 @@ describe('createKeyRotator', () => {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'm'
const upstream = {
keys: [{ ciphertext: crypto.encryptKey('sk-only', { keyEntryId: 'only', modelName }), id: 'only' }],
keys: [{ id: 'only', ciphertext: crypto.encryptKey('sk-only', { modelName, keyEntryId: 'only' }) }],
}
const { metrics } = makeMetrics()
@@ -94,10 +94,10 @@ describe('createKeyRotator', () => {
const modelName = 'm'
const upstream = {
keys: [
{ ciphertext: 'v1.AAAA.BBBB.CCCC', id: 'bad' },
{ id: 'bad', ciphertext: 'v1.AAAA.BBBB.CCCC' },
],
}
const { decryptFailures, metrics } = makeMetrics()
const { metrics, decryptFailures } = makeMetrics()
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
@@ -123,7 +123,7 @@ describe('createKeyRotator', () => {
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({ key_entry_id: 'bad', provider: 'openrouter' })
expect(firstCall[1]).toEqual({ provider: 'openrouter', key_entry_id: 'bad' })
})
it('decrypt failure on a later key still aborts iteration immediately (no partial yields)', () => {
@@ -131,8 +131,8 @@ describe('createKeyRotator', () => {
const modelName = 'm'
const upstream = {
keys: [
{ ciphertext: crypto.encryptKey('sk-good', { keyEntryId: 'k1', modelName }), id: 'k1' },
{ ciphertext: 'v1.AAAA.BBBB.CCCC', id: 'bad' },
{ id: 'k1', ciphertext: crypto.encryptKey('sk-good', { modelName, keyEntryId: 'k1' }) },
{ id: 'bad', ciphertext: 'v1.AAAA.BBBB.CCCC' },
],
}
const { metrics } = makeMetrics()
@@ -153,7 +153,7 @@ describe('createKeyRotator', () => {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'm'
const upstream = {
keys: [{ ciphertext: crypto.encryptKey('sk-x', { keyEntryId: 'k1', modelName }), id: 'k1' }],
keys: [{ id: 'k1', ciphertext: crypto.encryptKey('sk-x', { modelName, keyEntryId: 'k1' }) }],
}
const rotator = createKeyRotator(upstream, crypto, modelName, null, 'openrouter')
File diff suppressed because it is too large Load Diff
@@ -25,81 +25,26 @@ import type {
} from '../../adapters/config-kv'
/**
* ASR model entry provider tag + ordered upstreams for realtime transcription.
* Composite router config (the value at `LLM_ROUTER_CONFIG` in configKV).
*/
export type AsrModel = InferOutput<typeof asrModelSchema>
export type RouterConfig = InferOutput<typeof llmRouterConfigSchema>
/**
* ASR upstream one provider credential set plus adapter params.
* Top-level routing defaults (per-attempt + full-chain timeouts and
* fallback HTTP codes).
*/
export type AsrUpstream = InferOutput<typeof asrUpstreamSchema>
export type RouterDefaults = InferOutput<typeof llmRouterDefaultsSchema>
/**
* Per-(upstream) fallback trigger config: which upstream HTTP codes should
* cause the router to move on to the next key/upstream.
* LLM upstream one candidate endpoint with its ordered key list.
*/
export type FallbackTriggers = InferOutput<typeof fallbackTriggersSchema>
/**
* 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>
export type LlmUpstream = InferOutput<typeof llmUpstreamSchema>
/**
* LLM model entry upstream candidates plus an optional grouped route.
*/
export type LlmModel = InferOutput<typeof llmModelSchema>
/**
* 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 {
/** Most recent upstream failure status or `'timeout'`. */
lastStatus: 'timeout' | null | number
/** Provider tag for OTel labels (e.g. `openrouter`). */
provider: string
/** Number of keys attempted across all upstreams so far. */
triedKeys: number
/** Number of upstreams attempted so far. */
triedUpstreams: number
/** Actual model id sent to the winning upstream after `overrideModel` rewrites. */
upstreamModel?: string
}
/**
* 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 {
/**
* 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
/** 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>
/**
* 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
}
/**
* LLM route composed from ordered candidate groups.
*/
@@ -111,30 +56,9 @@ export type LlmRouting = InferOutput<typeof llmRoutingSchema>
export type LlmRoutingGroup = InferOutput<typeof llmRoutingGroupSchema>
/**
* LLM upstream one candidate endpoint with its ordered key list.
* TTS upstream one candidate endpoint with adapter params + key list.
*/
export type LlmUpstream = InferOutput<typeof llmUpstreamSchema>
/**
* Which surface the router orchestrates for a given route call.
*/
export type ModelKind = 'llm' | 'tts'
/**
* Failure allow-list that authorizes a routing transition.
*/
export type RouteFailureTriggers = InferOutput<typeof routeFailureTriggersSchema>
/**
* 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>
export type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
/**
* TTS model entry provider tag, upstream candidates, and optional grouped route.
@@ -152,6 +76,82 @@ export type TtsRouting = InferOutput<typeof ttsRoutingSchema>
export type TtsRoutingGroup = InferOutput<typeof ttsRoutingGroupSchema>
/**
* TTS upstream one candidate endpoint with adapter params + key list.
* ASR model entry provider tag + ordered upstreams for realtime transcription.
*/
export type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
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
}
@@ -6,9 +6,9 @@ import { startChatGeneration, startTtsGeneration } from '.'
// real exporter. `startObservation` returns a stub generation whose methods are
// spies; `otelSpan.setAttribute` captures trace-identity attributes.
const generationStub = {
end: vi.fn(),
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', () => ({
@@ -16,7 +16,7 @@ vi.mock('@langfuse/tracing', () => ({
}))
const BASE_INPUT = {
input: [{ content: 'hi', role: 'user' }],
input: [{ role: 'user', content: 'hi' }],
model: 'openai/gpt-5-mini',
requestId: 'req-1',
stream: false,
@@ -40,7 +40,7 @@ describe('startChatGeneration', () => {
// @example disabled deployment: no env set
const trace = startChatGeneration(BASE_INPUT)
trace.appendStreamChunk('data: {"choices":[{"delta":{"content":"x"}}]}\n')
trace.succeed({ completionTokens: 1, output: 'x', promptTokens: 1 })
trace.succeed({ output: 'x', promptTokens: 1, completionTokens: 1 })
trace.fail('should be ignored')
expect(startObservation).not.toHaveBeenCalled()
@@ -62,8 +62,8 @@ describe('startChatGeneration', () => {
'chat.completion',
{
input: BASE_INPUT.input,
metadata: { requestId: 'req-1', stream: true },
model: BASE_INPUT.model,
metadata: { requestId: 'req-1', stream: true },
},
{ asType: 'generation' },
)
@@ -82,12 +82,12 @@ describe('startChatGeneration', () => {
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({ completionTokens: 34, fluxConsumed: 5, output: { ok: true }, promptTokens: 12 })
trace.succeed({ output: { ok: true }, promptTokens: 12, completionTokens: 34, fluxConsumed: 5 })
expect(generationStub.update).toHaveBeenCalledWith({
metadata: { fluxConsumed: 5, requestId: 'req-1', stream: false },
output: { ok: true },
usageDetails: { input: 12, output: 34 },
metadata: { requestId: 'req-1', stream: false, fluxConsumed: 5 },
})
expect(generationStub.end).toHaveBeenCalledTimes(1)
})
@@ -99,12 +99,12 @@ describe('startChatGeneration', () => {
trace.appendStreamChunk('data: {"choices":[{"delta":{"con')
trace.appendStreamChunk('tent":"Hel"}}]}\ndata: {"choices":[{"delta":{"content":"lo"}}]}\n')
trace.appendStreamChunk('data: [DONE]\n')
trace.succeed({ completionTokens: 1, fluxConsumed: 1, promptTokens: 2 })
trace.succeed({ promptTokens: 2, completionTokens: 1, fluxConsumed: 1 })
expect(generationStub.update).toHaveBeenCalledWith({
metadata: { fluxConsumed: 1, requestId: 'req-1', stream: true },
output: 'Hello',
usageDetails: { input: 2, output: 1 },
metadata: { requestId: 'req-1', stream: true, fluxConsumed: 1 },
})
})
@@ -129,8 +129,8 @@ describe('startChatGeneration', () => {
expect(generationStub.update).toHaveBeenCalledWith({
level: 'ERROR',
metadata: { requestId: 'req-1', stream: false },
statusMessage: 'Gateway 502',
metadata: { requestId: 'req-1', stream: false },
})
expect(generationStub.end).toHaveBeenCalledTimes(1)
})
@@ -160,44 +160,44 @@ describe('startChatGeneration', () => {
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: { responseFormat: 'mp3', text: 'hello', voice: 'alloy' },
input: { text: 'hello', voice: 'alloy', responseFormat: 'mp3' },
model: 'tts-1',
requestId: 'tts-1',
sessionId: 'sess-1',
userId: 'user-1',
sessionId: 'sess-1',
})
trace.succeed({
fluxConsumed: 2,
inputChars: 5,
fluxConsumed: 2,
output: { contentType: 'audio/mpeg' },
})
expect(startObservation).toHaveBeenCalledWith(
'tts.speech',
{
input: { responseFormat: 'mp3', text: 'hello', voice: 'alloy' },
metadata: {
inputChars: 5,
requestId: 'tts-1',
responseFormat: 'mp3',
speed: undefined,
voice: 'alloy',
},
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({
metadata: {
fluxConsumed: 2,
inputChars: 5,
requestId: 'tts-1',
responseFormat: 'mp3',
speed: undefined,
voice: 'alloy',
},
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)
@@ -10,111 +10,21 @@ import { startObservation } from '@langfuse/tracing'
*/
const STREAM_OUTPUT_CHAR_CAP = 1_000_000
/** Parameters identifying the chat request a generation traces. */
export interface ChatGenerationInput extends Omit<GenerationInput, 'metadata' | 'name'> {
/** 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
}
/** Terminal usage/cost figures recorded when a chat generation completes successfully. */
export interface ChatGenerationResult {
completionTokens?: number
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
fluxConsumed?: number
/**
* Explicit completion to record. Omit for streaming requests to use the
* assistant text assembled from the streamed SSE deltas.
*/
output?: unknown
promptTokens?: number
}
/**
* Lifecycle handle for one chat completion's Langfuse generation.
* Whether per-request Langfuse generations should be created.
*
* 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).
* 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.
*/
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 failure (`level: ERROR` + message) and end the generation. */
fail: (statusMessage: string) => void
/** Record a successful completion with usage/cost and end the generation. */
succeed: (result: ChatGenerationResult) => void
}
/** Parameters identifying the TTS request a generation traces. */
export interface TtsGenerationInput extends Omit<GenerationInput, 'metadata' | 'name'> {
/** Adapter-neutral TTS request payload, recorded as trace input. */
input: {
responseFormat?: string
speed?: number
text: string
voice?: string
}
}
/** Terminal usage/cost figures recorded when a TTS generation completes successfully. */
export interface TtsGenerationResult {
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
fluxConsumed?: number
/** Input character count charged by the TTS flux meter. */
inputChars: number
/** Additional terminal metadata to merge with request metadata. */
metadata?: Record<string, unknown>
/** Output metadata only; binary audio is not buffered into Langfuse. */
output?: unknown
}
/** Lifecycle handle for one TTS Langfuse generation. */
export interface TtsGenerationTrace {
/** Record a failure (`level: ERROR` + message) and end the generation. */
fail: (statusMessage: string) => void
/** Record a successful speech generation with character usage/cost and end the generation. */
succeed: (result: TtsGenerationResult) => void
}
/** Parameters identifying a request a Langfuse generation traces. */
interface GenerationInput {
/** Provider-domain input payload, recorded verbatim as trace input. */
input: unknown
/** Extra observation metadata. */
metadata?: Record<string, unknown>
/** Resolved upstream model id (after `auto` aliases are replaced). */
model: string
/** Generation name shown in Langfuse. */
name: string
/** Correlation id shared with billing / request-log rows. */
requestId: string
/** Client-supplied conversation id (`x-airi-session-id`). Absent → user-only attribution. */
sessionId?: string
/** Billing/identity owner of the request. Lifted to trace-level `userId`. */
userId: string
}
/** Terminal usage/cost figures recorded when a generation completes successfully. */
interface GenerationResult {
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
fluxConsumed?: number
/** Additional terminal metadata to merge with request metadata. */
metadata?: Record<string, unknown>
/**
* 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>
function tracingActive(): boolean {
return process.env.LANGFUSE_TRACING_ACTIVE === '1'
}
/**
@@ -149,32 +59,165 @@ function extractSseDeltaText(sseLine: string): string {
}
}
/** 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>
}
/**
* Whether per-request Langfuse generations should be created.
* Lifecycle handle for one chat completion's Langfuse generation.
*
* 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.
* 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).
*/
function tracingActive(): boolean {
return process.env.LANGFUSE_TRACING_ACTIVE === '1'
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() {},
fail() {},
succeed() {},
fail() {},
}
const NOOP_TTS_TRACE: TtsGenerationTrace = {
fail() {},
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()
},
}
}
/**
@@ -195,12 +238,12 @@ const NOOP_TTS_TRACE: TtsGenerationTrace = {
export function startChatGeneration(input: ChatGenerationInput): ChatGenerationTrace {
const generation = startGeneration({
input: input.input,
metadata: { stream: input.stream },
model: input.model,
name: 'chat.completion',
requestId: input.requestId,
sessionId: input.sessionId,
name: 'chat.completion',
metadata: { stream: input.stream },
userId: input.userId,
sessionId: input.sessionId,
})
if (!generation)
return NOOP_CHAT_TRACE
@@ -227,16 +270,16 @@ export function startChatGeneration(input: ChatGenerationInput): ChatGenerationT
break
}
},
fail(statusMessage) {
generation.fail(statusMessage)
},
succeed(result) {
generation.succeed({
fluxConsumed: result.fluxConsumed,
output: result.output ?? assistantText,
usageDetails: { input: result.promptTokens ?? 0, output: result.completionTokens ?? 0 },
fluxConsumed: result.fluxConsumed,
})
},
fail(statusMessage) {
generation.fail(statusMessage)
},
}
}
@@ -257,75 +300,32 @@ export function startChatGeneration(input: ChatGenerationInput): ChatGenerationT
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,
responseFormat: input.input.responseFormat,
speed: input.input.speed,
voice: input.input.voice,
speed: input.input.speed,
responseFormat: input.input.responseFormat,
},
model: input.model,
name: 'tts.speech',
requestId: input.requestId,
sessionId: input.sessionId,
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)
},
succeed(result) {
generation.succeed({
fluxConsumed: result.fluxConsumed,
metadata: { inputChars: result.inputChars, ...result.metadata },
output: result.output,
usageDetails: { input: result.inputChars },
})
},
}
}
function startGeneration(input: GenerationInput): null | {
fail: (statusMessage: string) => void
succeed: (result: GenerationResult) => void
} {
if (!tracingActive())
return null
const baseMetadata = { requestId: input.requestId, ...input.metadata }
const generation = startObservation(input.name, {
input: input.input,
metadata: baseMetadata,
model: input.model,
}, { 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 {
fail(statusMessage) {
if (ended)
return
ended = true
generation.update({ level: 'ERROR', metadata: baseMetadata, statusMessage })
generation.end()
},
succeed(result) {
if (ended)
return
ended = true
generation.update({
metadata: { ...baseMetadata, ...result.metadata, fluxConsumed: result.fluxConsumed ?? 0 },
output: result.output,
usageDetails: result.usageDetails,
})
generation.end()
},
}
}
@@ -22,39 +22,51 @@ import {
const tracer = trace.getTracer('v1-completions')
const SAFE_RESPONSE_HEADERS = new Set([
'cache-control',
'content-length',
'content-type',
'content-length',
'transfer-encoding',
'cache-control',
])
export interface OpenAiSpeechRequest {
abortSignal?: AbortSignal
body: Record<string, unknown>
sessionId?: string
userId: string
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 {
configKV: ConfigKVService
fluxService: FluxService
genAi?: GenAiMetrics | null
configKV: ConfigKVService
requestLogService: RequestLogService
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
providerCatalogService: ProviderCatalogService
genAi?: GenAiMetrics | null
llmTracing: {
startTtsGeneration: (input: Parameters<typeof startTtsGeneration>[0]) => TtsGenerationTrace
}
providerCatalogService: ProviderCatalogService
requestLogService: RequestLogService
ttsMeter: FluxMeter
voicePackService: VoicePackService
}
interface TtsAnalyticsContext {
source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
trigger: TtsTrigger
export interface OpenAiSpeechRequest {
userId: string
body: Record<string, unknown>
sessionId?: string
abortSignal?: AbortSignal
}
type TtsTrigger = 'auto' | 'manual'
interface TtsAnalyticsContext {
trigger: TtsTrigger
source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
}
/**
* Runs the OpenAI-shaped text-to-speech gateway flow.
@@ -97,10 +109,10 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier)
logger.withFields({
inputChars: inputText.length,
model: requestModel,
requestId,
userId: input.userId,
model: requestModel,
inputChars: inputText.length,
voice: requestVoice,
}).log('tts speech request')
@@ -113,11 +125,11 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
throw err
logger.withError(err).withFields({
model: requestModel,
requestId,
source: analytics.source,
trigger: analytics.trigger,
userId: input.userId,
model: requestModel,
trigger: analytics.trigger,
source: analytics.source,
}).warn('tts speech blocked by pre-flight balance check')
if (analytics.trigger === 'auto')
@@ -127,37 +139,37 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
}
const ttsInput = {
extraOptions: voicePackRequest.extraOptions,
responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined,
speed: voicePackRequest.speed ?? (typeof input.body.speed === 'number' ? input.body.speed : undefined),
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,
sessionId: input.sessionId,
userId: input.userId,
sessionId: input.sessionId,
})
const span = tracer.startSpan('llm.gateway.tts', {
attributes: {
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech',
[GEN_AI_ATTR_REQUEST_MODEL]: requestModel,
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech',
},
})
const startedAt = Date.now()
const routeCtx = { lastStatus: null, provider: 'unknown', triedKeys: 0, triedUpstreams: 0 }
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({
abortSignal: input.abortSignal,
input: ttsInput,
modelName: requestModel,
input: ttsInput,
abortSignal: input.abortSignal,
}, routeCtx))
}
catch (err) {
@@ -182,29 +194,29 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` })
span.end()
generationTrace.fail(`Gateway ${response.status}`)
recordMetrics({ durationMs, fluxConsumed: 0, model: requestModel, provider: routeCtx.provider, status: response.status })
logger.withFields({ durationMs, model: requestModel, requestId, status: response.status, userId: input.userId })
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed: 0 })
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, {
headers: buildSafeResponseHeaders(response),
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
let fluxConsumed = 0
try {
const result = await deps.ttsMeter.accumulate({
currentBalance: flux.flux,
metadata: { costMultiplier: voicePackRequest.costMultiplier, model: requestModel },
requestId,
units: billingUnits,
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({
fluxConsumed,
inputChars: inputText.length,
fluxConsumed,
output: { contentType: response.headers.get('content-type') },
})
}
@@ -216,41 +228,41 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
span.end()
}
recordMetrics({ durationMs, fluxConsumed, model: requestModel, provider: routeCtx.provider, status: response.status })
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed })
deps.requestLogService.logRequest({
durationMs,
fluxConsumed,
userId: input.userId,
model: requestModel,
status: response.status,
userId: input.userId,
durationMs,
fluxConsumed,
}).catch(err => logger.withError(err).warn('Failed to write llm_request_log row'))
logger.withFields({
durationMs,
fluxConsumed,
inputChars: inputText.length,
model: requestModel,
requestId,
status: response.status,
userId: input.userId,
model: requestModel,
status: response.status,
durationMs,
inputChars: inputText.length,
fluxConsumed,
}).log('tts speech delivered')
return new Response(response.body, {
headers: buildSafeResponseHeaders(response),
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
function recordMetrics(input: {
model: string
status: number
provider: string
durationMs: number
fluxConsumed: number
model: string
provider: string
status: number
}): void {
const attrs = {
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'tts',
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'tts',
'http.response.status_code': input.status,
'provider': input.provider,
}
@@ -261,26 +273,56 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
return { handleSpeechRequest }
}
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 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'
return { trigger, source }
}
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
}
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
function readOptionalNumber(record: Record<string, unknown> | undefined, key: string): number | undefined {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined
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(
@@ -317,69 +359,27 @@ async function resolveVoicePackRequest(
return pack
}
function routerFailure(error: unknown): { message: string, reason: string, status: number } {
function routerFailure(error: unknown): { status: number, reason: string, message: string } {
if (error instanceof ApiError) {
return {
message: error.message,
reason: error.errorCode,
status: error.statusCode,
reason: error.errorCode,
message: error.message,
}
}
return {
message: 'TTS router exhausted or unknown model',
reason: 'router_exhausted',
status: 502,
reason: 'router_exhausted',
message: 'TTS router exhausted or unknown model',
}
}
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'
return { source, trigger }
}
async function voicePackRequestOptions(
body: Record<string, unknown>,
context: {
requestedModel: string
voice?: string
voicePackService: VoicePackService
},
): Promise<{
costMultiplier: number
extraOptions: Record<string, unknown> | undefined
model?: string
speed?: number
voice?: string
voicePackId?: string
}> {
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 {
costMultiplier: voicePack?.costMultiplier ?? 1,
extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined,
model: voicePack?.ttsModelId,
speed: voicePack?.params.rate,
voice: voicePack?.upstreamVoiceId,
voicePackId: voicePack?.id,
}
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
}
@@ -8,33 +8,33 @@ describe('productEventService', () => {
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await service.track({
action: 'user_signed_up',
userId: 'user-1',
feature: 'auth',
action: 'user_signed_up',
status: 'succeeded',
userId: 'user-1',
})
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'checkout_started',
feature: 'billing',
source: 'stripe.checkout',
status: 'succeeded',
userId: 'user-1',
source: 'stripe.checkout',
})
await service.track({
action: 'payment_completed',
feature: 'billing',
metadata: { amount_minor_unit: 990, currency: 'usd' },
source: 'stripe.webhook',
status: 'succeeded',
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: { amount_minor_unit: 990, currency: 'usd' },
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: 'signup_completed',
properties: {
airi_user_id: 'user-1',
app_surface: 'server',
airi_user_id: 'user-1',
feature: 'auth',
status: 'succeeded',
},
@@ -43,24 +43,24 @@ describe('productEventService', () => {
distinctId: 'user-1',
event: 'checkout_created',
properties: {
airi_user_id: 'user-1',
app_surface: 'server',
airi_user_id: 'user-1',
feature: 'billing',
source: 'stripe.checkout',
status: 'succeeded',
source: 'stripe.checkout',
},
})
expect(capture).toHaveBeenNthCalledWith(3, {
distinctId: 'user-1',
event: 'payment_completed',
properties: {
airi_user_id: 'user-1',
amount_minor_unit: 990,
app_surface: 'server',
currency: 'usd',
airi_user_id: 'user-1',
feature: 'billing',
source: 'stripe.webhook',
status: 'succeeded',
source: 'stripe.webhook',
amount_minor_unit: 990,
currency: 'usd',
},
})
})
@@ -70,33 +70,33 @@ describe('productEventService', () => {
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await service.track({
action: 'payment_completed',
eventId: 'cs_123',
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: 'cs_123',
metadata: {
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
},
status: 'succeeded',
userId: 'user-1',
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: '$identify',
uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/),
properties: {
$anon_distinct_id: 'anon-browser-1',
$insert_id: 'cs_123',
$anon_distinct_id: 'anon-browser-1',
$session_id: 'ph-session-1',
airi_user_id: 'user-1',
},
uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/),
})
expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({
distinctId: 'user-1',
event: 'payment_completed',
properties: expect.objectContaining({ $insert_id: 'cs_123' }),
uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/),
properties: expect.objectContaining({ $insert_id: 'cs_123' }),
}))
})
@@ -104,11 +104,11 @@ describe('productEventService', () => {
const capture = vi.fn(async () => {})
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
const input = {
action: 'payment_completed' as const,
eventId: 'cs_replayed',
feature: 'billing' as const,
status: 'succeeded' as const,
userId: 'user-1' as const,
feature: 'billing' as const,
action: 'payment_completed' as const,
status: 'succeeded' as const,
eventId: 'cs_replayed',
}
await service.track(input)
@@ -117,8 +117,8 @@ describe('productEventService', () => {
expect(capture).toHaveBeenCalledTimes(2)
const captures = capture.mock.calls as unknown as Array<[
{
properties: Record<string, unknown>
uuid?: string
properties: Record<string, unknown>
},
]>
const first = captures[0]![0]
@@ -133,11 +133,11 @@ describe('productEventService', () => {
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await expect(service.track({
action: 'payment_completed',
feature: 'billing',
metadata: { $insert_id: 'spoofed' },
status: 'succeeded',
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
metadata: { $insert_id: 'spoofed' },
})).resolves.toBeUndefined()
expect(capture).not.toHaveBeenCalled()
@@ -150,12 +150,12 @@ describe('productEventService', () => {
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await expect(service.track({
action: 'payment_completed',
eventId: 'cs_456',
feature: 'billing',
metadata: { posthog_distinct_id: 'anon-browser-1' },
status: 'succeeded',
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: 'cs_456',
metadata: { posthog_distinct_id: 'anon-browser-1' },
})).resolves.toBeUndefined()
expect(capture).toHaveBeenCalledTimes(2)
@@ -172,10 +172,10 @@ describe('productEventService', () => {
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await expect(service.track({
action: 'payment_completed',
feature: 'billing',
status: 'succeeded',
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
})
})
@@ -16,74 +16,74 @@ const RESERVED_POSTHOG_METADATA_KEYS = new Set([
'status',
])
export type ProductFeature = 'auth' | 'billing'
export type ProductEventStatus = 'succeeded'
export type ProductEventMetadata = Record<string, string | number | boolean | null>
export type ProductAction
= | 'user_signed_up'
| 'checkout_started'
| 'payment_completed'
/** Product funnel fact forwarded to PostHog from the server. */
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 primitive metadata for product analysis. Avoid PII and raw prompts. */
metadata?: ProductEventMetadata
/** Stable source event id used by PostHog for replay-safe deduplication. */
eventId?: string
}
/** Product runtime where the user initiated the AI generation. */
export type AiGenerationAppSurface = 'electron' | 'mobile' | 'web'
export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron'
/** Runtime that captured the `$ai_generation` fact. */
export type AiGenerationCaptureSurface = 'client' | 'server'
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 = 'estimated' | 'reported' | 'unavailable'
export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable'
/** Content-free PostHog AI generation fact keyed to the authenticated user. */
export interface AiGenerationEventInput {
/** 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
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
costUsdSource?: AiGenerationCostUsdSource
generationId: string
inputTokens?: number
latencySeconds?: number
model: string
outputTokens?: number
provider: string
providerType: 'custom' | 'official' | 'unknown'
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
totalCostUsd?: number
totalTokens?: number
traceId: string
usageSource: 'estimated' | 'reported' | 'unavailable'
userId: string
}
export type ProductAction
= | 'checkout_started'
| 'payment_completed'
| 'user_signed_up'
/** Product funnel fact forwarded to PostHog from the server. */
export interface ProductEventInput {
/** Bounded user/business action within the feature. */
action: ProductAction
/** Stable source event id used by PostHog for replay-safe deduplication. */
eventId?: string
/** Bounded product area used for product dashboards and funnels. */
feature: ProductFeature
/** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */
metadata?: ProductEventMetadata
/** Optional bounded route/surface label such as `openai.chat.completions`. */
source?: string
/** Lifecycle state for the action. */
status: ProductEventStatus
/** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */
userId: string
}
export type ProductEventMetadata = Record<string, boolean | null | number | string>
export type ProductEventStatus = 'succeeded'
export type ProductFeature = 'auth' | 'billing'
/**
* Server-side actions that anchor a PostHog product funnel. Per-request LLM
* and TTS telemetry stays in operational systems and does not enter this path.
@@ -93,12 +93,27 @@ export type ProductFeature = 'auth' | 'billing'
* 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',
checkout_started: 'checkout_created',
payment_completed: 'payment_completed',
user_signed_up: 'signup_completed',
}
export type ProductEventService = ReturnType<typeof createProductEventService>
function stringMetadata(input: ProductEventInput, key: string): string | undefined {
const value = input.metadata?.[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function posthogEventUuid(event: string, eventId: string): string {
const digest = createHash('sha256').update(`airi:posthog:${event}:${eventId}`, 'utf8').digest()
digest[6] = (digest[6] & 0x0F) | 0x50
digest[8] = (digest[8] & 0x3F) | 0x80
const hex = digest.subarray(0, 16).toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boolean {
return metadata != null && Object.keys(metadata).some(key => RESERVED_POSTHOG_METADATA_KEYS.has(key))
}
/**
* Creates AIRI's server-side PostHog product analytics writer.
@@ -113,8 +128,51 @@ export type ProductEventService = ReturnType<typeof createProductEventService>
* Returns:
* - A best-effort event writer. Capture errors never change the business flow.
*/
export function createProductEventService(posthog?: null | PosthogSink) {
export function createProductEventService(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> {
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action]
if (!posthog || !forwardedEvent)
@@ -153,8 +211,8 @@ export function createProductEventService(posthog?: null | PosthogSink) {
properties: {
...input.metadata,
...(input.eventId && { $insert_id: input.eventId }),
airi_user_id: input.userId,
app_surface: 'server',
airi_user_id: input.userId,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { $session_id: posthogSessionId }),
feature: input.feature,
@@ -168,65 +226,7 @@ export function createProductEventService(posthog?: null | PosthogSink) {
logger.withError(err).withFields({ action: input.action }).warn('PostHog product analytics capture failed')
}
},
trackGeneration(input: AiGenerationEventInput): void {
if (!posthog)
return
const event = {
distinctId: input.userId,
event: '$ai_generation',
properties: {
$ai_model: input.model,
$ai_provider: input.provider,
$ai_session_id: input.conversationId,
$ai_span_id: input.generationId,
$ai_trace_id: input.traceId,
...(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,
conversation_id: input.conversationId,
conversation_id_source: input.conversationIdSource,
cost_usd_known: input.totalCostUsd != null,
cost_usd_source: input.costUsdSource ?? 'unavailable',
provider_type: input.providerType,
token_usage_available: input.usageSource !== 'unavailable',
usage_source: input.usageSource,
...(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'))
},
}
}
function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boolean {
return metadata != null && Object.keys(metadata).some(key => RESERVED_POSTHOG_METADATA_KEYS.has(key))
}
function posthogEventUuid(event: string, eventId: string): string {
const digest = createHash('sha256').update(`airi:posthog:${event}:${eventId}`, 'utf8').digest()
digest[6] = (digest[6] & 0x0F) | 0x50
digest[8] = (digest[8] & 0x3F) | 0x80
const hex = digest.subarray(0, 16).toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function stringMetadata(input: ProductEventInput, key: string): string | undefined {
const value = input.metadata?.[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export type ProductEventService = ReturnType<typeof createProductEventService>
@@ -28,18 +28,18 @@ describe('providerCatalogService', () => {
it('syncs the default LLM auto alias and runtime model routes as enabled', async () => {
const aliases = await service.syncAliasesFromRouterConfig({
modelIds: ['chat-b', 'chat-a'],
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,
surface: 'llm',
})
const resolved = await service.resolveEnabledAlias('llm', 'auto')
@@ -49,25 +49,25 @@ describe('providerCatalogService', () => {
})
it('preserves alias and route curation across repeated syncs', async () => {
await service.syncAliasesFromRouterConfig({ modelIds: ['chat-a'], surface: 'llm' })
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({ displayName: 'Custom Auto', displayOrder: 5, enabled: false })
.set({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
.where(eq(capabilityAliases.id, alias.id))
await db.update(capabilityAliasRoutes)
.set({ displayOrder: 9, enabled: false })
.set({ enabled: false, displayOrder: 9 })
.where(eq(capabilityAliasRoutes.id, route.id))
await service.syncAliasesFromRouterConfig({ modelIds: ['chat-a', 'chat-b'], surface: 'llm' })
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({ displayName: 'Custom Auto', displayOrder: 5, enabled: false })
expect(preservedRoute).toMatchObject({ displayOrder: 9, enabled: false })
expect(newRoute).toMatchObject({ displayOrder: 1, enabled: true })
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 () => {
@@ -77,7 +77,7 @@ describe('providerCatalogService', () => {
},
})
await db.update(providerCatalogTtsModels)
.set({ displayName: 'Curated CosyVoice', displayOrder: 7, enabled: false })
.set({ enabled: false, displayName: 'Curated CosyVoice', displayOrder: 7 })
.where(eq(providerCatalogTtsModels.id, first[0].id))
await service.syncTtsModelsFromRouterConfig({
@@ -90,14 +90,14 @@ describe('providerCatalogService', () => {
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,
enabled: false,
provider: 'dashscope-cosyvoice',
})
expect(models.find(model => model.routerModelId === 'microsoft/v1')).toMatchObject({
displayName: 'microsoft/v1',
enabled: true,
displayName: 'microsoft/v1',
provider: 'azure',
})
})
@@ -111,24 +111,24 @@ describe('providerCatalogService', () => {
routerModelId: 'microsoft/v1',
voices: [{
id: 'en-US-AvaMultilingualNeural',
labels: { gender: 'female' },
languages: [{ code: 'en-US', title: 'English' }],
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',
providerVoiceId: 'en-US-AvaMultilingualNeural',
})
await db.update(providerCatalogTtsVoices)
.set({
enabled: true,
displayName: 'Curated Ava',
displayOrder: 3,
enabled: true,
previewAudioUrl: 'https://example.com/manual.mp3',
})
.where(eq(providerCatalogTtsVoices.id, first[0].id))
@@ -137,21 +137,21 @@ describe('providerCatalogService', () => {
routerModelId: 'microsoft/v1',
voices: [{
id: 'en-US-AvaMultilingualNeural',
labels: { gender: 'Female' },
languages: [{ code: 'en-US', title: 'English US' }],
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,
enabled: true,
previewAudioUrl: 'https://example.com/manual.mp3',
labels: { gender: 'Female' },
languages: [{ code: 'en-US', title: 'English US' }],
previewAudioUrl: 'https://example.com/manual.mp3',
})
})
@@ -189,7 +189,7 @@ describe('providerCatalogService', () => {
errorCode: 'CAPABILITY_ALIAS_NOT_FOUND',
})
await service.syncAliasesFromRouterConfig({ modelIds: ['chat-a'], surface: 'llm' })
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
const [alias] = await db.select().from(capabilityAliases)
await db.update(capabilityAliases)
.set({ enabled: false })
@@ -22,57 +22,71 @@ import { createBadRequestError } from '../../../utils/error'
const DEFAULT_ALIAS_ID = 'auto'
export interface CapabilityAliasRouteUpdateInput {
displayOrder?: number
enabled?: boolean
pool?: CapabilityAliasRoutePool
weight?: number
export interface ProviderCatalogTtsModelSyncInput {
provider: string
}
export interface CapabilityAliasUpdateInput {
displayName?: string
displayOrder?: number
enabled?: boolean
fallbackEnabled?: boolean
loadBalancingEnabled?: boolean
export interface ProviderCatalogTtsVoiceSyncInput {
id: string
name?: string
languages?: ProviderCatalogTtsVoiceLanguage[]
labels?: ProviderCatalogTtsVoiceLabels
previewAudioUrl?: string | null
}
export interface CapabilityAliasWithRoutes extends CapabilityAlias {
routes: CapabilityAliasRoute[]
}
export type ProviderCatalogService = ReturnType<typeof createProviderCatalogService>
export interface ProviderCatalogTtsVoiceWithModel {
model: ProviderCatalogTtsModel
voice: ProviderCatalogTtsVoice
}
export interface ProviderCatalogTtsModelSyncInput {
provider: string
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
displayOrder?: number
enabled?: boolean
}
export interface ProviderCatalogTtsVoiceSyncInput {
id: string
labels?: ProviderCatalogTtsVoiceLabels
languages?: ProviderCatalogTtsVoiceLanguage[]
name?: string
previewAudioUrl?: null | string
displayOrder?: number
}
export interface ProviderCatalogTtsVoiceUpdateInput {
displayName?: string
displayOrder?: number
enabled?: boolean
labels?: ProviderCatalogTtsVoiceLabels
displayOrder?: number
languages?: ProviderCatalogTtsVoiceLanguage[]
previewAudioUrl?: null | string
labels?: ProviderCatalogTtsVoiceLabels
previewAudioUrl?: string | null
}
export interface ProviderCatalogTtsVoiceWithModel {
model: ProviderCatalogTtsModel
voice: ProviderCatalogTtsVoice
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)
}
/**
@@ -103,27 +117,27 @@ export function createProviderCatalogService(db: Database) {
where: eq(capabilityAliases.surface, surface),
})
const [created] = await db.insert(capabilityAliases).values({
surface,
aliasId,
displayName: defaultAliasDisplayName(surface, aliasId),
displayOrder: nextOrder(existingAliases),
enabled: true,
displayOrder: nextOrder(existingAliases),
fallbackEnabled: true,
loadBalancingEnabled: false,
surface,
}).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', { aliasId, surface })
throw catalogError('Capability alias could not be synced', 'CAPABILITY_ALIAS_SYNC_FAILED', { surface, aliasId })
return alias
}
async function syncAliasRoute(input: {
aliasRowId: string
order: number
pool: CapabilityAliasRoutePool
routerModelId: string
pool: CapabilityAliasRoutePool
order: number
}) {
const existing = await db.query.capabilityAliasRoutes.findFirst({
where: and(
@@ -138,11 +152,11 @@ export function createProviderCatalogService(db: Database) {
const [created] = await db.insert(capabilityAliasRoutes).values({
aliasId: input.aliasRowId,
displayOrder: input.order,
enabled: true,
pool: input.pool,
routerModelId: input.routerModelId,
pool: input.pool,
enabled: true,
weight: 1,
displayOrder: input.order,
}).onConflictDoNothing({
target: [
capabilityAliasRoutes.aliasId,
@@ -159,14 +173,142 @@ export function createProviderCatalogService(db: Database) {
})
if (!route) {
throw catalogError('Capability alias route could not be synced', 'CAPABILITY_ALIAS_ROUTE_SYNC_FAILED', {
pool: input.pool,
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),
@@ -180,6 +322,100 @@ export function createProviderCatalogService(db: Database) {
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({
@@ -202,243 +438,7 @@ export function createProviderCatalogService(db: Database) {
}
return voice
},
async getTtsVoiceWithModel(id: string): Promise<null | ProviderCatalogTtsVoiceWithModel> {
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 listAliases(surface?: CapabilityAliasSurface): Promise<CapabilityAliasWithRoutes[]> {
const aliases = await db.query.capabilityAliases.findMany({
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
where: surface ? eq(capabilityAliases.surface, surface) : undefined,
})
if (aliases.length === 0)
return []
const routes = await db.query.capabilityAliasRoutes.findMany({
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
where: inArray(capabilityAliasRoutes.aliasId, aliases.map(alias => alias.id)),
})
return aliases.map(alias => ({
...alias,
routes: routes.filter(route => route.aliasId === alias.id),
}))
},
async listEnabledTtsModels(): Promise<ProviderCatalogTtsModel[]> {
return await db.query.providerCatalogTtsModels.findMany({
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
where: eq(providerCatalogTtsModels.enabled, true),
})
},
async listEnabledTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
const model = await this.assertTtsModelEnabled(routerModelId)
return await db.query.providerCatalogTtsVoices.findMany({
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
where: and(
eq(providerCatalogTtsVoices.ttsModelId, model.id),
eq(providerCatalogTtsVoices.enabled, true),
),
})
},
async listTtsModels(): Promise<ProviderCatalogTtsModel[]> {
return await db.query.providerCatalogTtsModels.findMany({
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
})
},
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({
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
})
},
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', { aliasId, surface })
}
if (!alias.enabled) {
throw catalogError('Capability alias is disabled', 'CAPABILITY_ALIAS_DISABLED', { aliasId, surface })
}
const routes = await db.query.capabilityAliasRoutes.findMany({
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
where: and(
eq(capabilityAliasRoutes.aliasId, alias.id),
eq(capabilityAliasRoutes.enabled, true),
),
})
if (routes.length === 0) {
throw catalogError('Capability alias has no enabled route', 'CAPABILITY_ALIAS_ROUTE_NOT_FOUND', { aliasId, surface })
}
return { ...alias, routes }
},
async syncAliasesFromRouterConfig(input: {
modelIds: string[]
surface: CapabilityAliasSurface
}) {
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,
order: index,
pool: 'primary',
routerModelId,
})
}
return await db.query.capabilityAliases.findMany({
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
where: eq(capabilityAliases.surface, input.surface),
})
},
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({
displayName: routerModelId,
displayOrder: nextOrder([...existingModels, ...synced]),
enabled: true,
lastSyncedAt: now,
provider: model.provider,
routerModelId,
}).onConflictDoUpdate({
set: {
lastSyncedAt: now,
provider: model.provider,
updatedAt: now,
},
target: providerCatalogTtsModels.routerModelId,
}).returning()
synced.push(syncedModel)
}
return synced
},
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({
displayName: voice.name ?? voice.id,
displayOrder: nextOrder([...existingVoices, ...synced]),
enabled: false,
labels: voice.labels ?? {},
languages: voice.languages ?? [],
lastSyncedAt: now,
previewAudioUrl: voice.previewAudioUrl ?? null,
providerVoiceId: voice.id,
source: 'provider-sync',
ttsModelId: model.id,
}).onConflictDoUpdate({
set: {
labels: voice.labels ?? existing?.labels ?? {},
languages: voice.languages ?? existing?.languages ?? [],
lastSyncedAt: now,
updatedAt: now,
},
target: [providerCatalogTtsVoices.ttsModelId, providerCatalogTtsVoices.providerVoiceId],
}).returning()
synced.push(syncedVoice)
}
return synced
},
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 updateTtsModel(id: string, input: ProviderCatalogTtsModelUpdateInput): Promise<null | ProviderCatalogTtsModel> {
const [updated] = await db.update(providerCatalogTtsModels)
.set({ ...input, updatedAt: new Date() })
.where(eq(providerCatalogTtsModels.id, id))
.returning()
return updated ?? null
},
async updateTtsVoice(id: string, input: ProviderCatalogTtsVoiceUpdateInput): Promise<null | ProviderCatalogTtsVoice> {
const [updated] = await db.update(providerCatalogTtsVoices)
.set({ ...input, updatedAt: new Date() })
.where(eq(providerCatalogTtsVoices.id, id))
.returning()
return updated ?? null
},
}
}
function catalogError(message: string, errorCode: string, details?: unknown) {
return createBadRequestError(message, errorCode, details)
}
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
}
export type ProviderCatalogService = ReturnType<typeof createProviderCatalogService>
@@ -1,18 +1,33 @@
import type { ProviderCatalogTtsVoice, ProviderCatalogTtsVoiceLabels, ProviderCatalogTtsVoiceLanguage } from '../../../schemas/provider-catalog'
export function catalogVoiceResponse(voice: ProviderCatalogTtsVoice) {
// NOTICE: Management previews can 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
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>
}
return {
id: voice.providerVoiceId,
labels: voice.labels,
languages: voice.languages,
name: voice.displayName,
preview_audio_url: previewAudioUrl ?? undefined,
}
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
}
/**
@@ -32,39 +47,24 @@ export function normalizeProviderVoiceForCatalog(value: unknown) {
return {
id,
labels: asLabels(record?.labels),
languages: asLanguageList(record?.languages),
name: asOptionalString(record?.name),
languages: asLanguageList(record?.languages),
labels: asLabels(record?.labels),
previewAudioUrl: asOptionalString(record?.previewAudioUrl) ?? asOptionalString(record?.previewUrl) ?? null,
}
}
function asLabels(value: unknown): ProviderCatalogTtsVoiceLabels | undefined {
const record = asRecord(value)
return record ? { ...record } : undefined
}
export function catalogVoiceResponse(voice: ProviderCatalogTtsVoice) {
// NOTICE: Management previews can 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
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 asOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
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>
return {
id: voice.providerVoiceId,
name: voice.displayName,
languages: voice.languages,
labels: voice.labels,
preview_audio_url: previewAudioUrl ?? undefined,
}
}
@@ -18,20 +18,20 @@ describe('providerService', () => {
// Create a test user for foreign key constraints
const [user] = await db.insert(schema.user).values({
email: 'test@example.com',
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
it('createUserConfig should handle provider config creation', async () => {
const providerData = {
config: { apiKey: 'sk-123' },
definitionId: 'openai',
id: 'prov-1',
name: 'My OpenAI',
ownerId: testUser.id,
definitionId: 'openai',
name: 'My OpenAI',
config: { apiKey: 'sk-123' },
validated: true,
validationBypassed: false,
}
@@ -55,10 +55,10 @@ describe('providerService', () => {
it('findAll should return both user and system configs', async () => {
// Create a system config
await db.insert(schema.systemProviderConfigs).values({
config: { apiKey: 'sys-sk' },
definitionId: 'anthropic',
id: 'sys-1',
definitionId: 'anthropic',
name: 'System Anthropic',
config: { apiKey: 'sys-sk' },
})
const result = await service.findAll(testUser.id)
@@ -7,79 +7,20 @@ import * as schema from '../../schemas/providers'
const logger = useLogger('providers')
export type ProviderService = ReturnType<typeof createProviderService>
export function createProviderService(db: Database) {
return {
async createSystemConfig(data: schema.NewSystemProviderConfig) {
const [inserted] = await db.insert(schema.systemProviderConfigs).values(data).returning()
logger.withFields({ definitionId: data.definitionId, id: inserted.id }).log('Created system provider config')
return inserted
},
async createUserConfig(data: schema.NewUserProviderConfig) {
const [inserted] = await db.insert(schema.userProviderConfigs).values(data).returning()
logger.withFields({ definitionId: data.definitionId, id: inserted.id, ownerId: data.ownerId }).log('Created user provider config')
return inserted
},
/**
* 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({ count: result.length, userId }).log('Provider configs soft-deleted for user')
},
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
},
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
},
async findAll(ownerId: string) {
const userConfigs = db
.select({
config: schema.userProviderConfigs.config,
createdAt: schema.userProviderConfigs.createdAt,
definitionId: schema.userProviderConfigs.definitionId,
id: schema.userProviderConfigs.id,
isSystem: sql<boolean>`false`.as('is_system'),
definitionId: schema.userProviderConfigs.definitionId,
name: schema.userProviderConfigs.name,
updatedAt: schema.userProviderConfigs.updatedAt,
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(
@@ -91,15 +32,15 @@ export function createProviderService(db: Database) {
const systemConfigs = db
.select({
config: schema.systemProviderConfigs.config,
createdAt: schema.systemProviderConfigs.createdAt,
definitionId: schema.systemProviderConfigs.definitionId,
id: schema.systemProviderConfigs.id,
isSystem: sql<boolean>`true`.as('is_system'),
definitionId: schema.systemProviderConfigs.definitionId,
name: schema.systemProviderConfigs.name,
updatedAt: schema.systemProviderConfigs.updatedAt,
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))
@@ -107,6 +48,15 @@ export function createProviderService(db: Database) {
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(
@@ -134,22 +84,6 @@ export function createProviderService(db: Database) {
return null
},
async findSystemConfigById(id: string) {
return await db.query.systemProviderConfigs.findFirst({
where: and(
eq(schema.systemProviderConfigs.id, id),
isNull(schema.systemProviderConfigs.deletedAt),
),
})
},
// System Provider Configs
async findSystemConfigs() {
return await db.query.systemProviderConfigs.findMany({
where: isNull(schema.systemProviderConfigs.deletedAt),
})
},
async findUserConfigById(id: string) {
return await db.query.userProviderConfigs.findFirst({
where: and(
@@ -159,15 +93,58 @@ export function createProviderService(db: Database) {
})
},
async findUserConfigsByOwnerId(ownerId: string) {
return await db.query.userProviderConfigs.findMany({
where: and(
eq(schema.userProviderConfigs.ownerId, ownerId),
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() })
@@ -180,16 +157,39 @@ export function createProviderService(db: Database) {
return updated
},
async updateUserConfig(id: string, data: Partial<schema.NewUserProviderConfig>) {
const [updated] = await db.update(schema.userProviderConfigs)
.set({ ...data, updatedAt: new Date() })
async deleteSystemConfig(id: string) {
const result = await db.update(schema.systemProviderConfigs)
.set({ deletedAt: new Date() })
.where(and(
eq(schema.userProviderConfigs.id, id),
isNull(schema.userProviderConfigs.deletedAt),
eq(schema.systemProviderConfigs.id, id),
isNull(schema.systemProviderConfigs.deletedAt),
))
.returning()
logger.withFields({ id }).log('Updated user provider config')
return updated
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>
@@ -3,17 +3,15 @@ import type { Database } from '../../libs/db'
import * as schema from '../../schemas/llm-request-log'
export interface RequestLogEntry {
completionTokens?: number
userId: string
model: string
status: number
durationMs: number
fluxConsumed: number
model: string
promptTokens?: number
status: number
userId: string
completionTokens?: number
}
export type RequestLogService = ReturnType<typeof createRequestLogService>
export function createRequestLogService(db: Database) {
return {
async logRequest(entry: RequestLogEntry) {
@@ -21,3 +19,5 @@ export function createRequestLogService(db: Database) {
},
}
}
export type RequestLogService = ReturnType<typeof createRequestLogService>
+105 -105
View File
@@ -16,8 +16,8 @@ describe('stripeService', () => {
db = await mockDB(schema)
await db.insert(schema.user).values([
{ email: 'stripe1@example.com', id: 'user-stripe-1', name: 'Stripe User 1' },
{ email: 'stripe2@example.com', id: 'user-stripe-2', name: 'Stripe User 2' },
{ id: 'user-stripe-1', name: 'Stripe User 1', email: 'stripe1@example.com' },
{ id: 'user-stripe-2', name: 'Stripe User 2', email: 'stripe2@example.com' },
])
})
@@ -36,9 +36,9 @@ describe('stripeService', () => {
describe('upsertCustomer', () => {
it('inserts a new customer', async () => {
const result = await stripeService.upsertCustomer({
email: 'stripe1@example.com',
stripeCustomerId: 'cus_new_1',
userId: 'user-stripe-1',
stripeCustomerId: 'cus_new_1',
email: 'stripe1@example.com',
})
expect(result.userId).toBe('user-stripe-1')
@@ -48,16 +48,16 @@ describe('stripeService', () => {
it('updates an existing customer on conflict (atomic upsert)', async () => {
await stripeService.upsertCustomer({
email: 'old@example.com',
stripeCustomerId: 'cus_dup_1',
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',
stripeCustomerId: 'cus_dup_1',
userId: 'user-stripe-1',
})
expect(updated.email).toBe('new@example.com')
@@ -72,14 +72,14 @@ describe('stripeService', () => {
// Simulate two webhook events arriving at the same time for the same customer
const results = await Promise.all([
stripeService.upsertCustomer({
email: 'a@example.com',
stripeCustomerId: 'cus_race_1',
userId: 'user-stripe-1',
stripeCustomerId: 'cus_race_1',
email: 'a@example.com',
}),
stripeService.upsertCustomer({
email: 'b@example.com',
stripeCustomerId: 'cus_race_1',
userId: 'user-stripe-1',
stripeCustomerId: 'cus_race_1',
email: 'b@example.com',
}),
])
@@ -96,8 +96,8 @@ describe('stripeService', () => {
describe('getCustomerByUserId', () => {
it('returns the customer for a given userId', async () => {
await stripeService.upsertCustomer({
stripeCustomerId: 'cus_lookup_1',
userId: 'user-stripe-1',
stripeCustomerId: 'cus_lookup_1',
})
const found = await stripeService.getCustomerByUserId('user-stripe-1')
@@ -113,8 +113,8 @@ describe('stripeService', () => {
describe('getCustomerByStripeId', () => {
it('returns the customer for a given stripeCustomerId', async () => {
await stripeService.upsertCustomer({
stripeCustomerId: 'cus_sid_1',
userId: 'user-stripe-1',
stripeCustomerId: 'cus_sid_1',
})
const found = await stripeService.getCustomerByStripeId('cus_sid_1')
@@ -132,13 +132,13 @@ describe('stripeService', () => {
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',
mode: 'payment',
paymentStatus: 'unpaid',
status: 'open',
stripeSessionId: 'cs_new_1',
userId: 'user-stripe-1',
})
expect(result.stripeSessionId).toBe('cs_new_1')
@@ -148,23 +148,23 @@ describe('stripeService', () => {
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',
mode: 'payment',
paymentStatus: 'unpaid',
status: 'open',
stripeSessionId: 'cs_upd_1',
userId: 'user-stripe-1',
})
const updated = await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_upd_1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 1000,
currency: 'usd',
mode: 'payment',
paymentStatus: 'paid',
status: 'complete',
stripeSessionId: 'cs_upd_1',
userId: 'user-stripe-1',
})
expect(updated.status).toBe('complete')
@@ -177,22 +177,22 @@ describe('stripeService', () => {
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',
mode: 'payment',
paymentStatus: 'unpaid',
status: 'open',
stripeSessionId: 'cs_race_1',
userId: 'user-stripe-1',
}),
stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_race_1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 500,
currency: 'usd',
mode: 'payment',
paymentStatus: 'paid',
status: 'complete',
stripeSessionId: 'cs_race_1',
userId: 'user-stripe-1',
}),
])
@@ -206,18 +206,18 @@ describe('stripeService', () => {
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',
mode: 'payment',
stripeSessionId: 'cs_list_1',
userId: 'user-stripe-1',
})
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_list_2',
mode: 'payment',
amountTotal: 200,
currency: 'usd',
mode: 'payment',
stripeSessionId: 'cs_list_2',
userId: 'user-stripe-1',
})
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
@@ -229,14 +229,14 @@ describe('stripeService', () => {
it('does not return sessions from other users', async () => {
await stripeService.upsertCheckoutSession({
mode: 'payment',
stripeSessionId: 'cs_iso_1',
userId: 'user-stripe-1',
stripeSessionId: 'cs_iso_1',
mode: 'payment',
})
await stripeService.upsertCheckoutSession({
mode: 'payment',
stripeSessionId: 'cs_iso_2',
userId: 'user-stripe-2',
stripeSessionId: 'cs_iso_2',
mode: 'payment',
})
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
@@ -250,15 +250,15 @@ describe('stripeService', () => {
describe('upsertSubscription', () => {
it('inserts a new subscription', async () => {
await stripeService.upsertCustomer({
stripeCustomerId: 'cus_sub_1',
userId: 'user-stripe-1',
stripeCustomerId: 'cus_sub_1',
})
const result = await stripeService.upsertSubscription({
status: 'active',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_new_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_new_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
expect(result.stripeSubscriptionId).toBe('sub_new_1')
@@ -267,17 +267,17 @@ describe('stripeService', () => {
it('updates an existing subscription on conflict', async () => {
await stripeService.upsertSubscription({
status: 'active',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_upd_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_upd_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
const updated = await stripeService.upsertSubscription({
status: 'canceled',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_upd_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_upd_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
expect(updated.status).toBe('canceled')
@@ -289,16 +289,16 @@ describe('stripeService', () => {
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertSubscription({
status: 'active',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_race_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_race_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
}),
stripeService.upsertSubscription({
status: 'past_due',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_race_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_race_1',
stripeCustomerId: 'cus_sub_1',
status: 'past_due',
}),
])
@@ -312,16 +312,16 @@ describe('stripeService', () => {
describe('getActiveSubscription', () => {
it('returns only the active subscription', async () => {
await stripeService.upsertSubscription({
status: 'canceled',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_active_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_active_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
await stripeService.upsertSubscription({
status: 'active',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_active_2',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_active_2',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
@@ -331,10 +331,10 @@ describe('stripeService', () => {
it('returns undefined when no active subscription exists', async () => {
await stripeService.upsertSubscription({
status: 'canceled',
stripeCustomerId: 'cus_sub_1',
stripeSubscriptionId: 'sub_none_1',
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_none_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
@@ -343,10 +343,10 @@ describe('stripeService', () => {
it('does not return subscriptions from other users', async () => {
await stripeService.upsertSubscription({
status: 'active',
stripeCustomerId: 'cus_other_1',
stripeSubscriptionId: 'sub_other_1',
userId: 'user-stripe-2',
stripeSubscriptionId: 'sub_other_1',
stripeCustomerId: 'cus_other_1',
status: 'active',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
@@ -359,13 +359,13 @@ describe('stripeService', () => {
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',
status: 'open',
stripeCustomerId: 'cus_inv_1',
stripeInvoiceId: 'inv_new_1',
userId: 'user-stripe-1',
})
expect(result.stripeInvoiceId).toBe('inv_new_1')
@@ -375,21 +375,21 @@ describe('stripeService', () => {
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',
status: 'open',
stripeInvoiceId: 'inv_upd_1',
userId: 'user-stripe-1',
})
const updated = await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_upd_1',
status: 'paid',
amountDue: 2000,
amountPaid: 2000,
currency: 'usd',
status: 'paid',
stripeInvoiceId: 'inv_upd_1',
userId: 'user-stripe-1',
})
expect(updated.status).toBe('paid')
@@ -402,18 +402,18 @@ describe('stripeService', () => {
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',
status: 'open',
stripeInvoiceId: 'inv_race_1',
userId: 'user-stripe-1',
}),
stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_race_1',
status: 'paid',
amountPaid: 1000,
currency: 'usd',
status: 'paid',
stripeInvoiceId: 'inv_race_1',
userId: 'user-stripe-1',
}),
])
@@ -427,16 +427,16 @@ describe('stripeService', () => {
describe('getInvoicesByUserId', () => {
it('returns all invoices for the user', async () => {
await stripeService.upsertInvoice({
currency: 'usd',
status: 'paid',
stripeInvoiceId: 'inv_list_1',
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_list_1',
status: 'paid',
currency: 'usd',
})
await stripeService.upsertInvoice({
currency: 'usd',
status: 'open',
stripeInvoiceId: 'inv_list_2',
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_list_2',
status: 'open',
currency: 'usd',
})
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
@@ -448,16 +448,16 @@ describe('stripeService', () => {
it('does not return invoices from other users', async () => {
await stripeService.upsertInvoice({
currency: 'usd',
status: 'paid',
stripeInvoiceId: 'inv_iso_1',
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_iso_1',
status: 'paid',
currency: 'usd',
})
await stripeService.upsertInvoice({
currency: 'usd',
status: 'paid',
stripeInvoiceId: 'inv_iso_2',
userId: 'user-stripe-2',
stripeInvoiceId: 'inv_iso_2',
status: 'paid',
currency: 'usd',
})
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
+112 -112
View File
@@ -10,8 +10,6 @@ import * as schema from '../../schemas/stripe'
const logger = useLogger('stripe-service')
export type StripeService = ReturnType<typeof createStripeService>
// NOTICE:
// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose
// stripe_* rows persist for billing audit) are invisible to user-facing
@@ -19,10 +17,115 @@ export type StripeService = ReturnType<typeof createStripeService>
// 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: null | Stripe) {
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
@@ -59,16 +162,16 @@ export function createStripeService(db: Database, stripe: null | Stripe) {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId, {
prorate: false,
})
logger.withFields({ prevStatus: sub.status, subscriptionId: sub.stripeSubscriptionId, userId }).log('Cancelled Stripe subscription')
logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription')
}
catch (err) {
logger.withError(err).withFields({ prevStatus: sub.status, subscriptionId: sub.stripeSubscriptionId, userId }).error('Failed to cancel Stripe subscription')
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({ cancellableSubCount: cancellableSubs.length, userId }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted')
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()
@@ -101,112 +204,9 @@ export function createStripeService(db: Database, stripe: null | Stripe) {
isNull(schema.stripeCustomer.deletedAt),
))
logger.withFields({ cancelledSubs: cancellableSubs.length, userId }).log('Stripe rows soft-deleted for user')
},
async getActiveSubscription(userId: string) {
return db.query.stripeSubscription.findFirst({
orderBy: (t, { desc }) => [desc(t.createdAt)],
where: and(
eq(schema.stripeSubscription.userId, userId),
eq(schema.stripeSubscription.status, 'active'),
isNull(schema.stripeSubscription.deletedAt),
),
})
},
async getCheckoutSessionsByUserId(userId: string) {
return db.query.stripeCheckoutSession.findMany({
orderBy: (t, { desc }) => [desc(t.createdAt)],
where: and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
),
})
},
// ---- Checkout Session ----
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),
})
},
async getCustomerByUserId(userId: string) {
return db.query.stripeCustomer.findFirst({
where: and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
),
})
},
// ---- Subscription ----
async getInvoicesByUserId(userId: string) {
return db.query.stripeInvoice.findMany({
orderBy: (t, { desc }) => [desc(t.createdAt)],
where: and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
),
})
},
async upsertCheckoutSession(data: NewStripeCheckoutSession) {
const [row] = await db.insert(schema.stripeCheckoutSession)
.values(data)
.onConflictDoUpdate({
set: { ...data, updatedAt: new Date() },
target: schema.stripeCheckoutSession.stripeSessionId,
})
.returning()
logger.withFields({ sessionId: data.stripeSessionId, status: data.status, userId: data.userId }).log('Upserted checkout session')
return row
},
// ---- Invoice ----
async upsertCustomer(data: NewStripeCustomer) {
const [row] = await db.insert(schema.stripeCustomer)
.values(data)
.onConflictDoUpdate({
set: { ...data, updatedAt: new Date() },
target: schema.stripeCustomer.stripeCustomerId,
})
.returning()
logger.withFields({ stripeCustomerId: data.stripeCustomerId, userId: data.userId }).log('Upserted Stripe customer')
return row
},
async upsertInvoice(data: NewStripeInvoice) {
const [row] = await db.insert(schema.stripeInvoice)
.values(data)
.onConflictDoUpdate({
set: { ...data, updatedAt: new Date() },
target: schema.stripeInvoice.stripeInvoiceId,
})
.returning()
logger.withFields({ invoiceId: data.stripeInvoiceId, status: data.status, userId: data.userId }).log('Upserted invoice')
return row
},
async upsertSubscription(data: NewStripeSubscription) {
const [row] = await db.insert(schema.stripeSubscription)
.values(data)
.onConflictDoUpdate({
set: { ...data, updatedAt: new Date() },
target: schema.stripeSubscription.stripeSubscriptionId,
})
.returning()
logger.withFields({ status: data.status, subscriptionId: data.stripeSubscriptionId, userId: data.userId }).log('Upserted subscription')
return row
logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user')
},
}
}
export type StripeService = ReturnType<typeof createStripeService>
@@ -46,14 +46,14 @@ export function createUserDeletionService(): UserDeletionService {
handlers.sort((a, b) => a.priority - b.priority)
},
async softDeleteAll({ reason, userId }) {
async softDeleteAll({ userId, reason }) {
const ctx = {
logger,
reason: reason as UserDeletionReason,
userId,
reason: reason as UserDeletionReason,
logger,
}
logger.withFields({ handlerCount: handlers.length, reason, userId }).log('starting user deletion')
logger.withFields({ userId, reason, handlerCount: handlers.length }).log('starting user deletion')
for (const handler of handlers) {
const startedAt = Date.now()
@@ -61,19 +61,19 @@ export function createUserDeletionService(): UserDeletionService {
try {
await handler.softDelete(ctx)
logger
.withFields({ durationMs: Date.now() - startedAt, handler: handler.name, userId })
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
.log('handler completed')
}
catch (err) {
logger
.withError(err)
.withFields({ durationMs: Date.now() - startedAt, handler: handler.name, userId })
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
.error('handler failed; aborting deletion pipeline')
throw err
}
}
logger.withFields({ reason, userId }).log('user deletion handlers completed')
logger.withFields({ userId, reason }).log('user deletion handlers completed')
},
}
}
@@ -36,7 +36,7 @@ describe('createUserDeletionService', () => {
calls.push('flux')
}))
await service.softDeleteAll({ reason: 'user-requested', userId: 'u1' })
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// register order: characters(30) -> stripe(10) -> flux(20)
@@ -53,12 +53,12 @@ describe('createUserDeletionService', () => {
service.register(a)
service.register(b)
await service.softDeleteAll({ reason: 'admin', userId: 'user-xyz' })
await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' })
expect(a.softDelete).toHaveBeenCalledTimes(1)
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ reason: 'admin', userId: 'user-xyz' }))
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
expect(b.softDelete).toHaveBeenCalledTimes(1)
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ reason: 'admin', userId: 'user-xyz' }))
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
})
it('aborts on first handler error and skips later handlers', async () => {
@@ -73,7 +73,7 @@ describe('createUserDeletionService', () => {
service.register(failing)
service.register(lateNeverRuns)
await expect(service.softDeleteAll({ reason: 'user-requested', userId: 'u1' }))
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }))
.rejects
.toThrow('stripe API down')
@@ -104,7 +104,7 @@ describe('createUserDeletionService', () => {
},
})
await service.softDeleteAll({ reason: 'user-requested', userId: 'u1' })
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// serial execution: slow:start -> slow:end -> fast:start -> fast:end
@@ -115,7 +115,7 @@ describe('createUserDeletionService', () => {
it('runs no handlers gracefully when registry is empty', async () => {
const service = createUserDeletionService()
await expect(service.softDeleteAll({ reason: 'user-requested', userId: 'u1' })).resolves.toBeUndefined()
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined()
})
})
})
@@ -28,8 +28,8 @@ describe('fluxService.deleteAllForUser', () => {
})
it('marks userFlux.deletedAt and invalidates Redis cache', async () => {
await db.insert(schema.user).values({ email: 'a@example.com', id: 'u-flux-1', name: 'A' })
await db.insert(schema.userFlux).values({ flux: 100, userId: 'u-flux-1' })
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 = createTestRedis()
const del = vi.spyOn(redis, 'del')
@@ -43,8 +43,8 @@ describe('fluxService.deleteAllForUser', () => {
})
it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => {
await db.insert(schema.user).values({ email: 'b@example.com', id: 'u-flux-2', name: 'B' })
await db.insert(schema.userFlux).values({ flux: 50, userId: 'u-flux-2' })
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 = createTestRedis()
const service = createFluxService(db, redis, fakeConfigKV())
@@ -69,10 +69,10 @@ describe('providerService.deleteAllForUser', () => {
})
it('marks every userProviderConfigs row owned by the user', async () => {
await db.insert(schema.user).values({ email: 'p@example.com', id: 'u-prov-1', name: 'P' })
await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' })
await db.insert(schema.userProviderConfigs).values([
{ definitionId: 'openai', name: 'a', ownerId: 'u-prov-1' },
{ definitionId: 'anthropic', name: 'b', ownerId: 'u-prov-1' },
{ ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' },
{ ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' },
])
const service = createProviderService(db)
@@ -84,8 +84,8 @@ describe('providerService.deleteAllForUser', () => {
})
it('does not touch other users rows', async () => {
await db.insert(schema.user).values({ email: 'o@example.com', id: 'u-prov-other', name: 'O' })
await db.insert(schema.userProviderConfigs).values({ definitionId: 'openai', name: 'kept', ownerId: 'u-prov-other' })
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')
@@ -104,13 +104,13 @@ describe('characterService.deleteAllForUser', () => {
it('soft-deletes characters where the user is owner OR creator', async () => {
await db.insert(schema.user).values([
{ email: 'c1@example.com', id: 'u-char-1', name: 'C1' },
{ email: 'c2@example.com', id: 'u-char-2', name: 'C2' },
{ 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([
{ characterId: 'cid-1', coverUrl: '', creatorId: 'u-char-2', id: 'char-owner', ownerId: 'u-char-1', version: '1' },
{ characterId: 'cid-2', coverUrl: '', creatorId: 'u-char-1', id: 'char-creator', ownerId: 'u-char-2', version: '1' },
{ characterId: 'cid-3', coverUrl: '', creatorId: 'u-char-2', id: 'char-other', ownerId: 'u-char-2', version: '1' },
{ 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)
@@ -127,21 +127,21 @@ describe('characterService.deleteAllForUser', () => {
it('decrements character engagement counters for soft-deleted likes and bookmarks', async () => {
await db.insert(schema.user).values([
{ email: 'counts@example.com', id: 'u-char-counts', name: 'Counts' },
{ email: 'owner@example.com', id: 'u-char-owner', name: 'Owner' },
{ 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({
bookmarksCount: 1,
characterId: 'cid-counts',
id: 'char-counts',
version: '1',
coverUrl: '',
creatorId: 'u-char-owner',
id: 'char-counts',
likesCount: 1,
ownerId: 'u-char-owner',
version: '1',
characterId: 'cid-counts',
likesCount: 1,
bookmarksCount: 1,
})
await db.insert(schema.characterLikes).values({ characterId: 'char-counts', userId: 'u-char-counts' })
await db.insert(schema.characterBookmarks).values({ characterId: 'char-counts', userId: 'u-char-counts' })
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')
@@ -158,17 +158,17 @@ describe('characterService.deleteAllForUser', () => {
})
it('soft-deletes the user likes and bookmarks', async () => {
await db.insert(schema.user).values({ email: 'c3@example.com', id: 'u-char-3', name: 'C3' })
await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' })
await db.insert(schema.character).values({
characterId: 'cid-z',
id: 'char-z',
version: '1',
coverUrl: '',
creatorId: 'u-char-3',
id: 'char-z',
ownerId: 'u-char-3',
version: '1',
characterId: 'cid-z',
})
await db.insert(schema.characterLikes).values({ characterId: 'char-z', userId: 'u-char-3' })
await db.insert(schema.characterBookmarks).values({ characterId: 'char-z', userId: 'u-char-3' })
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')
@@ -189,10 +189,10 @@ describe('chatService.deleteAllForUser', () => {
})
it('soft-deletes chats the user is a member of', async () => {
await db.insert(schema.user).values({ email: 'chat@example.com', id: 'u-chat-1', name: 'C' })
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', title: 'mine', type: 'private' },
{ id: 'chat-other', title: 'other', type: 'private' },
{ 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' })
@@ -210,10 +210,10 @@ describe('chatService.deleteAllForUser', () => {
// 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([
{ email: 'grpa@example.com', id: 'u-grp-a', name: 'A' },
{ email: 'grpb@example.com', id: 'u-grp-b', name: 'B' },
{ 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', title: 'team', type: 'group' })
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' },
@@ -236,17 +236,17 @@ describe('chatService.deleteAllForUser', () => {
// 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([
{ email: 'anona@example.com', id: 'u-anon-a', name: 'A' },
{ email: 'anonb@example.com', id: 'u-anon-b', name: 'B' },
{ 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', title: 'team', type: 'group' })
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([
{ chatId: 'chat-anon-grp', content: 'hi from A', id: 'm-a-1', mediaIds: [], role: 'user', senderId: 'u-anon-a', stickerIds: [] },
{ chatId: 'chat-anon-grp', content: 'hi from B', id: 'm-b-1', mediaIds: [], role: 'user', senderId: 'u-anon-b', stickerIds: [] },
{ 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)
@@ -264,26 +264,26 @@ describe('chatService.deleteAllForUser', () => {
})
it('soft-deletes messages the user sent in private/bot chats', async () => {
await db.insert(schema.user).values({ email: 'msg@example.com', id: 'u-chat-2', name: 'M' })
await db.insert(schema.chats).values({ id: 'chat-msg', title: 't', type: 'private' })
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([
{
chatId: 'chat-msg',
content: 'hi',
id: 'msg-mine',
mediaIds: [],
role: 'user',
chatId: 'chat-msg',
senderId: 'u-chat-2',
role: 'user',
content: 'hi',
mediaIds: [],
stickerIds: [],
},
{
chatId: 'chat-msg',
content: 'hello',
id: 'msg-other',
mediaIds: [],
role: 'assistant',
chatId: 'chat-msg',
senderId: 'someone-else',
role: 'assistant',
content: 'hello',
mediaIds: [],
stickerIds: [],
},
])
@@ -1,5 +1,15 @@
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.
*
@@ -8,32 +18,12 @@ import type { Logger } from '@guiiai/logg'
* - Logging within a handler use the provided `logger` so entries share the deletion correlation context.
*/
export interface UserDeletionContext {
/** Pre-scoped logger for handler diagnostics. */
logger: Logger
/** Why the deletion was triggered. */
reason: UserDeletionReason
/** The user being deleted. Handlers MUST scope their writes to this id. */
userId: string
}
/**
* Coordinator for account deletion across business modules.
*
* Use when:
* - Serving the authenticated internal request emitted by Auth server's
* `user.deleteUser.beforeDelete` hook.
* - 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 UserDeletionExecutor {
/**
* Run every registered handler in priority order. Returns when all
* handlers complete, or throws the first handler error and stops.
*/
softDeleteAll: (input: { reason: UserDeletionReason, userId: string }) => Promise<void>
/** Why the deletion was triggered. */
reason: UserDeletionReason
/** Pre-scoped logger for handler diagnostics. */
logger: Logger
}
/**
@@ -75,14 +65,24 @@ export interface UserDeletionHandler {
}
/**
* 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).
* Coordinator for account deletion across business modules.
*
* - `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).
* Use when:
* - Serving the authenticated internal request emitted by Auth server's
* `user.deleteUser.beforeDelete` hook.
* - 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 type UserDeletionReason = 'admin' | 'compliance' | 'user-requested'
export interface UserDeletionExecutor {
/**
* 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>
}
export interface UserDeletionService extends UserDeletionExecutor {
/**
@@ -23,15 +23,15 @@ describe('voicePackService', () => {
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,
model: 'seed-tts-2.0',
name: 'Neuro Sama',
params: { pitch: 20, volume: 5 },
provider: 'volcengine',
ttsModelId: 'volcengine/neuro-pool',
upstreamVoiceId: 'voice-neuro-upstream',
voiceId: 'voice-neuro',
})
expect(pack.name).toBe('Neuro Sama')
@@ -48,26 +48,26 @@ describe('voicePackService', () => {
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,
model: 'seed-tts-2.0',
name: 'Base',
params: {},
provider: 'volcengine',
ttsModelId: 'volcengine/pool',
upstreamVoiceId: 'voice-a-upstream',
voiceId: 'voice-a',
})
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,
model: 'seed-tts-2.0',
name: 'Pitched',
params: { pitch: 20 },
provider: 'volcengine',
ttsModelId: 'volcengine/pool',
upstreamVoiceId: 'voice-a-upstream',
voiceId: 'voice-a',
})
const packs = await service.list()
@@ -78,21 +78,21 @@ describe('voicePackService', () => {
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,
model: 'v1',
name: 'Old',
params: {},
provider: 'azure',
ttsModelId: 'microsoft/v1',
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
voiceId: 'en-US-AvaMultilingualNeural',
})
const updated = await service.update(pack.id, {
costMultiplier: 2,
name: 'New',
params: { rate: 1.1 },
costMultiplier: 2,
})
expect(updated?.id).toBe(pack.id)
@@ -104,15 +104,15 @@ describe('voicePackService', () => {
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,
model: 'cosyvoice-v2',
name: 'Disable me',
params: {},
provider: 'dashscope-cosyvoice',
ttsModelId: 'alibaba/cosyvoice-v2',
upstreamVoiceId: 'longxiaochun_v2',
voiceId: 'longxiaochun_v2',
})
const disabled = await service.disable(pack.id)
@@ -127,26 +127,26 @@ describe('voicePackService', () => {
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,
model: 'v1',
name: 'Disabled narrator',
params: {},
provider: 'azure',
ttsModelId: 'microsoft/v1',
upstreamVoiceId: 'disabled-upstream',
voiceId: 'narrator',
})
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,
model: 'v1',
name: 'Enabled narrator',
params: {},
provider: 'azure',
ttsModelId: 'microsoft/v1',
upstreamVoiceId: 'enabled-upstream',
voiceId: 'narrator',
})
expect(await service.findEnabledByVoiceId('narrator')).toMatchObject({
@@ -10,8 +10,8 @@ import * as schema from '../../../schemas/voice-packs'
export const VoicePackParamsSchema = object({
pitch: optional(number()),
rate: optional(pipe(number(), minValue(0.01, 'rate must be positive'))),
volume: optional(number()),
rate: optional(pipe(number(), minValue(0.01, 'rate must be positive'))),
})
export const VoicePackCostMultiplierSchema = pipe(
@@ -20,29 +20,29 @@ export const VoicePackCostMultiplierSchema = pipe(
)
export const CreateVoicePackInputSchema = object({
costMultiplier: VoicePackCostMultiplierSchema,
description: optional(pipe(string(), maxLength(500))),
enabled: optional(boolean(), true),
model: pipe(string(), nonEmpty('model is required'), maxLength(200)),
name: pipe(string(), nonEmpty('name is required'), maxLength(120)),
params: optional(VoicePackParamsSchema, {}),
description: optional(pipe(string(), maxLength(500))),
provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)),
ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)),
upstreamVoiceId: pipe(string(), nonEmpty('upstreamVoiceId is required'), maxLength(200)),
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({
costMultiplier: optional(VoicePackCostMultiplierSchema),
description: optional(pipe(string(), maxLength(500))),
enabled: optional(boolean()),
model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))),
name: optional(pipe(string(), nonEmpty('name must not be empty'), maxLength(120))),
params: optional(VoicePackParamsSchema),
description: optional(pipe(string(), maxLength(500))),
provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))),
ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))),
upstreamVoiceId: optional(pipe(string(), nonEmpty('upstreamVoiceId must not be empty'), maxLength(200))),
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()),
})
/**
@@ -55,8 +55,6 @@ export type CreateVoicePackInput = InferOutput<typeof CreateVoicePackInputSchema
*/
export type UpdateVoicePackInput = InferOutput<typeof UpdateVoicePackInputSchema>
export type VoicePackService = ReturnType<typeof createVoicePackService>
/**
* Handles the curated server-side Voice Pack library.
*
@@ -74,31 +72,32 @@ export function createVoicePackService(db: Database) {
return {
async create(input: CreateVoicePackInput) {
const [inserted] = await db.insert(schema.voicePacks).values({
costMultiplier: input.costMultiplier,
description: input.description,
enabled: input.enabled,
model: input.model,
name: input.name,
params: input.params,
description: input.description,
provider: input.provider,
ttsModelId: input.ttsModelId,
upstreamVoiceId: input.upstreamVoiceId,
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 disable(id: string): Promise<null | VoicePack> {
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()
async list() {
return await db.query.voicePacks.findMany({
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
return updated ?? null
async listEnabled() {
return await db.query.voicePacks.findMany({
where: eq(schema.voicePacks.enabled, true),
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
async findById(id: string) {
@@ -116,20 +115,7 @@ export function createVoicePackService(db: Database) {
})
},
async list() {
return await db.query.voicePacks.findMany({
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
async listEnabled() {
return await db.query.voicePacks.findMany({
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
where: eq(schema.voicePacks.enabled, true),
})
},
async update(id: string, input: UpdateVoicePackInput): Promise<null | VoicePack> {
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))
@@ -137,5 +123,19 @@ export function createVoicePackService(db: Database) {
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>