chore: move the server to an independent folder
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { configRedisKey } from '../../utils/redis-keys'
|
||||
import { createConfigKVService } from './config-kv'
|
||||
|
||||
function createMockRedis() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => { store.set(key, value) }),
|
||||
_store: store,
|
||||
}
|
||||
}
|
||||
|
||||
describe('configKVService', () => {
|
||||
let redis: ReturnType<typeof createMockRedis>
|
||||
let service: ReturnType<typeof createConfigKVService>
|
||||
|
||||
beforeEach(() => {
|
||||
redis = createMockRedis()
|
||||
service = createConfigKVService(redis as any)
|
||||
})
|
||||
|
||||
it('get should throw 503 when key is not set', async () => {
|
||||
await expect(service.getOrThrow('FLUX_PER_1K_CHARS_TTS'))
|
||||
.rejects
|
||||
.toThrow('Service configuration is incomplete')
|
||||
})
|
||||
|
||||
it('get should return numeric value when key is set', async () => {
|
||||
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '5')
|
||||
|
||||
const value = await service.getOrThrow('FLUX_PER_REQUEST')
|
||||
expect(value).toBe(5)
|
||||
})
|
||||
|
||||
it('get should read from correct prefixed key', async () => {
|
||||
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '3')
|
||||
|
||||
await service.getOrThrow('FLUX_PER_REQUEST')
|
||||
expect(redis.get).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'))
|
||||
})
|
||||
|
||||
it('getOptional should return schema default when key has one', async () => {
|
||||
const value = await service.getOptional('FLUX_PER_REQUEST')
|
||||
expect(value).toBe(5)
|
||||
})
|
||||
|
||||
it('getOptional should return null when required key is not set', async () => {
|
||||
const value = await service.getOptional('FLUX_PER_1K_CHARS_TTS')
|
||||
expect(value).toBeNull()
|
||||
})
|
||||
|
||||
it('getOptional should return numeric value when key is set', async () => {
|
||||
redis._store.set(configRedisKey('INITIAL_USER_FLUX'), '200')
|
||||
|
||||
const value = await service.getOptional('INITIAL_USER_FLUX')
|
||||
expect(value).toBe(200)
|
||||
})
|
||||
|
||||
it('getOptional should throw CONFIG_INVALID when Redis contains malformed JSON', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// If an operator edits config:LLM_ROUTER_CONFIG directly with invalid JSON,
|
||||
// JSON.parse used to throw SyntaxError through the request handler and log
|
||||
// it as an unhandled 500.
|
||||
//
|
||||
// We fixed this by translating stored config parse/validation failures into
|
||||
// a stable API error at the configKV boundary.
|
||||
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), '{"llm":{}')
|
||||
|
||||
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('getOptional should throw CONFIG_INVALID when Redis contains schema-invalid JSON', async () => {
|
||||
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), JSON.stringify('5'))
|
||||
|
||||
await expect(service.getOptional('FLUX_PER_REQUEST'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('set should write value to Redis with prefix', async () => {
|
||||
await service.set('FLUX_PER_REQUEST', 10)
|
||||
|
||||
expect(redis.set).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'), '10')
|
||||
expect(redis._store.get(configRedisKey('FLUX_PER_REQUEST'))).toBe('10')
|
||||
})
|
||||
|
||||
it('set should reject invalid values for string config keys', async () => {
|
||||
await expect(service.set('STRIPE_FLUX_PRODUCT_ID', { id: 'prod_123' } as any))
|
||||
.rejects
|
||||
.toThrow()
|
||||
})
|
||||
|
||||
it('set then get should round-trip correctly', async () => {
|
||||
await service.set('INITIAL_USER_FLUX', 500)
|
||||
|
||||
const value = await service.getOrThrow('INITIAL_USER_FLUX')
|
||||
expect(value).toBe(500)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* service.set('LLM_ROUTER_CONFIG', { asr: { models: { auto: model } } })
|
||||
*/
|
||||
it('llm router config should preserve official ASR model config', async () => {
|
||||
await service.set('LLM_ROUTER_CONFIG', {
|
||||
llm: { models: {} },
|
||||
tts: { models: {} },
|
||||
asr: {
|
||||
models: {
|
||||
auto: {
|
||||
provider: 'aliyun-nls',
|
||||
upstreams: [{
|
||||
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext: 'ciphertext' }],
|
||||
adapterParams: {
|
||||
accessKeyId: 'ak',
|
||||
appKey: 'app',
|
||||
region: 'cn-shanghai',
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
perAttemptTimeoutMs: 30000,
|
||||
fullChainTimeoutMs: 60000,
|
||||
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
|
||||
},
|
||||
})
|
||||
|
||||
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
|
||||
const asr = value.asr
|
||||
if (!asr)
|
||||
throw new Error('Expected ASR config to be preserved')
|
||||
|
||||
expect(asr.models.auto.provider).toBe('aliyun-nls')
|
||||
expect(asr.models.auto.upstreams[0].adapterParams).toEqual({
|
||||
accessKeyId: 'ak',
|
||||
appKey: 'app',
|
||||
region: 'cn-shanghai',
|
||||
})
|
||||
})
|
||||
|
||||
it('llm router config should preserve explicit LLM and TTS provider groups', async () => {
|
||||
await service.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'step-3.5-flash': {
|
||||
upstreams: [
|
||||
{
|
||||
id: 'plan',
|
||||
baseURL: 'https://api.stepfun.com/step_plan/v1',
|
||||
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
},
|
||||
{
|
||||
id: 'paygo',
|
||||
baseURL: 'https://api.stepfun.com/v1',
|
||||
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
groups: [
|
||||
{
|
||||
id: 'plan',
|
||||
upstreamIds: ['plan'],
|
||||
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
|
||||
continueOn: { httpCodes: [402], onTimeout: false },
|
||||
},
|
||||
{
|
||||
id: 'paygo',
|
||||
upstreamIds: ['paygo'],
|
||||
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
fallbackTriggers: {
|
||||
httpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
|
||||
onTimeout: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: {
|
||||
models: {
|
||||
'stepfun/stepaudio-2.5-tts': {
|
||||
provider: 'stepfun',
|
||||
upstreams: [
|
||||
{
|
||||
id: 'plan',
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
|
||||
adapterParams: { endpointProfile: 'step-plan' },
|
||||
maxConcurrency: 1,
|
||||
},
|
||||
{
|
||||
id: 'paygo',
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
|
||||
adapterParams: { endpointProfile: 'default' },
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
groups: [
|
||||
{
|
||||
id: 'plan',
|
||||
upstreamIds: ['plan'],
|
||||
strategy: 'least-inflight',
|
||||
retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true },
|
||||
continueOn: { httpCodes: [402], onTimeout: false },
|
||||
},
|
||||
{
|
||||
id: 'paygo',
|
||||
upstreamIds: ['paygo'],
|
||||
strategy: 'ordered',
|
||||
retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
fallbackTriggers: {
|
||||
httpCodes: [401, 402, 429, 500, 502, 503, 504],
|
||||
onTimeout: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
perAttemptTimeoutMs: 30000,
|
||||
fullChainTimeoutMs: 60000,
|
||||
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
|
||||
},
|
||||
})
|
||||
|
||||
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
|
||||
const model = value.tts.models['stepfun/stepaudio-2.5-tts']
|
||||
|
||||
expect(value.llm.models['step-3.5-flash'].routing?.groups.map(group => group.id)).toEqual(['plan', 'paygo'])
|
||||
expect(model.routing?.groups.map(group => group.id)).toEqual(['plan', 'paygo'])
|
||||
expect(model.routing?.groups[0].continueOn).toEqual({
|
||||
httpCodes: [402],
|
||||
onTimeout: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a TTS provider group that references an unknown upstream', async () => {
|
||||
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({
|
||||
llm: { models: {} },
|
||||
tts: {
|
||||
models: {
|
||||
tts: {
|
||||
provider: 'stepfun',
|
||||
upstreams: [{
|
||||
id: 'plan',
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }],
|
||||
}],
|
||||
routing: {
|
||||
groups: [{
|
||||
id: 'plan',
|
||||
upstreamIds: ['missing'],
|
||||
strategy: 'ordered',
|
||||
retryOn: { httpCodes: [402], onTimeout: false },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects least-inflight routing without an explicit concurrency cap', async () => {
|
||||
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({
|
||||
llm: { models: {} },
|
||||
tts: {
|
||||
models: {
|
||||
tts: {
|
||||
provider: 'stepfun',
|
||||
upstreams: [{
|
||||
id: 'plan',
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }],
|
||||
}],
|
||||
routing: {
|
||||
groups: [{
|
||||
id: 'plan',
|
||||
upstreamIds: ['plan'],
|
||||
strategy: 'least-inflight',
|
||||
retryOn: { httpCodes: [402], onTimeout: false },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('set should store string values as JSON strings', async () => {
|
||||
await service.set('STRIPE_FLUX_PRODUCT_ID', 'prod_abc123')
|
||||
|
||||
expect(redis._store.get(configRedisKey('STRIPE_FLUX_PRODUCT_ID'))).toBe(JSON.stringify('prod_abc123'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
import type Redis from 'ioredis'
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { any, array, boolean, check, nonEmpty, number, object, optional, parse, picklist, pipe, record, regex, string } from 'valibot'
|
||||
|
||||
import { createServiceUnavailableError } from '../../utils/error'
|
||||
import { configRedisKey } from '../../utils/redis-keys'
|
||||
|
||||
/**
|
||||
* LLM/TTS router config tree. Single composite entry under configKV holds the
|
||||
* entire routing surface: per-model upstream list, optional candidate groups,
|
||||
* per-upstream key array (envelope-encrypted ciphertexts), transition policies,
|
||||
* and default timeouts.
|
||||
*
|
||||
* Schema enforces:
|
||||
* - key entry id must not contain `|` — the envelope-crypto AAD uses `|` as
|
||||
* a reserved separator between `modelName` and `keyEntryId`.
|
||||
* - keys array is non-empty per upstream (an upstream with zero keys can
|
||||
* never serve a request and is almost certainly an admin mistake).
|
||||
*
|
||||
* Defaults at this layer apply when the admin omits the `defaults` object;
|
||||
* the router service is responsible for surfacing CONFIG_NOT_SET when the
|
||||
* whole `LLM_ROUTER_CONFIG` entry is absent.
|
||||
*/
|
||||
export const fallbackTriggersSchema = optional(
|
||||
object({
|
||||
httpCodes: optional(array(number()), [401, 402, 403, 429, 500, 502, 503, 504]),
|
||||
onTimeout: optional(boolean(), true),
|
||||
}),
|
||||
{ httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* Explicit allow-list for one routing transition.
|
||||
*
|
||||
* Unlike {@link fallbackTriggersSchema}, this contract has no permissive
|
||||
* defaults: an omitted status or timeout never authorizes a transition across
|
||||
* a configured routing boundary.
|
||||
*/
|
||||
export const routeFailureTriggersSchema = object({
|
||||
httpCodes: optional(array(number()), []),
|
||||
onTimeout: optional(boolean(), false),
|
||||
})
|
||||
|
||||
export const keyEntrySchema = object({
|
||||
id: pipe(
|
||||
string(),
|
||||
nonEmpty('keys[].id must not be empty'),
|
||||
regex(/^[^|]+$/, 'keys[].id must not contain "|" (reserved AAD separator)'),
|
||||
),
|
||||
ciphertext: pipe(string(), nonEmpty('keys[].ciphertext must not be empty')),
|
||||
})
|
||||
|
||||
export const llmUpstreamSchema = object({
|
||||
id: optional(pipe(
|
||||
string(),
|
||||
nonEmpty('llm.upstreams[].id must not be empty'),
|
||||
regex(/^[^|]+$/, 'llm.upstreams[].id must not contain "|"'),
|
||||
)),
|
||||
baseURL: pipe(string(), nonEmpty('llm.upstreams[].baseURL must not be empty')),
|
||||
overrideModel: optional(string()),
|
||||
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'llm.upstreams[].keys must contain at least 1 entry')),
|
||||
headerTemplate: optional(string(), 'Bearer {KEY}'),
|
||||
timeoutMs: optional(number()),
|
||||
})
|
||||
|
||||
export const llmRoutingGroupSchema = object({
|
||||
id: pipe(string(), nonEmpty('llm.routing.groups[].id must not be empty')),
|
||||
upstreamIds: pipe(
|
||||
array(pipe(string(), nonEmpty('llm.routing.groups[].upstreamIds[] must not be empty'))),
|
||||
check(v => v.length >= 1, 'llm.routing.groups[].upstreamIds must contain at least 1 entry'),
|
||||
check(v => new Set(v).size === v.length, 'llm.routing.groups[].upstreamIds must be unique'),
|
||||
),
|
||||
retryOn: routeFailureTriggersSchema,
|
||||
continueOn: optional(routeFailureTriggersSchema),
|
||||
})
|
||||
|
||||
export const llmRoutingSchema = object({
|
||||
groups: pipe(
|
||||
array(llmRoutingGroupSchema),
|
||||
check(v => v.length >= 1, 'llm.routing.groups must contain at least 1 entry'),
|
||||
check(v => new Set(v.map(group => group.id)).size === v.length, 'llm.routing.groups[].id must be unique'),
|
||||
),
|
||||
})
|
||||
|
||||
export const llmModelSchema = pipe(
|
||||
object({
|
||||
upstreams: pipe(array(llmUpstreamSchema), check(v => v.length >= 1, 'llm.models[].upstreams must contain at least 1 entry')),
|
||||
routing: optional(llmRoutingSchema),
|
||||
fallbackTriggers: fallbackTriggersSchema,
|
||||
}),
|
||||
check((model) => {
|
||||
if (model.routing == null)
|
||||
return true
|
||||
const upstreamIds = model.upstreams.map(upstream => upstream.id)
|
||||
return upstreamIds.every(id => id != null)
|
||||
&& new Set(upstreamIds).size === upstreamIds.length
|
||||
}, 'llm.models[].upstreams must have unique ids when routing is configured'),
|
||||
check((model) => {
|
||||
if (model.routing == null)
|
||||
return true
|
||||
const upstreamIds = new Set(model.upstreams.map(upstream => upstream.id))
|
||||
const referencedIds = model.routing.groups.flatMap(group => group.upstreamIds)
|
||||
return referencedIds.length === upstreamIds.size
|
||||
&& new Set(referencedIds).size === referencedIds.length
|
||||
&& referencedIds.every(id => upstreamIds.has(id))
|
||||
}, 'llm.routing.groups must reference every upstream id exactly once'),
|
||||
)
|
||||
|
||||
const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine'])
|
||||
const asrProviderSchema = picklist(['aliyun-nls'])
|
||||
|
||||
export const ttsUpstreamSchema = object({
|
||||
id: optional(pipe(
|
||||
string(),
|
||||
nonEmpty('tts.upstreams[].id must not be empty'),
|
||||
regex(/^[^|]+$/, 'tts.upstreams[].id must not contain "|"'),
|
||||
)),
|
||||
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
|
||||
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'tts.upstreams[].keys must contain at least 1 entry')),
|
||||
adapterParams: optional(record(string(), any()), {}),
|
||||
// Per-app_id concurrency cap for the pool load balancer. One upstream maps to
|
||||
// one app_id (Volcengine `adapterParams.appid`), capped by the provider at a
|
||||
// small number (e.g. 10). When set on any upstream of a model, the router
|
||||
// switches from fixed-order fallback to capacity-aware routing across pools.
|
||||
// Absent = unlimited: that model keeps the original fixed-order behavior and
|
||||
// makes zero Redis calls (no regression for existing single-app configs).
|
||||
maxConcurrency: optional(pipe(number(), check(v => v >= 1, 'tts.upstreams[].maxConcurrency must be >= 1 when set'))),
|
||||
})
|
||||
|
||||
export const ttsRoutingGroupSchema = object({
|
||||
id: pipe(string(), nonEmpty('tts.routing.groups[].id must not be empty')),
|
||||
upstreamIds: pipe(
|
||||
array(pipe(string(), nonEmpty('tts.routing.groups[].upstreamIds[] must not be empty'))),
|
||||
check(v => v.length >= 1, 'tts.routing.groups[].upstreamIds must contain at least 1 entry'),
|
||||
check(v => new Set(v).size === v.length, 'tts.routing.groups[].upstreamIds must be unique'),
|
||||
),
|
||||
strategy: optional(picklist(['ordered', 'least-inflight']), 'ordered'),
|
||||
retryOn: routeFailureTriggersSchema,
|
||||
continueOn: optional(routeFailureTriggersSchema),
|
||||
})
|
||||
|
||||
export const ttsRoutingSchema = object({
|
||||
groups: pipe(
|
||||
array(ttsRoutingGroupSchema),
|
||||
check(v => v.length >= 1, 'tts.routing.groups must contain at least 1 entry'),
|
||||
check(v => new Set(v.map(group => group.id)).size === v.length, 'tts.routing.groups[].id must be unique'),
|
||||
),
|
||||
})
|
||||
|
||||
export const streamingTtsUpstreamSchema = object({
|
||||
baseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.baseURL must not be empty')),
|
||||
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'UNSPEECH_UPSTREAM.streaming.keys must contain at least 1 entry')),
|
||||
adapterParams: optional(record(string(), any()), {}),
|
||||
models: optional(
|
||||
array(object({
|
||||
id: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.models[].id must not be empty')),
|
||||
name: optional(string()),
|
||||
description: optional(string()),
|
||||
})),
|
||||
[],
|
||||
),
|
||||
defaultModel: optional(string()),
|
||||
})
|
||||
|
||||
export const unspeechUpstreamSchema = object({
|
||||
restBaseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.restBaseURL must not be empty')),
|
||||
streaming: optional(streamingTtsUpstreamSchema),
|
||||
})
|
||||
|
||||
export const ttsModelSchema = pipe(
|
||||
object({
|
||||
provider: ttsProviderSchema,
|
||||
upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')),
|
||||
routing: optional(ttsRoutingSchema),
|
||||
fallbackTriggers: fallbackTriggersSchema,
|
||||
}),
|
||||
check((model) => {
|
||||
if (model.routing == null)
|
||||
return true
|
||||
const upstreamIds = model.upstreams.map(upstream => upstream.id)
|
||||
return upstreamIds.every(id => id != null)
|
||||
&& new Set(upstreamIds).size === upstreamIds.length
|
||||
}, 'tts.models[].upstreams must have unique ids when routing is configured'),
|
||||
check((model) => {
|
||||
if (model.routing == null)
|
||||
return true
|
||||
const upstreamIds = new Set(model.upstreams.map(upstream => upstream.id))
|
||||
const referencedIds = model.routing.groups.flatMap(group => group.upstreamIds)
|
||||
return referencedIds.length === upstreamIds.size
|
||||
&& new Set(referencedIds).size === referencedIds.length
|
||||
&& referencedIds.every(id => upstreamIds.has(id))
|
||||
}, 'tts.routing.groups must reference every upstream id exactly once'),
|
||||
check((model) => {
|
||||
if (model.routing == null)
|
||||
return true
|
||||
const upstreamById = new Map(model.upstreams.map(upstream => [upstream.id, upstream]))
|
||||
return model.routing.groups.every(group =>
|
||||
group.strategy !== 'least-inflight'
|
||||
|| group.upstreamIds.every(id => upstreamById.get(id)?.maxConcurrency != null),
|
||||
)
|
||||
}, 'tts.routing least-inflight groups require maxConcurrency on every upstream'),
|
||||
)
|
||||
|
||||
export const asrUpstreamSchema = object({
|
||||
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'asr.upstreams[].keys must contain at least 1 entry')),
|
||||
adapterParams: optional(record(string(), any()), {}),
|
||||
})
|
||||
|
||||
export const asrModelSchema = object({
|
||||
provider: asrProviderSchema,
|
||||
upstreams: pipe(array(asrUpstreamSchema), check(v => v.length >= 1, 'asr.models[].upstreams must contain at least 1 entry')),
|
||||
})
|
||||
|
||||
export const llmRouterDefaultsSchema = optional(
|
||||
object({
|
||||
perAttemptTimeoutMs: optional(number(), 30000),
|
||||
fullChainTimeoutMs: optional(number(), 60000),
|
||||
fallbackHttpCodes: optional(array(number()), [401, 402, 403, 429, 500, 502, 503, 504]),
|
||||
}),
|
||||
{ perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] },
|
||||
)
|
||||
|
||||
export const llmRouterConfigSchema = object({
|
||||
llm: object({
|
||||
models: record(string(), llmModelSchema),
|
||||
}),
|
||||
tts: object({
|
||||
models: record(string(), ttsModelSchema),
|
||||
}),
|
||||
asr: optional(object({
|
||||
models: record(string(), asrModelSchema),
|
||||
})),
|
||||
defaults: llmRouterDefaultsSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Config entry schemas are the single source of truth for:
|
||||
* - runtime validation
|
||||
* - default values
|
||||
* - Redis serialization/deserialization shape
|
||||
*/
|
||||
const ConfigEntrySchemas = {
|
||||
FLUX_PER_REQUEST: optional(number(), 5),
|
||||
INITIAL_USER_FLUX: optional(number(), 0),
|
||||
FLUX_PER_1K_TOKENS: optional(number(), 1),
|
||||
FLUX_PER_1K_CHARS_TTS: number(),
|
||||
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
|
||||
// 24h gives users a long-enough window for accumulated dust to settle naturally.
|
||||
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
|
||||
AUTH_RATE_LIMIT_MAX: optional(number(), 20),
|
||||
AUTH_RATE_LIMIT_WINDOW_SEC: optional(number(), 60),
|
||||
// No default — absent means top-up is not available yet
|
||||
STRIPE_FLUX_PRODUCT_ID: optional(string()),
|
||||
// No default — absent lets Stripe auto-select payment methods via Dashboard config
|
||||
STRIPE_PAYMENT_METHODS: optional(array(string())),
|
||||
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
|
||||
// model id → (BCP-47 locale → recommended voice id). Outer key is either a
|
||||
// router TTS model id (LLM_ROUTER_CONFIG.tts.models key) for REST or a
|
||||
// streaming api_resource_id (e.g. `seed-tts-2.0`) for the streaming surface.
|
||||
// The two key spaces do not overlap. Consumed by the client to preselect a
|
||||
// voice matching UI locale per active model.
|
||||
DEFAULT_TTS_VOICES: optional(record(string(), record(string(), string())), {}),
|
||||
// Server-side alias resolution for `model: 'auto'` in /chat/completions and
|
||||
// /audio/speech. The modelName written here must exist as a key in
|
||||
// LLM_ROUTER_CONFIG.{llm,tts}.models — the router itself doesn't understand
|
||||
// `auto`, this layer translates before dispatch. No default: missing entry
|
||||
// surfaces CONFIG_NOT_SET (resolveWithDefault swallows ValiError) so a
|
||||
// misconfigured deploy fails the request instead of silently routing to an
|
||||
// empty modelName. Naked schema (not wrapped in optional) keeps the inferred
|
||||
// type tight (`string` rather than `string | undefined`) for call sites.
|
||||
DEFAULT_CHAT_MODEL: pipe(string(), nonEmpty('DEFAULT_CHAT_MODEL must not be empty')),
|
||||
DEFAULT_TTS_MODEL: pipe(string(), nonEmpty('DEFAULT_TTS_MODEL must not be empty')),
|
||||
// No default — the router throws CONFIG_NOT_SET when this entry is absent
|
||||
// so the admin endpoint (U9) is forced to populate it before traffic flows.
|
||||
LLM_ROUTER_CONFIG: optional(llmRouterConfigSchema),
|
||||
// Single unspeech deployment used for every TTS surface: REST audio/speech,
|
||||
// REST voices catalog, ws audio/speech/stream. `streaming` is optional —
|
||||
// operator may run REST-only without the ws upstream. `streaming.keys`
|
||||
// carry the upstream-provider API key (Volcengine X-Api-Key), not an
|
||||
// unspeech tenant token (unspeech itself is unauthenticated).
|
||||
UNSPEECH_UPSTREAM: optional(unspeechUpstreamSchema),
|
||||
} as const
|
||||
|
||||
type ConfigDefinitions = {
|
||||
[K in keyof typeof ConfigEntrySchemas]: InferOutput<(typeof ConfigEntrySchemas)[K]>
|
||||
}
|
||||
|
||||
type ConfigKey = keyof ConfigDefinitions
|
||||
|
||||
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
|
||||
try {
|
||||
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
|
||||
}
|
||||
catch (error) {
|
||||
throw createServiceUnavailableError(
|
||||
'Service configuration is invalid',
|
||||
'CONFIG_INVALID',
|
||||
{
|
||||
key,
|
||||
message: errorMessageFrom(error) ?? 'Unknown config parse error',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function serializeValue<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): string {
|
||||
return JSON.stringify(parse(ConfigEntrySchemas[key], value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a config value: read from Redis, then apply valibot default if missing.
|
||||
* Returns `undefined` if both Redis and schema have no value (required key, not set).
|
||||
*/
|
||||
function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): ConfigDefinitions[K] | undefined {
|
||||
if (raw !== null)
|
||||
return parseValue(key, raw)
|
||||
|
||||
// Use the per-key schema with `undefined` to trigger the key default
|
||||
try {
|
||||
return parse(ConfigEntrySchemas[key], undefined) as ConfigDefinitions[K]
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfigKVService(redis: Redis) {
|
||||
return {
|
||||
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
|
||||
const raw = await redis.get(configRedisKey(key))
|
||||
const value = resolveWithDefault(key, raw)
|
||||
return value ?? null
|
||||
},
|
||||
|
||||
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
|
||||
const raw = await redis.get(configRedisKey(key))
|
||||
const value = resolveWithDefault(key, raw)
|
||||
if (value === undefined)
|
||||
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
|
||||
|
||||
return value as Exclude<ConfigDefinitions[K], undefined>
|
||||
},
|
||||
|
||||
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
|
||||
return this.getOrThrow(key)
|
||||
},
|
||||
|
||||
async set<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): Promise<void> {
|
||||
const serialized = serializeValue(key, value)
|
||||
await redis.set(configRedisKey(key), serialized)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigKVService = ReturnType<typeof createConfigKVService>
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { Logger } from '@guiiai/logg'
|
||||
|
||||
import type { EmailMetrics } from '../../otel'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Resend } from 'resend'
|
||||
|
||||
import { ApiError } from '../../utils/error'
|
||||
|
||||
/**
|
||||
* Outbound email payload accepted by {@link EmailService.send}.
|
||||
*
|
||||
* Use when:
|
||||
* - Building a higher-level transactional template (verification, reset, magic link, change-email).
|
||||
*
|
||||
* Expects:
|
||||
* - Both `html` and `text` set so deliverability scoring stays high (text fallback
|
||||
* is what spam filters score when HTML is hostile or stripped).
|
||||
* - `to` is already validated by Better Auth (we trust caller for internal flows).
|
||||
*/
|
||||
export interface EmailPayload {
|
||||
/** Recipient address. Single address — Better Auth callbacks always emit one. */
|
||||
to: string
|
||||
/** Subject line. Plain text. */
|
||||
subject: string
|
||||
/** HTML body. */
|
||||
html: string
|
||||
/** Plain-text body. Required for spam-filter parity and accessibility. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Email service abstraction shared by all Better Auth callbacks.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring `sendVerificationEmail` / `sendResetPassword` / `sendMagicLink` /
|
||||
* `sendChangeEmailConfirmation` in `createAuth()`.
|
||||
*
|
||||
* Expects:
|
||||
* - Service is constructed once per process by `injeca` and shared across requests.
|
||||
*
|
||||
* Returns:
|
||||
* - A `send` method plus four high-level helpers that own subject/body composition.
|
||||
*/
|
||||
export interface EmailService {
|
||||
send: (payload: EmailPayload) => Promise<void>
|
||||
sendVerification: (params: { to: string, url: string }) => Promise<void>
|
||||
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
|
||||
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
|
||||
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
|
||||
/**
|
||||
* Send the irreversible-action confirmation for `user.deleteUser` flow.
|
||||
*
|
||||
* Wired into better-auth's `user.deleteUser.sendDeleteAccountVerification`.
|
||||
* The link expires per `deleteTokenExpiresIn` (default 24h) and is
|
||||
* single-use; clicking it triggers `beforeDelete` → soft-delete handlers →
|
||||
* hard-delete user.
|
||||
*
|
||||
* Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-300.
|
||||
*/
|
||||
sendDeleteAccountVerification: (params: { to: string, url: string }) => Promise<void>
|
||||
}
|
||||
|
||||
interface EmailConfig {
|
||||
apiKey: string
|
||||
fromEmail: string
|
||||
fromName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an RFC 5322 display-name + address pair for the `From` header.
|
||||
*
|
||||
* Before:
|
||||
* - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }`
|
||||
*
|
||||
* After:
|
||||
* - `'AIRI <noreply@a.io>'`
|
||||
*/
|
||||
function formatFrom(config: EmailConfig): string {
|
||||
if (config.fromName)
|
||||
return `${config.fromName} <${config.fromEmail}>`
|
||||
return config.fromEmail
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the email service.
|
||||
*
|
||||
* Use when:
|
||||
* - DI assembly in `server/apps/api/src/app.ts`.
|
||||
*
|
||||
* Expects:
|
||||
* - `RESEND_API_KEY` is set in env. When empty, `send` throws an `ApiError`
|
||||
* instead of silently dropping mail — Better Auth surfaces it back to the
|
||||
* caller so frontend can show a clear "email service not configured" error.
|
||||
*/
|
||||
export function createEmailService(config: EmailConfig, logger: Logger = useLogger('email'), metrics?: EmailMetrics | null): EmailService {
|
||||
// NOTICE:
|
||||
// Construct Resend lazily so the server can boot in environments where the
|
||||
// RESEND_API_KEY is intentionally empty (e.g. local dev that never exercises
|
||||
// email flows). Calls to `send` will throw, which Better Auth surfaces.
|
||||
// Root cause summary: Resend's constructor logs but does not throw on empty
|
||||
// keys; explicit guard keeps the failure mode visible at the call site.
|
||||
// Source: node_modules/.pnpm/resend@*/node_modules/resend/dist/index.cjs
|
||||
// Removal condition: when we make RESEND_API_KEY required at env-parse time.
|
||||
let client: Resend | null = null
|
||||
function getClient(): Resend {
|
||||
if (!client) {
|
||||
if (!config.apiKey) {
|
||||
throw new ApiError(
|
||||
503,
|
||||
'email/service_not_configured',
|
||||
'Email service not configured (RESEND_API_KEY is missing).',
|
||||
)
|
||||
}
|
||||
client = new Resend(config.apiKey)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
const from = formatFrom(config)
|
||||
|
||||
async function send(payload: EmailPayload, template: string = 'unknown'): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const { error } = await getClient().emails.send({
|
||||
from,
|
||||
to: [payload.to],
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
text: payload.text,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
logger.withFields({ to: payload.to, subject: payload.subject, errorName: error.name }).error(error.message)
|
||||
metrics?.failures.add(1, { template, error_name: error.name })
|
||||
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'error' })
|
||||
throw new ApiError(502, 'email/send_failed', error.message, { providerError: error.name })
|
||||
}
|
||||
metrics?.send.add(1, { template })
|
||||
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'ok' })
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ApiError)
|
||||
throw error
|
||||
|
||||
const message = errorMessageFrom(error) ?? 'Unknown email send error'
|
||||
logger.withFields({ to: payload.to, subject: payload.subject }).error(message)
|
||||
metrics?.failures.add(1, { template, error_name: 'unhandled' })
|
||||
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'error' })
|
||||
throw new ApiError(502, 'email/send_failed', message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
async sendVerification({ to, url }) {
|
||||
await send({
|
||||
to,
|
||||
subject: 'Verify your email for Project AIRI',
|
||||
html: renderVerificationHtml(url),
|
||||
text: renderVerificationText(url),
|
||||
}, 'verification')
|
||||
},
|
||||
async sendPasswordReset({ to, url }) {
|
||||
await send({
|
||||
to,
|
||||
subject: 'Reset your Project AIRI password',
|
||||
html: renderPasswordResetHtml(url),
|
||||
text: renderPasswordResetText(url),
|
||||
}, 'password_reset')
|
||||
},
|
||||
async sendMagicLink({ to, url }) {
|
||||
await send({
|
||||
to,
|
||||
subject: 'Your Project AIRI sign-in link',
|
||||
html: renderMagicLinkHtml(url),
|
||||
text: renderMagicLinkText(url),
|
||||
}, 'magic_link')
|
||||
},
|
||||
async sendChangeEmailConfirmation({ to, newEmail, url }) {
|
||||
await send({
|
||||
to,
|
||||
subject: 'Confirm your new email address for Project AIRI',
|
||||
html: renderChangeEmailHtml(url, newEmail),
|
||||
text: renderChangeEmailText(url, newEmail),
|
||||
}, 'change_email')
|
||||
},
|
||||
async sendDeleteAccountVerification({ to, url }) {
|
||||
await send({
|
||||
to,
|
||||
subject: 'Confirm account deletion for Project AIRI',
|
||||
html: renderDeleteAccountHtml(url),
|
||||
text: renderDeleteAccountText(url),
|
||||
}, 'delete_account')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// Templates are intentionally minimal inline HTML. Goal here is functional
|
||||
// delivery + plaintext fallback. Visual design is deferred (see
|
||||
// docs/ai/context/email-auth-resend.md "不做" section).
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string {
|
||||
const safeUrl = escapeHtml(args.url)
|
||||
return `<!doctype html>
|
||||
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; color: #111; max-width: 480px; margin: 24px auto; padding: 0 16px;">
|
||||
<h2 style="margin: 0 0 16px;">${escapeHtml(args.heading)}</h2>
|
||||
<p style="margin: 0 0 16px;">${escapeHtml(args.body)}</p>
|
||||
<p style="margin: 0 0 16px;"><a href="${safeUrl}" style="display: inline-block; padding: 10px 16px; background: #111; color: #fff; border-radius: 6px; text-decoration: none;">${escapeHtml(args.ctaLabel)}</a></p>
|
||||
<p style="margin: 0 0 16px; font-size: 12px; color: #666;">If the button doesn't work, copy this URL into your browser:<br/><span style="word-break: break-all;">${safeUrl}</span></p>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #888;">${escapeHtml(args.footer)}</p>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function renderActionEmailText(args: { heading: string, body: string, url: string, footer: string }): string {
|
||||
return `${args.heading}\n\n${args.body}\n\n${args.url}\n\n${args.footer}\n`
|
||||
}
|
||||
|
||||
function renderVerificationHtml(url: string): string {
|
||||
return renderActionEmailHtml({
|
||||
heading: 'Verify your email',
|
||||
body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.',
|
||||
ctaLabel: 'Verify email',
|
||||
url,
|
||||
footer: 'If you did not create an account, you can safely ignore this email.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderVerificationText(url: string): string {
|
||||
return renderActionEmailText({
|
||||
heading: 'Verify your email',
|
||||
body: 'Welcome to Project AIRI. Open this link to confirm your email address:',
|
||||
url,
|
||||
footer: 'If you did not create an account, you can safely ignore this email.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderPasswordResetHtml(url: string): string {
|
||||
return renderActionEmailHtml({
|
||||
heading: 'Reset your password',
|
||||
body: 'We received a request to reset the password for your Project AIRI account.',
|
||||
ctaLabel: 'Reset password',
|
||||
url,
|
||||
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderPasswordResetText(url: string): string {
|
||||
return renderActionEmailText({
|
||||
heading: 'Reset your password',
|
||||
body: 'Open this link to reset your Project AIRI password:',
|
||||
url,
|
||||
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderMagicLinkHtml(url: string): string {
|
||||
return renderActionEmailHtml({
|
||||
heading: 'Sign in to Project AIRI',
|
||||
body: 'Click the button below to sign in. This link expires shortly and can be used once.',
|
||||
ctaLabel: 'Sign in',
|
||||
url,
|
||||
footer: 'If you did not request this link, you can safely ignore this email.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderMagicLinkText(url: string): string {
|
||||
return renderActionEmailText({
|
||||
heading: 'Sign in to Project AIRI',
|
||||
body: 'Open this link to sign in (single-use, expires shortly):',
|
||||
url,
|
||||
footer: 'If you did not request this link, you can safely ignore this email.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderChangeEmailHtml(url: string, newEmail: string): string {
|
||||
return renderActionEmailHtml({
|
||||
heading: 'Confirm your new email',
|
||||
body: `Confirm that ${newEmail} should become your Project AIRI account email.`,
|
||||
ctaLabel: 'Confirm new email',
|
||||
url,
|
||||
footer: 'If you did not request this change, contact support immediately.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderChangeEmailText(url: string, newEmail: string): string {
|
||||
return renderActionEmailText({
|
||||
heading: 'Confirm your new email',
|
||||
body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`,
|
||||
url,
|
||||
footer: 'If you did not request this change, contact support immediately.',
|
||||
})
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// Wording is intentionally short and direct. Account deletion hard-deletes
|
||||
// the auth identity (cascade) and soft-archives business records; the user
|
||||
// cannot recover the account through the UI.
|
||||
// See `server/apps/api/docs/ai-context/account-deletion.md`.
|
||||
function renderDeleteAccountHtml(url: string): string {
|
||||
return renderActionEmailHtml({
|
||||
heading: 'Confirm account deletion',
|
||||
body: 'Click below to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
|
||||
ctaLabel: 'Delete my account',
|
||||
url,
|
||||
footer: 'Did not request this? Ignore this email and rotate your password.',
|
||||
})
|
||||
}
|
||||
|
||||
function renderDeleteAccountText(url: string): string {
|
||||
return renderActionEmailText({
|
||||
heading: 'Confirm account deletion',
|
||||
body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
|
||||
url,
|
||||
footer: 'Did not request this? Ignore this email and rotate your password.',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { PostHog } from 'posthog-node'
|
||||
|
||||
const logger = useLogger('posthog')
|
||||
|
||||
/**
|
||||
* One product event forwarded to PostHog, keyed by the Better Auth user id
|
||||
* so it merges with the browser person identified via `posthog.identify()`.
|
||||
*/
|
||||
export interface PosthogCaptureInput {
|
||||
distinctId: string
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal capture boundary the product-events service depends on. Kept as
|
||||
* an interface so tests inject a fake instead of mocking the SDK.
|
||||
*/
|
||||
export interface PosthogSink {
|
||||
/**
|
||||
* Queue a high-volume analytics event without waiting for a network
|
||||
* roundtrip. Use on request hot paths where occasional process-exit loss is
|
||||
* preferable to user-visible latency.
|
||||
*/
|
||||
captureQueued?: (input: PosthogCaptureInput) => void
|
||||
capture: (input: PosthogCaptureInput) => Promise<void>
|
||||
/** Flush and close the underlying client. Call on server shutdown. */
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog sink for server-side product events.
|
||||
*
|
||||
* Low-frequency conversion facts use `captureImmediate` (one HTTP roundtrip
|
||||
* per event) because they terminate money/auth funnels. High-frequency AI
|
||||
* generation facts use `captureQueued`, which is buffered by the SDK and
|
||||
* flushed on shutdown, so chat completion requests don't wait on PostHog.
|
||||
*
|
||||
* Capture failures are logged and swallowed — analytics forwarding must
|
||||
* never fail the Stripe webhook or auth flow that triggered it. The
|
||||
* Postgres `product_events` row is the source of truth either way.
|
||||
*/
|
||||
export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink {
|
||||
const client = new PostHog(options.projectKey, { host: options.host })
|
||||
|
||||
return {
|
||||
captureQueued(input: PosthogCaptureInput): void {
|
||||
try {
|
||||
client.capture({
|
||||
distinctId: input.distinctId,
|
||||
event: input.event,
|
||||
properties: input.properties,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ event: input.event }).warn('Failed to enqueue product event to PostHog')
|
||||
}
|
||||
},
|
||||
|
||||
async capture(input: PosthogCaptureInput): Promise<void> {
|
||||
try {
|
||||
await client.captureImmediate({
|
||||
distinctId: input.distinctId,
|
||||
event: input.event,
|
||||
properties: input.properties,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
|
||||
}
|
||||
},
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await client.shutdown()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const AUDIO_MIME_TYPES: Record<string, string> = {
|
||||
flac: 'audio/flac',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg_opus: 'audio/ogg',
|
||||
opus: 'audio/opus',
|
||||
pcm: 'audio/L16',
|
||||
wav: 'audio/wav',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps provider audio format keys to response MIME types.
|
||||
*
|
||||
* Use when:
|
||||
* - A TTS adapter forwards OpenAI-shaped `response_format` / provider
|
||||
* encoding keys through unspeech and needs a gateway fallback MIME type.
|
||||
*
|
||||
* Expects:
|
||||
* - `format` is the exact provider/OpenAI format key.
|
||||
*
|
||||
* Returns:
|
||||
* - A known audio MIME type, or `application/octet-stream` for unknown custom
|
||||
* formats so operators can still experiment through config.
|
||||
*/
|
||||
export function audioMimeFromFormat(format: string): string {
|
||||
return AUDIO_MIME_TYPES[format] ?? 'application/octet-stream'
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { inferMicrosoftContentType, isMicrosoftVoiceId, resolveMicrosoftOutputFormat } from 'unspeech'
|
||||
|
||||
import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
/**
|
||||
* Azure Cognitive Services REST adapter.
|
||||
*
|
||||
* Use when:
|
||||
* - The router routes a hosted TTS request to an Azure upstream (e.g.
|
||||
* `https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1`).
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.baseURL` is the full Azure REST endpoint (region-prefixed).
|
||||
* - `ctx.keyPlaintext` is the subscription key string the gateway will send as
|
||||
* `Ocp-Apim-Subscription-Key`.
|
||||
*
|
||||
* Returns:
|
||||
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. The
|
||||
* `contentType` is taken from the upstream `content-type` header when
|
||||
* present, otherwise inferred from the requested format.
|
||||
*/
|
||||
export const azureAdapter: TtsAdapter = {
|
||||
id: 'azure',
|
||||
|
||||
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
|
||||
const defaultVoice = typeof ctx.adapterParams.defaultVoice === 'string'
|
||||
? ctx.adapterParams.defaultVoice
|
||||
: undefined
|
||||
const voice = input.voice ?? defaultVoice
|
||||
if (!voice)
|
||||
throw createBadRequestError('azure voice is required when adapterParams.defaultVoice is not configured', 'BAD_REQUEST')
|
||||
if (!isMicrosoftVoiceId(voice))
|
||||
throw createBadRequestError(`azure voice id contains unsupported characters: ${voice}`, 'BAD_REQUEST', { voice })
|
||||
const outputFormat = resolveMicrosoftOutputFormat(input.responseFormat)
|
||||
const disableSsml = input.extraOptions?.disableSsml === true
|
||||
|
||||
const ssml = disableSsml
|
||||
? input.text
|
||||
: buildAzureSsml(input.text, voice, input.speed, {
|
||||
pitch: typeof input.extraOptions?.pitch === 'number' ? input.extraOptions.pitch : undefined,
|
||||
volume: typeof input.extraOptions?.volume === 'number' ? input.extraOptions.volume : undefined,
|
||||
})
|
||||
|
||||
const region = ctx.adapterParams?.region
|
||||
if (typeof region !== 'string' || !region)
|
||||
throw createInternalError('azure tts upstream is missing adapterParams.region')
|
||||
|
||||
return sendSpeechViaUnSpeech({
|
||||
ctx,
|
||||
model: 'microsoft/v1',
|
||||
input: ssml,
|
||||
voice,
|
||||
responseFormat: outputFormat,
|
||||
extraBody: { region, disable_ssml: true },
|
||||
fallbackContentType: inferMicrosoftContentType(outputFormat),
|
||||
providerLabel: 'azure',
|
||||
})
|
||||
},
|
||||
|
||||
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
|
||||
// Azure has no static catalog. Voices live at Microsoft's `voices/list`
|
||||
// REST endpoint, which we reach via the unspeech `microsoft` backend
|
||||
// because unspeech already maps the proprietary response shape to
|
||||
// `types.Voice` (full formats table, masterpiece preview URLs, locale
|
||||
// metadata). Calling unspeech also keeps a single integration point for
|
||||
// every other provider that could grow this way later.
|
||||
if (!ctx.region)
|
||||
throw createServiceUnavailableError('azure tts region not configured', 'AZURE_TTS_NOT_CONFIGURED')
|
||||
if (!ctx.keyPlaintext)
|
||||
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
|
||||
|
||||
return listVoicesViaUnSpeech({
|
||||
ctx,
|
||||
query: `provider=microsoft®ion=${encodeURIComponent(ctx.region)}`,
|
||||
providerLabel: 'azure',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Azure-compatible SSML, preserving Voice Pack prosody settings.
|
||||
*
|
||||
* NOTICE:
|
||||
* `unspeech` owns the canonical Microsoft helpers, but the currently consumed
|
||||
* helper surface only lets AIRI pass speed. Voice Pack pitch and volume must be
|
||||
* encoded before the request reaches unspeech because AIRI sends pre-built SSML
|
||||
* with `disable_ssml: true`.
|
||||
* Source/context: this adapter's `extraOptions.pitch` and `extraOptions.volume`
|
||||
* contract, covered by `azureAdapter.send` tests.
|
||||
* Removal condition: delete this helper once `unspeech` exposes a
|
||||
* `buildMicrosoftSsml` overload that accepts pitch and volume.
|
||||
*/
|
||||
function buildAzureSsml(
|
||||
text: string,
|
||||
voice: string,
|
||||
speed: number | undefined,
|
||||
options: {
|
||||
pitch?: number
|
||||
volume?: number
|
||||
},
|
||||
): string {
|
||||
const safe = escapeForSsml(text)
|
||||
const rate = speedToProsodyRate(speed)
|
||||
const pitch = percentToProsodyValue(options.pitch)
|
||||
const volume = percentToProsodyValue(options.volume)
|
||||
const prosodyAttrs = [
|
||||
rate ? `rate='${rate}'` : undefined,
|
||||
pitch ? `pitch='${pitch}'` : undefined,
|
||||
volume ? `volume='${volume}'` : undefined,
|
||||
].filter(Boolean).join(' ')
|
||||
const inner = prosodyAttrs
|
||||
? `<prosody ${prosodyAttrs}>${safe}</prosody>`
|
||||
: safe
|
||||
|
||||
return `<speak version='1.0' xml:lang='en-US'><voice name='${voice}'>${inner}</voice></speak>`
|
||||
}
|
||||
|
||||
function speedToProsodyRate(speed: number | undefined): string {
|
||||
if (speed == null || speed === 1)
|
||||
return ''
|
||||
const delta = Math.round((speed - 1) * 100)
|
||||
if (delta === 0)
|
||||
return ''
|
||||
return delta > 0 ? `+${delta}%` : `${delta}%`
|
||||
}
|
||||
|
||||
function percentToProsodyValue(value: number | undefined): string {
|
||||
if (value == null)
|
||||
return ''
|
||||
if (value > 0)
|
||||
return `+${value}%`
|
||||
if (value < 0)
|
||||
return `${value}%`
|
||||
return '0%'
|
||||
}
|
||||
|
||||
function escapeForSsml(text: string): string {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('\'', ''')
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice'
|
||||
|
||||
const UNSPEECH = 'http://unspeech.local:5933'
|
||||
const SPEECH_URL = `${UNSPEECH}/v1/audio/speech`
|
||||
|
||||
function binaryResponse(bytes: Uint8Array, status = 200) {
|
||||
return new Response(bytes, {
|
||||
status,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('dashscopeCosyvoiceAdapter', () => {
|
||||
it('forwards to unspeech with model=alibaba/<adapterParams.model>, voice + response_format passthrough', async () => {
|
||||
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x04, 0x00, 0x00]) // ID3v2 mp3 header
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(audioBytes))
|
||||
|
||||
const result = await dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi there', voice: 'longxiaochun_v2', responseFormat: 'mp3' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
|
||||
unspeechBaseURL: UNSPEECH,
|
||||
adapterParams: { model: 'cosyvoice-v2' },
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
)
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [calledURL, init] = fetchImpl.mock.calls[0]
|
||||
expect(calledURL).toBe(SPEECH_URL)
|
||||
expect(init.method).toBe('POST')
|
||||
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body).toEqual({
|
||||
model: 'alibaba/cosyvoice-v2',
|
||||
input: 'hi there',
|
||||
voice: 'longxiaochun_v2',
|
||||
response_format: 'mp3',
|
||||
})
|
||||
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer sk-test')
|
||||
|
||||
expect(result.contentType).toBe('audio/mpeg')
|
||||
expect(result.body).toBeInstanceOf(ArrayBuffer)
|
||||
const out = new Uint8Array(result.body as ArrayBuffer)
|
||||
expect(Array.from(out)).toEqual(Array.from(audioBytes))
|
||||
})
|
||||
|
||||
it('throws Error with .status when unspeech returns non-2xx (router walks to next key)', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(new Response('bad key', { status: 401 }))
|
||||
|
||||
await expect(
|
||||
dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi', voice: 'longxiaochun_v2' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
|
||||
unspeechBaseURL: UNSPEECH,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ status: 401, message: expect.stringContaining('401') })
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects missing voice instead of hardcoding a model-specific default', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0])))
|
||||
|
||||
await expect(dashscopeCosyvoiceAdapter.send(
|
||||
{ text: 'hi' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
|
||||
unspeechBaseURL: UNSPEECH,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
)).rejects.toMatchObject({ statusCode: 400 })
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* dashscopeCosyvoiceAdapter.send({ text: 'hi', extraOptions: { volume: 5 } }, ctx)
|
||||
*/
|
||||
it('fails fast when Voice Pack pitch or volume params reach DashScope cosyvoice', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0])))
|
||||
|
||||
await expect(dashscopeCosyvoiceAdapter.send(
|
||||
{
|
||||
text: 'hi',
|
||||
voice: 'longxiaochun_v2',
|
||||
extraOptions: {
|
||||
volume: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('sk-test', 'utf8'),
|
||||
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
|
||||
unspeechBaseURL: UNSPEECH,
|
||||
adapterParams: {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
)).rejects.toMatchObject({ statusCode: 400 })
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('voice catalog is proxied through unspeech with the selected cosyvoice model', async () => {
|
||||
// The catalog itself is unspeech-owned now (embedded JSON in
|
||||
// unspeech/pkg/backend/alibaba/voices.go). This test only verifies the
|
||||
// wire contract — fixture content is intentionally minimal so an
|
||||
// unspeech-side roster change doesn't break us.
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
const catalog = await dashscopeCosyvoiceAdapter.getVoiceCatalog({
|
||||
adapterParams: { model: 'cosyvoice-v2' },
|
||||
unspeechBaseURL: UNSPEECH,
|
||||
fetchImpl,
|
||||
})
|
||||
expect(catalog).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
`${UNSPEECH}/api/voices?provider=alibaba&model=cosyvoice-v2`,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { createBadRequestError } from '../../../utils/error'
|
||||
import { audioMimeFromFormat } from './audio-format'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
/**
|
||||
* Default cosyvoice audio format. Mirrors the OpenAI `mp3` default expected by
|
||||
* downstream consumers.
|
||||
*/
|
||||
const DEFAULT_COSYVOICE_FORMAT = 'mp3'
|
||||
|
||||
/**
|
||||
* Default cosyvoice model id. v1 was dropped from the official "REST-supported
|
||||
* models" list (the official list now starts at v2 and runs through v3.5);
|
||||
* v2 is the most conservative current default and shares a request body shape
|
||||
* with v3/v3.5 so ops can retarget via `adapterParams.model` without code.
|
||||
* NOTICE:
|
||||
* If you bump this past v2, verify the configured default voice exists for
|
||||
* that model — voice catalogs differ between v2 (`*_v2`) and v3 (`*_v3`).
|
||||
*/
|
||||
const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v2'
|
||||
|
||||
/**
|
||||
* DashScope cosyvoice adapter.
|
||||
*
|
||||
* Use when:
|
||||
* - Routing a hosted TTS request to Alibaba DashScope's cosyvoice v2 / v3
|
||||
* family of models (Chinese + English + selected multilingual voices).
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.baseURL` is the **full** non-streaming endpoint, e.g.
|
||||
* `https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer`
|
||||
* (or `dashscope-intl.aliyuncs.com` for the Singapore region). The adapter
|
||||
* does not append a path — pointing at a bare `/api/v1` will 404.
|
||||
* - `ctx.keyPlaintext` is the DashScope API key (sent as `Bearer ...`).
|
||||
* - `ctx.adapterParams.model` (optional) names the cosyvoice variant; defaults
|
||||
* to {@link DEFAULT_COSYVOICE_MODEL}.
|
||||
*
|
||||
* Returns:
|
||||
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. The non-
|
||||
* streaming endpoint returns a JSON envelope whose `output.audio.url` is
|
||||
* a short-lived signed URL; this adapter performs the follow-up GET and
|
||||
* surfaces the final bytes so router callers get the same single-shot
|
||||
* contract as the Azure / Volcengine paths.
|
||||
*/
|
||||
export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
id: 'dashscope-cosyvoice',
|
||||
|
||||
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
|
||||
const model = typeof ctx.adapterParams.model === 'string'
|
||||
? ctx.adapterParams.model
|
||||
: DEFAULT_COSYVOICE_MODEL
|
||||
if (!input.voice)
|
||||
throw createBadRequestError('dashscope-cosyvoice voice is required', 'BAD_REQUEST')
|
||||
if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') {
|
||||
throw createBadRequestError(
|
||||
'dashscope-cosyvoice does not support Voice Pack pitch or volume parameters',
|
||||
'BAD_REQUEST',
|
||||
)
|
||||
}
|
||||
const voice = input.voice
|
||||
const format = input.responseFormat ?? DEFAULT_COSYVOICE_FORMAT
|
||||
|
||||
return sendSpeechViaUnSpeech({
|
||||
ctx,
|
||||
model: `alibaba/${model}`,
|
||||
input: input.text,
|
||||
voice,
|
||||
responseFormat: format,
|
||||
fallbackContentType: audioMimeFromFormat(format),
|
||||
providerLabel: 'dashscope-cosyvoice',
|
||||
})
|
||||
},
|
||||
|
||||
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
|
||||
// unspeech's alibaba backend embeds the catalog at build time
|
||||
// (unspeech/pkg/backend/alibaba/voices.go `//go:embed voices.json`),
|
||||
// so this call is in-memory on unspeech's side and only crosses a TCP
|
||||
// hop. No upstream credential is required.
|
||||
const params = new URLSearchParams({ provider: 'alibaba' })
|
||||
if (typeof ctx.adapterParams.model === 'string')
|
||||
params.set('model', ctx.adapterParams.model)
|
||||
return listVoicesViaUnSpeech({
|
||||
ctx,
|
||||
query: params.toString(),
|
||||
providerLabel: 'cosyvoice',
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ApiError } from '../../../utils/error'
|
||||
import { getAdapter } from './index'
|
||||
|
||||
describe('getAdapter', () => {
|
||||
it('returns the azure adapter by id', () => {
|
||||
const adapter = getAdapter('azure')
|
||||
expect(adapter.id).toBe('azure')
|
||||
})
|
||||
|
||||
it('returns the dashscope-cosyvoice adapter by id', () => {
|
||||
const adapter = getAdapter('dashscope-cosyvoice')
|
||||
expect(adapter.id).toBe('dashscope-cosyvoice')
|
||||
})
|
||||
|
||||
it('returns the volcengine adapter by id', () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
expect(adapter.id).toBe('volcengine')
|
||||
})
|
||||
|
||||
it('returns the stepfun adapter by id', () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
expect(adapter.id).toBe('stepfun')
|
||||
})
|
||||
|
||||
it('throws BAD_REQUEST on unknown id with the available list in details', () => {
|
||||
expect(() => getAdapter('unknown-provider')).toThrow(ApiError)
|
||||
try {
|
||||
getAdapter('unknown-provider')
|
||||
}
|
||||
catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
const apiErr = err as ApiError
|
||||
expect(apiErr.statusCode).toBe(400)
|
||||
expect(apiErr.errorCode).toBe('BAD_REQUEST')
|
||||
expect(apiErr.details).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'unknown-provider',
|
||||
available: expect.arrayContaining(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('every adapter delegates getVoiceCatalog to unspeech and returns the parsed list', async () => {
|
||||
for (const id of ['dashscope-cosyvoice', 'volcengine'] as const) {
|
||||
const adapter = getAdapter(id)
|
||||
expect(typeof adapter.send).toBe('function')
|
||||
expect(typeof adapter.getVoiceCatalog).toBe('function')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'v1', name: 'v1' }],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
adapterParams: {},
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})
|
||||
expect(voices).toEqual([{ id: 'v1', name: 'v1' }])
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
it('calls unspeech with provider=alibaba + model (no Bearer)', async () => {
|
||||
const adapter = getAdapter('dashscope-cosyvoice')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
adapterParams: { model: 'cosyvoice-v2' },
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
expect(voices).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
|
||||
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=alibaba&model=cosyvoice-v2')
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
expect(headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws 502 BAD_GATEWAY when unspeech non-2xx', async () => {
|
||||
const adapter = getAdapter('dashscope-cosyvoice')
|
||||
const fetchImpl = vi.fn(async () => new Response('boom', { status: 502 })) as unknown as typeof fetch
|
||||
await expect(adapter.getVoiceCatalog({
|
||||
adapterParams: {},
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})).rejects.toMatchObject({ statusCode: 502 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
it('calls unspeech with provider=volcengine and forwards adapterParams.model as ?model=', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'zh_female_x', name: 'X' }],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
adapterParams: { model: 'seed-tts-2.0' },
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
expect(voices).toEqual([{ id: 'zh_female_x', name: 'X' }])
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine&model=seed-tts-2.0')
|
||||
})
|
||||
|
||||
it('omits ?model= when adapterParams.model is not set', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ voices: [] }), { status: 200 })) as unknown as typeof fetch
|
||||
await adapter.getVoiceCatalog({
|
||||
adapterParams: {},
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('azureAdapter.getVoiceCatalog', () => {
|
||||
it('sends bearer + region to unspeech and returns voices on 200', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch
|
||||
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
keyPlaintext: Buffer.from('subscription-key-XYZ', 'utf8'),
|
||||
region: 'eastasia',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
expect(voices).toEqual([{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }])
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local:5933/api/voices?provider=microsoft®ion=eastasia')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer subscription-key-XYZ')
|
||||
})
|
||||
|
||||
it('throws 503 AZURE_TTS_NOT_CONFIGURED when region is missing', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
await expect(adapter.getVoiceCatalog({
|
||||
keyPlaintext: Buffer.from('k', 'utf8'),
|
||||
adapterParams: {},
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch,
|
||||
})).rejects.toMatchObject({ statusCode: 503, errorCode: 'AZURE_TTS_NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
it('throws 503 AZURE_TTS_NOT_CONFIGURED when keyPlaintext is missing', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
await expect(adapter.getVoiceCatalog({
|
||||
region: 'eastasia',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch,
|
||||
})).rejects.toMatchObject({ statusCode: 503, errorCode: 'AZURE_TTS_NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
it('throws 502 BAD_GATEWAY when unspeech responds non-2xx', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn(async () => new Response('upstream down', { status: 502 })) as unknown as typeof fetch
|
||||
await expect(adapter.getVoiceCatalog({
|
||||
keyPlaintext: Buffer.from('k', 'utf8'),
|
||||
region: 'eastasia',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})).rejects.toMatchObject({ statusCode: 502 })
|
||||
})
|
||||
|
||||
it('throws 502 BAD_GATEWAY when unspeech fetch throws', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}) as unknown as typeof fetch
|
||||
await expect(adapter.getVoiceCatalog({
|
||||
keyPlaintext: Buffer.from('k', 'utf8'),
|
||||
region: 'eastasia',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})).rejects.toMatchObject({ statusCode: 502 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('azureAdapter.send', () => {
|
||||
it('posts SSML to unspeech /v1/audio/speech with model=microsoft/v1 + region extra_body', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
await adapter.send(
|
||||
{
|
||||
text: 'hi there',
|
||||
voice: 'en-US-AvaMultilingualNeural',
|
||||
speed: 1.2,
|
||||
extraOptions: {
|
||||
pitch: 20,
|
||||
volume: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
|
||||
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>
|
||||
expect(body.model).toBe('microsoft/v1')
|
||||
expect(body.voice).toBe('en-US-AvaMultilingualNeural')
|
||||
expect((body.extra_body as { region?: string }).region).toBe('eastasia')
|
||||
expect((body.extra_body as { disable_ssml?: boolean }).disable_ssml).toBe(true)
|
||||
// SSML is built on our side so speed survives — verify the prosody tag is in
|
||||
// the input field unspeech receives.
|
||||
expect(body.input).toContain('<prosody rate=\'+20%\' pitch=\'+20%\' volume=\'+5%\'>')
|
||||
expect(body.input).toContain('hi there')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer azure-sub-key')
|
||||
})
|
||||
|
||||
it('uses adapterParams.defaultVoice when the request omits voice', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
await adapter.send(
|
||||
{ text: 'hi there' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
|
||||
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { region: 'eastasia', defaultVoice: 'en-US-AvaMultilingualNeural' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>
|
||||
expect(body.voice).toBe('en-US-AvaMultilingualNeural')
|
||||
})
|
||||
|
||||
it('rejects missing voice when adapterParams.defaultVoice is not configured', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn() as unknown as typeof fetch
|
||||
|
||||
await expect(adapter.send(
|
||||
{ text: 'hi' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('k', 'utf8'),
|
||||
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
fetchImpl,
|
||||
},
|
||||
)).rejects.toMatchObject({ statusCode: 400 })
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws Error with .status when unspeech non-2xx', async () => {
|
||||
const adapter = getAdapter('azure')
|
||||
const fetchImpl = vi.fn(async () => new Response('upstream rejected', { status: 401 })) as unknown as typeof fetch
|
||||
|
||||
await expect(adapter.send(
|
||||
{ text: 'hi', voice: 'en-US-AvaMultilingualNeural' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('k', 'utf8'),
|
||||
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { region: 'eastasia' },
|
||||
fetchImpl,
|
||||
},
|
||||
)).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stepfunAdapter', () => {
|
||||
it('uses unspeech as the StepFun voice-catalog source', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{
|
||||
id: 'cixingnansheng',
|
||||
name: '磁性男声',
|
||||
compatible_models: ['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'],
|
||||
}],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
adapterParams: {},
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
expect(voices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'cixingnansheng',
|
||||
name: '磁性男声',
|
||||
compatible_models: expect.arrayContaining(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini']),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=stepfun')
|
||||
})
|
||||
|
||||
it('posts OpenAI-compatible speech JSON to unspeech with model=stepfun/<model>', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
const result = await adapter.send(
|
||||
{
|
||||
text: '(轻声)你好',
|
||||
voice: 'cixingnansheng',
|
||||
responseFormat: 'mp3',
|
||||
speed: 1.2,
|
||||
extraOptions: {
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
volume: 1.1,
|
||||
sampleRate: 24000,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('step-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toMatchObject({
|
||||
'Authorization': 'Bearer step-key',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>
|
||||
expect(body).toEqual({
|
||||
model: 'stepfun/stepaudio-2.5-tts',
|
||||
input: '(轻声)你好',
|
||||
voice: 'cixingnansheng',
|
||||
response_format: 'mp3',
|
||||
speed: 1.2,
|
||||
extra_body: {
|
||||
volume: 1.1,
|
||||
sample_rate: 24000,
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
},
|
||||
})
|
||||
expect(result.contentType).toBe('audio/mpeg')
|
||||
expect(result.body).toBeInstanceOf(ArrayBuffer)
|
||||
})
|
||||
|
||||
it('passes the Step Plan endpoint profile to unspeech', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
const result = await adapter.send(
|
||||
{
|
||||
text: '你好',
|
||||
voice: 'cixingnansheng',
|
||||
responseFormat: 'mp3',
|
||||
speed: 1.1,
|
||||
extraOptions: {
|
||||
instruction: '温柔、克制',
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('step-plan-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: {
|
||||
endpointProfile: 'step-plan',
|
||||
model: 'stepaudio-2.5-tts',
|
||||
},
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toMatchObject({
|
||||
'Authorization': 'Bearer step-plan-key',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
model: 'stepfun/stepaudio-2.5-tts',
|
||||
input: '你好',
|
||||
voice: 'cixingnansheng',
|
||||
response_format: 'mp3',
|
||||
speed: 1.1,
|
||||
extra_body: {
|
||||
endpoint_profile: 'step-plan',
|
||||
instruction: '温柔、克制',
|
||||
},
|
||||
})
|
||||
expect(result.contentType).toBe('audio/mpeg')
|
||||
expect(result.body).toBeInstanceOf(ArrayBuffer)
|
||||
})
|
||||
|
||||
it('passes voice_label through to unspeech for provider-level validation', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
await adapter.send(
|
||||
{
|
||||
text: 'hi',
|
||||
extraOptions: {
|
||||
voice_label: { emotion: '高兴' },
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('step-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
const body = JSON.parse(init.body as string) as Record<string, { voice_label?: unknown }>
|
||||
expect(body.extra_body.voice_label).toEqual({ emotion: '高兴' })
|
||||
})
|
||||
|
||||
it('throws Error with .status when unspeech returns non-2xx', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const fetchImpl = vi.fn(async () => new Response('bad key', { status: 401 })) as unknown as typeof fetch
|
||||
|
||||
await expect(adapter.send(
|
||||
{ text: 'hi', voice: 'cixingnansheng' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('bad-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
},
|
||||
)).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
it('preserves an unspeech request abort for router timeout classification', async () => {
|
||||
const adapter = getAdapter('stepfun')
|
||||
const abortController = new AbortController()
|
||||
const abortError = new Error('attempt-timeout')
|
||||
abortController.abort(abortError)
|
||||
const fetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
throw init?.signal?.reason ?? new Error('aborted')
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
await expect(adapter.send(
|
||||
{ text: 'hi', voice: 'cixingnansheng' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('step-key', 'utf8'),
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
unspeechBaseURL: 'http://unspeech.local',
|
||||
adapterParams: { model: 'stepaudio-2.5-tts' },
|
||||
fetchImpl,
|
||||
abortSignal: abortController.signal,
|
||||
},
|
||||
)).rejects.toBe(abortError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('volcengineAdapter.send', () => {
|
||||
it('posts to unspeech with model=volcengine/<api_resource_id> and app/cluster in extra_body', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49, 0x44, 0x33]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
const result = await adapter.send(
|
||||
{ text: 'hi', voice: 'BV001_streaming', responseFormat: 'mp3', speed: 1.0 },
|
||||
{
|
||||
keyPlaintext: Buffer.from('volc-token', 'utf8'),
|
||||
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { appid: 'APP-123', cluster: 'volcano_tts', model: 'seed-tts-2.0' },
|
||||
fetchImpl,
|
||||
},
|
||||
)
|
||||
|
||||
const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech')
|
||||
const body = JSON.parse(init.body as string) as Record<string, any>
|
||||
expect(body.model).toBe('volcengine/seed-tts-2.0')
|
||||
expect(body.voice).toBe('BV001_streaming')
|
||||
expect(body.response_format).toBe('mp3')
|
||||
expect(body.extra_body.app).toEqual({ appid: 'APP-123', cluster: 'volcano_tts' })
|
||||
expect(typeof body.extra_body.request.reqid).toBe('string')
|
||||
expect(body.extra_body.request.operation).toBe('query')
|
||||
|
||||
// Plain Bearer — unspeech itself re-attaches as `Bearer; <token>` to the
|
||||
// upstream Volcengine call.
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer volc-token')
|
||||
|
||||
expect(result.contentType).toBe('audio/mpeg')
|
||||
expect(result.body).toBeInstanceOf(ArrayBuffer)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* volcengineAdapter.send({ text: 'hi', extraOptions: { pitch: 20 } }, ctx)
|
||||
*/
|
||||
it('fails fast when Voice Pack pitch or volume params reach Volcengine', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49]))) as unknown as typeof fetch
|
||||
|
||||
await expect(adapter.send(
|
||||
{
|
||||
text: 'hi',
|
||||
voice: 'BV001_streaming',
|
||||
extraOptions: {
|
||||
pitch: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyPlaintext: Buffer.from('volc-token', 'utf8'),
|
||||
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: { appid: 'APP-123' },
|
||||
fetchImpl,
|
||||
},
|
||||
)).rejects.toMatchObject({ statusCode: 400 })
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when adapterParams.appid is missing', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn() as unknown as typeof fetch
|
||||
await expect(adapter.send(
|
||||
{ text: 'hi' },
|
||||
{
|
||||
keyPlaintext: Buffer.from('k', 'utf8'),
|
||||
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
|
||||
unspeechBaseURL: 'http://unspeech.local:5933',
|
||||
adapterParams: {},
|
||||
fetchImpl,
|
||||
},
|
||||
)).rejects.toMatchObject({ statusCode: 500 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { TtsAdapter, TtsAdapterId } from './types'
|
||||
|
||||
import { createBadRequestError } from '../../../utils/error'
|
||||
import { azureAdapter } from './azure'
|
||||
import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice'
|
||||
import { stepfunAdapter } from './stepfun'
|
||||
import { volcengineAdapter } from './volcengine'
|
||||
|
||||
const ADAPTERS: Record<TtsAdapterId, TtsAdapter> = {
|
||||
'azure': azureAdapter,
|
||||
'dashscope-cosyvoice': dashscopeCosyvoiceAdapter,
|
||||
'stepfun': stepfunAdapter,
|
||||
'volcengine': volcengineAdapter,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a TTS adapter by its stable id.
|
||||
*
|
||||
* Use when:
|
||||
* - The router has loaded a TTS model config slice and needs to dispatch the
|
||||
* request to the matching provider adapter.
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is one of the {@link TtsAdapterId} union members. Anything else means
|
||||
* the configKV entry is desynced from the code (admin added a provider id we
|
||||
* don't ship yet) — surface as a 400 with the offending id so ops can
|
||||
* diagnose without digging through logs.
|
||||
*
|
||||
* Returns:
|
||||
* - The adapter implementation. Throws `BAD_REQUEST` on unknown id.
|
||||
*/
|
||||
export function getAdapter(id: string): TtsAdapter {
|
||||
if (id in ADAPTERS)
|
||||
return ADAPTERS[id as TtsAdapterId]
|
||||
|
||||
throw createBadRequestError(
|
||||
`unknown_tts_provider: ${id}`,
|
||||
'BAD_REQUEST',
|
||||
{ id, available: Object.keys(ADAPTERS) },
|
||||
)
|
||||
}
|
||||
|
||||
export type { TtsAdapter, TtsAdapterContext, TtsAdapterId, TtsInput, TtsResult } from './types'
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
import { audioMimeFromFormat } from './audio-format'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
const STEPFUN_DEFAULT_MODEL = 'stepaudio-2.5-tts'
|
||||
const STEPFUN_DEFAULT_FORMAT = 'mp3'
|
||||
const STEPFUN_DEFAULT_VOICE = 'cixingnansheng'
|
||||
|
||||
/**
|
||||
* StepFun TTS adapter.
|
||||
*
|
||||
* Use when:
|
||||
* - Routing speech synthesis to StepFun through unspeech's OpenAI-compatible
|
||||
* `stepfun/*` backend.
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.unspeechBaseURL` points at an unspeech deployment that includes the
|
||||
* StepFun backend.
|
||||
* - `ctx.keyPlaintext` is the StepFun API key.
|
||||
* - `ctx.adapterParams.model` optionally selects `stepaudio-2.5-tts`,
|
||||
* `step-tts-2`, or `step-tts-mini`.
|
||||
* - `ctx.adapterParams.endpointProfile` optionally selects a provider-owned
|
||||
* endpoint profile such as `step-plan`; AIRI never owns the endpoint URL.
|
||||
*
|
||||
* Returns:
|
||||
* - {@link TtsResult} with the upstream audio body and content type.
|
||||
*/
|
||||
export const stepfunAdapter: TtsAdapter = {
|
||||
id: 'stepfun',
|
||||
|
||||
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
|
||||
const model = typeof ctx.adapterParams.model === 'string' && ctx.adapterParams.model
|
||||
? ctx.adapterParams.model
|
||||
: STEPFUN_DEFAULT_MODEL
|
||||
const voice = input.voice ?? (typeof ctx.adapterParams.defaultVoice === 'string' && ctx.adapterParams.defaultVoice
|
||||
? ctx.adapterParams.defaultVoice
|
||||
: STEPFUN_DEFAULT_VOICE)
|
||||
const responseFormat = input.responseFormat ?? (typeof ctx.adapterParams.responseFormat === 'string' && ctx.adapterParams.responseFormat
|
||||
? ctx.adapterParams.responseFormat
|
||||
: STEPFUN_DEFAULT_FORMAT)
|
||||
const extraBody = buildExtraBody(input, ctx)
|
||||
|
||||
return sendSpeechViaUnSpeech({
|
||||
ctx,
|
||||
model: `stepfun/${model}`,
|
||||
input: input.text,
|
||||
voice,
|
||||
speed: input.speed,
|
||||
responseFormat,
|
||||
extraBody,
|
||||
fallbackContentType: audioMimeFromFormat(responseFormat),
|
||||
providerLabel: 'stepfun',
|
||||
})
|
||||
},
|
||||
|
||||
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
|
||||
return listVoicesViaUnSpeech({
|
||||
ctx,
|
||||
query: 'provider=stepfun',
|
||||
providerLabel: 'stepfun',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function buildExtraBody(input: TtsInput, ctx: TtsAdapterContext): Record<string, unknown> {
|
||||
const extraOptions = input.extraOptions ?? {}
|
||||
const body: Record<string, unknown> = {}
|
||||
|
||||
if (typeof ctx.adapterParams.endpointProfile === 'string' && ctx.adapterParams.endpointProfile)
|
||||
body.endpoint_profile = ctx.adapterParams.endpointProfile
|
||||
|
||||
if (typeof extraOptions.volume === 'number' && Number.isFinite(extraOptions.volume))
|
||||
body.volume = extraOptions.volume
|
||||
else if (typeof ctx.adapterParams.volume === 'number' && Number.isFinite(ctx.adapterParams.volume))
|
||||
body.volume = ctx.adapterParams.volume
|
||||
|
||||
if (typeof extraOptions.sample_rate === 'number' && Number.isFinite(extraOptions.sample_rate))
|
||||
body.sample_rate = extraOptions.sample_rate
|
||||
else if (typeof extraOptions.sampleRate === 'number' && Number.isFinite(extraOptions.sampleRate))
|
||||
body.sample_rate = extraOptions.sampleRate
|
||||
else if (typeof ctx.adapterParams.sampleRate === 'number' && Number.isFinite(ctx.adapterParams.sampleRate))
|
||||
body.sample_rate = ctx.adapterParams.sampleRate
|
||||
|
||||
if (isPlainObject(extraOptions.pronunciation_map))
|
||||
body.pronunciation_map = extraOptions.pronunciation_map
|
||||
else if (isPlainObject(extraOptions.pronunciationMap))
|
||||
body.pronunciation_map = extraOptions.pronunciationMap
|
||||
|
||||
if (typeof extraOptions.markdown_filter === 'boolean')
|
||||
body.markdown_filter = extraOptions.markdown_filter
|
||||
else if (typeof extraOptions.markdownFilter === 'boolean')
|
||||
body.markdown_filter = extraOptions.markdownFilter
|
||||
|
||||
if (typeof extraOptions.instruction === 'string' && extraOptions.instruction)
|
||||
body.instruction = extraOptions.instruction
|
||||
else if (typeof ctx.adapterParams.instruction === 'string' && ctx.adapterParams.instruction)
|
||||
body.instruction = ctx.adapterParams.instruction
|
||||
|
||||
if (isPlainObject(extraOptions.voice_label))
|
||||
body.voice_label = extraOptions.voice_label
|
||||
else if (isPlainObject(extraOptions.voiceLabel))
|
||||
body.voice_label = extraOptions.voiceLabel
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
/**
|
||||
* Inbound TTS request shape passed to every adapter.
|
||||
*
|
||||
* Adapters translate this provider-neutral payload into the
|
||||
* provider's native protocol body (Azure SSML, DashScope JSON,
|
||||
* Volcengine JSON, etc.).
|
||||
*/
|
||||
export interface TtsInput {
|
||||
/** Caller-supplied speech text (raw text or SSML when {@link extraOptions} signals so). */
|
||||
text: string
|
||||
/** Provider voice id (e.g. `en-US-AvaMultilingualNeural`, `longxiaochun`, `BV001_streaming`). */
|
||||
voice?: string
|
||||
/**
|
||||
* Speech rate multiplier. `1.0` = native rate, `1.2` = 20% faster, `0.8` = 20% slower.
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
speed?: number
|
||||
/** Provider format key (e.g. `mp3`, `wav`, Azure-specific `audio-24khz-48kbitrate-mono-mp3`). */
|
||||
responseFormat?: string
|
||||
/**
|
||||
* Adapter-specific escape hatch for niche flags that aren't worth promoting
|
||||
* to the canonical shape (e.g. Azure's `disableSsml`, future per-call quirks).
|
||||
*/
|
||||
extraOptions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-call context carrying the resolved key, upstream wiring, and abort
|
||||
* plumbing. The router builds this before delegating to {@link TtsAdapter.send}.
|
||||
*
|
||||
* The plaintext key is held in a Node Buffer so callers can zero/scrub it on
|
||||
* exit; adapters MUST NOT log or persist it.
|
||||
*/
|
||||
export interface TtsAdapterContext {
|
||||
/** Decrypted upstream credential. Plain text — keep in-memory only. */
|
||||
keyPlaintext: Buffer
|
||||
/**
|
||||
* Per-upstream baseURL from `LLM_ROUTER_CONFIG.tts.upstreams[i].baseURL`.
|
||||
*
|
||||
* Adapters forward through unspeech and may use this as provider metadata.
|
||||
* Provider endpoint selection belongs to unspeech, not this URL.
|
||||
*/
|
||||
baseURL: string
|
||||
/** unspeech REST base URL (no trailing slash). */
|
||||
unspeechBaseURL: string
|
||||
/** Free-form adapter-specific params from `tts.upstreams[i].adapterParams` (e.g. Volcengine `appid` / `cluster`). */
|
||||
adapterParams: Record<string, unknown>
|
||||
/** Fetch implementation. Tests inject a `vi.fn()`; production passes `globalThis.fetch`. */
|
||||
fetchImpl: typeof fetch
|
||||
/** Caller-side abort signal — propagated to the upstream fetch. */
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a successful upstream call.
|
||||
*
|
||||
* `body` is either a fully-buffered `ArrayBuffer` (current v1 behavior — Azure
|
||||
* REST + DashScope JSON + Volcengine JSON are all one-shot) or a streaming
|
||||
* body for future streaming adapters.
|
||||
*/
|
||||
export interface TtsResult {
|
||||
/** MIME type to forward to the caller (e.g. `audio/mpeg`, `audio/wav`). */
|
||||
contentType: string
|
||||
/** Audio payload (buffered or streamed). */
|
||||
body: ArrayBuffer | ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable provider identifier for the v1 adapter registry.
|
||||
*
|
||||
* Adding a new adapter means adding a new id here AND registering it in
|
||||
* `./index.ts` — the union is intentionally tight so unknown ids fail at the
|
||||
* type level (router config validation handles runtime).
|
||||
*/
|
||||
export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'stepfun' | 'volcengine'
|
||||
|
||||
/**
|
||||
* Per-call context for {@link TtsAdapter.getVoiceCatalog}.
|
||||
*
|
||||
* `keyPlaintext` and `region` are mandatory for live providers (Azure) that
|
||||
* proxy through unspeech and call the upstream provider with a subscription
|
||||
* key; the router decrypts the envelope key and forwards `adapterParams.region`
|
||||
* verbatim. Unspeech-backed static catalogs ignore both fields.
|
||||
*
|
||||
* `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the router.
|
||||
* Passing it through the context keeps adapters free of configKV coupling.
|
||||
*/
|
||||
export interface TtsVoiceCatalogContext {
|
||||
/** Decrypted upstream credential (live providers only). */
|
||||
keyPlaintext?: Buffer
|
||||
/** Provider region (live providers only). */
|
||||
region?: string
|
||||
/** Free-form adapter-specific params (mirrors `tts.upstreams[i].adapterParams`). */
|
||||
adapterParams: Record<string, unknown>
|
||||
/** unspeech REST base URL, no trailing slash. */
|
||||
unspeechBaseURL: string
|
||||
/** Fetch implementation. Tests inject `vi.fn()`; production passes `globalThis.fetch`. */
|
||||
fetchImpl: typeof fetch
|
||||
/** Caller-side abort signal — propagated to the upstream fetch. */
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure protocol translator between OpenAI-shaped `/v1/audio/speech` requests
|
||||
* and one upstream TTS provider.
|
||||
*
|
||||
* Use when:
|
||||
* - Routing a hosted TTS request through the gateway.
|
||||
* - Listing supported voices for a provider via {@link getVoiceCatalog}.
|
||||
*
|
||||
* Expects:
|
||||
* - {@link TtsAdapterContext.fetchImpl} is wired by the caller.
|
||||
* - {@link TtsAdapterContext.keyPlaintext} has already been decrypted from the
|
||||
* key entry — adapters never touch envelope ciphertext.
|
||||
*
|
||||
* Returns:
|
||||
* - A {@link TtsResult} on 2xx upstream responses.
|
||||
* - Throws (Error subclass) on upstream non-2xx — the router maps the error to
|
||||
* the next fallback key/upstream or to a 5xx for the caller. Adapters MUST
|
||||
* NOT swallow upstream failures.
|
||||
*/
|
||||
export interface TtsAdapter {
|
||||
/** Stable id used by the registry and config (`tts.upstreams[i].adapter`). */
|
||||
id: TtsAdapterId
|
||||
/** Dispatches one TTS request and resolves with the audio payload. */
|
||||
send: (input: TtsInput, ctx: TtsAdapterContext) => Promise<TtsResult>
|
||||
/**
|
||||
* Returns the voice catalog for the provider.
|
||||
*
|
||||
* Live providers (Azure) call upstream through unspeech using the supplied
|
||||
* region + plaintext key. Static provider catalogs are also owned and served
|
||||
* by unspeech. Adapters MUST throw on upstream failure — no empty fallback.
|
||||
*/
|
||||
getVoiceCatalog: (ctx: TtsVoiceCatalogContext) => Promise<Voice[]>
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { TtsAdapterContext, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { generateSpeechResponse, listVoices, UnSpeechAPIError } from 'unspeech'
|
||||
|
||||
import { createBadGatewayError, createInternalError } from '../../../utils/error'
|
||||
|
||||
interface SendSpeechOptions {
|
||||
ctx: TtsAdapterContext
|
||||
model: string
|
||||
input: string
|
||||
voice: string
|
||||
speed?: number
|
||||
responseFormat: string
|
||||
extraBody?: Record<string, unknown>
|
||||
fallbackContentType: string
|
||||
providerLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one OpenAI-shaped speech request through the unspeech SDK.
|
||||
*
|
||||
* Use when:
|
||||
* - A TTS adapter has resolved AIRI's provider policy and needs to delegate the
|
||||
* actual HTTP request to unspeech.
|
||||
*
|
||||
* Expects:
|
||||
* - `model`, `voice`, `responseFormat`, and `extraBody` already match the
|
||||
* provider-specific unspeech contract.
|
||||
*
|
||||
* Returns:
|
||||
* - The binary audio payload plus a content type for the OpenAI route.
|
||||
*/
|
||||
export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise<TtsResult> {
|
||||
const {
|
||||
ctx,
|
||||
extraBody,
|
||||
fallbackContentType,
|
||||
input,
|
||||
model,
|
||||
providerLabel,
|
||||
responseFormat,
|
||||
speed,
|
||||
voice,
|
||||
} = options
|
||||
|
||||
try {
|
||||
const result = await generateSpeechResponse({
|
||||
apiKey: ctx.keyPlaintext.toString('utf8'),
|
||||
baseURL: `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/v1/`,
|
||||
fetch: ctx.fetchImpl,
|
||||
input,
|
||||
model,
|
||||
responseFormat,
|
||||
speed,
|
||||
voice,
|
||||
abortSignal: ctx.abortSignal,
|
||||
extraBody,
|
||||
})
|
||||
|
||||
return {
|
||||
contentType: result.contentType ?? fallbackContentType,
|
||||
body: result.body,
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Keep abort identity intact so the router can apply `onTimeout`
|
||||
// independently from HTTP 500 fallback policy.
|
||||
if (ctx.abortSignal?.aborted)
|
||||
throw error
|
||||
|
||||
if (error instanceof UnSpeechAPIError) {
|
||||
const err = new Error(`${providerLabel} tts upstream ${error.status}: ${error.responseBody.slice(0, 256)}`) as Error & { status?: number }
|
||||
err.status = error.status
|
||||
throw err
|
||||
}
|
||||
|
||||
throw createInternalError(`${providerLabel} tts fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface ListVoicesOptions {
|
||||
ctx: TtsVoiceCatalogContext
|
||||
query: string
|
||||
providerLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists unspeech voices and maps SDK failures into AIRI gateway errors.
|
||||
*
|
||||
* Use when:
|
||||
* - A TTS adapter needs unspeech's normalized `Voice[]` catalog.
|
||||
*
|
||||
* Expects:
|
||||
* - `query` is an unspeech `/api/voices` query string such as
|
||||
* `provider=microsoft®ion=eastasia`.
|
||||
*
|
||||
* Returns:
|
||||
* - The parsed voice catalog.
|
||||
*/
|
||||
export async function listVoicesViaUnSpeech(options: ListVoicesOptions): Promise<Voice[]> {
|
||||
const { ctx, providerLabel, query } = options
|
||||
|
||||
try {
|
||||
return await listVoices({
|
||||
apiKey: ctx.keyPlaintext?.toString('utf8'),
|
||||
baseURL: ctx.unspeechBaseURL.replace(/\/+$/, ''),
|
||||
fetch: ctx.fetchImpl,
|
||||
query,
|
||||
abortSignal: ctx.abortSignal,
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof UnSpeechAPIError) {
|
||||
throw createBadGatewayError(
|
||||
`${providerLabel} voices upstream ${error.status}: ${error.responseBody.slice(0, 256)}`,
|
||||
{ lastStatusCode: error.status },
|
||||
)
|
||||
}
|
||||
|
||||
throw createBadGatewayError(`${providerLabel} voices fetch failed: ${errorMessageFrom(error) ?? 'unknown'}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
|
||||
|
||||
import { createBadRequestError, createInternalError } from '../../../utils/error'
|
||||
import { nanoid } from '../../../utils/id'
|
||||
import { audioMimeFromFormat } from './audio-format'
|
||||
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
|
||||
|
||||
/**
|
||||
* Default Volcengine TTS voice id. `BV001_streaming` is Volcengine's standard
|
||||
* Chinese general-purpose streaming voice referenced in their docs.
|
||||
*/
|
||||
const DEFAULT_VOLCENGINE_VOICE = 'BV001_streaming'
|
||||
|
||||
/**
|
||||
* Default Volcengine audio encoding. Matches our OpenAI-shape `mp3` default.
|
||||
*/
|
||||
const DEFAULT_VOLCENGINE_FORMAT = 'mp3'
|
||||
|
||||
/**
|
||||
* Default Volcengine cluster. Documented as `volcano_tts` for the generic
|
||||
* hosted TTS endpoint; ops can override via `adapterParams.cluster`.
|
||||
*/
|
||||
const DEFAULT_VOLCENGINE_CLUSTER = 'volcano_tts'
|
||||
|
||||
/**
|
||||
* Volcengine non-streaming REST adapter.
|
||||
*
|
||||
* Use when:
|
||||
* - Routing a hosted TTS request to Volcengine OpenSpeech.
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.baseURL` is the Volcengine TTS endpoint, e.g.
|
||||
* `https://openspeech.bytedance.com/api/v1/tts`.
|
||||
* - `ctx.keyPlaintext` is the access token. The auth header uses Volcengine's
|
||||
* non-standard `Bearer; <token>` format (semicolon after `Bearer`).
|
||||
* - `ctx.adapterParams.appid` is the Volcengine application id (required).
|
||||
* - `ctx.adapterParams.cluster` overrides the default cluster id when set.
|
||||
*
|
||||
* Returns:
|
||||
* - {@link TtsResult} with the audio bytes as an `ArrayBuffer`. Body is
|
||||
* decoded from the upstream JSON `data` base64 field.
|
||||
*/
|
||||
export const volcengineAdapter: TtsAdapter = {
|
||||
id: 'volcengine',
|
||||
|
||||
async send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult> {
|
||||
const appid = ctx.adapterParams.appid
|
||||
if (typeof appid !== 'string' || !appid)
|
||||
throw createInternalError('volcengine tts: adapterParams.appid is required')
|
||||
|
||||
const cluster = typeof ctx.adapterParams.cluster === 'string'
|
||||
? ctx.adapterParams.cluster
|
||||
: DEFAULT_VOLCENGINE_CLUSTER
|
||||
|
||||
const apiResourceId = typeof ctx.adapterParams.model === 'string'
|
||||
? ctx.adapterParams.model
|
||||
: undefined
|
||||
|
||||
const voice = input.voice ?? DEFAULT_VOLCENGINE_VOICE
|
||||
if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') {
|
||||
throw createBadRequestError(
|
||||
'volcengine does not support Voice Pack pitch or volume parameters',
|
||||
'BAD_REQUEST',
|
||||
)
|
||||
}
|
||||
const encoding = input.responseFormat ?? DEFAULT_VOLCENGINE_FORMAT
|
||||
const speed = input.speed ?? 1
|
||||
|
||||
// unspeech volcengine backend (unspeech/pkg/backend/volcengine/speech.go):
|
||||
// - reads token from `Authorization: Bearer <token>` (strips "Bearer "
|
||||
// prefix), then re-attaches as `Bearer; <token>` to the upstream — so
|
||||
// we send a normal Bearer here, NOT the `Bearer; ` form.
|
||||
// - takes `app.appid`, `app.cluster`, `user.uid`, `request.reqid`,
|
||||
// `audio.encoding`, `audio.speed_ratio` from `extra_body` jsonpath.
|
||||
// - decodes the upstream base64 audio frame itself and returns binary.
|
||||
return sendSpeechViaUnSpeech({
|
||||
ctx,
|
||||
model: apiResourceId ? `volcengine/${apiResourceId}` : 'volcengine',
|
||||
input: input.text,
|
||||
voice,
|
||||
responseFormat: encoding,
|
||||
extraBody: {
|
||||
app: { appid, cluster },
|
||||
user: { uid: 'airi-server' },
|
||||
audio: { speed_ratio: speed },
|
||||
request: { reqid: nanoid(), operation: 'query' },
|
||||
},
|
||||
fallbackContentType: audioMimeFromFormat(encoding),
|
||||
providerLabel: 'volcengine',
|
||||
})
|
||||
},
|
||||
|
||||
async getVoiceCatalog(ctx: TtsVoiceCatalogContext): Promise<Voice[]> {
|
||||
// unspeech embeds the Volcengine catalog at build time
|
||||
// (unspeech/pkg/backend/volcengine/voices.go), filtered server-side to
|
||||
// streaming-compatible voices. Passing `model=<api_resource_id>` narrows
|
||||
// further by `compatible_models` — adapterParams.model is the operator-
|
||||
// configured resource id (e.g. `seed-tts-2.0`).
|
||||
const params = new URLSearchParams({ provider: 'volcengine' })
|
||||
const apiResourceId = typeof ctx.adapterParams?.model === 'string'
|
||||
? ctx.adapterParams.model
|
||||
: undefined
|
||||
if (apiResourceId)
|
||||
params.set('model', apiResourceId)
|
||||
|
||||
return listVoicesViaUnSpeech({
|
||||
ctx,
|
||||
query: params.toString(),
|
||||
providerLabel: 'volcengine',
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Database } from '../../../../libs/db'
|
||||
import type { BillingService } from '../../billing/billing-service'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
|
||||
import * as accountsSchema from '../../../../schemas/accounts'
|
||||
import * as fluxSchema from '../../../../schemas/flux'
|
||||
|
||||
const logger = useLogger('admin-flux-grants').useGlobalConfig()
|
||||
|
||||
export type SkipReason = 'duplicate_in_input' | 'not_found' | 'user_deleted'
|
||||
|
||||
export interface ResolvedRecipient {
|
||||
inputEmail: string
|
||||
userId: string | null
|
||||
status: 'pending' | 'skipped'
|
||||
errorReason: SkipReason | null
|
||||
}
|
||||
|
||||
export interface PreviewSummary {
|
||||
totalEmails: number
|
||||
willGrant: number
|
||||
willSkip: { notFound: number, userDeleted: number, duplicateInInput: number }
|
||||
totalFluxToIssue: number
|
||||
samples: { willGrant: string[], notFound: string[], userDeleted: string[] }
|
||||
}
|
||||
|
||||
export interface GrantResult {
|
||||
granted: { email: string, userId: string, fluxTransactionId: string, balanceAfter: number }[]
|
||||
skipped: { email: string, reason: SkipReason }[]
|
||||
failed: { email: string, userId: string, error: string }[]
|
||||
}
|
||||
|
||||
export interface GrantInput {
|
||||
amount: number
|
||||
description: string
|
||||
emails: string[]
|
||||
createdByUserId: string
|
||||
/**
|
||||
* When provided, recipient `requestId`s are derived as
|
||||
* `flux-grant:${idempotencyKey}:${userId}` so re-running the same call
|
||||
* with the same key + recipients is a no-op (handled by the partial
|
||||
* unique index on `flux_transaction(user_id, request_id)`).
|
||||
* When omitted, every recipient gets a fresh requestId — re-issuing the
|
||||
* same grant will double-credit, which is the right default for "I made
|
||||
* a typo and want to send again".
|
||||
*/
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve operator-supplied input emails against the user table.
|
||||
*
|
||||
* Use when:
|
||||
* - Either `preview` (dry-run) or the actual grant call needs the same
|
||||
* per-email outcome shape
|
||||
*
|
||||
* Expects:
|
||||
* - `user.email` is stored lowercase (better-auth normalizes on signup
|
||||
* for both email/password and OAuth). Wrapping the column in `LOWER()`
|
||||
* in the query would bypass the unique index on `email` and force a
|
||||
* sequential scan, so input is lowercased instead.
|
||||
*
|
||||
* Returns:
|
||||
* - One `ResolvedRecipient` per input email (duplicates included with
|
||||
* `duplicate_in_input` so the caller can audit them)
|
||||
*/
|
||||
async function resolveEmails(db: Database, emails: string[]): Promise<ResolvedRecipient[]> {
|
||||
const seenLower = new Map<string, number>()
|
||||
const resolved: ResolvedRecipient[] = emails.map((email, idx) => {
|
||||
const lower = email.toLowerCase()
|
||||
if (seenLower.has(lower))
|
||||
return { inputEmail: email, userId: null, status: 'skipped', errorReason: 'duplicate_in_input' }
|
||||
seenLower.set(lower, idx)
|
||||
return { inputEmail: email, userId: null, status: 'pending', errorReason: null }
|
||||
})
|
||||
|
||||
const lowerEmails = Array.from(seenLower.keys())
|
||||
|
||||
const users = lowerEmails.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({ id: accountsSchema.user.id, email: accountsSchema.user.email })
|
||||
.from(accountsSchema.user)
|
||||
.where(inArray(accountsSchema.user.email, lowerEmails))
|
||||
|
||||
const userByLowerEmail = new Map(users.map(u => [u.email.toLowerCase(), u.id]))
|
||||
|
||||
const matchedUserIds = users.map(u => u.id)
|
||||
const fluxRows = matchedUserIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({ userId: fluxSchema.userFlux.userId, deletedAt: fluxSchema.userFlux.deletedAt })
|
||||
.from(fluxSchema.userFlux)
|
||||
.where(inArray(fluxSchema.userFlux.userId, matchedUserIds))
|
||||
const deletedUserIds = new Set(fluxRows.filter(r => r.deletedAt != null).map(r => r.userId))
|
||||
|
||||
for (const entry of resolved) {
|
||||
if (entry.errorReason === 'duplicate_in_input')
|
||||
continue
|
||||
|
||||
const userId = userByLowerEmail.get(entry.inputEmail.toLowerCase())
|
||||
if (!userId) {
|
||||
entry.status = 'skipped'
|
||||
entry.errorReason = 'not_found'
|
||||
continue
|
||||
}
|
||||
|
||||
if (deletedUserIds.has(userId)) {
|
||||
entry.userId = userId
|
||||
entry.status = 'skipped'
|
||||
entry.errorReason = 'user_deleted'
|
||||
continue
|
||||
}
|
||||
|
||||
entry.userId = userId
|
||||
entry.status = 'pending'
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildPreviewSummary(resolved: ResolvedRecipient[], amountPerUser: number): PreviewSummary {
|
||||
const willGrant = resolved.filter(r => r.status === 'pending').length
|
||||
const notFound = resolved.filter(r => r.errorReason === 'not_found').length
|
||||
const userDeleted = resolved.filter(r => r.errorReason === 'user_deleted').length
|
||||
const duplicateInInput = resolved.filter(r => r.errorReason === 'duplicate_in_input').length
|
||||
|
||||
return {
|
||||
totalEmails: resolved.length,
|
||||
willGrant,
|
||||
willSkip: { notFound, userDeleted, duplicateInInput },
|
||||
totalFluxToIssue: willGrant * amountPerUser,
|
||||
samples: {
|
||||
willGrant: resolved.filter(r => r.status === 'pending').slice(0, 5).map(r => r.inputEmail),
|
||||
notFound: resolved.filter(r => r.errorReason === 'not_found').slice(0, 5).map(r => r.inputEmail),
|
||||
userDeleted: resolved.filter(r => r.errorReason === 'user_deleted').slice(0, 5).map(r => r.inputEmail),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdminFluxGrantsService(deps: { db: Database, billingService: BillingService }) {
|
||||
const { db, billingService } = deps
|
||||
|
||||
return {
|
||||
/**
|
||||
* Dry-run preview: returns what would happen without writing anything.
|
||||
*/
|
||||
async preview(input: { amount: number, emails: string[] }): Promise<PreviewSummary> {
|
||||
const resolved = await resolveEmails(db, input.emails)
|
||||
return buildPreviewSummary(resolved, input.amount)
|
||||
},
|
||||
|
||||
/**
|
||||
* Issue a grant to every resolvable email, sequentially.
|
||||
*
|
||||
* Use when:
|
||||
* - Admin clicks "send" on a grant. Returns once every recipient has
|
||||
* either been credited, marked skipped (resolution-time issue), or
|
||||
* marked failed (`creditFlux` threw).
|
||||
*
|
||||
* Expects:
|
||||
* - Caller has admin authority (route middleware enforces this)
|
||||
* - Batch size fits inside the load balancer timeout — the route
|
||||
* layer caps `emails.length`
|
||||
*
|
||||
* Returns:
|
||||
* - Per-email outcome buckets. The same `inputEmail` order is preserved
|
||||
* inside each bucket so the operator can spot recipient-specific
|
||||
* issues without correlating across responses.
|
||||
*/
|
||||
async grant(input: GrantInput): Promise<{ summary: PreviewSummary, result: GrantResult }> {
|
||||
const resolved = await resolveEmails(db, input.emails)
|
||||
const summary = buildPreviewSummary(resolved, input.amount)
|
||||
|
||||
const result: GrantResult = { granted: [], skipped: [], failed: [] }
|
||||
|
||||
for (const entry of resolved) {
|
||||
if (entry.status === 'skipped') {
|
||||
result.skipped.push({ email: entry.inputEmail, reason: entry.errorReason ?? 'not_found' })
|
||||
continue
|
||||
}
|
||||
// entry.status === 'pending' implies userId is set
|
||||
const userId = entry.userId!
|
||||
const requestId = input.idempotencyKey != null
|
||||
? `flux-grant:${input.idempotencyKey}:${userId}`
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const credited = await billingService.creditFlux({
|
||||
userId,
|
||||
amount: input.amount,
|
||||
type: 'promo',
|
||||
requestId,
|
||||
description: input.description,
|
||||
source: 'admin_promo',
|
||||
auditMetadata: {
|
||||
description: input.description,
|
||||
issuedByUserId: input.createdByUserId,
|
||||
...(input.idempotencyKey != null && { idempotencyKey: input.idempotencyKey }),
|
||||
},
|
||||
})
|
||||
result.granted.push({
|
||||
email: entry.inputEmail,
|
||||
userId,
|
||||
fluxTransactionId: credited.fluxTransactionId,
|
||||
balanceAfter: credited.balanceAfter,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = errorMessageFrom(err) ?? 'Unknown error'
|
||||
result.failed.push({ email: entry.inputEmail, userId, error: message.slice(0, 500) })
|
||||
logger.withError(err).withFields({ userId, email: entry.inputEmail }).warn('Flux grant failed')
|
||||
}
|
||||
}
|
||||
|
||||
logger.withFields({
|
||||
description: input.description,
|
||||
attempted: summary.willGrant,
|
||||
granted: result.granted.length,
|
||||
skipped: result.skipped.length,
|
||||
failed: result.failed.length,
|
||||
amount: input.amount,
|
||||
issuedByUserId: input.createdByUserId,
|
||||
}).log('Admin flux grant completed')
|
||||
|
||||
return { summary, result }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminFluxGrantsService = ReturnType<typeof createAdminFluxGrantsService>
|
||||
|
||||
/**
|
||||
* Exported for unit tests of resolution edge cases (case folding, duplicate
|
||||
* handling, soft-delete detection).
|
||||
*/
|
||||
export { resolveEmails }
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
import type { Database } from '../../../../../libs/db'
|
||||
import type { BillingService } from '../../../billing/billing-service'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAdminFluxGrantsService, resolveEmails } from '..'
|
||||
import { mockDB } from '../../../../../libs/mock-db'
|
||||
|
||||
import * as schema from '../../../../../schemas'
|
||||
|
||||
describe('resolveEmails', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
|
||||
// Three users: one normal, one with deleted user_flux, one we never insert
|
||||
// user_flux for at all (so it shows up as "user exists, no flux row" → still
|
||||
// pending since user.id matches; default flux init happens at credit time).
|
||||
//
|
||||
// NOTICE: All stored emails are lowercase. resolveEmails relies on this
|
||||
// (Codex review 2026-05-08 flagged that wrapping user.email in LOWER()
|
||||
// bypasses the unique index and seq-scans). better-auth normalizes emails
|
||||
// on signup, so this matches production reality.
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'uid_normal', name: 'Normal', email: 'normal@example.com' },
|
||||
{ id: 'uid_deleted', name: 'Deleted', email: 'deleted@example.com' },
|
||||
{ id: 'uid_no_flux', name: 'NoFlux', email: 'noflux@example.com' },
|
||||
{ id: 'uid_mixed_case', name: 'MixedCase', email: 'Mixed@Example.com' },
|
||||
])
|
||||
|
||||
await db.insert(schema.userFlux).values([
|
||||
{ userId: 'uid_normal', flux: 100 },
|
||||
{ userId: 'uid_deleted', flux: 0, deletedAt: new Date() },
|
||||
])
|
||||
})
|
||||
|
||||
it('lowercases input before matching the (lowercase) stored email', async () => {
|
||||
const resolved = await resolveEmails(db, ['NORMAL@example.com'])
|
||||
expect(resolved).toHaveLength(1)
|
||||
expect(resolved[0]).toMatchObject({
|
||||
inputEmail: 'NORMAL@example.com',
|
||||
userId: 'uid_normal',
|
||||
status: 'pending',
|
||||
errorReason: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats non-lowercase stored emails as not_found (documented limitation)', async () => {
|
||||
// Stored email is 'Mixed@Example.com' (mixed case); we look up by lowercase
|
||||
// 'mixed@example.com', which won't match because we don't wrap user.email
|
||||
// in LOWER() — that would defeat the unique index and seq-scan the table.
|
||||
const resolved = await resolveEmails(db, ['mixed@example.com'])
|
||||
expect(resolved[0]).toMatchObject({
|
||||
inputEmail: 'mixed@example.com',
|
||||
userId: null,
|
||||
status: 'skipped',
|
||||
errorReason: 'not_found',
|
||||
})
|
||||
})
|
||||
|
||||
it('marks unknown emails as not_found', async () => {
|
||||
const resolved = await resolveEmails(db, ['ghost@example.com'])
|
||||
expect(resolved[0]).toMatchObject({
|
||||
inputEmail: 'ghost@example.com',
|
||||
userId: null,
|
||||
status: 'skipped',
|
||||
errorReason: 'not_found',
|
||||
})
|
||||
})
|
||||
|
||||
it('marks soft-deleted users as user_deleted (userId still attached for audit)', async () => {
|
||||
const resolved = await resolveEmails(db, ['deleted@example.com'])
|
||||
expect(resolved[0]).toMatchObject({
|
||||
inputEmail: 'deleted@example.com',
|
||||
userId: 'uid_deleted',
|
||||
status: 'skipped',
|
||||
errorReason: 'user_deleted',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the first occurrence and tags subsequent duplicates', async () => {
|
||||
const resolved = await resolveEmails(db, [
|
||||
'normal@example.com',
|
||||
'NORMAL@EXAMPLE.COM',
|
||||
'normal@example.com',
|
||||
])
|
||||
expect(resolved).toHaveLength(3)
|
||||
expect(resolved[0].errorReason).toBeNull()
|
||||
expect(resolved[0].status).toBe('pending')
|
||||
expect(resolved[1].errorReason).toBe('duplicate_in_input')
|
||||
expect(resolved[2].errorReason).toBe('duplicate_in_input')
|
||||
})
|
||||
|
||||
it('returns empty resolution for empty input without hitting the DB', async () => {
|
||||
const resolved = await resolveEmails(db, [])
|
||||
expect(resolved).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminFluxGrantsService.preview', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'uid_prev_a', name: 'A', email: 'preva@example.com' },
|
||||
])
|
||||
await db.insert(schema.userFlux).values([
|
||||
{ userId: 'uid_prev_a', flux: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns counts and samples without writing anything', async () => {
|
||||
const billingService = { creditFlux: vi.fn() } as unknown as BillingService
|
||||
const service = createAdminFluxGrantsService({ db, billingService })
|
||||
|
||||
const summary = await service.preview({
|
||||
amount: 50,
|
||||
emails: ['preva@example.com', 'ghost@example.com', 'preva@example.com'],
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalEmails: 3,
|
||||
willGrant: 1,
|
||||
willSkip: { notFound: 1, userDeleted: 0, duplicateInInput: 1 },
|
||||
totalFluxToIssue: 50,
|
||||
samples: {
|
||||
willGrant: ['preva@example.com'],
|
||||
notFound: ['ghost@example.com'],
|
||||
userDeleted: [],
|
||||
},
|
||||
})
|
||||
expect(billingService.creditFlux).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps preview samples at 5 entries per category', async () => {
|
||||
const billingService = { creditFlux: vi.fn() } as unknown as BillingService
|
||||
const service = createAdminFluxGrantsService({ db, billingService })
|
||||
const ghosts = Array.from({ length: 12 }, (_, i) => `ghost${i}@example.com`)
|
||||
|
||||
const summary = await service.preview({ amount: 50, emails: ghosts })
|
||||
|
||||
expect(summary.willSkip.notFound).toBe(12)
|
||||
expect(summary.samples.notFound).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminFluxGrantsService.grant', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'uid_grant_a', name: 'A', email: 'granta@example.com' },
|
||||
{ id: 'uid_grant_b', name: 'B', email: 'grantb@example.com' },
|
||||
{ id: 'uid_grant_c', name: 'C', email: 'grantc@example.com' },
|
||||
])
|
||||
await db.insert(schema.userFlux).values([
|
||||
{ userId: 'uid_grant_a', flux: 0 },
|
||||
{ userId: 'uid_grant_b', flux: 0, deletedAt: new Date() },
|
||||
{ userId: 'uid_grant_c', flux: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.fluxTransaction)
|
||||
})
|
||||
|
||||
it('credits resolvable recipients and bucket-sorts the per-email outcomes', async () => {
|
||||
const creditFlux = vi.fn(async ({ userId }: { userId: string }) => ({
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 100,
|
||||
fluxTransactionId: `ftx-${userId}`,
|
||||
idempotent: false,
|
||||
}))
|
||||
const billingService = { creditFlux } as unknown as BillingService
|
||||
const service = createAdminFluxGrantsService({ db, billingService })
|
||||
|
||||
const { summary, result } = await service.grant({
|
||||
amount: 100,
|
||||
description: 'Beta thanks',
|
||||
emails: [
|
||||
'granta@example.com', // pending → granted
|
||||
'grantb@example.com', // soft-deleted → skipped(user_deleted)
|
||||
'GRANTA@example.com', // duplicate
|
||||
'ghost@example.com', // not_found
|
||||
],
|
||||
createdByUserId: 'uid_admin',
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
totalEmails: 4,
|
||||
willGrant: 1,
|
||||
willSkip: { notFound: 1, userDeleted: 1, duplicateInInput: 1 },
|
||||
totalFluxToIssue: 100,
|
||||
})
|
||||
|
||||
expect(result.granted).toEqual([{
|
||||
email: 'granta@example.com',
|
||||
userId: 'uid_grant_a',
|
||||
fluxTransactionId: 'ftx-uid_grant_a',
|
||||
balanceAfter: 100,
|
||||
}])
|
||||
expect(result.skipped).toEqual(expect.arrayContaining([
|
||||
{ email: 'grantb@example.com', reason: 'user_deleted' },
|
||||
{ email: 'GRANTA@example.com', reason: 'duplicate_in_input' },
|
||||
{ email: 'ghost@example.com', reason: 'not_found' },
|
||||
]))
|
||||
expect(result.failed).toEqual([])
|
||||
|
||||
expect(creditFlux).toHaveBeenCalledTimes(1)
|
||||
expect(creditFlux).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'uid_grant_a',
|
||||
amount: 100,
|
||||
type: 'promo',
|
||||
description: 'Beta thanks',
|
||||
source: 'admin_promo',
|
||||
requestId: undefined, // no idempotencyKey in this test
|
||||
auditMetadata: expect.objectContaining({
|
||||
description: 'Beta thanks',
|
||||
issuedByUserId: 'uid_admin',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('catches per-recipient creditFlux errors and continues with the rest', async () => {
|
||||
let calls = 0
|
||||
const creditFlux = vi.fn(async ({ userId }: { userId: string }) => {
|
||||
calls += 1
|
||||
if (calls === 1)
|
||||
throw new Error('DB timeout')
|
||||
return {
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 100,
|
||||
fluxTransactionId: `ftx-${userId}`,
|
||||
idempotent: false,
|
||||
}
|
||||
})
|
||||
const billingService = { creditFlux } as unknown as BillingService
|
||||
const service = createAdminFluxGrantsService({ db, billingService })
|
||||
|
||||
const { result } = await service.grant({
|
||||
amount: 100,
|
||||
description: 'Resilience test',
|
||||
emails: ['granta@example.com', 'grantc@example.com'],
|
||||
createdByUserId: 'uid_admin',
|
||||
})
|
||||
|
||||
expect(result.granted).toHaveLength(1)
|
||||
expect(result.granted[0].userId).toBe('uid_grant_c')
|
||||
expect(result.failed).toHaveLength(1)
|
||||
expect(result.failed[0]).toMatchObject({ email: 'granta@example.com', userId: 'uid_grant_a', error: 'DB timeout' })
|
||||
})
|
||||
|
||||
it('forwards a deterministic requestId per recipient when idempotencyKey is provided', async () => {
|
||||
const creditFlux = vi.fn(async () => ({
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 100,
|
||||
fluxTransactionId: 'ftx',
|
||||
idempotent: false,
|
||||
}))
|
||||
const billingService = { creditFlux } as unknown as BillingService
|
||||
const service = createAdminFluxGrantsService({ db, billingService })
|
||||
|
||||
await service.grant({
|
||||
amount: 100,
|
||||
description: 'Idempotent thanks',
|
||||
emails: ['granta@example.com', 'grantc@example.com'],
|
||||
createdByUserId: 'uid_admin',
|
||||
idempotencyKey: 'beta-2026-q2',
|
||||
})
|
||||
|
||||
expect(creditFlux).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
userId: 'uid_grant_a',
|
||||
requestId: 'flux-grant:beta-2026-q2:uid_grant_a',
|
||||
}))
|
||||
expect(creditFlux).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
userId: 'uid_grant_c',
|
||||
requestId: 'flux-grant:beta-2026-q2:uid_grant_c',
|
||||
}))
|
||||
})
|
||||
|
||||
it('end-to-end: actually writes flux_transaction rows for granted recipients via the real BillingService path', async () => {
|
||||
// Light integration sanity check — we still mock BillingService here, but
|
||||
// verify the service pipes through the right shape and granted set
|
||||
// matches what the route would surface.
|
||||
const inserted: { userId: string, requestId?: string }[] = []
|
||||
const creditFlux = vi.fn(async ({ userId, requestId, amount }: { userId: string, requestId?: string, amount: number }) => {
|
||||
const [row] = await db.insert(schema.fluxTransaction).values({
|
||||
userId,
|
||||
type: 'promo',
|
||||
amount,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: amount,
|
||||
requestId: requestId ?? null,
|
||||
description: 'mocked',
|
||||
}).returning()
|
||||
inserted.push({ userId, requestId })
|
||||
return {
|
||||
balanceBefore: 0,
|
||||
balanceAfter: amount,
|
||||
fluxTransactionId: row!.id,
|
||||
idempotent: false,
|
||||
}
|
||||
})
|
||||
const billingService = { creditFlux } as unknown as BillingService
|
||||
const service = createAdminFluxGrantsService({ db, billingService })
|
||||
|
||||
const { result } = await service.grant({
|
||||
amount: 25,
|
||||
description: 'Integration test',
|
||||
emails: ['granta@example.com', 'grantc@example.com'],
|
||||
createdByUserId: 'uid_admin',
|
||||
idempotencyKey: 'int-1',
|
||||
})
|
||||
|
||||
expect(result.granted).toHaveLength(2)
|
||||
const ledger = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.requestId, 'flux-grant:int-1:uid_grant_a'))
|
||||
expect(ledger).toHaveLength(1)
|
||||
expect(ledger[0]?.amount).toBe(25)
|
||||
expect(inserted.map(r => r.requestId).sort()).toEqual([
|
||||
'flux-grant:int-1:uid_grant_a',
|
||||
'flux-grant:int-1:uid_grant_c',
|
||||
])
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
+822
@@ -0,0 +1,822 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { ConfigKVService } from '../../../../adapters/config-kv'
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
buildAliyunNlsAsrSlice,
|
||||
buildAzureSlice,
|
||||
buildBedrockSlice,
|
||||
buildDashscopeSlice,
|
||||
buildNextRouterConfig,
|
||||
buildOpenRouterSlice,
|
||||
buildStepfunSlice,
|
||||
buildUnspeechSlice,
|
||||
createAdminRouterConfigService,
|
||||
redactCiphertext,
|
||||
} from '..'
|
||||
import { createEnvelopeCrypto } from '../../../../../utils/envelope-crypto'
|
||||
|
||||
function freshEnvelope() {
|
||||
return createEnvelopeCrypto({ masterKey: randomBytes(32) })
|
||||
}
|
||||
|
||||
const DEFAULT_FALLBACK_TRIGGERS = {
|
||||
httpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
|
||||
onTimeout: true,
|
||||
}
|
||||
|
||||
interface FakeConfigKV {
|
||||
store: Map<string, unknown>
|
||||
service: ConfigKVService
|
||||
publishedChannels: { channel: string, payload: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake for ConfigKVService + a thin Redis publish stub.
|
||||
*
|
||||
* Use when:
|
||||
* - Service tests need to assert which configKV entries got written and
|
||||
* which channel publishes fired, without touching real Redis.
|
||||
*/
|
||||
function fakeConfigKV(): FakeConfigKV {
|
||||
const store = new Map<string, unknown>()
|
||||
const service: Partial<ConfigKVService> = {
|
||||
async getOptional(key: string) {
|
||||
return (store.get(key) ?? null) as never
|
||||
},
|
||||
async getOrThrow(key: string) {
|
||||
const v = store.get(key)
|
||||
if (v === undefined)
|
||||
throw new Error(`fake getOrThrow missing ${key}`)
|
||||
return v as never
|
||||
},
|
||||
async get(key: string) {
|
||||
return this.getOrThrow!(key as never)
|
||||
},
|
||||
async set(key: string, value: unknown) {
|
||||
store.set(key, value)
|
||||
},
|
||||
}
|
||||
return { store, service: service as ConfigKVService, publishedChannels: [] }
|
||||
}
|
||||
|
||||
function fakeRedis(captured: { channel: string, payload: string }[]): Redis {
|
||||
return {
|
||||
publish: vi.fn(async (channel: string, payload: string) => {
|
||||
captured.push({ channel, payload })
|
||||
return 1
|
||||
}),
|
||||
} as unknown as Redis
|
||||
}
|
||||
|
||||
describe('redactCiphertext', () => {
|
||||
it('replaces ciphertext strings with a length tag', () => {
|
||||
const input = {
|
||||
keys: [{ id: 'k1', ciphertext: 'a'.repeat(100) }],
|
||||
adapterParams: { nested: { ciphertext: 'b'.repeat(50) } },
|
||||
}
|
||||
expect(redactCiphertext(input)).toEqual({
|
||||
keys: [{ id: 'k1', ciphertext: '<ciphertext: 100 chars>' }],
|
||||
adapterParams: { nested: { ciphertext: '<ciphertext: 50 chars>' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves non-ciphertext fields unchanged', () => {
|
||||
expect(redactCiphertext({ baseURL: 'https://x', count: 3, flag: true })).toEqual({
|
||||
baseURL: 'https://x',
|
||||
count: 3,
|
||||
flag: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('walks arrays', () => {
|
||||
expect(redactCiphertext([{ ciphertext: 'xx' }, { ciphertext: 'yyy' }])).toEqual([
|
||||
{ ciphertext: '<ciphertext: 2 chars>' },
|
||||
{ ciphertext: '<ciphertext: 3 chars>' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpenRouterSlice', () => {
|
||||
it('encrypts the plaintext key under {modelName, keyEntryId} AAD', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildOpenRouterSlice({
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk-or-secret',
|
||||
}, envelope)
|
||||
|
||||
expect(built.target).toBe('llm-router')
|
||||
expect(built.surface).toBe('llm')
|
||||
expect(built.modelName).toBe('chat-default')
|
||||
expect(built.keyEntryId).toBe('openrouter-prod-1')
|
||||
|
||||
const upstream = built.model.upstreams[0]
|
||||
expect(upstream.baseURL).toBe('https://openrouter.ai/api/v1')
|
||||
expect(upstream.overrideModel).toBe('openai/gpt-4o-mini')
|
||||
expect(upstream.headerTemplate).toBe('Bearer {KEY}')
|
||||
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
|
||||
|
||||
// Round-trip the ciphertext under the same AAD — guards against the
|
||||
// AAD getting silently changed (which would surface as DECRYPT_FAILED
|
||||
// at gateway runtime, but never in tests like this if we only checked
|
||||
// the ciphertext length).
|
||||
const decrypted = envelope.decryptKey(upstream.keys[0].ciphertext, {
|
||||
modelName: 'chat-default',
|
||||
keyEntryId: 'openrouter-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe('sk-or-secret')
|
||||
})
|
||||
|
||||
it('respects custom baseURL, keyEntryId, and headerTemplate', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildOpenRouterSlice({
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk',
|
||||
baseURL: 'https://proxy.example/api/v1',
|
||||
keyEntryId: 'openrouter-prod-2',
|
||||
headerTemplate: 'X-Custom-Token {KEY}',
|
||||
}, envelope)
|
||||
|
||||
expect(built.keyEntryId).toBe('openrouter-prod-2')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://proxy.example/api/v1')
|
||||
expect(built.model.upstreams[0].headerTemplate).toBe('X-Custom-Token {KEY}')
|
||||
expect(built.model.upstreams[0].keys[0].id).toBe('openrouter-prod-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBedrockSlice', () => {
|
||||
it('accepts and encrypts multi-kilobyte Bedrock bearer tokens', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const token = `bedrock-api-key-${'x'.repeat(2200)}`
|
||||
const built = buildBedrockSlice({
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
plaintextKey: token,
|
||||
}, envelope)
|
||||
|
||||
expect(built.kind).toBe('bedrock')
|
||||
expect(built.keyEntryId).toBe('bedrock-prod-1')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'chat-bedrock',
|
||||
keyEntryId: 'bedrock-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe(token)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAzureSlice', () => {
|
||||
it('builds the cognitiveservices baseURL from region and surfaces region in adapterParams', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildAzureSlice({
|
||||
kind: 'azure',
|
||||
modelName: 'microsoft/v1',
|
||||
region: 'eastasia',
|
||||
defaultVoice: 'en-US-AvaMultilingualNeural',
|
||||
plaintextKey: 'azure-key',
|
||||
}, envelope)
|
||||
|
||||
expect(built.kind).toBe('azure')
|
||||
expect(built.model.provider).toBe('azure')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1')
|
||||
expect(built.model.upstreams[0].adapterParams).toEqual({
|
||||
region: 'eastasia',
|
||||
defaultVoice: 'en-US-AvaMultilingualNeural',
|
||||
})
|
||||
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'microsoft/v1',
|
||||
keyEntryId: 'azure-tts-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe('azure-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAliyunNlsAsrSlice', () => {
|
||||
/**
|
||||
* @example
|
||||
* buildAliyunNlsAsrSlice({ kind: 'aliyun-nls-asr', modelName: 'auto', accessKeyId: 'ak', appKey: 'app', plaintextKey: 'secret' }, envelope)
|
||||
*/
|
||||
it('encrypts the access key secret under the ASR model AAD', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildAliyunNlsAsrSlice({
|
||||
kind: 'aliyun-nls-asr',
|
||||
modelName: 'auto',
|
||||
accessKeyId: 'ak',
|
||||
appKey: 'app',
|
||||
plaintextKey: 'secret',
|
||||
}, envelope)
|
||||
|
||||
expect(built.target).toBe('llm-router')
|
||||
expect(built.surface).toBe('asr')
|
||||
expect(built.modelName).toBe('auto')
|
||||
expect(built.keyEntryId).toBe('aliyun-nls-asr-prod-1')
|
||||
expect(built.model.provider).toBe('aliyun-nls')
|
||||
expect(built.model.upstreams[0].adapterParams).toEqual({
|
||||
accessKeyId: 'ak',
|
||||
appKey: 'app',
|
||||
region: 'cn-shanghai',
|
||||
})
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'auto',
|
||||
keyEntryId: 'aliyun-nls-asr-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe('secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDashscopeSlice', () => {
|
||||
it.each([
|
||||
['intl', 'dashscope-intl.aliyuncs.com'],
|
||||
['cn', 'dashscope.aliyuncs.com'],
|
||||
] as const)('uses the %s region host', (region, host) => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildDashscopeSlice({
|
||||
kind: 'dashscope-cosyvoice',
|
||||
modelName: 'alibaba/cosyvoice-v2',
|
||||
region,
|
||||
upstreamModel: 'cosyvoice-v2',
|
||||
plaintextKey: 'sk-dash',
|
||||
}, envelope)
|
||||
|
||||
expect(built.model.upstreams[0].baseURL).toBe(`https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`)
|
||||
expect(built.model.upstreams[0].adapterParams).toEqual({ model: 'cosyvoice-v2' })
|
||||
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildStepfunSlice', () => {
|
||||
it('builds the StepFun TTS endpoint and surfaces model defaults in adapterParams', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildStepfunSlice({
|
||||
kind: 'stepfun',
|
||||
modelName: 'stepfun/stepaudio-2.5-tts',
|
||||
upstreamModel: 'stepaudio-2.5-tts',
|
||||
defaultVoice: 'cixingnansheng',
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
plaintextKey: 'step-key',
|
||||
}, envelope)
|
||||
|
||||
expect(built.kind).toBe('stepfun')
|
||||
expect(built.model.provider).toBe('stepfun')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://api.stepfun.com')
|
||||
expect(built.model.upstreams[0].adapterParams).toEqual({
|
||||
model: 'stepaudio-2.5-tts',
|
||||
defaultVoice: 'cixingnansheng',
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
})
|
||||
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'stepfun/stepaudio-2.5-tts',
|
||||
keyEntryId: 'stepfun-tts-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe('step-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUnspeechSlice', () => {
|
||||
it('writes restBaseURL with no streaming subtree when the slice omits streaming', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildUnspeechSlice({
|
||||
kind: 'unspeech',
|
||||
restBaseURL: 'http://unspeech.example:5933',
|
||||
}, envelope)
|
||||
|
||||
expect(built.target).toBe('unspeech')
|
||||
expect(built.value.restBaseURL).toBe('http://unspeech.example:5933')
|
||||
expect(built.value.streaming).toBeUndefined()
|
||||
expect(built.keyEntryId).toBeNull()
|
||||
})
|
||||
|
||||
it('encrypts streaming.plaintextKey under the streaming-tts AAD model label (must match audio-speech-ws decrypt)', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildUnspeechSlice({
|
||||
kind: 'unspeech',
|
||||
restBaseURL: 'http://unspeech.example:5933',
|
||||
streaming: {
|
||||
upstreamURL: 'ws://unspeech.example:5933/v1/audio/speech/stream',
|
||||
plaintextKey: 'volc-key',
|
||||
},
|
||||
}, envelope)
|
||||
|
||||
expect(built.target).toBe('unspeech')
|
||||
expect(built.value.streaming?.baseURL).toBe('ws://unspeech.example:5933/v1/audio/speech/stream')
|
||||
|
||||
// The AAD modelName MUST be the literal 'streaming-tts' — anything else
|
||||
// surfaces as DECRYPT_FAILED at session start in audio-speech-ws.
|
||||
const ct = built.value.streaming!.keys[0].ciphertext
|
||||
const decrypted = envelope.decryptKey(ct, {
|
||||
modelName: 'streaming-tts',
|
||||
keyEntryId: 'volcengine-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe('volc-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNextRouterConfig', () => {
|
||||
it('merge mode preserves models not touched this run', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const existing = {
|
||||
llm: {
|
||||
models: {
|
||||
'untouched-chat': {
|
||||
upstreams: [{ baseURL: 'https://old', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: {
|
||||
models: {
|
||||
'untouched-tts': {
|
||||
provider: 'azure' as const,
|
||||
upstreams: [{ baseURL: 'https://old-tts', keys: [{ id: 'k', ciphertext: 'c' }], adapterParams: {} }],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: { perAttemptTimeoutMs: 12345, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
}
|
||||
const newSlice = buildOpenRouterSlice({
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk',
|
||||
}, envelope)
|
||||
|
||||
const next = buildNextRouterConfig('merge', existing, [newSlice])
|
||||
|
||||
expect(Object.keys(next.llm.models).sort()).toEqual(['chat-default', 'untouched-chat'])
|
||||
expect(Object.keys(next.tts.models)).toEqual(['untouched-tts'])
|
||||
// Defaults preserved verbatim in merge mode — the admin endpoint does
|
||||
// not currently re-tune timeouts, and zeroing them would silently break
|
||||
// gateway timeouts.
|
||||
expect(next.defaults?.perAttemptTimeoutMs).toBe(12345)
|
||||
})
|
||||
|
||||
it('reset mode drops every prior entry and uses default timeouts', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const existing = {
|
||||
llm: {
|
||||
models: {
|
||||
old: {
|
||||
upstreams: [{ baseURL: 'https://old', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 12345, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
}
|
||||
const newSlice = buildOpenRouterSlice({
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk',
|
||||
}, envelope)
|
||||
|
||||
const next = buildNextRouterConfig('reset', existing, [newSlice])
|
||||
|
||||
expect(Object.keys(next.llm.models)).toEqual(['chat-default'])
|
||||
expect(next.defaults?.perAttemptTimeoutMs).toBe(30000)
|
||||
})
|
||||
|
||||
it('starts from empty when existing is null (first-time bootstrap)', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const newSlice = buildAzureSlice({
|
||||
kind: 'azure',
|
||||
modelName: 'microsoft/v1',
|
||||
region: 'eastasia',
|
||||
plaintextKey: 'azure-key',
|
||||
}, envelope)
|
||||
|
||||
const next = buildNextRouterConfig('merge', null, [newSlice])
|
||||
expect(Object.keys(next.llm.models)).toEqual([])
|
||||
expect(Object.keys(next.tts.models)).toEqual(['microsoft/v1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAdminRouterConfigService', () => {
|
||||
let kv: FakeConfigKV
|
||||
let captured: { channel: string, payload: string }[]
|
||||
let redis: Redis
|
||||
let envelope: ReturnType<typeof createEnvelopeCrypto>
|
||||
|
||||
beforeEach(() => {
|
||||
kv = fakeConfigKV()
|
||||
captured = []
|
||||
redis = fakeRedis(captured)
|
||||
envelope = freshEnvelope()
|
||||
})
|
||||
|
||||
it('dry-run returns redacted preview without touching the store or pub/sub', async () => {
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const result = await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: true,
|
||||
slices: [{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk-or-secret',
|
||||
}],
|
||||
defaults: {
|
||||
chatModel: 'chat-default',
|
||||
ttsVoices: {
|
||||
'alibaba/cosyvoice-v2': { 'zh-CN': 'longxiaochun_v2' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(kv.store.size).toBe(0)
|
||||
expect(captured).toEqual([])
|
||||
expect(result.invalidatedKeys).toEqual([])
|
||||
|
||||
// Preview must redact ciphertext — leaking plaintext OR raw ciphertext
|
||||
// back to admin response would be a regression.
|
||||
const preview = result.preview.LLM_ROUTER_CONFIG as { llm: { models: Record<string, { upstreams: { keys: { ciphertext: string }[] }[] }> } }
|
||||
const ct = preview.llm.models['chat-default'].upstreams[0].keys[0].ciphertext
|
||||
expect(ct).toMatch(/^<ciphertext: \d+ chars>$/)
|
||||
expect(ct).not.toContain('sk-or-secret')
|
||||
|
||||
expect(result.preview.DEFAULT_CHAT_MODEL).toBe('chat-default')
|
||||
expect(result.preview.DEFAULT_TTS_VOICES).toEqual({
|
||||
'alibaba/cosyvoice-v2': { 'zh-CN': 'longxiaochun_v2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('writes LLM_ROUTER_CONFIG, DEFAULT_CHAT_MODEL, and publishes invalidation', async () => {
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const result = await service.apply({
|
||||
mode: 'reset',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk',
|
||||
}],
|
||||
defaults: { chatModel: 'chat-default' },
|
||||
})
|
||||
|
||||
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(true)
|
||||
expect(kv.store.get('DEFAULT_CHAT_MODEL')).toBe('chat-default')
|
||||
expect(result.invalidatedKeys.sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG'])
|
||||
expect(captured.map(p => JSON.parse(p.payload).key).sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG'])
|
||||
})
|
||||
|
||||
it('writes DEFAULT_TTS_VOICES without requiring provider slices', async () => {
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const result = await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: false,
|
||||
slices: [],
|
||||
defaults: {
|
||||
ttsVoices: {
|
||||
'alibaba/cosyvoice-v2': {
|
||||
'zh-CN': 'longxiaochun_v2',
|
||||
'en-US': 'loongava_v2',
|
||||
},
|
||||
'volcengine/seed-tts-2.0': {
|
||||
'zh-CN': 'zh_female_vv_uranus_bigtts',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(kv.store.get('DEFAULT_TTS_VOICES')).toEqual({
|
||||
'alibaba/cosyvoice-v2': {
|
||||
'zh-CN': 'longxiaochun_v2',
|
||||
'en-US': 'loongava_v2',
|
||||
},
|
||||
'volcengine/seed-tts-2.0': {
|
||||
'zh-CN': 'zh_female_vv_uranus_bigtts',
|
||||
},
|
||||
})
|
||||
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
|
||||
expect(result.preview.DEFAULT_TTS_VOICES).toEqual(kv.store.get('DEFAULT_TTS_VOICES'))
|
||||
expect(result.invalidatedKeys).toEqual(['DEFAULT_TTS_VOICES'])
|
||||
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['DEFAULT_TTS_VOICES'])
|
||||
})
|
||||
|
||||
it('writes UNSPEECH_UPSTREAM and publishes invalidation when an unspeech slice is included', async () => {
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const result = await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'unspeech',
|
||||
restBaseURL: 'http://unspeech.example:5933',
|
||||
streaming: {
|
||||
upstreamURL: 'wss://unspeech.example/v1/audio/speech/stream',
|
||||
plaintextKey: 'volc',
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
expect(kv.store.has('UNSPEECH_UPSTREAM')).toBe(true)
|
||||
expect(kv.store.get('UNSPEECH_UPSTREAM')).toMatchObject({
|
||||
streaming: {
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
})
|
||||
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
|
||||
expect(result.invalidatedKeys).toEqual(['UNSPEECH_UPSTREAM'])
|
||||
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['UNSPEECH_UPSTREAM'])
|
||||
})
|
||||
|
||||
it('rejects multiple unspeech slices', async () => {
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
await expect(service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: true,
|
||||
slices: [
|
||||
{ kind: 'unspeech', restBaseURL: 'http://a' },
|
||||
{ kind: 'unspeech', restBaseURL: 'http://b' },
|
||||
],
|
||||
})).rejects.toThrow(/At most one unspeech/i)
|
||||
})
|
||||
|
||||
it('merge mode reads existing LLM_ROUTER_CONFIG and preserves untouched models', async () => {
|
||||
// Seed an existing entry directly into the fake store, matching the
|
||||
// shape configKV.getOptional would have returned after a prior admin call.
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: { models: { 'preexisting-chat': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'azure',
|
||||
modelName: 'microsoft/v1',
|
||||
region: 'eastasia',
|
||||
plaintextKey: 'azure-key',
|
||||
}],
|
||||
})
|
||||
|
||||
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, unknown> }, tts: { models: Record<string, unknown> } }
|
||||
expect(Object.keys(written.llm.models)).toEqual(['preexisting-chat'])
|
||||
expect(Object.keys(written.tts.models)).toEqual(['microsoft/v1'])
|
||||
})
|
||||
|
||||
it('rejects legacy merge updates that would replace a grouped TTS model', async () => {
|
||||
const modelName = 'stepfun/stepaudio-2.5-tts'
|
||||
const existingConfig = {
|
||||
llm: { models: {} },
|
||||
tts: {
|
||||
models: {
|
||||
[modelName]: {
|
||||
provider: 'stepfun' as const,
|
||||
upstreams: [
|
||||
{
|
||||
id: 'plan',
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }],
|
||||
adapterParams: { endpointProfile: 'step-plan', model: 'stepaudio-2.5-tts' },
|
||||
},
|
||||
{
|
||||
id: 'paygo',
|
||||
baseURL: 'https://api.stepfun.com',
|
||||
keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }],
|
||||
adapterParams: { endpointProfile: 'default', model: 'stepaudio-2.5-tts' },
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
groups: [
|
||||
{
|
||||
id: 'plan',
|
||||
upstreamIds: ['plan'],
|
||||
strategy: 'ordered' as const,
|
||||
retryOn: { httpCodes: [402], onTimeout: false },
|
||||
continueOn: { httpCodes: [402], onTimeout: false },
|
||||
},
|
||||
{
|
||||
id: 'paygo',
|
||||
upstreamIds: ['paygo'],
|
||||
strategy: 'ordered' as const,
|
||||
retryOn: { httpCodes: [429, 500], onTimeout: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
}
|
||||
kv.store.set('LLM_ROUTER_CONFIG', existingConfig)
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
|
||||
await expect(service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'stepfun',
|
||||
modelName,
|
||||
upstreamModel: 'stepaudio-2.5-tts',
|
||||
plaintextKey: 'rotated-key',
|
||||
}],
|
||||
})).rejects.toThrow(/cannot update grouped tts model/i)
|
||||
|
||||
expect(kv.store.get('LLM_ROUTER_CONFIG')).toBe(existingConfig)
|
||||
expect(captured).toEqual([])
|
||||
})
|
||||
|
||||
it('current returns editable slices from configKV without exposing raw ciphertext', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-live': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
kv.store.set('DEFAULT_CHAT_MODEL', 'chat-live')
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const current = await service.current()
|
||||
|
||||
expect(current.request.slices).toEqual([{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-live',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'openrouter-live',
|
||||
existingKeyEntryId: 'openrouter-live',
|
||||
}])
|
||||
expect(current.request.defaults.chatModel).toBe('chat-live')
|
||||
expect(JSON.stringify(current.preview)).toContain('<ciphertext: 17 chars>')
|
||||
expect(JSON.stringify(current.preview)).not.toContain('secret-ciphertext')
|
||||
})
|
||||
|
||||
it('current classifies Bedrock and generic OpenAI-compatible LLM upstreams by baseURL', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-bedrock': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
keys: [{ id: 'bedrock-live', ciphertext: 'bedrock-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
'chat-compatible': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
keys: [{ id: 'compatible-live', ciphertext: 'compatible-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const current = await service.current()
|
||||
|
||||
expect(current.request.slices).toEqual([
|
||||
{
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'bedrock-live',
|
||||
existingKeyEntryId: 'bedrock-live',
|
||||
},
|
||||
{
|
||||
kind: 'openai-compatible',
|
||||
modelName: 'chat-compatible',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'compatible-live',
|
||||
existingKeyEntryId: 'compatible-live',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an existing key entry when an applied slice omits plaintextKey', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-live': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-live',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
baseURL: 'https://proxy.example/api/v1',
|
||||
keyEntryId: 'openrouter-live',
|
||||
existingKeyEntryId: 'openrouter-live',
|
||||
}],
|
||||
})
|
||||
|
||||
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, { upstreams: Array<{ baseURL: string, keys: Array<{ id: string, ciphertext: string }> }> }> } }
|
||||
const upstream = written.llm.models['chat-live'].upstreams[0]
|
||||
expect(upstream.baseURL).toBe('https://proxy.example/api/v1')
|
||||
expect(upstream.keys).toEqual([{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }])
|
||||
})
|
||||
|
||||
it('reset mode skips the existing read and drops prior entries', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: { models: { 'should-be-dropped': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
|
||||
const getOptionalSpy = vi.spyOn(kv.service, 'getOptional')
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
await service.apply({
|
||||
mode: 'reset',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-default',
|
||||
overrideModel: 'openai/gpt-4o-mini',
|
||||
plaintextKey: 'sk',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(getOptionalSpy).not.toHaveBeenCalled()
|
||||
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, unknown> } }
|
||||
expect(Object.keys(written.llm.models)).toEqual(['chat-default'])
|
||||
})
|
||||
|
||||
it('returns per-slice applied summaries that the audit log can use', async () => {
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const result = await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: true,
|
||||
slices: [
|
||||
{ kind: 'openrouter', modelName: 'chat-default', overrideModel: 'openai/gpt-4o-mini', plaintextKey: 'sk' },
|
||||
{ kind: 'dashscope-cosyvoice', modelName: 'alibaba/cosyvoice-v2', region: 'intl', upstreamModel: 'cosyvoice-v2', plaintextKey: 'sk' },
|
||||
{ kind: 'stepfun', modelName: 'stepfun/stepaudio-2.5-tts', upstreamModel: 'stepaudio-2.5-tts', plaintextKey: 'sk' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.applied).toEqual([
|
||||
{ kind: 'openrouter', target: 'llm-router', surface: 'llm', modelName: 'chat-default', keyEntryId: 'openrouter-prod-1' },
|
||||
{ kind: 'dashscope-cosyvoice', target: 'llm-router', surface: 'tts', modelName: 'alibaba/cosyvoice-v2', keyEntryId: 'dashscope-tts-prod-1' },
|
||||
{ kind: 'stepfun', target: 'llm-router', surface: 'tts', modelName: 'stepfun/stepaudio-2.5-tts', keyEntryId: 'stepfun-tts-prod-1' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Database } from '../../../../libs/db'
|
||||
import type { BillingService } from '../../billing/billing-service'
|
||||
import type { UserSelector } from '../../users/resolve-user'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
import { resolveUserByIdOrEmail } from '../../users/resolve-user'
|
||||
|
||||
const logger = useLogger('admin-users').useGlobalConfig()
|
||||
|
||||
export interface SetBalanceInput extends UserSelector {
|
||||
/** Target absolute balance. Non-negative integer; the route validates this. */
|
||||
balance: number
|
||||
/** Audit description stored on the ledger row. */
|
||||
description: string
|
||||
/** Admin user id issuing the change. */
|
||||
issuedByUserId: string
|
||||
}
|
||||
|
||||
export interface SetBalanceResult {
|
||||
userId: string
|
||||
email: string
|
||||
balanceBefore: number
|
||||
balanceAfter: number
|
||||
fluxTransactionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin operations that target a single user by id or email.
|
||||
*
|
||||
* Balance overrides go through {@link BillingService.setFlux} so the write +
|
||||
* ledger stay in one place; this service only resolves the selector to a
|
||||
* concrete user before delegating.
|
||||
*/
|
||||
export function createAdminUsersService(deps: { db: Database, billingService: BillingService }) {
|
||||
const { db, billingService } = deps
|
||||
|
||||
return {
|
||||
/**
|
||||
* Set a user's flux balance to an absolute value (including 0).
|
||||
*
|
||||
* Use when:
|
||||
* - An admin overrides a balance, e.g. zeroing it for testing.
|
||||
*
|
||||
* Returns:
|
||||
* - The resolved user plus the before/after balance and ledger row id.
|
||||
*/
|
||||
async setBalance(input: SetBalanceInput): Promise<SetBalanceResult> {
|
||||
const target = await resolveUserByIdOrEmail(db, input)
|
||||
|
||||
const { balanceBefore, balanceAfter, fluxTransactionId } = await billingService.setFlux({
|
||||
userId: target.id,
|
||||
balance: input.balance,
|
||||
description: input.description,
|
||||
issuedByUserId: input.issuedByUserId,
|
||||
})
|
||||
|
||||
logger.withFields({
|
||||
userId: target.id,
|
||||
email: target.email,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
issuedByUserId: input.issuedByUserId,
|
||||
}).log('Admin set balance')
|
||||
|
||||
return { userId: target.id, email: target.email, balanceBefore, balanceAfter, fluxTransactionId }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminUsersService = ReturnType<typeof createAdminUsersService>
|
||||
@@ -0,0 +1,559 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../../../libs/db'
|
||||
import type { RevenueMetrics } from '../../../otel'
|
||||
import type { ConfigKVService } from '../../adapters/config-kv'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
import { createPaymentRequiredError } from '../../../utils/error'
|
||||
import { userFluxRedisKey } from '../../../utils/redis-keys'
|
||||
|
||||
import * as fluxSchema from '../../../schemas/flux'
|
||||
import * as fluxTxSchema from '../../../schemas/flux-transaction'
|
||||
import * as stripeSchema from '../../../schemas/stripe'
|
||||
|
||||
const logger = useLogger('billing-service')
|
||||
|
||||
export function createBillingService(
|
||||
db: Database,
|
||||
redis: Redis,
|
||||
_configKV: ConfigKVService,
|
||||
metrics?: RevenueMetrics | null,
|
||||
) {
|
||||
/**
|
||||
* Update Redis cache after a successful DB transaction.
|
||||
* Best-effort: cache loss is harmless since DB is the source of truth.
|
||||
*/
|
||||
async function updateRedisCache(userId: string, balance: number): Promise<void> {
|
||||
try {
|
||||
await redis.set(userFluxRedisKey(userId), String(balance))
|
||||
}
|
||||
catch {
|
||||
logger.withFields({ userId }).warn('Failed to update Redis cache after balance change')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit flux from a user's balance within a single DB transaction.
|
||||
*
|
||||
* The transaction locks the user_flux row, validates the balance, updates
|
||||
* it, and writes the matching `flux_transaction` ledger entry — all in one
|
||||
* commit. The unique partial index `(user_id, request_id) WHERE request_id IS NOT NULL`
|
||||
* keeps retries idempotent at the DB level.
|
||||
*
|
||||
* Partial-debit semantics:
|
||||
* When `0 < balance < amount`, the balance is drained to zero and the
|
||||
* ledger row is written with `amount = charged` and metadata recording
|
||||
* `requestedAmount` + `unbilled`. The function returns `charged < requested`
|
||||
* so callers can attribute the delta to a metric counter. This prevents
|
||||
* the post-streaming leak where a partial-balance user could replay the
|
||||
* same request indefinitely (each attempt rolled back the whole tx,
|
||||
* leaving the balance untouched). The very next call sees `flux <= 0`
|
||||
* and hits the throw branch.
|
||||
*
|
||||
* Private — call domain-specific wrappers (e.g. consumeFluxForLLM) instead.
|
||||
*/
|
||||
async function debitFlux(input: {
|
||||
userId: string
|
||||
amount: number
|
||||
requestId?: string
|
||||
description?: string
|
||||
source: string
|
||||
metadata?: Record<string, unknown>
|
||||
}): Promise<{ userId: string, flux: number, charged: number, requested: number }> {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Idempotency: a previous successful debit with the same requestId
|
||||
// returns the prior post-balance and skips the second deduction.
|
||||
// Mirrors creditFlux's idempotent path so retries (network errors,
|
||||
// worker restarts) don't double-charge.
|
||||
if (input.requestId != null) {
|
||||
const [existing] = await tx
|
||||
.select({
|
||||
amount: fluxTxSchema.fluxTransaction.amount,
|
||||
balanceAfter: fluxTxSchema.fluxTransaction.balanceAfter,
|
||||
})
|
||||
.from(fluxTxSchema.fluxTransaction)
|
||||
.where(and(
|
||||
eq(fluxTxSchema.fluxTransaction.userId, input.userId),
|
||||
eq(fluxTxSchema.fluxTransaction.requestId, input.requestId),
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
// Replay reuses the historical `charged`; we deliberately reflect
|
||||
// the original (possibly partial) outcome instead of the caller's
|
||||
// current `amount`, so the caller doesn't double-fire unbilled
|
||||
// counters on retries.
|
||||
return {
|
||||
userId: input.userId,
|
||||
flux: existing.balanceAfter,
|
||||
charged: existing.amount,
|
||||
requested: existing.amount,
|
||||
idempotent: true as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await tx
|
||||
.select({ flux: fluxSchema.userFlux.flux })
|
||||
.from(fluxSchema.userFlux)
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
.for('update')
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`No flux record for user ${input.userId}`)
|
||||
}
|
||||
|
||||
const balanceBefore = row.flux
|
||||
// Hard floor: zero (or somehow negative) balance still throws so
|
||||
// streaming callers' catch path fires `fluxUnbilled` with the full
|
||||
// amount and TTS meter restores its debt counter. Partial debit only
|
||||
// kicks in when there is *some* balance left to drain.
|
||||
if (balanceBefore <= 0) {
|
||||
metrics?.fluxInsufficientBalance.add(1)
|
||||
throw createPaymentRequiredError('Insufficient flux')
|
||||
}
|
||||
|
||||
const chargedAmount = Math.min(input.amount, balanceBefore)
|
||||
const balanceAfter = balanceBefore - chargedAmount
|
||||
const isPartial = chargedAmount < input.amount
|
||||
if (isPartial) {
|
||||
metrics?.fluxInsufficientBalance.add(1)
|
||||
}
|
||||
|
||||
await tx.update(fluxSchema.userFlux)
|
||||
.set({ flux: balanceAfter, updatedAt: new Date() })
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
|
||||
await tx.insert(fluxTxSchema.fluxTransaction).values({
|
||||
userId: input.userId,
|
||||
type: 'debit',
|
||||
amount: chargedAmount,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
requestId: input.requestId,
|
||||
description: input.description ?? input.source,
|
||||
metadata: {
|
||||
...input.metadata,
|
||||
source: input.source,
|
||||
...(isPartial && {
|
||||
requestedAmount: input.amount,
|
||||
unbilled: input.amount - chargedAmount,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
userId: input.userId,
|
||||
flux: balanceAfter,
|
||||
charged: chargedAmount,
|
||||
requested: input.amount,
|
||||
idempotent: false as const,
|
||||
}
|
||||
})
|
||||
|
||||
if (!result.idempotent) {
|
||||
await updateRedisCache(input.userId, result.flux)
|
||||
}
|
||||
|
||||
logger.withFields({
|
||||
userId: input.userId,
|
||||
amount: input.amount,
|
||||
charged: result.charged,
|
||||
balance: result.flux,
|
||||
idempotent: result.idempotent,
|
||||
}).log('Debited flux')
|
||||
return {
|
||||
userId: result.userId,
|
||||
flux: result.flux,
|
||||
charged: result.charged,
|
||||
requested: result.requested,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Debit flux for an LLM API request (chat, TTS).
|
||||
* Token usage is persisted in the `flux_transaction.metadata` column so
|
||||
* the existing transaction-history UI can render per-request token counts.
|
||||
*/
|
||||
async consumeFluxForLLM(input: {
|
||||
userId: string
|
||||
amount: number
|
||||
requestId?: string
|
||||
description?: string
|
||||
model?: string
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
}): Promise<{ userId: string, flux: number, charged: number, requested: number }> {
|
||||
return debitFlux({
|
||||
userId: input.userId,
|
||||
amount: input.amount,
|
||||
requestId: input.requestId,
|
||||
description: input.description,
|
||||
source: 'llm.request',
|
||||
metadata: {
|
||||
...(input.model != null && { model: input.model }),
|
||||
...(input.promptTokens != null && { promptTokens: input.promptTokens }),
|
||||
...(input.completionTokens != null && { completionTokens: input.completionTokens }),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Credit flux to a user's balance within a DB transaction.
|
||||
* Generic credit method for non-Stripe flows (e.g. admin grants).
|
||||
*
|
||||
* Idempotency:
|
||||
* When `requestId` is provided, the call is idempotent across crash /
|
||||
* retry boundaries. If a `flux_transaction` row with the same
|
||||
* `(user_id, request_id)` already exists, this method returns that
|
||||
* existing row's balance + id without re-crediting the user, without
|
||||
* touching `user_flux`, and without re-emitting the Redis cache write.
|
||||
*
|
||||
* This guards against the worker crash window where:
|
||||
* 1. `creditFlux` commits the credit
|
||||
* 2. caller crashes before marking its own state (e.g. recipient row) granted
|
||||
* 3. on restart, caller sees pending state and calls `creditFlux` again with same requestId
|
||||
*
|
||||
* Without idempotency, step 3 would hit the `(user_id, request_id)`
|
||||
* unique index and throw — causing the caller to mark the work failed
|
||||
* even though the user was already credited.
|
||||
*/
|
||||
async creditFlux(input: {
|
||||
userId: string
|
||||
amount: number
|
||||
requestId?: string
|
||||
description: string
|
||||
source: string
|
||||
/**
|
||||
* Ledger row `type`. Defaults to `'credit'` for backward compatibility
|
||||
* with existing callers (Stripe top-up). Admin promo grants pass
|
||||
* `'promo'` so reports / dashboards can distinguish them.
|
||||
*/
|
||||
type?: 'credit' | 'promo'
|
||||
auditMetadata?: Record<string, unknown>
|
||||
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string, idempotent: boolean }> {
|
||||
const ledgerType = input.type ?? 'credit'
|
||||
|
||||
const txResult = await db.transaction(async (tx) => {
|
||||
if (input.requestId != null) {
|
||||
const [existing] = await tx
|
||||
.select({
|
||||
id: fluxTxSchema.fluxTransaction.id,
|
||||
balanceBefore: fluxTxSchema.fluxTransaction.balanceBefore,
|
||||
balanceAfter: fluxTxSchema.fluxTransaction.balanceAfter,
|
||||
})
|
||||
.from(fluxTxSchema.fluxTransaction)
|
||||
.where(and(
|
||||
eq(fluxTxSchema.fluxTransaction.userId, input.userId),
|
||||
eq(fluxTxSchema.fluxTransaction.requestId, input.requestId),
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
balanceBefore: existing.balanceBefore,
|
||||
balanceAfter: existing.balanceAfter,
|
||||
fluxTransactionId: existing.id,
|
||||
idempotent: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await tx.insert(fluxSchema.userFlux)
|
||||
.values({ userId: input.userId, flux: 0 })
|
||||
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
|
||||
|
||||
const [row] = await tx
|
||||
.select({ flux: fluxSchema.userFlux.flux })
|
||||
.from(fluxSchema.userFlux)
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
.for('update')
|
||||
|
||||
const balanceBefore = row!.flux
|
||||
const balanceAfter = balanceBefore + input.amount
|
||||
|
||||
await tx.update(fluxSchema.userFlux)
|
||||
.set({ flux: balanceAfter, updatedAt: new Date() })
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
|
||||
const [insertedTx] = await tx.insert(fluxTxSchema.fluxTransaction).values({
|
||||
userId: input.userId,
|
||||
type: ledgerType,
|
||||
amount: input.amount,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
requestId: input.requestId,
|
||||
description: input.description,
|
||||
metadata: input.auditMetadata,
|
||||
}).returning({ id: fluxTxSchema.fluxTransaction.id })
|
||||
|
||||
return {
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
fluxTransactionId: insertedTx!.id,
|
||||
idempotent: false,
|
||||
}
|
||||
})
|
||||
|
||||
if (txResult.idempotent) {
|
||||
logger.withFields({
|
||||
userId: input.userId,
|
||||
requestId: input.requestId,
|
||||
fluxTransactionId: txResult.fluxTransactionId,
|
||||
}).log('Credited flux (idempotent replay — no side effects emitted)')
|
||||
return txResult
|
||||
}
|
||||
|
||||
await updateRedisCache(input.userId, txResult.balanceAfter)
|
||||
metrics?.fluxCredited.add(input.amount, { source: input.source, type: ledgerType })
|
||||
|
||||
logger.withFields({ userId: input.userId, amount: input.amount, balance: txResult.balanceAfter }).log('Credited flux')
|
||||
return txResult
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a user's flux balance to an absolute value within a DB transaction.
|
||||
*
|
||||
* Use when:
|
||||
* - An admin overrides a balance directly (e.g. zeroing it out for
|
||||
* testing). Unlike credit/debit this is not request-driven and carries
|
||||
* no idempotency key — every call rewrites the balance to `balance` and
|
||||
* appends one `admin_set` ledger row recording the before/after.
|
||||
*
|
||||
* Expects:
|
||||
* - `balance` is a non-negative integer. The route layer validates this.
|
||||
*
|
||||
* Returns:
|
||||
* - The balance before and after, plus the appended ledger row id. The
|
||||
* ledger `amount` is the absolute delta magnitude; direction lives in
|
||||
* `metadata.direction` since a set can move the balance either way.
|
||||
*/
|
||||
async setFlux(input: {
|
||||
userId: string
|
||||
balance: number
|
||||
description: string
|
||||
issuedByUserId: string
|
||||
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string }> {
|
||||
const txResult = await db.transaction(async (tx) => {
|
||||
await tx.insert(fluxSchema.userFlux)
|
||||
.values({ userId: input.userId, flux: 0 })
|
||||
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
|
||||
|
||||
const [row] = await tx
|
||||
.select({ flux: fluxSchema.userFlux.flux })
|
||||
.from(fluxSchema.userFlux)
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
.for('update')
|
||||
|
||||
const balanceBefore = row!.flux
|
||||
const balanceAfter = input.balance
|
||||
const delta = balanceAfter - balanceBefore
|
||||
|
||||
await tx.update(fluxSchema.userFlux)
|
||||
.set({ flux: balanceAfter, updatedAt: new Date() })
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
|
||||
const [insertedTx] = await tx.insert(fluxTxSchema.fluxTransaction).values({
|
||||
userId: input.userId,
|
||||
type: 'admin_set',
|
||||
amount: Math.abs(delta),
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
description: input.description,
|
||||
metadata: {
|
||||
source: 'admin_set',
|
||||
requestedBalance: input.balance,
|
||||
direction: delta >= 0 ? 'credit' : 'debit',
|
||||
issuedByUserId: input.issuedByUserId,
|
||||
},
|
||||
}).returning({ id: fluxTxSchema.fluxTransaction.id })
|
||||
|
||||
return { balanceBefore, balanceAfter, fluxTransactionId: insertedTx!.id }
|
||||
})
|
||||
|
||||
// NOTICE:
|
||||
// Invalidate (DEL) rather than write (SET) the cache. An admin override
|
||||
// is a "truth changed" event, so we drop the key and let the next
|
||||
// getFlux miss reload from Postgres — mirrors FluxService.deleteAllForUser.
|
||||
// Writing the new value instead would have setFlux contribute its own
|
||||
// post-commit SET to the existing cross-operation cache-write race that
|
||||
// credit/debit already have (a slower concurrent SET can land last and
|
||||
// clobber it); DEL keeps setFlux from adding to that and defers to truth.
|
||||
// Best-effort: a failed DEL only leaves a stale cache entry that the next
|
||||
// mutation or TTL-less overwrite corrects; Postgres stays authoritative.
|
||||
try {
|
||||
await redis.del(userFluxRedisKey(input.userId))
|
||||
}
|
||||
catch {
|
||||
logger.withFields({ userId: input.userId }).warn('Failed to invalidate flux cache after setFlux')
|
||||
}
|
||||
|
||||
logger.withFields({
|
||||
userId: input.userId,
|
||||
balanceBefore: txResult.balanceBefore,
|
||||
balanceAfter: txResult.balanceAfter,
|
||||
issuedByUserId: input.issuedByUserId,
|
||||
}).log('Set flux balance')
|
||||
|
||||
return txResult
|
||||
},
|
||||
|
||||
/**
|
||||
* Credit flux from a Stripe checkout session (one-time payment).
|
||||
* Idempotent: claims the checkout session row by flipping `fluxCredited`
|
||||
* from false to true; replays of the same Stripe event observe the row
|
||||
* already claimed and apply nothing.
|
||||
*/
|
||||
async creditFluxFromStripeCheckout(input: {
|
||||
stripeEventId: string
|
||||
userId: string
|
||||
stripeSessionId: string
|
||||
amountTotal: number
|
||||
currency: string | null
|
||||
fluxAmount: number
|
||||
}): Promise<{ applied: boolean, balanceAfter?: number }> {
|
||||
const txResult = await db.transaction(async (tx) => {
|
||||
// NOTICE: Webhook idempotency is enforced at the business-object level, not by a
|
||||
// dedicated processed-events table keyed on Stripe `event.id`. We claim the
|
||||
// checkout session row exactly once via `fluxCredited = false -> true`, which
|
||||
// covers both Stripe retries of the same event and distinct Event objects that
|
||||
// still refer to the same checkout session.
|
||||
const [claimed] = await tx.update(stripeSchema.stripeCheckoutSession)
|
||||
.set({ fluxCredited: true, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId),
|
||||
eq(stripeSchema.stripeCheckoutSession.fluxCredited, false),
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!claimed) {
|
||||
return { applied: false }
|
||||
}
|
||||
|
||||
await tx.insert(fluxSchema.userFlux)
|
||||
.values({ userId: input.userId, flux: 0 })
|
||||
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
|
||||
|
||||
const [currentFlux] = await tx
|
||||
.select({ flux: fluxSchema.userFlux.flux })
|
||||
.from(fluxSchema.userFlux)
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
.for('update')
|
||||
|
||||
const balanceBefore = currentFlux!.flux
|
||||
const balanceAfter = balanceBefore + input.fluxAmount
|
||||
|
||||
await tx.update(fluxSchema.userFlux)
|
||||
.set({ flux: balanceAfter, updatedAt: new Date() })
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
|
||||
const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}`
|
||||
|
||||
await tx.insert(fluxTxSchema.fluxTransaction).values({
|
||||
userId: input.userId,
|
||||
type: 'credit',
|
||||
amount: input.fluxAmount,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
requestId: input.stripeEventId,
|
||||
description,
|
||||
metadata: {
|
||||
stripeEventId: input.stripeEventId,
|
||||
stripeSessionId: input.stripeSessionId,
|
||||
source: 'stripe.checkout.completed',
|
||||
},
|
||||
})
|
||||
|
||||
return { applied: true, balanceAfter }
|
||||
})
|
||||
|
||||
if (txResult.applied && txResult.balanceAfter != null) {
|
||||
await updateRedisCache(input.userId, txResult.balanceAfter)
|
||||
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.checkout', type: 'credit' })
|
||||
}
|
||||
|
||||
return txResult
|
||||
},
|
||||
|
||||
/**
|
||||
* Credit flux from a Stripe invoice payment (subscription).
|
||||
* Idempotent: claims the invoice row by flipping `fluxCredited`
|
||||
* from false to true; replays observe it already claimed and apply nothing.
|
||||
*/
|
||||
async creditFluxFromInvoice(input: {
|
||||
stripeEventId: string
|
||||
userId: string
|
||||
stripeInvoiceId: string
|
||||
amountPaid: number
|
||||
currency: string
|
||||
fluxAmount: number
|
||||
}): Promise<{ applied: boolean, balanceAfter?: number }> {
|
||||
const txResult = await db.transaction(async (tx) => {
|
||||
// NOTICE: Invoice webhook idempotency follows the same object-level claim model
|
||||
// as checkout sessions. We intentionally dedupe on the invoice record instead of
|
||||
// only on Stripe `event.id`, because Stripe may emit multiple events that map to
|
||||
// the same paid invoice while the balance must only be credited once.
|
||||
const [claimed] = await tx.update(stripeSchema.stripeInvoice)
|
||||
.set({ fluxCredited: true, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId),
|
||||
eq(stripeSchema.stripeInvoice.fluxCredited, false),
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!claimed) {
|
||||
return { applied: false }
|
||||
}
|
||||
|
||||
await tx.insert(fluxSchema.userFlux)
|
||||
.values({ userId: input.userId, flux: 0 })
|
||||
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
|
||||
|
||||
const [currentFlux] = await tx
|
||||
.select({ flux: fluxSchema.userFlux.flux })
|
||||
.from(fluxSchema.userFlux)
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
.for('update')
|
||||
|
||||
const balanceBefore = currentFlux!.flux
|
||||
const balanceAfter = balanceBefore + input.fluxAmount
|
||||
|
||||
await tx.update(fluxSchema.userFlux)
|
||||
.set({ flux: balanceAfter, updatedAt: new Date() })
|
||||
.where(eq(fluxSchema.userFlux.userId, input.userId))
|
||||
|
||||
const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}`
|
||||
|
||||
await tx.insert(fluxTxSchema.fluxTransaction).values({
|
||||
userId: input.userId,
|
||||
type: 'credit',
|
||||
amount: input.fluxAmount,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
requestId: input.stripeEventId,
|
||||
description,
|
||||
metadata: {
|
||||
stripeEventId: input.stripeEventId,
|
||||
stripeInvoiceId: input.stripeInvoiceId,
|
||||
source: 'invoice.paid',
|
||||
},
|
||||
})
|
||||
|
||||
return { applied: true, balanceAfter }
|
||||
})
|
||||
|
||||
if (txResult.applied && txResult.balanceAfter != null) {
|
||||
await updateRedisCache(input.userId, txResult.balanceAfter)
|
||||
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.invoice', type: 'credit' })
|
||||
}
|
||||
|
||||
return txResult
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type BillingService = ReturnType<typeof createBillingService>
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface UsageInfo {
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
}
|
||||
|
||||
export function extractUsageFromBody(body: any): UsageInfo {
|
||||
const usage = body?.usage
|
||||
if (!usage)
|
||||
return {}
|
||||
return {
|
||||
promptTokens: usage.prompt_tokens ?? undefined,
|
||||
completionTokens: usage.completion_tokens ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number {
|
||||
const { promptTokens, completionTokens } = usage
|
||||
if (promptTokens != null && completionTokens != null) {
|
||||
const totalTokens = promptTokens + completionTokens
|
||||
return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens))
|
||||
}
|
||||
return fallbackRate
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { RevenueMetrics } from '../../../otel'
|
||||
import type { BillingService } from './billing-service'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
import { createPaymentRequiredError } from '../../../utils/error'
|
||||
import { GEN_AI_ATTR_REQUEST_MODEL } from '../../../utils/observability'
|
||||
import { userFluxMeterDebtRedisKey } from '../../../utils/redis-keys'
|
||||
|
||||
const logger = useLogger('flux-meter')
|
||||
|
||||
// NOTICE: Atomic accumulate-and-settle. Integer Flux is the billing unit, but the
|
||||
// metered service (TTS chars, STT seconds, tokens, ...) charges at sub-Flux
|
||||
// granularity. We keep unsettled small units in a Redis counter and only debit
|
||||
// whole Flux when the counter crosses `unitsPerFlux`. Residual <unitsPerFlux
|
||||
// survives via TTL; callers accept that sub-1-Flux dust may expire unbilled.
|
||||
const ACCUMULATE_SCRIPT = `
|
||||
local key = KEYS[1]
|
||||
local units = tonumber(ARGV[1])
|
||||
local unitsPerFlux = tonumber(ARGV[2])
|
||||
local ttl = tonumber(ARGV[3])
|
||||
|
||||
local debt = redis.call('INCRBY', key, units)
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
|
||||
if debt >= unitsPerFlux then
|
||||
local flux = math.floor(debt / unitsPerFlux)
|
||||
local consumed = flux * unitsPerFlux
|
||||
redis.call('DECRBY', key, consumed)
|
||||
return {flux, debt - consumed}
|
||||
end
|
||||
|
||||
return {0, debt}
|
||||
`
|
||||
|
||||
interface FluxMeterRuntime {
|
||||
/** How many small units equal one Flux. */
|
||||
unitsPerFlux: number
|
||||
/** Debt key TTL. Residual debt below unitsPerFlux is forgiven on expiry. */
|
||||
debtTtlSeconds: number
|
||||
}
|
||||
|
||||
interface FluxMeterConfig {
|
||||
/** Meter identifier, used as Redis key segment and billing description prefix. */
|
||||
name: string
|
||||
/**
|
||||
* Resolves runtime pricing/TTL per call. Reads from Redis-backed configKV,
|
||||
* so every instance sees config changes immediately. Called lazily so a
|
||||
* missing pricing config surfaces as a per-request 503 (handled by the
|
||||
* route's configGuard), not as a server-wide startup failure.
|
||||
*
|
||||
* NOTICE: Do NOT memoise across calls. Multi-instance deploys would then
|
||||
* disagree on billing rate during config rollout windows.
|
||||
*/
|
||||
resolveRuntime: () => Promise<FluxMeterRuntime>
|
||||
}
|
||||
|
||||
interface AccumulateInput {
|
||||
userId: string
|
||||
units: number
|
||||
currentBalance: number
|
||||
requestId: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface AccumulateResult {
|
||||
/** Actual flux charged to the user (== amount we are sure was billed). */
|
||||
fluxDebited: number
|
||||
/** Residual debt left in Redis after this call. Includes unbilled units restored on partial drain. */
|
||||
debtAfter: number
|
||||
/** User's flux balance after this call. */
|
||||
balanceAfter: number
|
||||
/**
|
||||
* Flux that crossed the meter threshold but couldn't be charged because the
|
||||
* user's balance was lower than what the request required. > 0 means the
|
||||
* user received service they only partially paid for. Reflects the gap
|
||||
* between `requested` and `charged` returned by `billingService.consumeFluxForLLM`.
|
||||
*/
|
||||
unbilledFlux: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a metered Flux consumer for services that charge in small units
|
||||
* (TTS chars, STT seconds, embedding tokens). Accumulates usage in Redis and
|
||||
* only triggers a Flux debit when accumulated units cross the integer boundary.
|
||||
*
|
||||
* @see docs/ai-context/flux-meter.md
|
||||
*/
|
||||
export function createFluxMeter(
|
||||
redis: Redis,
|
||||
billingService: BillingService,
|
||||
config: FluxMeterConfig,
|
||||
metrics?: RevenueMetrics | null,
|
||||
) {
|
||||
async function getRuntime(): Promise<FluxMeterRuntime> {
|
||||
const runtime = await config.resolveRuntime()
|
||||
if (runtime.unitsPerFlux <= 0)
|
||||
throw new Error(`Invalid unitsPerFlux ${runtime.unitsPerFlux} for meter ${config.name}`)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
async function runScript(key: string, units: number, runtime: FluxMeterRuntime): Promise<[number, number]> {
|
||||
const raw = await redis.eval(
|
||||
ACCUMULATE_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
units,
|
||||
runtime.unitsPerFlux,
|
||||
runtime.debtTtlSeconds,
|
||||
) as [number | string, number | string]
|
||||
|
||||
return [Number(raw[0]), Number(raw[1])]
|
||||
}
|
||||
|
||||
async function readDebt(userId: string): Promise<number> {
|
||||
const raw = await redis.get(userFluxMeterDebtRedisKey(userId, config.name))
|
||||
return raw == null ? 0 : Number(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight balance check. Throws 402 if the user cannot afford the worst-case
|
||||
* Flux consumption implied by current debt + new units. Call before invoking
|
||||
* the upstream service so we fail fast and refuse to render unbillable usage.
|
||||
*/
|
||||
async function assertCanAfford(userId: string, newUnits: number, currentBalance: number): Promise<void> {
|
||||
const runtime = await getRuntime()
|
||||
const existingDebt = await readDebt(userId)
|
||||
const projectedFlux = Math.floor((existingDebt + newUnits) / runtime.unitsPerFlux)
|
||||
// At minimum require the user can cover a single Flux crossing; avoids
|
||||
// letting zero-balance users accumulate indefinitely on the boundary.
|
||||
const required = Math.max(projectedFlux, currentBalance <= 0 ? 1 : 0)
|
||||
if (currentBalance < required) {
|
||||
metrics?.ttsPreflightRejections.add(1, { meter: config.name, reason: 'insufficient_balance' })
|
||||
throw createPaymentRequiredError('Insufficient flux')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate usage, atomically settle any whole-Flux portion, and record the
|
||||
* debit via BillingService. Returns 0 fluxDebited when the new usage does
|
||||
* not cross a Flux boundary (cheap path for short TTS segments).
|
||||
*/
|
||||
async function accumulate(input: AccumulateInput): Promise<AccumulateResult> {
|
||||
if (!Number.isFinite(input.units) || input.units <= 0)
|
||||
return { fluxDebited: 0, debtAfter: await readDebt(input.userId), balanceAfter: input.currentBalance, unbilledFlux: 0 }
|
||||
|
||||
const modelLabel = typeof input.metadata?.model === 'string' ? input.metadata.model : 'unknown'
|
||||
metrics?.ttsChars.add(input.units, { meter: config.name, model: modelLabel })
|
||||
|
||||
const runtime = await getRuntime()
|
||||
const key = userFluxMeterDebtRedisKey(input.userId, config.name)
|
||||
const [fluxRequested, debtAfterSettlement] = await runScript(key, input.units, runtime)
|
||||
|
||||
if (fluxRequested === 0) {
|
||||
logger.withFields({
|
||||
userId: input.userId,
|
||||
meter: config.name,
|
||||
units: input.units,
|
||||
debtAfter: debtAfterSettlement,
|
||||
}).debug('Accumulated units below flux threshold')
|
||||
return { fluxDebited: 0, debtAfter: debtAfterSettlement, balanceAfter: input.currentBalance, unbilledFlux: 0 }
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof billingService.consumeFluxForLLM>>
|
||||
try {
|
||||
result = await billingService.consumeFluxForLLM({
|
||||
userId: input.userId,
|
||||
amount: fluxRequested,
|
||||
requestId: input.requestId,
|
||||
description: `${config.name}_request`,
|
||||
...(typeof input.metadata?.model === 'string' && { model: input.metadata.model }),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
// The billing call threw (balance <= 0 hard floor, transient DB error,
|
||||
// network blip). The debit did NOT commit, so restore the full
|
||||
// already-settled portion back into the debt counter for the next
|
||||
// request to retry.
|
||||
const restoreUnits = fluxRequested * runtime.unitsPerFlux
|
||||
try {
|
||||
await redis.incrby(key, restoreUnits)
|
||||
await redis.expire(key, runtime.debtTtlSeconds)
|
||||
}
|
||||
catch (rollbackError) {
|
||||
logger.withError(rollbackError).withFields({
|
||||
userId: input.userId,
|
||||
meter: config.name,
|
||||
restoreUnits,
|
||||
requestId: input.requestId,
|
||||
}).error('Failed to roll back meter debt after billing failure')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Billing-service invariant — checked OUTSIDE the try/catch above so a
|
||||
// post-debit assertion failure does NOT trigger the "restore full debt"
|
||||
// rollback path. The DB tx already committed `result.charged`; restoring
|
||||
// `fluxRequested * unitsPerFlux` would set up a double-charge on the
|
||||
// next request (LUA re-settles the restored debt, billing re-debits the
|
||||
// same usage). Surface loud, but don't compensate.
|
||||
if (!Number.isInteger(result.charged) || result.charged < 0 || result.charged > result.requested) {
|
||||
logger.withFields({
|
||||
userId: input.userId,
|
||||
meter: config.name,
|
||||
requestId: input.requestId,
|
||||
requested: result.requested,
|
||||
charged: result.charged,
|
||||
}).error('billing-service returned invalid charged/requested — manual reconciliation needed')
|
||||
throw new Error(`billing-service returned invalid charged=${result.charged} for requested=${result.requested}`)
|
||||
}
|
||||
|
||||
// Partial-debit path: balance was insufficient and `debitFlux` drained
|
||||
// it to zero. We've already DECRBY'd `fluxRequested * unitsPerFlux` from
|
||||
// the debt counter via the LUA script, but only `result.charged` of
|
||||
// those flux were actually billed. Restore the gap so the debt counter
|
||||
// reflects the user's true outstanding obligation, and surface it on
|
||||
// the same `fluxUnbilled` counter the streaming/non-streaming chat
|
||||
// paths use (different `reason` label).
|
||||
//
|
||||
// REVIEW: Settlement (LUA `runScript`) and the `INCRBY` restore below
|
||||
// are not atomic. A concurrent `accumulate()` could observe the debt
|
||||
// counter mid-window (between DECRBY and the restore INCRBY) and
|
||||
// mis-bill. In practice the window is small (one in-flight DB tx) and
|
||||
// a re-billing attempt would land in the same partial-debit branch,
|
||||
// but the right long-term fix is either a short Redis lock keyed by
|
||||
// `{userId, meter}` around `runScript → consumeFluxForLLM → restore`,
|
||||
// or moving the unbilled portion into a separate Redis key that the
|
||||
// LUA script doesn't touch. See codex review thread on PR.
|
||||
if (result.charged < result.requested) {
|
||||
const unbilledFlux = result.requested - result.charged
|
||||
const restoreUnits = unbilledFlux * runtime.unitsPerFlux
|
||||
|
||||
metrics?.fluxUnbilled.add(unbilledFlux, {
|
||||
source: 'tts_meter',
|
||||
meter: config.name,
|
||||
reason: 'partial_debit_drained',
|
||||
...(typeof input.metadata?.model === 'string' && { [GEN_AI_ATTR_REQUEST_MODEL]: input.metadata.model }),
|
||||
})
|
||||
|
||||
let debtAfterRestore = debtAfterSettlement
|
||||
try {
|
||||
debtAfterRestore = await redis.incrby(key, restoreUnits)
|
||||
await redis.expire(key, runtime.debtTtlSeconds)
|
||||
}
|
||||
catch (rollbackError) {
|
||||
// Log loudly so on-call can reconcile manually; don't shadow the
|
||||
// partial-debit signal by re-throwing.
|
||||
logger.withError(rollbackError).withFields({
|
||||
userId: input.userId,
|
||||
meter: config.name,
|
||||
restoreUnits,
|
||||
requestId: input.requestId,
|
||||
}).error('Failed to restore meter debt after partial-debit drain')
|
||||
}
|
||||
|
||||
logger.withFields({
|
||||
userId: input.userId,
|
||||
meter: config.name,
|
||||
requestId: input.requestId,
|
||||
requested: result.requested,
|
||||
charged: result.charged,
|
||||
unbilledFlux,
|
||||
restoreUnits,
|
||||
}).warn('Partial debit on flux meter — flux drained to zero')
|
||||
|
||||
return {
|
||||
fluxDebited: result.charged,
|
||||
debtAfter: debtAfterRestore,
|
||||
balanceAfter: result.flux,
|
||||
unbilledFlux,
|
||||
}
|
||||
}
|
||||
|
||||
return { fluxDebited: result.charged, debtAfter: debtAfterSettlement, balanceAfter: result.flux, unbilledFlux: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
assertCanAfford,
|
||||
accumulate,
|
||||
peekDebt: readDebt,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
export type FluxMeter = ReturnType<typeof createFluxMeter>
|
||||
@@ -0,0 +1,430 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../../../../libs/db'
|
||||
import type { createConfigKVService } from '../../../adapters/config-kv'
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../../../libs/mock-db'
|
||||
import { userFluxRedisKey } from '../../../../utils/redis-keys'
|
||||
import { createBillingService } from '../billing-service'
|
||||
|
||||
import * as schema from '../../../../schemas'
|
||||
|
||||
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
|
||||
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
|
||||
return {
|
||||
get: vi.fn(async (key: string) => defaults[key]),
|
||||
getOrThrow: vi.fn(async (key: string) => defaults[key]),
|
||||
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
|
||||
set: vi.fn(),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockRedis(): Redis {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
return 'OK'
|
||||
}),
|
||||
del: vi.fn(async (key: string) => {
|
||||
const existed = store.delete(key)
|
||||
return existed ? 1 : 0
|
||||
}),
|
||||
} as unknown as Redis
|
||||
}
|
||||
|
||||
describe('billingService', () => {
|
||||
let db: Database
|
||||
let redis: Redis
|
||||
let billingService: ReturnType<typeof createBillingService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
|
||||
await db.insert(schema.user).values({
|
||||
id: 'user-billing-1',
|
||||
name: 'Billing User',
|
||||
email: 'billing@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = createMockRedis()
|
||||
billingService = createBillingService(db, redis, createMockConfigKV())
|
||||
|
||||
await db.delete(schema.fluxTransaction)
|
||||
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
await db.delete(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
|
||||
|
||||
await db.insert(schema.stripeCheckoutSession).values({
|
||||
userId: 'user-billing-1',
|
||||
stripeSessionId: 'sess-billing-1',
|
||||
mode: 'payment',
|
||||
status: 'complete',
|
||||
paymentStatus: 'paid',
|
||||
amountTotal: 500,
|
||||
currency: 'usd',
|
||||
fluxCredited: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('creditFluxFromStripeCheckout', () => {
|
||||
it('credits flux, records transaction, and enqueues outbox events in one transaction', async () => {
|
||||
const result = await billingService.creditFluxFromStripeCheckout({
|
||||
stripeEventId: 'stripe-evt-1',
|
||||
userId: 'user-billing-1',
|
||||
stripeSessionId: 'sess-billing-1',
|
||||
amountTotal: 500,
|
||||
currency: 'usd',
|
||||
fluxAmount: 50,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ applied: true, balanceAfter: 50 })
|
||||
|
||||
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRecord?.flux).toBe(50)
|
||||
|
||||
// Verify transaction entry
|
||||
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
|
||||
expect(txRecords).toHaveLength(1)
|
||||
expect(txRecords[0]?.type).toBe('credit')
|
||||
expect(txRecords[0]?.amount).toBe(50)
|
||||
expect(txRecords[0]?.balanceBefore).toBe(0)
|
||||
expect(txRecords[0]?.balanceAfter).toBe(50)
|
||||
|
||||
// Verify metadata on transaction entry
|
||||
expect(txRecords[0]?.metadata).toMatchObject({
|
||||
stripeEventId: 'stripe-evt-1',
|
||||
stripeSessionId: 'sess-billing-1',
|
||||
source: 'stripe.checkout.completed',
|
||||
})
|
||||
|
||||
// Verify stripe session marked as credited
|
||||
const [sessionRecord] = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
|
||||
expect(sessionRecord?.fluxCredited).toBe(true)
|
||||
|
||||
// Verify Redis cache updated
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '50')
|
||||
})
|
||||
|
||||
it('is idempotent when the checkout session was already credited', async () => {
|
||||
await billingService.creditFluxFromStripeCheckout({
|
||||
stripeEventId: 'stripe-evt-1',
|
||||
userId: 'user-billing-1',
|
||||
stripeSessionId: 'sess-billing-1',
|
||||
amountTotal: 500,
|
||||
currency: 'usd',
|
||||
fluxAmount: 50,
|
||||
})
|
||||
|
||||
const second = await billingService.creditFluxFromStripeCheckout({
|
||||
stripeEventId: 'stripe-evt-1',
|
||||
userId: 'user-billing-1',
|
||||
stripeSessionId: 'sess-billing-1',
|
||||
amountTotal: 500,
|
||||
currency: 'usd',
|
||||
fluxAmount: 50,
|
||||
})
|
||||
|
||||
expect(second).toEqual({ applied: false })
|
||||
|
||||
// Idempotent replay must not double-write the ledger
|
||||
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
|
||||
expect(txRecords).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('consumeFluxForLLM', () => {
|
||||
it('deducts balance, writes the ledger row inside the transaction, and refreshes Redis', async () => {
|
||||
// Setup: give user some flux first
|
||||
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 100 })
|
||||
|
||||
const result = await billingService.consumeFluxForLLM({
|
||||
userId: 'user-billing-1',
|
||||
amount: 30,
|
||||
requestId: 'req-1',
|
||||
description: 'gpt-4',
|
||||
promptTokens: 120,
|
||||
completionTokens: 80,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ userId: 'user-billing-1', flux: 70, charged: 30, requested: 30 })
|
||||
|
||||
// Verify DB balance
|
||||
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRecord?.flux).toBe(70)
|
||||
|
||||
// Ledger row written inline (no async consumer involved post-refactor)
|
||||
const [txRecord] = await db.select().from(schema.fluxTransaction).where(and(
|
||||
eq(schema.fluxTransaction.userId, 'user-billing-1'),
|
||||
eq(schema.fluxTransaction.requestId, 'req-1'),
|
||||
))
|
||||
expect(txRecord).toMatchObject({
|
||||
userId: 'user-billing-1',
|
||||
type: 'debit',
|
||||
amount: 30,
|
||||
balanceBefore: 100,
|
||||
balanceAfter: 70,
|
||||
requestId: 'req-1',
|
||||
description: 'gpt-4',
|
||||
})
|
||||
expect(txRecord?.metadata).toMatchObject({
|
||||
promptTokens: 120,
|
||||
completionTokens: 80,
|
||||
source: 'llm.request',
|
||||
})
|
||||
|
||||
// Verify Redis cache updated
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '70')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Before: when `0 < balance < amount`, debitFlux threw and rolled back the
|
||||
// whole tx. The streaming proxy had already delivered the response, so the
|
||||
// unpaid request was logged but the user's balance was untouched.
|
||||
// A scripted attacker on a partial balance could replay forever — balance
|
||||
// never moved, line 129 (`flux <= 0`) kept letting requests through, and
|
||||
// every call landed in the catch path crediting `fluxUnbilled` for the
|
||||
// full amount.
|
||||
//
|
||||
// After: balance is drained to zero, the ledger records `amount = charged`
|
||||
// plus `metadata.requestedAmount` / `metadata.unbilled`, and the caller
|
||||
// gets `charged < requested` so it can attribute the leak to
|
||||
// `fluxUnbilled{reason="partial_debit_drained"}`. The next request from
|
||||
// the same user is rejected at the pre-flight gate.
|
||||
it('partial-debits when balance is below the requested amount and writes unbilled metadata (Issue: unpaid-usage-exploit)', async () => {
|
||||
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
|
||||
|
||||
const result = await billingService.consumeFluxForLLM({
|
||||
userId: 'user-billing-1',
|
||||
amount: 38,
|
||||
requestId: 'req-partial',
|
||||
description: 'gpt-4',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ userId: 'user-billing-1', flux: 0, charged: 5, requested: 38 })
|
||||
|
||||
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRecord?.flux).toBe(0)
|
||||
|
||||
const [txRecord] = await db.select().from(schema.fluxTransaction).where(and(
|
||||
eq(schema.fluxTransaction.userId, 'user-billing-1'),
|
||||
eq(schema.fluxTransaction.requestId, 'req-partial'),
|
||||
))
|
||||
expect(txRecord).toMatchObject({
|
||||
type: 'debit',
|
||||
amount: 5,
|
||||
balanceBefore: 5,
|
||||
balanceAfter: 0,
|
||||
})
|
||||
expect(txRecord?.metadata).toMatchObject({
|
||||
source: 'llm.request',
|
||||
requestedAmount: 38,
|
||||
unbilled: 33,
|
||||
})
|
||||
|
||||
// Redis cache reflects the zero balance, so the next pre-flight gate
|
||||
// (`flux < fallbackRate`) rejects immediately.
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '0')
|
||||
})
|
||||
|
||||
it('throws 402 when balance is already zero (no ledger row, no balance change)', async () => {
|
||||
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 0 })
|
||||
|
||||
await expect(billingService.consumeFluxForLLM({
|
||||
userId: 'user-billing-1',
|
||||
amount: 10,
|
||||
})).rejects.toThrow('Insufficient flux')
|
||||
|
||||
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRecord?.flux).toBe(0)
|
||||
|
||||
const txRecords = await db.select().from(schema.fluxTransaction)
|
||||
expect(txRecords).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('idempotent replay returns the historical charge without re-debiting (partial debits stay partial on retry)', async () => {
|
||||
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
|
||||
|
||||
const first = await billingService.consumeFluxForLLM({
|
||||
userId: 'user-billing-1',
|
||||
amount: 38,
|
||||
requestId: 'req-replay',
|
||||
})
|
||||
const second = await billingService.consumeFluxForLLM({
|
||||
userId: 'user-billing-1',
|
||||
amount: 38,
|
||||
requestId: 'req-replay',
|
||||
})
|
||||
|
||||
expect(first.charged).toBe(5)
|
||||
expect(first.flux).toBe(0)
|
||||
// Replay reflects the original partial outcome — equal `charged` and
|
||||
// `requested` prevent the streaming caller from double-firing
|
||||
// `fluxUnbilled` on retries.
|
||||
expect(second.charged).toBe(5)
|
||||
expect(second.requested).toBe(5)
|
||||
expect(second.flux).toBe(0)
|
||||
|
||||
// Ledger has exactly one row for `req-replay`
|
||||
const txRecords = await db.select().from(schema.fluxTransaction).where(and(
|
||||
eq(schema.fluxTransaction.userId, 'user-billing-1'),
|
||||
eq(schema.fluxTransaction.requestId, 'req-replay'),
|
||||
))
|
||||
expect(txRecords).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('creditFlux', () => {
|
||||
it('credits balance and writes the ledger row in one transaction', async () => {
|
||||
const result = await billingService.creditFlux({
|
||||
userId: 'user-billing-1',
|
||||
amount: 50,
|
||||
description: 'Admin grant',
|
||||
source: 'admin',
|
||||
})
|
||||
|
||||
expect(result.balanceAfter).toBe(50)
|
||||
expect(result.balanceBefore).toBe(0)
|
||||
expect(result.idempotent).toBe(false)
|
||||
|
||||
// Verify transaction
|
||||
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
|
||||
expect(txRecords).toHaveLength(1)
|
||||
expect(txRecords[0]).toMatchObject({
|
||||
type: 'credit',
|
||||
amount: 50,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('is idempotent across retries with the same requestId', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Worker crash window: creditFlux commits the credit, then the
|
||||
// grant-batch poller crashes before marking its own state row
|
||||
// (e.g. flux_grant_batch_recipient) as granted. On restart the poller
|
||||
// re-claims the same row and calls creditFlux again with the same
|
||||
// requestId.
|
||||
//
|
||||
// Before the fix: second call hit the unique index on
|
||||
// (user_id, request_id) and threw, the poller's catch block marked
|
||||
// the recipient as `failed` despite the user already having been credited.
|
||||
// User got the FLUX but the recipient row was stuck in failed.
|
||||
//
|
||||
// After the fix: second call detects the existing flux_transaction row,
|
||||
// returns it as an idempotent success without touching balance or cache.
|
||||
// Poller advances to granted normally.
|
||||
const requestId = 'campaign-replay-test'
|
||||
|
||||
const first = await billingService.creditFlux({
|
||||
userId: 'user-billing-1',
|
||||
amount: 100,
|
||||
requestId,
|
||||
description: 'Replay test',
|
||||
source: 'admin',
|
||||
})
|
||||
expect(first.idempotent).toBe(false)
|
||||
expect(first.balanceAfter).toBe(100)
|
||||
|
||||
// Second call with same requestId — simulates crash-recovery retry.
|
||||
const second = await billingService.creditFlux({
|
||||
userId: 'user-billing-1',
|
||||
amount: 100,
|
||||
requestId,
|
||||
description: 'Replay test',
|
||||
source: 'admin',
|
||||
})
|
||||
|
||||
expect(second.idempotent).toBe(true)
|
||||
// Same record returned, not a fresh credit
|
||||
expect(second.fluxTransactionId).toBe(first.fluxTransactionId)
|
||||
expect(second.balanceAfter).toBe(first.balanceAfter)
|
||||
|
||||
// Balance must NOT have doubled
|
||||
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRow!.flux).toBe(100)
|
||||
|
||||
// Only one ledger row exists (unique index would prevent a second anyway,
|
||||
// but verify the function didn't try to insert and silently swallow)
|
||||
const txRecords = await db.select().from(schema.fluxTransaction).where(and(
|
||||
eq(schema.fluxTransaction.userId, 'user-billing-1'),
|
||||
eq(schema.fluxTransaction.requestId, requestId),
|
||||
))
|
||||
expect(txRecords).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFlux', () => {
|
||||
it('sets the balance to an absolute value and records an admin_set ledger row', async () => {
|
||||
// Start from a known balance so the delta direction is observable.
|
||||
await billingService.creditFlux({ userId: 'user-billing-1', amount: 100, description: 'seed', source: 'test' })
|
||||
|
||||
const result = await billingService.setFlux({
|
||||
userId: 'user-billing-1',
|
||||
balance: 250,
|
||||
description: 'admin top-up',
|
||||
issuedByUserId: 'admin-1',
|
||||
})
|
||||
|
||||
expect(result.balanceBefore).toBe(100)
|
||||
expect(result.balanceAfter).toBe(250)
|
||||
|
||||
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRow!.flux).toBe(250)
|
||||
|
||||
const [tx] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.id, result.fluxTransactionId))
|
||||
expect(tx!.type).toBe('admin_set')
|
||||
expect(tx!.amount).toBe(150)
|
||||
expect(tx!.balanceBefore).toBe(100)
|
||||
expect(tx!.balanceAfter).toBe(250)
|
||||
expect(tx!.metadata).toMatchObject({ source: 'admin_set', direction: 'credit', requestedBalance: 250, issuedByUserId: 'admin-1' })
|
||||
})
|
||||
|
||||
it('can zero out a balance and records the debit direction (the primary testing use case)', async () => {
|
||||
await billingService.creditFlux({ userId: 'user-billing-1', amount: 500, description: 'seed', source: 'test' })
|
||||
|
||||
const result = await billingService.setFlux({
|
||||
userId: 'user-billing-1',
|
||||
balance: 0,
|
||||
description: 'admin zero',
|
||||
issuedByUserId: 'admin-1',
|
||||
})
|
||||
|
||||
expect(result.balanceBefore).toBe(500)
|
||||
expect(result.balanceAfter).toBe(0)
|
||||
|
||||
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
|
||||
expect(fluxRow!.flux).toBe(0)
|
||||
|
||||
const [tx] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.id, result.fluxTransactionId))
|
||||
expect(tx!.type).toBe('admin_set')
|
||||
expect(tx!.amount).toBe(500)
|
||||
expect(tx!.metadata).toMatchObject({ direction: 'debit', requestedBalance: 0 })
|
||||
})
|
||||
|
||||
it('initializes a user_flux row when none exists and invalidates the Redis cache', async () => {
|
||||
// Pre-warm the cache with a stale value to prove setFlux drops it.
|
||||
await redis.set(userFluxRedisKey('user-billing-1'), '999')
|
||||
|
||||
const result = await billingService.setFlux({
|
||||
userId: 'user-billing-1',
|
||||
balance: 42,
|
||||
description: 'admin set from zero',
|
||||
issuedByUserId: 'admin-1',
|
||||
})
|
||||
|
||||
expect(result.balanceBefore).toBe(0)
|
||||
expect(result.balanceAfter).toBe(42)
|
||||
// Invalidate, not write: next getFlux miss reloads truth from Postgres.
|
||||
expect(redis.del).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'))
|
||||
expect(await redis.get(userFluxRedisKey('user-billing-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { calculateFluxFromUsage, extractUsageFromBody } from '../billing'
|
||||
|
||||
describe('extractUsageFromBody', () => {
|
||||
it('returns promptTokens and completionTokens from a normal body', () => {
|
||||
const body = { usage: { prompt_tokens: 100, completion_tokens: 200 } }
|
||||
expect(extractUsageFromBody(body)).toEqual({ promptTokens: 100, completionTokens: 200 })
|
||||
})
|
||||
|
||||
it('returns empty object when body has no usage field', () => {
|
||||
expect(extractUsageFromBody({ model: 'gpt-4' })).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty object for null body', () => {
|
||||
expect(extractUsageFromBody(null)).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty object for undefined body', () => {
|
||||
expect(extractUsageFromBody(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty object when usage is null', () => {
|
||||
expect(extractUsageFromBody({ usage: null })).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty object when usage is falsy zero-like value (0)', () => {
|
||||
expect(extractUsageFromBody({ usage: 0 })).toEqual({})
|
||||
})
|
||||
|
||||
it('returns only promptTokens when completion_tokens is missing', () => {
|
||||
const body = { usage: { prompt_tokens: 50 } }
|
||||
const result = extractUsageFromBody(body)
|
||||
expect(result.promptTokens).toBe(50)
|
||||
expect(result.completionTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns only completionTokens when prompt_tokens is missing', () => {
|
||||
const body = { usage: { completion_tokens: 75 } }
|
||||
const result = extractUsageFromBody(body)
|
||||
expect(result.promptTokens).toBeUndefined()
|
||||
expect(result.completionTokens).toBe(75)
|
||||
})
|
||||
|
||||
it('treats explicit null fields in usage as undefined', () => {
|
||||
const body = { usage: { prompt_tokens: null, completion_tokens: null } }
|
||||
const result = extractUsageFromBody(body)
|
||||
expect(result.promptTokens).toBeUndefined()
|
||||
expect(result.completionTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles zero token values correctly', () => {
|
||||
const body = { usage: { prompt_tokens: 0, completion_tokens: 0 } }
|
||||
const result = extractUsageFromBody(body)
|
||||
expect(result.promptTokens).toBe(0)
|
||||
expect(result.completionTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateFluxFromUsage', () => {
|
||||
it('calculates flux based on total tokens and rate', () => {
|
||||
const usage = { promptTokens: 500, completionTokens: 500 }
|
||||
// 1000 tokens * 1 per 1k = 1
|
||||
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
|
||||
})
|
||||
|
||||
it('applies ceiling to fractional flux values', () => {
|
||||
const usage = { promptTokens: 500, completionTokens: 501 }
|
||||
// 1001 tokens * 1 per 1k = 1.001 → ceil → 2
|
||||
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(2)
|
||||
})
|
||||
|
||||
it('enforces a minimum of 1 flux even when calculation yields 0', () => {
|
||||
const usage = { promptTokens: 1, completionTokens: 1 }
|
||||
// 2 tokens * 1 per 1k = 0.002 → ceil → 1, max(1, 1) = 1
|
||||
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
|
||||
})
|
||||
|
||||
it('enforces minimum of 1 flux when tokens are zero', () => {
|
||||
const usage = { promptTokens: 0, completionTokens: 0 }
|
||||
// 0 tokens * anything = 0 → ceil → 0, max(1, 0) = 1
|
||||
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to fallbackRate when promptTokens is missing', () => {
|
||||
const usage = { completionTokens: 500 }
|
||||
expect(calculateFluxFromUsage(usage, 1, 7)).toBe(7)
|
||||
})
|
||||
|
||||
it('falls back to fallbackRate when completionTokens is missing', () => {
|
||||
const usage = { promptTokens: 500 }
|
||||
expect(calculateFluxFromUsage(usage, 1, 7)).toBe(7)
|
||||
})
|
||||
|
||||
it('falls back to fallbackRate when usage is empty', () => {
|
||||
expect(calculateFluxFromUsage({}, 1, 3)).toBe(3)
|
||||
})
|
||||
|
||||
it('uses a higher fluxPer1kTokens multiplier correctly', () => {
|
||||
const usage = { promptTokens: 1000, completionTokens: 1000 }
|
||||
// 2000 tokens * 5 per 1k = 10
|
||||
expect(calculateFluxFromUsage(usage, 5, 1)).toBe(10)
|
||||
})
|
||||
|
||||
it('uses a fractional fluxPer1kTokens multiplier with ceiling', () => {
|
||||
const usage = { promptTokens: 200, completionTokens: 200 }
|
||||
// 400 tokens * 0.5 per 1k = 0.2 → ceil → 1, max(1, 1) = 1
|
||||
expect(calculateFluxFromUsage(usage, 0.5, 3)).toBe(1)
|
||||
})
|
||||
|
||||
it('handles very large token counts', () => {
|
||||
const usage = { promptTokens: 1_000_000, completionTokens: 1_000_000 }
|
||||
// 2_000_000 tokens * 1 per 1k = 2000
|
||||
expect(calculateFluxFromUsage(usage, 1, 5)).toBe(2000)
|
||||
})
|
||||
|
||||
it('handles exact 1k token boundary without ceiling', () => {
|
||||
const usage = { promptTokens: 500, completionTokens: 500 }
|
||||
// 1000 tokens * 2 per 1k = 2 (exact, no ceiling needed)
|
||||
expect(calculateFluxFromUsage(usage, 2, 5)).toBe(2)
|
||||
})
|
||||
|
||||
it('returns fallbackRate when both token fields are undefined (not null)', () => {
|
||||
const usage = { promptTokens: undefined, completionTokens: undefined }
|
||||
expect(calculateFluxFromUsage(usage, 1, 99)).toBe(99)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { BillingService } from '../billing-service'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createFluxMeter } from '../flux-meter'
|
||||
|
||||
function createMockRedis() {
|
||||
const store = new Map<string, number>()
|
||||
|
||||
// NOTICE: Mimic the subset of EVAL semantics used by ACCUMULATE_SCRIPT
|
||||
// (INCRBY + EXPIRE + conditional DECRBY). Sufficient for unit tests; the real
|
||||
// atomicity is verified by ioredis hitting Redis in integration.
|
||||
const evalImpl = vi.fn(async (
|
||||
_script: string,
|
||||
_numKeys: number,
|
||||
key: string,
|
||||
units: string | number,
|
||||
unitsPerFlux: string | number,
|
||||
_ttl: string | number,
|
||||
) => {
|
||||
const u = Number(units)
|
||||
const upf = Number(unitsPerFlux)
|
||||
const debt = (store.get(key) ?? 0) + u
|
||||
store.set(key, debt)
|
||||
if (debt >= upf) {
|
||||
const flux = Math.floor(debt / upf)
|
||||
const consumed = flux * upf
|
||||
store.set(key, debt - consumed)
|
||||
return [flux, debt - consumed]
|
||||
}
|
||||
return [0, debt]
|
||||
})
|
||||
|
||||
const incrby = vi.fn(async (key: string, amount: number) => {
|
||||
const next = (store.get(key) ?? 0) + amount
|
||||
store.set(key, next)
|
||||
return next
|
||||
})
|
||||
|
||||
const expire = vi.fn(async () => 1)
|
||||
|
||||
return {
|
||||
redis: {
|
||||
eval: evalImpl,
|
||||
incrby,
|
||||
expire,
|
||||
get: vi.fn(async (key: string) => {
|
||||
const v = store.get(key)
|
||||
return v == null ? null : String(v)
|
||||
}),
|
||||
} as unknown as Redis,
|
||||
store,
|
||||
incrby,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockBilling(opts: { throwOn?: number, partialChargeOn?: { amount: number, charged: number } } = {}): BillingService {
|
||||
return {
|
||||
consumeFluxForLLM: vi.fn(async ({ userId, amount }: { userId: string, amount: number }) => {
|
||||
if (opts.throwOn != null && amount === opts.throwOn)
|
||||
throw new Error('mock billing failure')
|
||||
// Mirror real billing-service partial-debit semantics: drain to zero
|
||||
// returns `charged < requested`.
|
||||
if (opts.partialChargeOn != null && amount === opts.partialChargeOn.amount) {
|
||||
return { userId, flux: 0, charged: opts.partialChargeOn.charged, requested: amount }
|
||||
}
|
||||
return { userId, flux: 100 - amount, charged: amount, requested: amount }
|
||||
}),
|
||||
} as unknown as BillingService
|
||||
}
|
||||
|
||||
function createMockMetrics() {
|
||||
const fluxUnbilled = { add: vi.fn() }
|
||||
const ttsChars = { add: vi.fn() }
|
||||
const ttsPreflightRejections = { add: vi.fn() }
|
||||
return {
|
||||
metrics: { fluxUnbilled, ttsChars, ttsPreflightRejections } as any,
|
||||
fluxUnbilled,
|
||||
}
|
||||
}
|
||||
|
||||
function staticRuntime(unitsPerFlux = 1000, debtTtlSeconds = 60) {
|
||||
return vi.fn(async () => ({ unitsPerFlux, debtTtlSeconds }))
|
||||
}
|
||||
|
||||
describe('fluxMeter', () => {
|
||||
let mockRedis: ReturnType<typeof createMockRedis>
|
||||
let billing: BillingService
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = createMockRedis()
|
||||
billing = createMockBilling()
|
||||
})
|
||||
|
||||
it('does not debit when accumulated units stay below threshold', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
const result = await meter.accumulate({
|
||||
userId: 'u1',
|
||||
units: 500,
|
||||
currentBalance: 10,
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ fluxDebited: 0, debtAfter: 500, balanceAfter: 10, unbilledFlux: 0 })
|
||||
expect(billing.consumeFluxForLLM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('debits exactly one flux when crossing the threshold', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await meter.accumulate({ userId: 'u1', units: 700, currentBalance: 10, requestId: 'a' })
|
||||
const result = await meter.accumulate({ userId: 'u1', units: 400, currentBalance: 10, requestId: 'b' })
|
||||
|
||||
expect(result.fluxDebited).toBe(1)
|
||||
expect(result.debtAfter).toBe(100)
|
||||
expect(billing.consumeFluxForLLM).toHaveBeenCalledTimes(1)
|
||||
expect(billing.consumeFluxForLLM).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: 1,
|
||||
requestId: 'b',
|
||||
description: 'tts_request',
|
||||
}))
|
||||
})
|
||||
|
||||
it('debits multiple flux when one request crosses several thresholds', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
const result = await meter.accumulate({ userId: 'u1', units: 3500, currentBalance: 10, requestId: 'big' })
|
||||
|
||||
expect(result.fluxDebited).toBe(3)
|
||||
expect(result.debtAfter).toBe(500)
|
||||
expect(billing.consumeFluxForLLM).toHaveBeenCalledWith(expect.objectContaining({ amount: 3 }))
|
||||
})
|
||||
|
||||
it('returns 0 fluxDebited for zero, negative, or non-finite units', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
const result = await meter.accumulate({ userId: 'u1', units: bad, currentBalance: 10, requestId: 'x' })
|
||||
expect(result.fluxDebited).toBe(0)
|
||||
}
|
||||
expect(billing.consumeFluxForLLM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws 402 when projected debt would exceed user balance', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await expect(meter.assertCanAfford('u1', 5000, 2)).rejects.toMatchObject({ statusCode: 402 })
|
||||
})
|
||||
|
||||
it('allows sub-threshold accumulation when balance >= 1', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
await expect(meter.assertCanAfford('u1', 200, 1)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects sub-threshold accumulation when balance is zero', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
await expect(meter.assertCanAfford('u1', 200, 0)).rejects.toMatchObject({ statusCode: 402 })
|
||||
})
|
||||
|
||||
it('throws from runtime resolver when unitsPerFlux is invalid', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, {
|
||||
name: 'bad',
|
||||
resolveRuntime: async () => ({ unitsPerFlux: 0, debtTtlSeconds: 60 }),
|
||||
})
|
||||
await expect(meter.accumulate({ userId: 'u1', units: 10, currentBalance: 10, requestId: 'r' })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('peekDebt reflects current accumulated units', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await meter.accumulate({ userId: 'u1', units: 250, currentBalance: 10, requestId: 'p' })
|
||||
expect(await meter.peekDebt('u1')).toBe(250)
|
||||
})
|
||||
|
||||
it('does not read config at construction time (lazy resolver)', async () => {
|
||||
const resolver = staticRuntime()
|
||||
|
||||
createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: resolver })
|
||||
|
||||
expect(resolver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves runtime on every call so multi-instance config changes propagate immediately', async () => {
|
||||
const resolver = staticRuntime()
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: resolver })
|
||||
|
||||
await meter.accumulate({ userId: 'u1', units: 100, currentBalance: 10, requestId: 'a' })
|
||||
await meter.accumulate({ userId: 'u1', units: 100, currentBalance: 10, requestId: 'b' })
|
||||
await meter.assertCanAfford('u1', 100, 10)
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('restores debt back into the counter when billing debit throws', async () => {
|
||||
// Billing rejects the exact flux amount we expect to settle.
|
||||
const failingBilling = createMockBilling({ throwOn: 2 })
|
||||
const meter = createFluxMeter(mockRedis.redis, failingBilling, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await expect(
|
||||
meter.accumulate({ userId: 'u1', units: 2500, currentBalance: 10, requestId: 'fail' }),
|
||||
).rejects.toThrow('mock billing failure')
|
||||
|
||||
// Settlement was rolled back: 2500 units should be fully recovered
|
||||
// (500 residual + 2000 rolled back), not 500.
|
||||
expect(await meter.peekDebt('u1')).toBe(2500)
|
||||
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Prior to commit 7267b0d6b billing-service.consumeFluxForLLM threw on
|
||||
// any insufficient-balance, which let flux-meter's catch path restore
|
||||
// the *entire* settled portion back to the debt counter. After that
|
||||
// commit billing-service introduced partial-debit semantics: when
|
||||
// 0 < balance < amount, it drains the balance to zero and returns
|
||||
// `charged < requested` instead of throwing. flux-meter.accumulate
|
||||
// continued to read only `{ flux }` from the result, so:
|
||||
// - the un-charged portion (`requested - charged` flux) was silently
|
||||
// lost — Redis debt was already DECRBY'd by the LUA script,
|
||||
// - airi_billing_flux_unbilled_total never fired for tts_meter, so
|
||||
// the partial-debit revenue leak was invisible in Grafana.
|
||||
//
|
||||
// After patch: accumulate destructures `charged / requested`, restores
|
||||
// `(requested - charged) * unitsPerFlux` back into the debt counter, and
|
||||
// increments airi_billing_flux_unbilled_total with
|
||||
// `{ source: 'tts_meter', reason: 'partial_debit_drained' }`.
|
||||
it('restores partial-drain delta to debt and reports fluxUnbilled (Issue: unpaid-usage-exploit follow-up)', async () => {
|
||||
// After settlement the meter wants to debit 3 flux, but billing only
|
||||
// manages to charge 1 (user balance was 1 flux). Expect:
|
||||
// - fluxDebited == 1 (actual charged), not 3
|
||||
// - unbilledFlux == 2
|
||||
// - Redis debt restored by 2 * unitsPerFlux = 2000
|
||||
// - fluxUnbilled metric incremented by 2 with partial_debit_drained reason
|
||||
const partialBilling = createMockBilling({ partialChargeOn: { amount: 3, charged: 1 } })
|
||||
const { metrics, fluxUnbilled } = createMockMetrics()
|
||||
const meter = createFluxMeter(mockRedis.redis, partialBilling, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
|
||||
|
||||
const result = await meter.accumulate({
|
||||
userId: 'u1',
|
||||
units: 3500,
|
||||
currentBalance: 1,
|
||||
requestId: 'partial',
|
||||
metadata: { model: 'eleven_multilingual_v2' },
|
||||
})
|
||||
|
||||
expect(result.fluxDebited).toBe(1)
|
||||
expect(result.unbilledFlux).toBe(2)
|
||||
expect(result.balanceAfter).toBe(0)
|
||||
// Debt = 500 residual (LUA leftover) + 2000 restored from partial drain.
|
||||
expect(await meter.peekDebt('u1')).toBe(2500)
|
||||
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
|
||||
expect(fluxUnbilled.add).toHaveBeenCalledWith(2, expect.objectContaining({
|
||||
'source': 'tts_meter',
|
||||
'meter': 'tts',
|
||||
'reason': 'partial_debit_drained',
|
||||
'gen_ai.request.model': 'eleven_multilingual_v2',
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not report fluxUnbilled when billing fully charges', async () => {
|
||||
const { metrics, fluxUnbilled } = createMockMetrics()
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
|
||||
|
||||
const result = await meter.accumulate({ userId: 'u1', units: 1500, currentBalance: 10, requestId: 'full' })
|
||||
|
||||
expect(result.fluxDebited).toBe(1)
|
||||
expect(result.unbilledFlux).toBe(0)
|
||||
expect(fluxUnbilled.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createCharacterService } from './characters'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('characterService', () => {
|
||||
let db: Database
|
||||
let service: ReturnType<typeof createCharacterService>
|
||||
let testUser: any
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
service = createCharacterService(db)
|
||||
|
||||
// Create a test user for foreign key constraints
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
}).returning()
|
||||
testUser = user
|
||||
})
|
||||
|
||||
it('create should handle full character creation', async () => {
|
||||
const characterData = {
|
||||
id: 'char-1',
|
||||
version: '1.0',
|
||||
coverUrl: 'url',
|
||||
characterId: 'cid',
|
||||
ownerId: testUser.id,
|
||||
creatorId: testUser.id,
|
||||
}
|
||||
|
||||
const result = await service.create({
|
||||
character: characterData,
|
||||
i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }],
|
||||
cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' },
|
||||
})
|
||||
|
||||
expect(result.id).toBe('char-1')
|
||||
|
||||
const found = await service.findById('char-1')
|
||||
expect(found?.i18n[0].name).toBe('Aster')
|
||||
expect(found?.cover?.foregroundUrl).toBe('fg')
|
||||
})
|
||||
|
||||
it('findAll should return characters with relations', async () => {
|
||||
const result = await service.findAll()
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0].i18n).toBeDefined()
|
||||
})
|
||||
|
||||
it('like should toggle like status and update counter', async () => {
|
||||
const charId = 'char-1'
|
||||
|
||||
// First like
|
||||
const res1 = await service.like(testUser.id, charId)
|
||||
expect(res1.liked).toBe(true)
|
||||
|
||||
let char = await service.findById(charId)
|
||||
expect(char?.likesCount).toBe(1)
|
||||
expect(char?.likes.length).toBe(1)
|
||||
|
||||
// Second like (unlike)
|
||||
const res2 = await service.like(testUser.id, charId)
|
||||
expect(res2.liked).toBe(false)
|
||||
|
||||
char = await service.findById(charId)
|
||||
expect(char?.likesCount).toBe(0)
|
||||
expect(char?.likes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('bookmark should toggle bookmark status and update counter', async () => {
|
||||
const charId = 'char-1'
|
||||
|
||||
// First bookmark
|
||||
const res1 = await service.bookmark(testUser.id, charId)
|
||||
expect(res1.bookmarked).toBe(true)
|
||||
|
||||
let char = await service.findById(charId)
|
||||
expect(char?.bookmarksCount).toBe(1)
|
||||
|
||||
// Second bookmark (unbookmark)
|
||||
const res2 = await service.bookmark(testUser.id, charId)
|
||||
expect(res2.bookmarked).toBe(false)
|
||||
|
||||
char = await service.findById(charId)
|
||||
expect(char?.bookmarksCount).toBe(0)
|
||||
})
|
||||
|
||||
it('update should update character fields', async () => {
|
||||
await service.update('char-1', { version: '2.0' })
|
||||
const char = await service.findById('char-1')
|
||||
expect(char?.version).toBe('2.0')
|
||||
})
|
||||
|
||||
it('delete should soft delete character', async () => {
|
||||
await service.delete('char-1')
|
||||
const char = await service.findById('char-1')
|
||||
expect(char).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { EngagementMetrics } from '../../otel'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, isNull, or, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../../schemas/characters'
|
||||
import * as userCharacterSchema from '../../schemas/user-character'
|
||||
|
||||
const logger = useLogger('characters')
|
||||
|
||||
export function createCharacterService(db: Database, metrics?: EngagementMetrics | null) {
|
||||
return {
|
||||
async findById(id: string) {
|
||||
return await db.query.character.findFirst({
|
||||
where: and(
|
||||
eq(schema.character.id, id),
|
||||
isNull(schema.character.deletedAt),
|
||||
),
|
||||
with: {
|
||||
capabilities: true,
|
||||
avatarModels: true,
|
||||
i18n: true,
|
||||
prompts: true,
|
||||
likes: true,
|
||||
bookmarks: true,
|
||||
cover: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async findByOwnerId(ownerId: string) {
|
||||
return await db.query.character.findMany({
|
||||
where: and(
|
||||
eq(schema.character.ownerId, ownerId),
|
||||
isNull(schema.character.deletedAt),
|
||||
),
|
||||
with: {
|
||||
i18n: true,
|
||||
capabilities: true,
|
||||
likes: true,
|
||||
bookmarks: true,
|
||||
cover: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async findAll() {
|
||||
return await db.query.character.findMany({
|
||||
where: isNull(schema.character.deletedAt),
|
||||
with: {
|
||||
i18n: true,
|
||||
capabilities: true,
|
||||
likes: true,
|
||||
bookmarks: true,
|
||||
cover: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async like(userId: string, characterId: string) {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const existing = await tx.query.characterLikes.findFirst({
|
||||
where: and(
|
||||
eq(userCharacterSchema.characterLikes.userId, userId),
|
||||
eq(userCharacterSchema.characterLikes.characterId, characterId),
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx.delete(userCharacterSchema.characterLikes)
|
||||
.where(and(
|
||||
eq(userCharacterSchema.characterLikes.userId, userId),
|
||||
eq(userCharacterSchema.characterLikes.characterId, characterId),
|
||||
))
|
||||
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
likesCount: sql`${schema.character.likesCount} - 1`,
|
||||
})
|
||||
.where(eq(schema.character.id, characterId))
|
||||
|
||||
return { liked: false }
|
||||
}
|
||||
else {
|
||||
await tx.insert(userCharacterSchema.characterLikes).values({ userId, characterId })
|
||||
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
likesCount: sql`${schema.character.likesCount} + 1`,
|
||||
})
|
||||
.where(eq(schema.character.id, characterId))
|
||||
|
||||
return { liked: true }
|
||||
}
|
||||
})
|
||||
|
||||
metrics?.characterEngagement.add(1, { action: result.liked ? 'like' : 'unlike' })
|
||||
return result
|
||||
},
|
||||
|
||||
async bookmark(userId: string, characterId: string) {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const existing = await tx.query.characterBookmarks.findFirst({
|
||||
where: and(
|
||||
eq(userCharacterSchema.characterBookmarks.userId, userId),
|
||||
eq(userCharacterSchema.characterBookmarks.characterId, characterId),
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx.delete(userCharacterSchema.characterBookmarks)
|
||||
.where(and(
|
||||
eq(userCharacterSchema.characterBookmarks.userId, userId),
|
||||
eq(userCharacterSchema.characterBookmarks.characterId, characterId),
|
||||
))
|
||||
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
bookmarksCount: sql`${schema.character.bookmarksCount} - 1`,
|
||||
})
|
||||
.where(eq(schema.character.id, characterId))
|
||||
|
||||
return { bookmarked: false }
|
||||
}
|
||||
else {
|
||||
await tx.insert(userCharacterSchema.characterBookmarks).values({ userId, characterId })
|
||||
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
bookmarksCount: sql`${schema.character.bookmarksCount} + 1`,
|
||||
})
|
||||
.where(eq(schema.character.id, characterId))
|
||||
|
||||
return { bookmarked: true }
|
||||
}
|
||||
})
|
||||
|
||||
metrics?.characterEngagement.add(1, { action: result.bookmarked ? 'bookmark' : 'unbookmark' })
|
||||
return result
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
character: schema.NewCharacter
|
||||
cover?: Omit<schema.NewCharacterCover, 'characterId'>
|
||||
capabilities?: Omit<schema.NewCharacterCapability, 'characterId'>[]
|
||||
avatarModels?: Omit<schema.NewAvatarModel, 'characterId'>[]
|
||||
i18n?: Omit<schema.NewCharacterI18n, 'characterId'>[]
|
||||
prompts?: Omit<schema.NewCharacterPrompt, 'characterId'>[]
|
||||
}) {
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
const [inserted] = await tx.insert(schema.character).values(data.character).returning()
|
||||
logger.withFields({ id: inserted.id, ownerId: data.character.ownerId }).log('Created character')
|
||||
|
||||
if (data.cover) {
|
||||
await tx.insert(schema.characterCovers).values({
|
||||
...data.cover,
|
||||
characterId: inserted.id,
|
||||
})
|
||||
}
|
||||
|
||||
if (data.capabilities?.length) {
|
||||
await tx.insert(schema.characterCapabilities).values(
|
||||
data.capabilities.map(c => ({ ...c, characterId: inserted.id })),
|
||||
)
|
||||
}
|
||||
|
||||
if (data.avatarModels?.length) {
|
||||
await tx.insert(schema.avatarModel).values(
|
||||
data.avatarModels.map(a => ({ ...a, characterId: inserted.id })),
|
||||
)
|
||||
}
|
||||
|
||||
if (data.i18n?.length) {
|
||||
await tx.insert(schema.characterI18n).values(
|
||||
data.i18n.map(i => ({ ...i, characterId: inserted.id })),
|
||||
)
|
||||
}
|
||||
|
||||
if (data.prompts?.length) {
|
||||
await tx.insert(schema.characterPrompts).values(
|
||||
data.prompts.map(p => ({ ...p, characterId: inserted.id })),
|
||||
)
|
||||
}
|
||||
|
||||
return inserted
|
||||
})
|
||||
|
||||
metrics?.characterCreated.add(1)
|
||||
return inserted
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<schema.NewCharacter>) {
|
||||
// TODO: Return a stable single-object response shape for HTTP callers.
|
||||
// leaking Drizzle returning() arrays across the service boundary makes route contracts drift.
|
||||
const result = await db.update(schema.character)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.character.id, id),
|
||||
isNull(schema.character.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Updated character')
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const result = await db.update(schema.character)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.character.id, id),
|
||||
isNull(schema.character.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.withFields({ id }).log('Deleted character')
|
||||
metrics?.characterDeleted.add(1)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* Soft-delete every character owned or created by the user, plus their
|
||||
* likes and bookmarks. Called from the user-deletion pipeline.
|
||||
*
|
||||
* Marks `creatorId === userId` rows too — fork attribution is tied to the
|
||||
* creator's identity, so removing the creator soft-archives the lineage
|
||||
* even if the current owner is someone else.
|
||||
*
|
||||
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows.
|
||||
*/
|
||||
async deleteAllForUser(userId: string) {
|
||||
const now = new Date()
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const charRows = await tx.update(schema.character)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
or(
|
||||
eq(schema.character.ownerId, userId),
|
||||
eq(schema.character.creatorId, userId),
|
||||
),
|
||||
isNull(schema.character.deletedAt),
|
||||
))
|
||||
.returning({ id: schema.character.id })
|
||||
|
||||
const likeRows = await tx.update(userCharacterSchema.characterLikes)
|
||||
.set({ deletedAt: now })
|
||||
.where(and(
|
||||
eq(userCharacterSchema.characterLikes.userId, userId),
|
||||
isNull(userCharacterSchema.characterLikes.deletedAt),
|
||||
))
|
||||
.returning({ characterId: userCharacterSchema.characterLikes.characterId })
|
||||
|
||||
const bookmarkRows = await tx.update(userCharacterSchema.characterBookmarks)
|
||||
.set({ deletedAt: now })
|
||||
.where(and(
|
||||
eq(userCharacterSchema.characterBookmarks.userId, userId),
|
||||
isNull(userCharacterSchema.characterBookmarks.deletedAt),
|
||||
))
|
||||
.returning({ characterId: userCharacterSchema.characterBookmarks.characterId })
|
||||
|
||||
for (const characterId of new Set(likeRows.map(row => row.characterId))) {
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
likesCount: sql`greatest(${schema.character.likesCount} - 1, 0)`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.character.id, characterId))
|
||||
}
|
||||
|
||||
for (const characterId of new Set(bookmarkRows.map(row => row.characterId))) {
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
bookmarksCount: sql`greatest(${schema.character.bookmarksCount} - 1, 0)`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.character.id, characterId))
|
||||
}
|
||||
|
||||
return { charRows, likeRows, bookmarkRows }
|
||||
})
|
||||
|
||||
logger
|
||||
.withFields({
|
||||
userId,
|
||||
characters: result.charRows.length,
|
||||
likes: result.likeRows.length,
|
||||
bookmarks: result.bookmarkRows.length,
|
||||
})
|
||||
.log('Characters / likes / bookmarks soft-deleted for user')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type CharacterService = ReturnType<typeof createCharacterService>
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { clampLimit, createChatService, resolveSenderId } from './chats'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('resolveSenderId', () => {
|
||||
it('returns userId for user role', () => {
|
||||
expect(resolveSenderId('user', 'user-123')).toBe('user-123')
|
||||
})
|
||||
it('returns userId for assistant role', () => {
|
||||
expect(resolveSenderId('assistant', 'user-123')).toBe('user-123')
|
||||
expect(resolveSenderId('system', 'user-123')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampLimit', () => {
|
||||
it('returns default 100 when no limit', () => {
|
||||
expect(clampLimit()).toBe(100)
|
||||
expect(clampLimit(undefined)).toBe(100)
|
||||
})
|
||||
it('returns default 100 for zero or negative', () => {
|
||||
expect(clampLimit(0)).toBe(100)
|
||||
expect(clampLimit(-5)).toBe(100)
|
||||
})
|
||||
it('returns limit when within range', () => {
|
||||
expect(clampLimit(50)).toBe(50)
|
||||
expect(clampLimit(500)).toBe(500)
|
||||
})
|
||||
it('clamps to max 500', () => {
|
||||
expect(clampLimit(501)).toBe(500)
|
||||
expect(clampLimit(1000)).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushMessages', () => {
|
||||
let db: Database
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('rejects a member attempt to update another member’s message', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'group', memberType: 'user', userId: 'author' },
|
||||
{ chatId: 'group', memberType: 'user', userId: 'member' },
|
||||
])
|
||||
await db.insert(schema.messages).values({
|
||||
id: 'message',
|
||||
chatId: 'group',
|
||||
senderId: 'author',
|
||||
role: 'user',
|
||||
seq: 1,
|
||||
content: 'original',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
})
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'user', content: 'forged' }]))
|
||||
.rejects
|
||||
.toMatchObject({ statusCode: 403, errorCode: 'FORBIDDEN', message: 'Forbidden' })
|
||||
|
||||
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
|
||||
expect(message?.content).toBe('original')
|
||||
expect(message?.senderId).toBe('author')
|
||||
expect(message?.seq).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects an existing message ID from another chat', async () => {
|
||||
await db.insert(schema.chats).values([
|
||||
{ id: 'source', type: 'group' },
|
||||
{ id: 'target', type: 'group' },
|
||||
])
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'source', memberType: 'user', userId: 'member' },
|
||||
{ chatId: 'target', memberType: 'user', userId: 'member' },
|
||||
])
|
||||
await db.insert(schema.messages).values({
|
||||
id: 'message',
|
||||
chatId: 'source',
|
||||
senderId: 'member',
|
||||
role: 'user',
|
||||
seq: 1,
|
||||
content: 'source message',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
})
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'target', [{ id: 'message', role: 'user', content: 'target message' }]))
|
||||
.rejects
|
||||
.toMatchObject({ statusCode: 409, errorCode: 'CONFLICT', message: 'Message already belongs to another chat' })
|
||||
|
||||
const sourceMessage = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
|
||||
const targetMessages = await db.query.messages.findMany({ where: eq(schema.messages.chatId, 'target') })
|
||||
expect(sourceMessage?.content).toBe('source message')
|
||||
expect(targetMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('allows an author to update their own message', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'author' })
|
||||
await db.insert(schema.messages).values({
|
||||
id: 'message',
|
||||
chatId: 'group',
|
||||
senderId: 'author',
|
||||
role: 'user',
|
||||
seq: 1,
|
||||
content: 'original',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
})
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('author', 'group', [{ id: 'message', role: 'user', content: 'updated' }]))
|
||||
.resolves
|
||||
.toMatchObject({ seq: 2, fromSeq: 2, toSeq: 2 })
|
||||
|
||||
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
|
||||
expect(message?.content).toBe('updated')
|
||||
expect(message?.senderId).toBe('author')
|
||||
expect(message?.role).toBe('user')
|
||||
expect(message?.chatId).toBe('group')
|
||||
expect(message?.seq).toBe(2)
|
||||
})
|
||||
|
||||
it('acknowledges an unchanged legacy assistant retry without mutating it', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
|
||||
await db.insert(schema.messages).values({
|
||||
id: 'message',
|
||||
chatId: 'group',
|
||||
senderId: null,
|
||||
role: 'assistant',
|
||||
seq: 1,
|
||||
content: 'original response',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
})
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'original response' }]))
|
||||
.resolves
|
||||
.toMatchObject({ seq: 1, fromSeq: 2, toSeq: 1 })
|
||||
|
||||
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
|
||||
expect(message?.content).toBe('original response')
|
||||
expect(message?.senderId).toBeNull()
|
||||
expect(message?.role).toBe('assistant')
|
||||
expect(message?.seq).toBe(1)
|
||||
})
|
||||
|
||||
it('persists later messages batched with an unchanged legacy assistant retry', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
|
||||
await db.insert(schema.messages).values({
|
||||
id: 'legacy-assistant',
|
||||
chatId: 'group',
|
||||
senderId: null,
|
||||
role: 'assistant',
|
||||
seq: 1,
|
||||
content: 'original response',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
})
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'group', [
|
||||
{ id: 'legacy-assistant', role: 'assistant', content: 'original response' },
|
||||
{ id: 'new-user-message', role: 'user', content: 'next turn' },
|
||||
]))
|
||||
.resolves
|
||||
.toMatchObject({ seq: 2, fromSeq: 2, toSeq: 2 })
|
||||
|
||||
const messages = await db.query.messages.findMany({
|
||||
where: eq(schema.messages.chatId, 'group'),
|
||||
orderBy: schema.messages.seq,
|
||||
})
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]?.id).toBe('legacy-assistant')
|
||||
expect(messages[0]?.seq).toBe(1)
|
||||
expect(messages[1]?.id).toBe('new-user-message')
|
||||
expect(messages[1]?.senderId).toBe('member')
|
||||
expect(messages[1]?.seq).toBe(2)
|
||||
})
|
||||
|
||||
it('accepts an assistant message from local-first sync', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'response' }]))
|
||||
.resolves
|
||||
.toMatchObject({ seq: 1, fromSeq: 1, toSeq: 1 })
|
||||
|
||||
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
|
||||
expect(message?.role).toBe('assistant')
|
||||
expect(message?.content).toBe('response')
|
||||
expect(message?.senderId).toBe('member')
|
||||
})
|
||||
|
||||
it('rejects updates to unowned assistant messages', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'group', memberType: 'user', userId: 'author' },
|
||||
{ chatId: 'group', memberType: 'user', userId: 'member' },
|
||||
])
|
||||
await db.insert(schema.messages).values({
|
||||
id: 'message',
|
||||
chatId: 'group',
|
||||
senderId: null,
|
||||
role: 'assistant',
|
||||
seq: 1,
|
||||
content: 'original response',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
})
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'assistant', content: 'forged response' }]))
|
||||
.rejects
|
||||
.toMatchObject({ statusCode: 403, errorCode: 'FORBIDDEN', message: 'Forbidden' })
|
||||
|
||||
const message = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'message') })
|
||||
expect(message?.content).toBe('original response')
|
||||
expect(message?.seq).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects roles that are not part of cloud chat sync', async () => {
|
||||
await db.insert(schema.chats).values({ id: 'group', type: 'group' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'group', memberType: 'user', userId: 'member' })
|
||||
|
||||
const service = createChatService(db)
|
||||
|
||||
await expect(service.pushMessages('member', 'group', [{ id: 'message', role: 'system', content: 'local prompt' }]))
|
||||
.rejects
|
||||
.toMatchObject({ statusCode: 400, errorCode: 'BAD_REQUEST', message: 'Only user and assistant messages can be synchronized' })
|
||||
|
||||
const messages = await db.query.messages.findMany({ where: eq(schema.messages.chatId, 'group') })
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,505 @@
|
||||
import type { MessageRole, WireMessage } from '@proj-airi/server-sdk-shared'
|
||||
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { EngagementMetrics } from '../../otel'
|
||||
import type { ProductEventService } from './product-events'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import { createBadRequestError, createConflictError, createForbiddenError, createNotFoundError } from '../../utils/error'
|
||||
import { nanoid } from '../../utils/id'
|
||||
|
||||
import * as schema from '../../schemas/chats'
|
||||
|
||||
const logger = useLogger('chats')
|
||||
|
||||
type ChatType = 'private' | 'bot' | 'group' | 'channel'
|
||||
type ChatMemberType = 'user' | 'character' | 'bot'
|
||||
|
||||
interface CreateChatPayload {
|
||||
id?: string
|
||||
type?: ChatType
|
||||
title?: string
|
||||
members?: { type: ChatMemberType, userId?: string, characterId?: string }[]
|
||||
}
|
||||
|
||||
interface PushMessage {
|
||||
id: string
|
||||
role: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function clampLimit(limit?: number): number {
|
||||
if (!limit || limit <= 0)
|
||||
return 100
|
||||
return Math.min(limit, 500)
|
||||
}
|
||||
|
||||
export function resolveSenderId(role: string, userId: string): string | null {
|
||||
if (role === 'user' || role === 'assistant')
|
||||
return userId
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createChatService(db: Database, metrics?: EngagementMetrics | null, productEventService?: ProductEventService) {
|
||||
// ---- internal helpers ---------------------------------------------------
|
||||
|
||||
async function verifyMembership(tx: Parameters<Parameters<Database['transaction']>[0]>[0], chatId: string, userId: string) {
|
||||
const chat = await tx.query.chats.findFirst({
|
||||
where: and(eq(schema.chats.id, chatId), isNull(schema.chats.deletedAt)),
|
||||
})
|
||||
if (!chat)
|
||||
throw createNotFoundError('Chat not found')
|
||||
|
||||
const member = await tx.query.chatMembers.findFirst({
|
||||
where: and(
|
||||
eq(schema.chatMembers.chatId, chatId),
|
||||
eq(schema.chatMembers.memberType, 'user'),
|
||||
eq(schema.chatMembers.userId, userId),
|
||||
),
|
||||
})
|
||||
if (!member) {
|
||||
logger.withFields({ userId, chatId }).warn('User not a member of chat, forbidden')
|
||||
throw createForbiddenError()
|
||||
}
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
// ---- public API ---------------------------------------------------------
|
||||
|
||||
return {
|
||||
// -- Chat management (REST) ---------------------------------------------
|
||||
|
||||
async createChat(userId: string, payload: CreateChatPayload) {
|
||||
return db.transaction(async (tx) => {
|
||||
const chatId = payload.id ?? nanoid()
|
||||
const now = new Date()
|
||||
|
||||
await tx.insert(schema.chats).values({
|
||||
id: chatId,
|
||||
type: payload.type ?? 'group',
|
||||
title: payload.title ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Always add creator as a user member
|
||||
await tx.insert(schema.chatMembers).values({
|
||||
chatId,
|
||||
memberType: 'user',
|
||||
userId,
|
||||
characterId: null,
|
||||
})
|
||||
|
||||
// Add additional members if provided
|
||||
if (payload.members && payload.members.length > 0) {
|
||||
const extra = payload.members
|
||||
.filter(m => m.type !== 'user' || m.userId !== userId) // skip duplicate creator
|
||||
.map(m => ({
|
||||
chatId,
|
||||
memberType: m.type,
|
||||
userId: m.type === 'user' ? (m.userId ?? null) : null,
|
||||
characterId: m.type !== 'user' ? (m.characterId ?? null) : null,
|
||||
}))
|
||||
|
||||
if (extra.length > 0) {
|
||||
await tx.insert(schema.chatMembers).values(extra)
|
||||
}
|
||||
}
|
||||
|
||||
return { id: chatId, type: payload.type ?? 'group', title: payload.title ?? null, createdAt: now, updatedAt: now }
|
||||
})
|
||||
},
|
||||
|
||||
async getChat(userId: string, chatId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
const chat = await verifyMembership(tx, chatId, userId)
|
||||
const members = await tx.query.chatMembers.findMany({
|
||||
where: eq(schema.chatMembers.chatId, chatId),
|
||||
})
|
||||
return { ...chat, members }
|
||||
})
|
||||
},
|
||||
|
||||
async listChats(userId: string) {
|
||||
const rows = await db
|
||||
.select({ chat: schema.chats })
|
||||
.from(schema.chatMembers)
|
||||
.innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id))
|
||||
.where(and(
|
||||
eq(schema.chatMembers.memberType, 'user'),
|
||||
eq(schema.chatMembers.userId, userId),
|
||||
isNull(schema.chats.deletedAt),
|
||||
))
|
||||
|
||||
return rows.map(r => r.chat)
|
||||
},
|
||||
|
||||
async updateChat(userId: string, chatId: string, updates: { title?: string }) {
|
||||
return db.transaction(async (tx) => {
|
||||
await verifyMembership(tx, chatId, userId)
|
||||
const now = new Date()
|
||||
|
||||
const [updated] = await tx.update(schema.chats)
|
||||
.set({ ...updates, updatedAt: now })
|
||||
.where(eq(schema.chats.id, chatId))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
})
|
||||
},
|
||||
|
||||
async deleteChat(userId: string, chatId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
await verifyMembership(tx, chatId, userId)
|
||||
const now = new Date()
|
||||
|
||||
const [deleted] = await tx.update(schema.chats)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(eq(schema.chats.id, chatId))
|
||||
.returning()
|
||||
|
||||
return deleted
|
||||
})
|
||||
},
|
||||
|
||||
async addMember(userId: string, chatId: string, member: { type: ChatMemberType, userId?: string, characterId?: string }) {
|
||||
// TODO: Push these invariants up into the HTTP schema and convert failures to API errors instead of generic Error.
|
||||
// Validate that user-type members have a userId and non-user members have a characterId
|
||||
if (member.type === 'user' && !member.userId) {
|
||||
throw new Error('userId is required for user-type members')
|
||||
}
|
||||
if (member.type !== 'user' && !member.characterId) {
|
||||
throw new Error('characterId is required for non-user-type members')
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await verifyMembership(tx, chatId, userId)
|
||||
|
||||
const [added] = await tx.insert(schema.chatMembers).values({
|
||||
chatId,
|
||||
memberType: member.type,
|
||||
userId: member.type === 'user' ? (member.userId ?? null) : null,
|
||||
characterId: member.type !== 'user' ? (member.characterId ?? null) : null,
|
||||
}).returning()
|
||||
|
||||
return added
|
||||
})
|
||||
},
|
||||
|
||||
async getMembers(chatId: string) {
|
||||
return db.query.chatMembers.findMany({
|
||||
where: eq(schema.chatMembers.chatId, chatId),
|
||||
})
|
||||
},
|
||||
|
||||
async removeMember(userId: string, chatId: string, memberId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
await verifyMembership(tx, chatId, userId)
|
||||
|
||||
const [removed] = await tx.delete(schema.chatMembers)
|
||||
.where(and(
|
||||
eq(schema.chatMembers.id, memberId),
|
||||
eq(schema.chatMembers.chatId, chatId),
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!removed)
|
||||
throw createNotFoundError('Member not found')
|
||||
return removed
|
||||
})
|
||||
},
|
||||
|
||||
// -- Message sync (WS) --------------------------------------------------
|
||||
|
||||
async pushMessages(userId: string, chatId: string, messages: PushMessage[]) {
|
||||
if (messages.some(message => message.role !== 'user' && message.role !== 'assistant'))
|
||||
throw createBadRequestError('Only user and assistant messages can be synchronized')
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await verifyMembership(tx, chatId, userId)
|
||||
|
||||
// Lock chat row to serialize seq assignment
|
||||
const [chatRow] = await tx
|
||||
.select({ id: schema.chats.id })
|
||||
.from(schema.chats)
|
||||
.where(eq(schema.chats.id, chatId))
|
||||
.for('update')
|
||||
|
||||
if (!chatRow)
|
||||
throw createNotFoundError('Chat not found')
|
||||
|
||||
// Get current max seq for this chat
|
||||
const [{ maxSeq }] = await tx
|
||||
.select({ maxSeq: sql<number>`coalesce(max(${schema.messages.seq}), 0)` })
|
||||
.from(schema.messages)
|
||||
.where(eq(schema.messages.chatId, chatId))
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Split into new vs existing messages
|
||||
const messageIds = messages.map(m => m.id)
|
||||
const existingMessages = messageIds.length > 0
|
||||
? await tx.select({
|
||||
id: schema.messages.id,
|
||||
chatId: schema.messages.chatId,
|
||||
senderId: schema.messages.senderId,
|
||||
role: schema.messages.role,
|
||||
content: schema.messages.content,
|
||||
}).from(schema.messages).where(inArray(schema.messages.id, messageIds))
|
||||
: []
|
||||
|
||||
if (existingMessages.some(message => message.chatId !== chatId))
|
||||
throw createConflictError('Message already belongs to another chat')
|
||||
|
||||
const existingMessagesById = new Map(existingMessages.map(message => [message.id, message]))
|
||||
const unchangedLegacyAssistantIds = new Set<string>()
|
||||
if (messages.some((message) => {
|
||||
const existingMessage = existingMessagesById.get(message.id)
|
||||
if (existingMessage == null)
|
||||
return false
|
||||
|
||||
if (existingMessage.senderId === resolveSenderId(message.role, userId))
|
||||
return false
|
||||
|
||||
// A pre-ownership assistant row cannot be safely attributed to a user.
|
||||
// An exact retry is nevertheless safe to acknowledge because it does
|
||||
// not mutate the stored message or its sequence.
|
||||
if (
|
||||
existingMessage.senderId == null
|
||||
&& existingMessage.role === 'assistant'
|
||||
&& message.role === 'assistant'
|
||||
&& existingMessage.content === message.content
|
||||
) {
|
||||
unchangedLegacyAssistantIds.add(message.id)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})) {
|
||||
throw createForbiddenError()
|
||||
}
|
||||
|
||||
const existingIds = new Set(existingMessages.map(m => m.id))
|
||||
|
||||
const newMsgs = messages.filter(m => !existingIds.has(m.id))
|
||||
const updateMsgs = messages.filter(m => existingIds.has(m.id) && !unchangedLegacyAssistantIds.has(m.id))
|
||||
|
||||
let currentSeq = maxSeq
|
||||
|
||||
// Insert new messages with seq
|
||||
if (newMsgs.length > 0) {
|
||||
const values = newMsgs.map((m) => {
|
||||
currentSeq++
|
||||
return {
|
||||
id: m.id,
|
||||
chatId,
|
||||
senderId: resolveSenderId(m.role, userId),
|
||||
role: m.role,
|
||||
seq: currentSeq,
|
||||
content: m.content,
|
||||
mediaIds: [] as string[],
|
||||
stickerIds: [] as string[],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
await tx.insert(schema.messages).values(values)
|
||||
}
|
||||
|
||||
// Update existing messages (content + updatedAt + seq bump)
|
||||
for (const m of updateMsgs) {
|
||||
currentSeq++
|
||||
await tx.update(schema.messages)
|
||||
.set({ content: m.content, seq: currentSeq, updatedAt: now })
|
||||
.where(and(eq(schema.messages.id, m.id), eq(schema.messages.chatId, chatId)))
|
||||
}
|
||||
|
||||
// Update chat updatedAt
|
||||
await tx.update(schema.chats)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(schema.chats.id, chatId))
|
||||
|
||||
return {
|
||||
seq: currentSeq,
|
||||
fromSeq: maxSeq + 1,
|
||||
toSeq: currentSeq,
|
||||
newCount: newMsgs.length,
|
||||
totalCount: messages.length,
|
||||
}
|
||||
})
|
||||
|
||||
if (result.totalCount > 0) {
|
||||
metrics?.chatMessages.add(result.totalCount)
|
||||
void productEventService?.track({
|
||||
userId,
|
||||
feature: 'chat',
|
||||
action: 'message_pushed',
|
||||
status: 'succeeded',
|
||||
source: 'chat.ws.push_messages',
|
||||
metadata: {
|
||||
message_count: result.totalCount,
|
||||
new_count: result.newCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
metrics?.wsMessagesReceived.add(result.totalCount)
|
||||
|
||||
return { seq: result.seq, fromSeq: result.fromSeq, toSeq: result.toSeq }
|
||||
},
|
||||
|
||||
/**
|
||||
* Soft-delete the user's footprint in chats. Per-chat strategy depends
|
||||
* on `chat.type`:
|
||||
*
|
||||
* - `private` / `bot` (1-on-1, user IS the chat): soft-delete the chat
|
||||
* row + the user's messages. Nothing else can read those messages
|
||||
* (chat is gone), so soft-deleting them is just keeping audit consistent.
|
||||
*
|
||||
* - `group` / `channel` (shared): drop only this user's `chat_members`
|
||||
* row; the chat + other members survive. The user's messages are
|
||||
* **kept intact** — deleting them would corrupt the conversation
|
||||
* context for remaining members ("B replied to nothing"). Sender
|
||||
* anonymization is automatic: `messages.senderId` is bare text with
|
||||
* no FK, so after better-auth hard-deletes the user row, the senderId
|
||||
* string still groups the user's messages together but cannot be
|
||||
* joined to any PII (name / email are gone with the user row). The
|
||||
* UI is expected to render `senderId` whose user lookup misses as
|
||||
* "Deleted User".
|
||||
*
|
||||
* `chat_members` rows for shared chats are **hard-deleted** because the
|
||||
* table was designed without a `deletedAt` column; auditing who was in
|
||||
* which chat is preserved through `messages.senderId` for the messages
|
||||
* the user actually authored.
|
||||
*
|
||||
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows on
|
||||
* retry; re-deleting an already-removed `chat_members` row is a no-op.
|
||||
*/
|
||||
async deleteAllForUser(userId: string) {
|
||||
const now = new Date()
|
||||
|
||||
// Join chat_members → chats so we can branch by chat.type without a
|
||||
// second round-trip per row.
|
||||
const memberChats = await db
|
||||
.select({ chatId: schema.chatMembers.chatId, chatType: schema.chats.type })
|
||||
.from(schema.chatMembers)
|
||||
.innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id))
|
||||
.where(eq(schema.chatMembers.userId, userId))
|
||||
|
||||
const soloChatIds = memberChats
|
||||
.filter(r => r.chatType === 'private' || r.chatType === 'bot')
|
||||
.map(r => r.chatId)
|
||||
const sharedChatIds = memberChats
|
||||
.filter(r => r.chatType === 'group' || r.chatType === 'channel')
|
||||
.map(r => r.chatId)
|
||||
|
||||
let soloChatCount = 0
|
||||
let droppedMemberships = 0
|
||||
let soloMessageCount = 0
|
||||
let preservedSharedMessages = 0
|
||||
|
||||
if (soloChatIds.length > 0) {
|
||||
const updatedChats = await db.update(schema.chats)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
inArray(schema.chats.id, soloChatIds),
|
||||
isNull(schema.chats.deletedAt),
|
||||
))
|
||||
.returning({ id: schema.chats.id })
|
||||
soloChatCount = updatedChats.length
|
||||
|
||||
// Soft-delete user-authored messages in solo chats only. The chat
|
||||
// itself is gone, so this is purely audit/consistency hygiene.
|
||||
const updatedMessages = await db.update(schema.messages)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
inArray(schema.messages.chatId, soloChatIds),
|
||||
eq(schema.messages.senderId, userId),
|
||||
isNull(schema.messages.deletedAt),
|
||||
))
|
||||
.returning({ id: schema.messages.id })
|
||||
soloMessageCount = updatedMessages.length
|
||||
}
|
||||
|
||||
if (sharedChatIds.length > 0) {
|
||||
const dropped = await db.delete(schema.chatMembers)
|
||||
.where(and(
|
||||
inArray(schema.chatMembers.chatId, sharedChatIds),
|
||||
eq(schema.chatMembers.userId, userId),
|
||||
))
|
||||
.returning({ id: schema.chatMembers.id })
|
||||
droppedMemberships = dropped.length
|
||||
|
||||
// Count (do not mutate) the user's messages in shared chats to make
|
||||
// the preservation visible in logs. These rows stay live so other
|
||||
// members keep their conversation context; sender anonymizes itself
|
||||
// once better-auth hard-deletes the user row.
|
||||
const kept = await db.select({ id: schema.messages.id })
|
||||
.from(schema.messages)
|
||||
.where(and(
|
||||
inArray(schema.messages.chatId, sharedChatIds),
|
||||
eq(schema.messages.senderId, userId),
|
||||
isNull(schema.messages.deletedAt),
|
||||
))
|
||||
preservedSharedMessages = kept.length
|
||||
}
|
||||
|
||||
logger.withFields({
|
||||
userId,
|
||||
soloChats: soloChatCount,
|
||||
sharedChatMembershipsDropped: droppedMemberships,
|
||||
soloMessages: soloMessageCount,
|
||||
preservedSharedMessages,
|
||||
}).log('Chats footprint processed for user (solo soft-deleted, shared anonymized)')
|
||||
},
|
||||
|
||||
async pullMessages(userId: string, chatId: string, afterSeq: number, limit?: number) {
|
||||
return db.transaction(async (tx) => {
|
||||
await verifyMembership(tx, chatId, userId)
|
||||
|
||||
const clamped = clampLimit(limit)
|
||||
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(schema.messages)
|
||||
.where(and(
|
||||
eq(schema.messages.chatId, chatId),
|
||||
gt(schema.messages.seq, afterSeq),
|
||||
))
|
||||
.orderBy(schema.messages.seq)
|
||||
.limit(clamped)
|
||||
|
||||
// Get current max seq
|
||||
const [{ maxSeq }] = await tx
|
||||
.select({ maxSeq: sql<number>`coalesce(max(${schema.messages.seq}), 0)` })
|
||||
.from(schema.messages)
|
||||
.where(eq(schema.messages.chatId, chatId))
|
||||
|
||||
const wireMessages: WireMessage[] = rows.map(r => ({
|
||||
id: r.id,
|
||||
chatId: r.chatId,
|
||||
senderId: r.senderId,
|
||||
role: r.role as MessageRole,
|
||||
content: r.content,
|
||||
seq: r.seq!,
|
||||
createdAt: r.createdAt.getTime(),
|
||||
updatedAt: r.updatedAt.getTime(),
|
||||
}))
|
||||
|
||||
return { messages: wireMessages, seq: maxSeq }
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatService = ReturnType<typeof createChatService>
|
||||
@@ -0,0 +1,28 @@
|
||||
export type FluxBalanceBucket = 'zero' | '1_100' | '101_1000' | '1001_10000' | '10000_plus' | 'unknown'
|
||||
|
||||
/**
|
||||
* Normalizes exact Flux balance values into analytics-safe buckets.
|
||||
*
|
||||
* Before:
|
||||
* - 0
|
||||
* - 42
|
||||
* - 1200
|
||||
*
|
||||
* After:
|
||||
* - "zero"
|
||||
* - "1_100"
|
||||
* - "1001_10000"
|
||||
*/
|
||||
export function fluxBalanceBucket(balance: number | null | undefined): FluxBalanceBucket {
|
||||
if (balance == null || Number.isNaN(balance))
|
||||
return 'unknown'
|
||||
if (balance <= 0)
|
||||
return 'zero'
|
||||
if (balance <= 100)
|
||||
return '1_100'
|
||||
if (balance <= 1000)
|
||||
return '101_1000'
|
||||
if (balance <= 10000)
|
||||
return '1001_10000'
|
||||
return '10000_plus'
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createFluxTransactionService } from './flux-transaction'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('fluxTransactionService', () => {
|
||||
let db: any
|
||||
let service: ReturnType<typeof createFluxTransactionService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
await db.insert(schema.user).values({
|
||||
id: 'user-tx',
|
||||
name: 'Transaction User',
|
||||
email: 'tx@example.com',
|
||||
})
|
||||
service = createFluxTransactionService(db)
|
||||
})
|
||||
|
||||
it('log should insert a single transaction entry', async () => {
|
||||
await service.log({
|
||||
userId: 'user-tx',
|
||||
type: 'credit',
|
||||
amount: 500,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 500,
|
||||
description: 'Stripe payment',
|
||||
metadata: { stripeSessionId: 'sess_123' },
|
||||
})
|
||||
|
||||
const { records } = await service.getHistory('user-tx', 10, 0)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].type).toBe('credit')
|
||||
expect(records[0].amount).toBe(500)
|
||||
})
|
||||
|
||||
it('logBatch should insert multiple entries', async () => {
|
||||
await service.logBatch([
|
||||
{ userId: 'user-tx', type: 'debit', amount: 10, balanceBefore: 500, balanceAfter: 490, description: 'gpt-4o' },
|
||||
{ userId: 'user-tx', type: 'debit', amount: 5, balanceBefore: 490, balanceAfter: 485, description: 'gpt-4o-mini' },
|
||||
])
|
||||
|
||||
const { records } = await service.getHistory('user-tx', 10, 0)
|
||||
expect(records).toHaveLength(3) // 1 from previous test + 2 batch
|
||||
})
|
||||
|
||||
it('logBatch with empty array should be a no-op', async () => {
|
||||
await service.logBatch([])
|
||||
const { records } = await service.getHistory('user-tx', 10, 0)
|
||||
expect(records).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('getHistory should paginate correctly with hasMore', async () => {
|
||||
const { records, hasMore } = await service.getHistory('user-tx', 2, 0)
|
||||
expect(records).toHaveLength(2)
|
||||
expect(hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('getHistory should return hasMore=false on last page', async () => {
|
||||
const { records, hasMore } = await service.getHistory('user-tx', 10, 0)
|
||||
expect(records).toHaveLength(3)
|
||||
expect(hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('getHistory should respect offset', async () => {
|
||||
const { records } = await service.getHistory('user-tx', 10, 2)
|
||||
expect(records).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('getHistory should return records ordered by createdAt desc', async () => {
|
||||
const { records } = await service.getHistory('user-tx', 10, 0)
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
expect(new Date(records[i - 1].createdAt).getTime())
|
||||
.toBeGreaterThanOrEqual(new Date(records[i].createdAt).getTime())
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../../schemas/flux-transaction'
|
||||
|
||||
const logger = useLogger('flux-transaction')
|
||||
|
||||
export interface TransactionEntry {
|
||||
userId: string
|
||||
type: 'credit' | 'debit' | 'initial' | 'promo'
|
||||
amount: number
|
||||
balanceBefore: number
|
||||
balanceAfter: number
|
||||
requestId?: string
|
||||
description: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createFluxTransactionService(db: Database) {
|
||||
return {
|
||||
async log(entry: TransactionEntry) {
|
||||
await db.insert(schema.fluxTransaction).values(entry)
|
||||
logger.withFields({ userId: entry.userId, type: entry.type, amount: entry.amount }).log('Transaction recorded')
|
||||
},
|
||||
|
||||
async logBatch(entries: TransactionEntry[]) {
|
||||
if (entries.length === 0)
|
||||
return
|
||||
await db.insert(schema.fluxTransaction).values(entries)
|
||||
logger.withFields({ count: entries.length }).log('Transaction batch recorded')
|
||||
},
|
||||
|
||||
async getHistory(userId: string, limit: number, offset: number) {
|
||||
const records = await db.query.fluxTransaction.findMany({
|
||||
where: eq(schema.fluxTransaction.userId, userId),
|
||||
orderBy: [desc(schema.fluxTransaction.createdAt)],
|
||||
limit: limit + 1, // fetch one extra to determine hasMore
|
||||
offset,
|
||||
})
|
||||
|
||||
const hasMore = records.length > limit
|
||||
if (hasMore)
|
||||
records.pop()
|
||||
|
||||
return { records, hasMore }
|
||||
},
|
||||
|
||||
async getStats(userId: string) {
|
||||
// Get the balance right after the most recent credit/initial/promo transaction
|
||||
// as the "capacity" for the progress bar. 'promo' (admin grant) bumps capacity
|
||||
// so the user's progress bar reflects the new total they have to spend.
|
||||
const [latestCredit] = await db.select({
|
||||
balanceAfter: schema.fluxTransaction.balanceAfter,
|
||||
})
|
||||
.from(schema.fluxTransaction)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.fluxTransaction.userId, userId),
|
||||
inArray(schema.fluxTransaction.type, ['credit', 'initial', 'promo']),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.fluxTransaction.createdAt))
|
||||
.limit(1)
|
||||
|
||||
return { capacity: latestCredit?.balanceAfter ?? 0 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FluxTransactionService = ReturnType<typeof createFluxTransactionService>
|
||||
@@ -0,0 +1,103 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { createConfigKVService } from '../adapters/config-kv'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { userFluxRedisKey } from '../../utils/redis-keys'
|
||||
import { createFluxService } from './flux'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
|
||||
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
|
||||
return {
|
||||
get: vi.fn(async (key: string) => defaults[key]),
|
||||
getOrThrow: vi.fn(async (key: string) => defaults[key]),
|
||||
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
|
||||
set: vi.fn(),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockRedis(): Redis {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
return 'OK'
|
||||
}),
|
||||
} as unknown as Redis
|
||||
}
|
||||
|
||||
describe('fluxService (DB-backed)', () => {
|
||||
let db: Database
|
||||
let redis: Redis
|
||||
let service: ReturnType<typeof createFluxService>
|
||||
let testUser: any
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
}).returning()
|
||||
testUser = user
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = createMockRedis()
|
||||
service = createFluxService(db, redis, createMockConfigKV())
|
||||
|
||||
// Clean up flux-related tables
|
||||
await db.delete(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
|
||||
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, testUser.id))
|
||||
})
|
||||
|
||||
it('getFlux should initialize new user with INITIAL_USER_FLUX and populate Redis', async () => {
|
||||
const record = await service.getFlux(testUser.id)
|
||||
expect(record.flux).toBe(100)
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '100')
|
||||
})
|
||||
|
||||
it('getFlux should write a transaction entry on initialization', async () => {
|
||||
await service.getFlux(testUser.id)
|
||||
|
||||
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
|
||||
expect(txRecords).toHaveLength(1)
|
||||
expect(txRecords[0]).toMatchObject({
|
||||
type: 'initial',
|
||||
amount: 100,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('getFlux should return cached value from Redis on subsequent calls', async () => {
|
||||
await service.getFlux(testUser.id)
|
||||
await service.getFlux(testUser.id)
|
||||
// Second call hits Redis cache
|
||||
expect(redis.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('getFlux should load from DB when Redis cache misses', async () => {
|
||||
// Pre-insert user flux directly
|
||||
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 42 })
|
||||
|
||||
const record = await service.getFlux(testUser.id)
|
||||
expect(record.flux).toBe(42)
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '42')
|
||||
})
|
||||
|
||||
it('updateStripeCustomerId should update DB only', async () => {
|
||||
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 100 })
|
||||
|
||||
const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123')
|
||||
expect(result!.stripeCustomerId).toBe('cus_abc123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { ConfigKVService } from '../adapters/config-kv'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
|
||||
import { userFluxRedisKey } from '../../utils/redis-keys'
|
||||
|
||||
import * as schema from '../../schemas/flux'
|
||||
import * as fluxTxSchema from '../../schemas/flux-transaction'
|
||||
|
||||
const logger = useLogger('flux-service')
|
||||
|
||||
// NOTICE:
|
||||
// All read paths here treat soft-deleted rows (`deletedAt IS NOT NULL`) as
|
||||
// invisible. After account deletion the auth tables hard-delete the user
|
||||
// so this filter is mostly defense-in-depth against routes that bypass
|
||||
// `sessionMiddleware`. See `server/apps/api/docs/ai-context/account-deletion.md`.
|
||||
export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) {
|
||||
return {
|
||||
async getFlux(userId: string) {
|
||||
// 1. Try Redis cache
|
||||
const cached = await redis.get(userFluxRedisKey(userId))
|
||||
if (cached !== null) {
|
||||
return { userId, flux: Number.parseInt(cached, 10) }
|
||||
}
|
||||
|
||||
// 2. Cache miss — load from DB
|
||||
let record = await db.query.userFlux.findFirst({
|
||||
where: and(
|
||||
eq(schema.userFlux.userId, userId),
|
||||
isNull(schema.userFlux.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (!record) {
|
||||
const initialFlux = await configKV.getOrThrow('INITIAL_USER_FLUX')
|
||||
|
||||
// Transaction: create user_flux + flux_transaction atomically
|
||||
await db.transaction(async (tx) => {
|
||||
const [inserted] = await tx.insert(schema.userFlux)
|
||||
.values({ userId, flux: initialFlux })
|
||||
.onConflictDoNothing({ target: schema.userFlux.userId })
|
||||
.returning()
|
||||
|
||||
// Only write transaction if we actually created the record (not a conflict)
|
||||
if (inserted) {
|
||||
await tx.insert(fluxTxSchema.fluxTransaction).values({
|
||||
userId,
|
||||
type: 'initial',
|
||||
amount: initialFlux,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: initialFlux,
|
||||
description: 'Initial grant',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Re-read to handle race condition (another request may have initialized first)
|
||||
record = await db.query.userFlux.findFirst({
|
||||
where: and(
|
||||
eq(schema.userFlux.userId, userId),
|
||||
isNull(schema.userFlux.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (!record) {
|
||||
throw new Error(`Failed to initialize flux for user ${userId}`)
|
||||
}
|
||||
|
||||
logger.withFields({ userId, initialFlux }).log('Initialized new user flux')
|
||||
}
|
||||
|
||||
// 3. Populate Redis cache
|
||||
await redis.set(userFluxRedisKey(userId), String(record.flux))
|
||||
|
||||
return record
|
||||
},
|
||||
|
||||
async updateStripeCustomerId(userId: string, stripeCustomerId: string) {
|
||||
const [updated] = await db.update(schema.userFlux)
|
||||
.set({
|
||||
stripeCustomerId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(schema.userFlux.userId, userId),
|
||||
isNull(schema.userFlux.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
|
||||
/**
|
||||
* Soft-delete the user's flux balance and drop the cached value from
|
||||
* Redis. Does NOT touch `flux_transaction` — that ledger is preserved
|
||||
* across user deletion for billing audit (and the table has no
|
||||
* `deletedAt` column by design).
|
||||
*
|
||||
* Idempotent: `WHERE deletedAt IS NULL` skips an already-stamped row,
|
||||
* `redis.del` is a no-op when the key is absent.
|
||||
*/
|
||||
async deleteAllForUser(userId: string) {
|
||||
const now = new Date()
|
||||
|
||||
const result = await db.update(schema.userFlux)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.userFlux.userId, userId),
|
||||
isNull(schema.userFlux.deletedAt),
|
||||
))
|
||||
.returning({ flux: schema.userFlux.flux })
|
||||
|
||||
// Drop the cached balance so any in-flight read does not see a
|
||||
// ghost balance for the soft-deleted user.
|
||||
await redis.del(userFluxRedisKey(userId))
|
||||
|
||||
logger
|
||||
.withFields({ userId, clearedFlux: result[0]?.flux ?? 0 })
|
||||
.log('Flux balance soft-deleted and cache invalidated')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FluxService = ReturnType<typeof createFluxService>
|
||||
@@ -0,0 +1,149 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import {
|
||||
ttsPoolInflightRedisKey,
|
||||
ttsPoolKnownRedisKey,
|
||||
ttsPoolSaturatedRedisKey,
|
||||
} from '../../../utils/redis-keys'
|
||||
|
||||
// NOTICE: Atomic capacity-gated acquire. The TTSpool routes requests across
|
||||
// multiple app_ids, each capped at a small concurrency limit (e.g. 10). To use
|
||||
// the pooled capacity without overshooting any single app_id, we track in-flight
|
||||
// requests per pool in Redis (shared across replicas — the server is multi-instance
|
||||
// on Railway). A check-then-INCR done in two round-trips would race between
|
||||
// replicas and overshoot the cap, so the check + increment happen inside one Lua
|
||||
// script. The EXPIRE bounds leakage: if a replica crashes between acquire and
|
||||
// release, the counter self-heals after `inflightTtlSeconds` instead of pinning
|
||||
// the pool as permanently full. Source: flux-meter.ts ACCUMULATE_SCRIPT (same
|
||||
// "INCR + EXPIRE, TTL survives crash" shape).
|
||||
const ACQUIRE_SCRIPT = `
|
||||
local inflightKey = KEYS[1]
|
||||
local knownKey = KEYS[2]
|
||||
local max = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local poolId = ARGV[3]
|
||||
|
||||
local current = tonumber(redis.call('GET', inflightKey) or '0')
|
||||
if current < max then
|
||||
local next = redis.call('INCR', inflightKey)
|
||||
redis.call('EXPIRE', inflightKey, ttl)
|
||||
redis.call('SADD', knownKey, poolId)
|
||||
return next
|
||||
end
|
||||
|
||||
return -1
|
||||
`
|
||||
|
||||
// NOTICE: Floor-guarded release. A bare DECR on a missing/expired key would
|
||||
// drive the counter negative (Redis DECR on a nonexistent key yields -1), which
|
||||
// would then let the pool accept more than `max` concurrent requests. Guarding
|
||||
// with GET>0 inside Lua keeps release idempotent against the TTL self-heal: if
|
||||
// the inflight key already expired, release is a no-op rather than a corruption.
|
||||
const RELEASE_SCRIPT = `
|
||||
local inflightKey = KEYS[1]
|
||||
local current = tonumber(redis.call('GET', inflightKey) or '0')
|
||||
if current > 0 then
|
||||
return redis.call('DECR', inflightKey)
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
/**
|
||||
* Tracks per-pool in-flight concurrency in Redis so the TTS router can spread
|
||||
* load across multiple app_ids without overshooting any one app_id's cap.
|
||||
*
|
||||
* Use when:
|
||||
* - Building the LLM/TTS router service (`createLlmRouterService`), which
|
||||
* acquires a slot before dispatching to a capacity-capped upstream and
|
||||
* releases it once the attempt finishes.
|
||||
*
|
||||
* Expects:
|
||||
* - `redis` is the shared cluster Redis (the same instance the flux meter and
|
||||
* config cache use). Counts are cluster-wide, not per-process.
|
||||
*
|
||||
* Returns:
|
||||
* - An acquire/release/saturation API. `tryAcquire` is the only capacity
|
||||
* decision; everything else is bookkeeping the router and the watermark
|
||||
* gauge read.
|
||||
*/
|
||||
export function createConcurrencyLedger(redis: Redis, options?: {
|
||||
/**
|
||||
* TTL (seconds) on the in-flight counter. Bounds leakage when a replica
|
||||
* crashes between acquire and release. Should comfortably exceed the longest
|
||||
* single TTS attempt so a live request is never evicted mid-flight.
|
||||
* @default 60
|
||||
*/
|
||||
inflightTtlSeconds?: number
|
||||
}) {
|
||||
const inflightTtlSeconds = options?.inflightTtlSeconds ?? 60
|
||||
const knownKey = ttsPoolKnownRedisKey()
|
||||
|
||||
/**
|
||||
* Atomically acquire one slot on `poolId` if it is below `maxConcurrency`.
|
||||
* Returns true when the slot was taken (caller MUST later call `release`),
|
||||
* false when the pool is already at capacity (caller should try another pool).
|
||||
*/
|
||||
async function tryAcquire(poolId: string, maxConcurrency: number): Promise<boolean> {
|
||||
const result = await redis.eval(
|
||||
ACQUIRE_SCRIPT,
|
||||
2,
|
||||
ttsPoolInflightRedisKey(poolId),
|
||||
knownKey,
|
||||
maxConcurrency,
|
||||
inflightTtlSeconds,
|
||||
poolId,
|
||||
) as number | string
|
||||
return Number(result) >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one slot previously taken via {@link tryAcquire}. Idempotent and
|
||||
* floor-guarded — releasing an already-zero/expired counter is a no-op.
|
||||
*/
|
||||
async function release(poolId: string): Promise<void> {
|
||||
await redis.eval(RELEASE_SCRIPT, 1, ttsPoolInflightRedisKey(poolId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag `poolId` as saturated for `ttlSeconds`. Called when an upstream
|
||||
* exhausts with a 429 (app_id concurrency exceeded upstream-side) so the
|
||||
* router skips this pool during the cool-down instead of re-probing a pool it
|
||||
* already knows is full.
|
||||
*/
|
||||
async function markSaturated(poolId: string, ttlSeconds: number): Promise<void> {
|
||||
await redis.set(ttsPoolSaturatedRedisKey(poolId), '1', 'EX', ttlSeconds)
|
||||
}
|
||||
|
||||
/** Whether `poolId` is within a saturation cool-down window. */
|
||||
async function isSaturated(poolId: string): Promise<boolean> {
|
||||
const exists = await redis.exists(ttsPoolSaturatedRedisKey(poolId))
|
||||
return exists === 1
|
||||
}
|
||||
|
||||
/** Current in-flight count for `poolId` (0 when the counter is absent). */
|
||||
async function currentInflight(poolId: string): Promise<number> {
|
||||
const raw = await redis.get(ttsPoolInflightRedisKey(poolId))
|
||||
return raw == null ? 0 : Number(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every known pool's in-flight count. Backs the watermark gauge —
|
||||
* reads the known-pools set, then MGETs each counter in one round-trip.
|
||||
* Returns an empty array when no pool has ever been acquired.
|
||||
*/
|
||||
async function snapshot(): Promise<Array<{ poolId: string, inflight: number }>> {
|
||||
const poolIds = await redis.smembers(knownKey)
|
||||
if (poolIds.length === 0)
|
||||
return []
|
||||
|
||||
const values = await redis.mget(poolIds.map(ttsPoolInflightRedisKey))
|
||||
return poolIds.map((poolId, i) => ({
|
||||
poolId,
|
||||
inflight: values[i] == null ? 0 : Number(values[i]),
|
||||
}))
|
||||
}
|
||||
|
||||
return { tryAcquire, release, markSaturated, isSaturated, currentInflight, snapshot }
|
||||
}
|
||||
|
||||
export type ConcurrencyLedger = ReturnType<typeof createConcurrencyLedger>
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ConfigKVService } from '../../adapters/config-kv'
|
||||
import type { LlmModel, ModelKind, RouterConfig, TtsModel } from './types'
|
||||
|
||||
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
|
||||
|
||||
/**
|
||||
* Default TTL for the in-memory config cache. Plan KTD-4 fallback path:
|
||||
* Pub/Sub invalidation is best-effort; TTL is the self-heal upper bound on
|
||||
* staleness when a Pub/Sub message is missed. 5s keeps admin config edits
|
||||
* propagating to every instance within a 5s window even with no Pub/Sub.
|
||||
*/
|
||||
const DEFAULT_CACHE_TTL_MS = 5_000
|
||||
|
||||
export interface ConfigLoaderOptions {
|
||||
/** ConfigKV service used to read `LLM_ROUTER_CONFIG`. */
|
||||
configKV: ConfigKVService
|
||||
/**
|
||||
* Cache TTL in milliseconds.
|
||||
* @default 5_000
|
||||
*/
|
||||
ttlMs?: number
|
||||
/**
|
||||
* Clock injected for tests. Defaults to `Date.now`. We do NOT mock the
|
||||
* global Date object — tests pass a stub instead.
|
||||
* @default Date.now
|
||||
*/
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-model config slice for a specific kind. Distinguished as a tagged
|
||||
* union so callers handle `llm` and `tts` shapes explicitly.
|
||||
*/
|
||||
export type ModelConfigSlice
|
||||
= | { kind: 'llm', model: LlmModel, defaults: RouterConfig['defaults'] }
|
||||
| { kind: 'tts', model: TtsModel, defaults: RouterConfig['defaults'] }
|
||||
|
||||
/**
|
||||
* Build the in-process config loader for the router.
|
||||
*
|
||||
* Use when:
|
||||
* - The router or admin endpoint needs to resolve `LLM_ROUTER_CONFIG` and
|
||||
* wants a single shared in-memory cache across requests.
|
||||
*
|
||||
* Expects:
|
||||
* - `configKV.getOptional('LLM_ROUTER_CONFIG')` returns either a parsed
|
||||
* config tree or `null` when the entry is missing.
|
||||
*
|
||||
* Returns:
|
||||
* - `getModelConfig(kind, modelName)` — resolves one model slice; throws
|
||||
* `BAD_REQUEST` for unknown models and `CONFIG_NOT_SET` (503) when the
|
||||
* whole config entry is absent.
|
||||
* - `invalidate()` — clears the cache. Wired to Pub/Sub in U7 for cross-
|
||||
* instance propagation; admin endpoint calls it on write.
|
||||
*/
|
||||
export function createConfigLoader(options: ConfigLoaderOptions) {
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS
|
||||
const now = options.now ?? Date.now
|
||||
|
||||
let cached: { value: RouterConfig, loadedAt: number } | null = null
|
||||
|
||||
async function loadFresh(): Promise<RouterConfig> {
|
||||
const value = await options.configKV.getOptional('LLM_ROUTER_CONFIG')
|
||||
if (value == null) {
|
||||
throw createServiceUnavailableError(
|
||||
'LLM_ROUTER_CONFIG not set',
|
||||
'CONFIG_NOT_SET',
|
||||
)
|
||||
}
|
||||
cached = { value, loadedAt: now() }
|
||||
return value
|
||||
}
|
||||
|
||||
async function getConfig(): Promise<RouterConfig> {
|
||||
if (cached != null && now() - cached.loadedAt < ttlMs)
|
||||
return cached.value
|
||||
return loadFresh()
|
||||
}
|
||||
|
||||
async function getModelConfig(kind: ModelKind, modelName: string): Promise<ModelConfigSlice> {
|
||||
const config = await getConfig()
|
||||
if (kind === 'llm') {
|
||||
const model = config.llm.models[modelName]
|
||||
if (model == null) {
|
||||
throw createBadRequestError(
|
||||
'unknown_model',
|
||||
'BAD_REQUEST',
|
||||
{ requested: modelName, available: Object.keys(config.llm.models) },
|
||||
)
|
||||
}
|
||||
return { kind: 'llm', model, defaults: config.defaults }
|
||||
}
|
||||
const model = config.tts.models[modelName]
|
||||
if (model == null) {
|
||||
throw createBadRequestError(
|
||||
'unknown_model',
|
||||
'BAD_REQUEST',
|
||||
{ requested: modelName, available: Object.keys(config.tts.models) },
|
||||
)
|
||||
}
|
||||
return { kind: 'tts', model, defaults: config.defaults }
|
||||
}
|
||||
|
||||
function invalidate(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
return { getModelConfig, invalidate }
|
||||
}
|
||||
|
||||
export type ConfigLoader = ReturnType<typeof createConfigLoader>
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { useLogger } from '@guiiai/logg'
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { GatewayMetrics } from '../../../otel'
|
||||
import type { LlmRouterService } from './router'
|
||||
|
||||
/**
|
||||
* Dependencies needed to wire the cross-instance config invalidation
|
||||
* subscriber.
|
||||
*/
|
||||
export interface ConfigSyncSubscriberOptions {
|
||||
/**
|
||||
* Primary Redis client. The subscriber takes its own connection via
|
||||
* `.duplicate()` because ioredis forbids non-pubsub commands on a
|
||||
* connection in subscribe mode.
|
||||
*/
|
||||
redis: Redis
|
||||
/** Router service whose in-memory `LLM_ROUTER_CONFIG` cache we invalidate. */
|
||||
llmRouter: LlmRouterService
|
||||
/**
|
||||
* OTel gateway metric bundle. `null` when OTel is disabled — emit calls
|
||||
* become no-ops.
|
||||
*/
|
||||
gatewayMetrics: GatewayMetrics | null
|
||||
/** Value attached to the `service_instance_id` label on emitted metrics. */
|
||||
instanceId: string
|
||||
/** Logger handle. Caller supplies a scoped logger so namespacing is theirs. */
|
||||
logger: ReturnType<typeof useLogger>
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-call shape returned to the caller. Kept narrow so the caller can hold
|
||||
* the subscriber handle for graceful shutdown or tests without leaking the
|
||||
* internal emit closure.
|
||||
*/
|
||||
export interface ConfigSyncSubscriber {
|
||||
/** Underlying ioredis subscriber connection. */
|
||||
subscriber: Redis
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the cross-instance `configkv:invalidate` subscriber to the router's
|
||||
* cache and OTel gateway metrics.
|
||||
*
|
||||
* Use when:
|
||||
* - Booting a server replica that has a live `LlmRouterService` and needs
|
||||
* to react to peer-instance config writes within the Pub/Sub propagation
|
||||
* window (R16 / KTD-4, ≤5s under healthy Redis).
|
||||
*
|
||||
* Expects:
|
||||
* - `redis` is the application's primary client. We `.duplicate()` it here
|
||||
* because ioredis forbids non-pubsub commands on a subscribed connection.
|
||||
* - `llmRouter` is already constructed. The caller owns its lifecycle.
|
||||
*
|
||||
* Returns:
|
||||
* - `subscriber` — the dedicated ioredis subscriber connection, so the
|
||||
* caller can `await subscriber.quit()` during graceful shutdown.
|
||||
*
|
||||
* Emits the following `airi.gen_ai.gateway.*` metrics:
|
||||
* - `config_reload` (source = `pubsub`) once per accepted invalidation msg
|
||||
* - `subscriber_state` with `state` = `connected` / `error` / `reconnecting`
|
||||
*
|
||||
* The router's in-memory cache reloads on either a pub/sub message OR the
|
||||
* `configCacheTtlMs` fallback (default 5s); a silently-disconnected
|
||||
* subscriber means the instance drifts inside that window. `subscriber_state`
|
||||
* is the only direct signal for that drift.
|
||||
*/
|
||||
export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): ConfigSyncSubscriber {
|
||||
const subscriber = opts.redis.duplicate()
|
||||
|
||||
function recordSubscriberState(state: 'connected' | 'error' | 'reconnecting') {
|
||||
opts.gatewayMetrics?.subscriberState.add(1, {
|
||||
state,
|
||||
service_instance_id: opts.instanceId,
|
||||
})
|
||||
}
|
||||
|
||||
subscriber.on('message', (channel, message) => {
|
||||
if (channel !== 'configkv:invalidate')
|
||||
return
|
||||
try {
|
||||
const payload = JSON.parse(message) as { key?: unknown }
|
||||
// LLM_ROUTER_CONFIG drives a model-config cache + voice-catalog cache
|
||||
// invalidation (key rotation, model add/remove, region swap all need to
|
||||
// surface immediately). UNSPEECH_UPSTREAM only affects the voice catalog
|
||||
// cache because no other in-process structure references it.
|
||||
if (payload?.key === 'LLM_ROUTER_CONFIG') {
|
||||
opts.llmRouter.invalidateConfig()
|
||||
void opts.llmRouter.invalidateTtsVoicesCache().catch((err) => {
|
||||
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on LLM_ROUTER_CONFIG change')
|
||||
})
|
||||
opts.gatewayMetrics?.configReload.add(1, {
|
||||
source: 'pubsub',
|
||||
service_instance_id: opts.instanceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (payload?.key === 'UNSPEECH_UPSTREAM') {
|
||||
void opts.llmRouter.invalidateTtsVoicesCache().catch((err) => {
|
||||
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on UNSPEECH_UPSTREAM change')
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
opts.logger.withError(err).warn('Failed to parse configkv:invalidate payload')
|
||||
}
|
||||
})
|
||||
|
||||
subscriber.on('error', (err: Error) => {
|
||||
opts.logger.withError(err).warn('configkv:invalidate subscriber connection error')
|
||||
recordSubscriberState('error')
|
||||
})
|
||||
|
||||
// ioredis emits `reconnecting` before each reconnect attempt; the
|
||||
// subscription itself is restored automatically because `autoResubscribe`
|
||||
// defaults to true.
|
||||
subscriber.on('reconnecting', () => recordSubscriberState('reconnecting'))
|
||||
|
||||
subscriber.subscribe('configkv:invalidate')
|
||||
.then(() => recordSubscriberState('connected'))
|
||||
.catch((err: unknown) => {
|
||||
opts.logger.withError(err).warn('Failed to subscribe to configkv:invalidate channel')
|
||||
recordSubscriberState('error')
|
||||
})
|
||||
|
||||
return { subscriber }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ApiError } from '../../../utils/error'
|
||||
|
||||
import { createBadGatewayError, createGatewayTimeoutError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
|
||||
|
||||
/**
|
||||
* Sanitized context for `mapUpstreamError`.
|
||||
*
|
||||
* Per SEC-5: upstream response bodies and headers must never enter this
|
||||
* shape. Body content can leak provider-internal info (subscription IDs,
|
||||
* region tags, rate-limit metadata) to the end client. Only counts and the
|
||||
* final status code are safe to surface.
|
||||
*/
|
||||
export interface UpstreamErrorContext {
|
||||
/** How many distinct keys were attempted across all upstreams. */
|
||||
triedKeys: number
|
||||
/** How many distinct upstreams were attempted. */
|
||||
triedUpstreams: number
|
||||
/** The status of the **last** attempt — drives the 502/503/504 selection. */
|
||||
lastStatusCode: number | 'timeout'
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded upstream attempt. Carries the raw provider response snippet
|
||||
* and network error message so operators can diagnose 502s without re-
|
||||
* probing the upstream. Lives on `ApiError.cause`, never on `details`, so
|
||||
* SEC-5 (no upstream content in client-facing response body) still holds.
|
||||
*/
|
||||
export interface UpstreamAttempt {
|
||||
provider: string
|
||||
keyId: string
|
||||
status: number | 'timeout'
|
||||
/**
|
||||
* First ≤256 bytes of the upstream response body when the attempt
|
||||
* received an HTTP response. Helps tell apart "key invalid", "region
|
||||
* blocked", "model not enabled" without re-running the request.
|
||||
*/
|
||||
bodySnippet?: string
|
||||
/**
|
||||
* Result of `errorMessageFrom(err)` when the attempt threw before getting
|
||||
* an HTTP response (per-attempt timeout, DNS, ECONNRESET) or when an
|
||||
* adapter wrapped a network failure. TTS adapters bake the body snippet
|
||||
* into this message, so chat upstreams populate `bodySnippet` and TTS
|
||||
* upstreams populate `errorMessage`.
|
||||
*/
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-only cause attached to the {@link ApiError} that
|
||||
* {@link mapUpstreamError} produces. Surfaced through logger + OTel
|
||||
* span attributes, never through the HTTP response body.
|
||||
*/
|
||||
export interface RouterErrorCause {
|
||||
attempts: UpstreamAttempt[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a final upstream failure to a client-facing {@link ApiError} per
|
||||
* KTD-1 last-attempt-wins policy.
|
||||
*
|
||||
* Use when:
|
||||
* - The router has exhausted every (upstream, key) combo. The status code
|
||||
* of the **last** attempt drives the response.
|
||||
*
|
||||
* Expects:
|
||||
* - `status` is a non-2xx HTTP code or the literal `'timeout'` token. Passing
|
||||
* a 2xx code is a programmer error (this mapper should only run after the
|
||||
* router decides every attempt failed) and throws an internal error.
|
||||
* - `attempts` (when provided) lists every recorded upstream attempt.
|
||||
* Attached to `ApiError.cause` so logger / OTel can surface the real
|
||||
* upstream message; SEC-5 forbids the same content on `details`.
|
||||
*
|
||||
* Returns:
|
||||
* - `504 GATEWAY_TIMEOUT` when the last attempt timed out.
|
||||
* - `503 SERVICE_UNAVAILABLE` when the last attempt was a 429 (so retry-able
|
||||
* rate-limit hints reach the client correctly).
|
||||
* - `502 BAD_GATEWAY` for every other non-2xx upstream status (401/402/403,
|
||||
* 5xx, anything else).
|
||||
*/
|
||||
export function mapUpstreamError(
|
||||
status: number | 'timeout',
|
||||
context: UpstreamErrorContext,
|
||||
attempts?: UpstreamAttempt[],
|
||||
): ApiError {
|
||||
const details = {
|
||||
triedKeys: context.triedKeys,
|
||||
triedUpstreams: context.triedUpstreams,
|
||||
lastStatusCode: context.lastStatusCode,
|
||||
}
|
||||
|
||||
const apiErr = buildApiError(status, details)
|
||||
if (attempts != null && attempts.length > 0) {
|
||||
// Server-only cause. Logger / OTel pick this up; SEC-5 keeps it out
|
||||
// of the client-facing response body.
|
||||
const cause: RouterErrorCause = { attempts }
|
||||
;(apiErr as { cause?: unknown }).cause = cause
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
function buildApiError(status: number | 'timeout', details: UpstreamErrorContext): ApiError {
|
||||
if (status === 'timeout')
|
||||
return createGatewayTimeoutError('Upstream timeout', details)
|
||||
|
||||
// Programmer error: only non-2xx statuses should reach this mapper. We
|
||||
// refuse to return 502 for a 2xx because that masks a real bug — the
|
||||
// caller decided the request succeeded somewhere upstream of here.
|
||||
if (status >= 200 && status < 300) {
|
||||
throw createInternalError(
|
||||
`mapUpstreamError received success status ${status} — only non-2xx upstream statuses should reach this mapper`,
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 429)
|
||||
return createServiceUnavailableError('Upstream rate-limited', 'SERVICE_UNAVAILABLE', details)
|
||||
|
||||
return createBadGatewayError('Upstream unavailable', details)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { createConcurrencyLedger } from './concurrency-ledger'
|
||||
|
||||
export type { ConcurrencyLedger } from './concurrency-ledger'
|
||||
export { createConfigSyncSubscriber } from './config-sync-subscriber'
|
||||
|
||||
export { createLlmRouterService } from './router'
|
||||
export type { LlmRouterService } from './router'
|
||||
|
||||
export type { LlmModel, LlmRouteContext, TtsModel, TtsUpstream } from './types'
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
|
||||
import type { GatewayMetrics } from '../../../otel'
|
||||
import type { EnvelopeCrypto } from '../../../utils/envelope-crypto'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { createServiceUnavailableError } from '../../../utils/error'
|
||||
|
||||
/**
|
||||
* Minimal shape of one upstream as needed by the rotator. We do not depend
|
||||
* on the inferred `LlmUpstream` / `TtsUpstream` types directly here so the
|
||||
* same rotator works for both surfaces without a union split — both surfaces
|
||||
* carry `keys` + each key has `id` + `ciphertext`.
|
||||
*/
|
||||
export interface RotatableUpstream {
|
||||
keys: ReadonlyArray<{ id: string, ciphertext: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* One yielded key entry. `plaintext` is a {@link Buffer} so the caller can
|
||||
* `buf.fill(0)` after use to wipe the secret from memory promptly.
|
||||
*/
|
||||
export interface RotatedKey {
|
||||
/** Stable key id from the config entry — safe for OTel labels. */
|
||||
id: string
|
||||
/**
|
||||
* Decrypted plaintext key bytes. The caller is expected to wipe this with
|
||||
* `plaintext.fill(0)` in a `finally` once the request attempt finishes.
|
||||
*/
|
||||
plaintext: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-request iterator over decrypted keys for one upstream.
|
||||
*
|
||||
* Use when:
|
||||
* - The router needs to walk an upstream's `keys[]` in order, decrypting
|
||||
* each only when it's actually about to be used.
|
||||
*
|
||||
* Expects:
|
||||
* - `upstream.keys` is non-empty (configKV Valibot enforces this).
|
||||
* - `modelName` matches the AAD the keys were encrypted under. A mismatch
|
||||
* surfaces as a decrypt failure (AAD binding — see envelope-crypto.ts).
|
||||
* - `gatewayMetrics` may be `null` when OTel is disabled.
|
||||
*
|
||||
* Returns:
|
||||
* - An iterable that yields `{id, plaintext: Buffer}` per key in config
|
||||
* order. On decrypt failure it throws `createServiceUnavailableError`
|
||||
* immediately (does NOT silently skip — silent skip would hide config
|
||||
* poisoning attempts).
|
||||
*/
|
||||
export function createKeyRotator(
|
||||
upstream: RotatableUpstream,
|
||||
envelopeCrypto: EnvelopeCrypto,
|
||||
modelName: string,
|
||||
gatewayMetrics: GatewayMetrics | null,
|
||||
provider: string,
|
||||
): Iterable<RotatedKey> {
|
||||
return {
|
||||
* [Symbol.iterator](): Iterator<RotatedKey> {
|
||||
for (const entry of upstream.keys) {
|
||||
let plaintext: Buffer
|
||||
try {
|
||||
plaintext = envelopeCrypto.decryptKey(entry.ciphertext, {
|
||||
modelName,
|
||||
keyEntryId: entry.id,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
gatewayMetrics?.decryptFailures.add(1, {
|
||||
provider,
|
||||
key_entry_id: entry.id,
|
||||
})
|
||||
// NOTICE:
|
||||
// Surfacing decrypt failure as CONFIG_NOT_SET (503) rather than
|
||||
// letting the raw crypto error bubble. Silent skip would hide
|
||||
// config poisoning / forged-blob attempts. Source: plan U3 test
|
||||
// scenario (3) "Decrypt failure on one key … does NOT silently
|
||||
// skip (security: silent skip would hide config-poisoning)".
|
||||
// Removal condition: never — security-critical surfacing.
|
||||
throw createServiceUnavailableError(
|
||||
`Failed to decrypt key ${entry.id} for model ${modelName}: ${errorMessageFrom(err) ?? 'unknown error'}`,
|
||||
'DECRYPT_FAILED',
|
||||
{ keyEntryId: entry.id, modelName },
|
||||
)
|
||||
}
|
||||
yield { id: entry.id, plaintext }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createConcurrencyLedger } from '../concurrency-ledger'
|
||||
|
||||
// NOTICE: Mimic the subset of Redis semantics the ledger uses (EVAL for the
|
||||
// ACQUIRE/RELEASE Lua, plus SET/EXISTS/GET/SADD/SMEMBERS/MGET). The two Lua
|
||||
// scripts are told apart by numKeys (acquire passes 2 keys, release passes 1) —
|
||||
// same approach flux-meter.test.ts uses for its single script. Real Lua
|
||||
// atomicity is exercised by ioredis hitting Redis in integration; here we verify
|
||||
// the capacity decision, floor-guarded release, saturation flags, and snapshot.
|
||||
function createMockRedis() {
|
||||
const inflight = new Map<string, number>()
|
||||
const saturated = new Set<string>()
|
||||
const known = new Set<string>()
|
||||
|
||||
const evalImpl = async (_script: string, numKeys: number, ...args: Array<string | number>) => {
|
||||
if (numKeys === 2) {
|
||||
// ACQUIRE_SCRIPT: inflightKey, knownKey, max, ttl, poolId
|
||||
const inflightKey = String(args[0])
|
||||
const knownKey = String(args[1])
|
||||
const max = Number(args[2])
|
||||
const poolId = String(args[4])
|
||||
const current = inflight.get(inflightKey) ?? 0
|
||||
if (current < max) {
|
||||
const next = current + 1
|
||||
inflight.set(inflightKey, next)
|
||||
known.add(`${knownKey}::${poolId}`)
|
||||
return next
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// RELEASE_SCRIPT: inflightKey
|
||||
const inflightKey = String(args[0])
|
||||
const current = inflight.get(inflightKey) ?? 0
|
||||
if (current > 0) {
|
||||
const next = current - 1
|
||||
inflight.set(inflightKey, next)
|
||||
return next
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const redis = {
|
||||
eval: evalImpl,
|
||||
set: async (key: string, _val: string, _mode: string, _ttl: number) => {
|
||||
saturated.add(key)
|
||||
return 'OK'
|
||||
},
|
||||
exists: async (key: string) => (saturated.has(key) ? 1 : 0),
|
||||
get: async (key: string) => {
|
||||
const v = inflight.get(key)
|
||||
return v == null ? null : String(v)
|
||||
},
|
||||
smembers: async (key: string) => {
|
||||
const prefix = `${key}::`
|
||||
return [...known].filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length))
|
||||
},
|
||||
mget: async (keys: string[]) => keys.map(k => (inflight.has(k) ? String(inflight.get(k)) : null)),
|
||||
} as unknown as Redis
|
||||
|
||||
return { redis, inflight, saturated }
|
||||
}
|
||||
|
||||
describe('concurrencyLedger', () => {
|
||||
let mock: ReturnType<typeof createMockRedis>
|
||||
let ledger: ReturnType<typeof createConcurrencyLedger>
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createMockRedis()
|
||||
ledger = createConcurrencyLedger(mock.redis)
|
||||
})
|
||||
|
||||
it('tryAcquire grants a slot while the pool is below max and increments inflight', async () => {
|
||||
// @example acquire on an empty pool (cap 10) -> granted, inflight becomes 1
|
||||
const granted = await ledger.tryAcquire('app-1', 10)
|
||||
expect(granted).toBe(true)
|
||||
expect(await ledger.currentInflight('app-1')).toBe(1)
|
||||
})
|
||||
|
||||
it('tryAcquire rejects once the pool is at max without incrementing past the cap', async () => {
|
||||
// @example cap 2 -> first two granted, third rejected, inflight stays 2
|
||||
expect(await ledger.tryAcquire('app-1', 2)).toBe(true)
|
||||
expect(await ledger.tryAcquire('app-1', 2)).toBe(true)
|
||||
expect(await ledger.tryAcquire('app-1', 2)).toBe(false)
|
||||
expect(await ledger.currentInflight('app-1')).toBe(2)
|
||||
})
|
||||
|
||||
it('grants no more than max across many acquires on one pool (capacity invariant)', async () => {
|
||||
// @example cap 10, attempt 15 acquires -> exactly 10 granted
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 15 }, () => ledger.tryAcquire('app-1', 10)),
|
||||
)
|
||||
expect(results.filter(Boolean)).toHaveLength(10)
|
||||
expect(await ledger.currentInflight('app-1')).toBe(10)
|
||||
})
|
||||
|
||||
it('release returns a slot so a previously-full pool can grant again', async () => {
|
||||
// @example cap 1: acquire, reject second, release, then acquire succeeds
|
||||
expect(await ledger.tryAcquire('app-1', 1)).toBe(true)
|
||||
expect(await ledger.tryAcquire('app-1', 1)).toBe(false)
|
||||
await ledger.release('app-1')
|
||||
expect(await ledger.currentInflight('app-1')).toBe(0)
|
||||
expect(await ledger.tryAcquire('app-1', 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('release floors at zero and never drives the counter negative', async () => {
|
||||
// @example releasing an idle pool keeps inflight at 0 (no negative overshoot)
|
||||
await ledger.release('app-1')
|
||||
expect(await ledger.currentInflight('app-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('isSaturated reflects markSaturated', async () => {
|
||||
// @example before mark -> false; after mark -> true
|
||||
expect(await ledger.isSaturated('app-1')).toBe(false)
|
||||
await ledger.markSaturated('app-1', 5)
|
||||
expect(await ledger.isSaturated('app-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('snapshot lists every acquired pool with its current inflight count', async () => {
|
||||
// @example acquire on two pools -> snapshot reports both with counts
|
||||
await ledger.tryAcquire('app-1', 10)
|
||||
await ledger.tryAcquire('app-1', 10)
|
||||
await ledger.tryAcquire('app-2', 10)
|
||||
const snap = await ledger.snapshot()
|
||||
expect(snap).toContainEqual({ poolId: 'app-1', inflight: 2 })
|
||||
expect(snap).toContainEqual({ poolId: 'app-2', inflight: 1 })
|
||||
})
|
||||
|
||||
it('snapshot is empty before any pool is acquired', async () => {
|
||||
// @example fresh ledger -> snapshot returns []
|
||||
expect(await ledger.snapshot()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ConfigKVService } from '../../../adapters/config-kv'
|
||||
import type { RouterConfig } from '../types'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ApiError } from '../../../../utils/error'
|
||||
import { createConfigLoader } from '../config-loader'
|
||||
|
||||
function makeConfig(): RouterConfig {
|
||||
return {
|
||||
llm: {
|
||||
models: {
|
||||
'openai/gpt-5-mini': {
|
||||
upstreams: [
|
||||
{
|
||||
baseURL: 'https://openrouter.example/v1',
|
||||
keys: [{ id: 'k1', ciphertext: 'v1.aa.bb.cc' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
},
|
||||
],
|
||||
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: {
|
||||
models: {
|
||||
'tts-1': {
|
||||
provider: 'azure',
|
||||
upstreams: [
|
||||
{
|
||||
baseURL: 'https://azure.example/tts',
|
||||
keys: [{ id: 'tk1', ciphertext: 'v1.aa.bb.cc' }],
|
||||
adapterParams: {},
|
||||
},
|
||||
],
|
||||
fallbackTriggers: { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] },
|
||||
} as RouterConfig
|
||||
}
|
||||
|
||||
function makeMockConfigKV(value: RouterConfig | null): ConfigKVService {
|
||||
return {
|
||||
getOptional: vi.fn(async (key: string) => (key === 'LLM_ROUTER_CONFIG' ? value : null)),
|
||||
getOrThrow: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
} as unknown as ConfigKVService
|
||||
}
|
||||
|
||||
describe('createConfigLoader', () => {
|
||||
/**
|
||||
* @example loader.getModelConfig('llm', 'x') hits configKV once, then serves cache
|
||||
*/
|
||||
it('first call reads from configKV; subsequent calls within TTL serve from cache (one read)', async () => {
|
||||
const configKV = makeMockConfigKV(makeConfig())
|
||||
let nowValue = 1000
|
||||
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
|
||||
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
nowValue = 2000
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
nowValue = 4999
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
|
||||
expect((configKV.getOptional as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('invalidate() clears cache; next call re-reads from configKV', async () => {
|
||||
const configKV = makeMockConfigKV(makeConfig())
|
||||
let nowValue = 1000
|
||||
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
|
||||
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
loader.invalidate()
|
||||
nowValue = 1001
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
|
||||
expect((configKV.getOptional as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('tTL expiry triggers fresh read on next call', async () => {
|
||||
const configKV = makeMockConfigKV(makeConfig())
|
||||
let nowValue = 1000
|
||||
const loader = createConfigLoader({ configKV, ttlMs: 5000, now: () => nowValue })
|
||||
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
nowValue = 1000 + 5001
|
||||
await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
|
||||
expect((configKV.getOptional as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('missing LLM_ROUTER_CONFIG → throws CONFIG_NOT_SET (503)', async () => {
|
||||
const configKV = makeMockConfigKV(null)
|
||||
const loader = createConfigLoader({ configKV })
|
||||
|
||||
await expect(loader.getModelConfig('llm', 'any-model')).rejects.toBeInstanceOf(ApiError)
|
||||
try {
|
||||
await loader.getModelConfig('llm', 'any-model')
|
||||
}
|
||||
catch (err) {
|
||||
expect((err as ApiError).statusCode).toBe(503)
|
||||
expect((err as ApiError).errorCode).toBe('CONFIG_NOT_SET')
|
||||
}
|
||||
})
|
||||
|
||||
it('unknown LLM model name → 400 BAD_REQUEST with requested + available list (pre-upstream rejection)', async () => {
|
||||
const configKV = makeMockConfigKV(makeConfig())
|
||||
const loader = createConfigLoader({ configKV })
|
||||
|
||||
try {
|
||||
await loader.getModelConfig('llm', 'nope/does-not-exist')
|
||||
throw new Error('expected throw')
|
||||
}
|
||||
catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).statusCode).toBe(400)
|
||||
expect((err as ApiError).errorCode).toBe('BAD_REQUEST')
|
||||
expect((err as ApiError).details).toEqual({
|
||||
requested: 'nope/does-not-exist',
|
||||
available: ['openai/gpt-5-mini'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('unknown TTS model name → 400 BAD_REQUEST with TTS model list (not LLM list)', async () => {
|
||||
const configKV = makeMockConfigKV(makeConfig())
|
||||
const loader = createConfigLoader({ configKV })
|
||||
|
||||
try {
|
||||
await loader.getModelConfig('tts', 'nope-tts')
|
||||
throw new Error('expected throw')
|
||||
}
|
||||
catch (err) {
|
||||
expect((err as ApiError).statusCode).toBe(400)
|
||||
expect((err as ApiError).details).toEqual({
|
||||
requested: 'nope-tts',
|
||||
available: ['tts-1'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the tagged model slice for both kinds', async () => {
|
||||
const configKV = makeMockConfigKV(makeConfig())
|
||||
const loader = createConfigLoader({ configKV })
|
||||
|
||||
const llm = await loader.getModelConfig('llm', 'openai/gpt-5-mini')
|
||||
expect(llm.kind).toBe('llm')
|
||||
if (llm.kind === 'llm') {
|
||||
expect(llm.model.upstreams).toHaveLength(1)
|
||||
expect(llm.model.upstreams[0].baseURL).toBe('https://openrouter.example/v1')
|
||||
}
|
||||
|
||||
const tts = await loader.getModelConfig('tts', 'tts-1')
|
||||
expect(tts.kind).toBe('tts')
|
||||
if (tts.kind === 'tts') {
|
||||
expect(tts.model.provider).toBe('azure')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ApiError } from '../../../../utils/error'
|
||||
import { mapUpstreamError } from '../error-mapping'
|
||||
|
||||
const exampleContext = { triedKeys: 2, triedUpstreams: 1, lastStatusCode: 401 as const }
|
||||
|
||||
describe('mapUpstreamError', () => {
|
||||
/**
|
||||
* @example mapUpstreamError(401, ctx) → 502 BAD_GATEWAY
|
||||
*/
|
||||
it('401 → 502 BAD_GATEWAY', () => {
|
||||
const err = mapUpstreamError(401, { ...exampleContext, lastStatusCode: 401 })
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('402 → 502 BAD_GATEWAY (payment required from upstream is still gateway-side per KTD-1)', () => {
|
||||
const err = mapUpstreamError(402, { ...exampleContext, lastStatusCode: 402 })
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('403 → 502 BAD_GATEWAY', () => {
|
||||
const err = mapUpstreamError(403, { ...exampleContext, lastStatusCode: 403 })
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('429 → 503 SERVICE_UNAVAILABLE (rate-limit hint preserves retry-ability)', () => {
|
||||
const err = mapUpstreamError(429, { ...exampleContext, lastStatusCode: 429 })
|
||||
expect(err.statusCode).toBe(503)
|
||||
expect(err.errorCode).toBe('SERVICE_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('500 → 502 BAD_GATEWAY', () => {
|
||||
const err = mapUpstreamError(500, { ...exampleContext, lastStatusCode: 500 })
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('502 → 502 BAD_GATEWAY (upstream 5xx still surfaces as our 502)', () => {
|
||||
const err = mapUpstreamError(502, { ...exampleContext, lastStatusCode: 502 })
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('503 → 502 BAD_GATEWAY (upstream 503 is not retry-after, treat as bad gateway)', () => {
|
||||
const err = mapUpstreamError(503, { ...exampleContext, lastStatusCode: 503 })
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('504 upstream → 502 BAD_GATEWAY (only our own timeouts produce 504)', () => {
|
||||
const err = mapUpstreamError(504, { ...exampleContext, lastStatusCode: 504 })
|
||||
expect(err.statusCode).toBe(502)
|
||||
expect(err.errorCode).toBe('BAD_GATEWAY')
|
||||
})
|
||||
|
||||
it('timeout → 504 GATEWAY_TIMEOUT', () => {
|
||||
const err = mapUpstreamError('timeout', { ...exampleContext, lastStatusCode: 'timeout' })
|
||||
expect(err.statusCode).toBe(504)
|
||||
expect(err.errorCode).toBe('GATEWAY_TIMEOUT')
|
||||
})
|
||||
|
||||
it('attaches sanitized details (triedKeys / triedUpstreams / lastStatusCode) — no upstream body', () => {
|
||||
const err = mapUpstreamError(500, { triedKeys: 4, triedUpstreams: 2, lastStatusCode: 500 })
|
||||
expect(err.details).toEqual({ triedKeys: 4, triedUpstreams: 2, lastStatusCode: 500 })
|
||||
})
|
||||
|
||||
it('2xx input is a programmer error and throws an internal error (never maps to 5xx)', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// If a 2xx status reaches this mapper the caller has already decided the
|
||||
// request succeeded but is still trying to map it to a failure. Silently
|
||||
// returning 502 would hide that bug. We throw INTERNAL_SERVER_ERROR to
|
||||
// surface it instead.
|
||||
expect(() => mapUpstreamError(200, { ...exampleContext, lastStatusCode: 200 as unknown as 401 })).toThrow(/success status/)
|
||||
expect(() => mapUpstreamError(299, { ...exampleContext, lastStatusCode: 299 as unknown as 401 })).toThrow(/success status/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
|
||||
import type { Counter } from '@opentelemetry/api'
|
||||
|
||||
import type { GatewayMetrics } from '../../../../otel'
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createEnvelopeCrypto } from '../../../../utils/envelope-crypto'
|
||||
import { ApiError } from '../../../../utils/error'
|
||||
import { createKeyRotator } from '../key-rotator'
|
||||
|
||||
function freshMasterKey(): Buffer {
|
||||
return randomBytes(32)
|
||||
}
|
||||
|
||||
function makeCounter(): Counter {
|
||||
return { add: vi.fn() } as unknown as Counter
|
||||
}
|
||||
|
||||
function makeMetrics(): { metrics: GatewayMetrics, decryptFailures: Counter } {
|
||||
const decryptFailures = makeCounter()
|
||||
// We only exercise decryptFailures here; the rest are unused stubs.
|
||||
const metrics = {
|
||||
fallbackCount: makeCounter(),
|
||||
upstreamErrors: makeCounter(),
|
||||
keyExhaustedCount: makeCounter(),
|
||||
sameStatusExhaustion: makeCounter(),
|
||||
configReload: makeCounter(),
|
||||
decryptFailures,
|
||||
subscriberState: makeCounter(),
|
||||
configWrite: makeCounter(),
|
||||
configInvalidHmac: makeCounter(),
|
||||
} as GatewayMetrics
|
||||
return { metrics, decryptFailures }
|
||||
}
|
||||
|
||||
describe('createKeyRotator', () => {
|
||||
/**
|
||||
* @example iterator yields {id, plaintext} for each key in config order
|
||||
*/
|
||||
it('yields keys in config order with decrypted plaintext', () => {
|
||||
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
|
||||
const modelName = 'openai/gpt-5-mini'
|
||||
const upstream = {
|
||||
keys: [
|
||||
{ id: 'k1', ciphertext: crypto.encryptKey('sk-key-one', { modelName, keyEntryId: 'k1' }) },
|
||||
{ id: 'k2', ciphertext: crypto.encryptKey('sk-key-two', { modelName, keyEntryId: 'k2' }) },
|
||||
{ id: 'k3', ciphertext: crypto.encryptKey('sk-key-three', { modelName, keyEntryId: 'k3' }) },
|
||||
],
|
||||
}
|
||||
const { metrics } = makeMetrics()
|
||||
|
||||
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
|
||||
const collected: { id: string, secret: string }[] = []
|
||||
for (const entry of rotator) {
|
||||
collected.push({ id: entry.id, secret: entry.plaintext.toString('utf8') })
|
||||
entry.plaintext.fill(0)
|
||||
}
|
||||
|
||||
expect(collected).toHaveLength(3)
|
||||
expect(collected[0]).toEqual({ id: 'k1', secret: 'sk-key-one' })
|
||||
expect(collected[1]).toEqual({ id: 'k2', secret: 'sk-key-two' })
|
||||
expect(collected[2]).toEqual({ id: 'k3', secret: 'sk-key-three' })
|
||||
})
|
||||
|
||||
it('iterator stops after final key (no extra yields)', () => {
|
||||
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
|
||||
const modelName = 'm'
|
||||
const upstream = {
|
||||
keys: [{ id: 'only', ciphertext: crypto.encryptKey('sk-only', { modelName, keyEntryId: 'only' }) }],
|
||||
}
|
||||
const { metrics } = makeMetrics()
|
||||
|
||||
const it1 = createKeyRotator(upstream, crypto, modelName, metrics, 'p')[Symbol.iterator]()
|
||||
const first = it1.next()
|
||||
const second = it1.next()
|
||||
|
||||
expect(first.done).toBe(false)
|
||||
expect(first.value?.id).toBe('only')
|
||||
expect(second.done).toBe(true)
|
||||
})
|
||||
|
||||
it('decrypt failure throws DECRYPT_FAILED (503) and increments decryptFailures counter — does NOT silently skip', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// If a stored ciphertext is corrupted or AAD-forged, decryptKey throws.
|
||||
// Silently skipping that key would mask config-poisoning attempts (an
|
||||
// attacker with configKV write access could overwrite a valid blob with
|
||||
// a junk blob to force the router to fall back). Per plan U3 test (3)
|
||||
// we surface as 503 DECRYPT_FAILED and increment the metric.
|
||||
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
|
||||
const modelName = 'm'
|
||||
const upstream = {
|
||||
keys: [
|
||||
{ id: 'bad', ciphertext: 'v1.AAAA.BBBB.CCCC' },
|
||||
],
|
||||
}
|
||||
const { metrics, decryptFailures } = makeMetrics()
|
||||
|
||||
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
|
||||
|
||||
expect(() => {
|
||||
for (const _ of rotator) {
|
||||
// unreachable on the first key
|
||||
}
|
||||
}).toThrow(ApiError)
|
||||
|
||||
try {
|
||||
for (const _ of rotator) {
|
||||
// re-walk so we can inspect the thrown ApiError details
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).statusCode).toBe(503)
|
||||
expect((err as ApiError).errorCode).toBe('DECRYPT_FAILED')
|
||||
expect((err as ApiError).details).toMatchObject({ keyEntryId: 'bad', modelName: 'm' })
|
||||
}
|
||||
|
||||
// Counter incremented twice (we walked the iterator twice above).
|
||||
expect((decryptFailures.add as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1)
|
||||
const firstCall = (decryptFailures.add as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(firstCall[0]).toBe(1)
|
||||
expect(firstCall[1]).toEqual({ provider: 'openrouter', key_entry_id: 'bad' })
|
||||
})
|
||||
|
||||
it('decrypt failure on a later key still aborts iteration immediately (no partial yields)', () => {
|
||||
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
|
||||
const modelName = 'm'
|
||||
const upstream = {
|
||||
keys: [
|
||||
{ id: 'k1', ciphertext: crypto.encryptKey('sk-good', { modelName, keyEntryId: 'k1' }) },
|
||||
{ id: 'bad', ciphertext: 'v1.AAAA.BBBB.CCCC' },
|
||||
],
|
||||
}
|
||||
const { metrics } = makeMetrics()
|
||||
|
||||
const rotator = createKeyRotator(upstream, crypto, modelName, metrics, 'openrouter')
|
||||
const collected: string[] = []
|
||||
expect(() => {
|
||||
for (const entry of rotator) {
|
||||
collected.push(entry.id)
|
||||
entry.plaintext.fill(0)
|
||||
}
|
||||
}).toThrow(/DECRYPT_FAILED|Failed to decrypt/)
|
||||
|
||||
expect(collected).toEqual(['k1'])
|
||||
})
|
||||
|
||||
it('tolerates null gatewayMetrics (OTel disabled) without throwing', () => {
|
||||
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
|
||||
const modelName = 'm'
|
||||
const upstream = {
|
||||
keys: [{ id: 'k1', ciphertext: crypto.encryptKey('sk-x', { modelName, keyEntryId: 'k1' }) }],
|
||||
}
|
||||
|
||||
const rotator = createKeyRotator(upstream, crypto, modelName, null, 'openrouter')
|
||||
const first = rotator[Symbol.iterator]().next()
|
||||
expect(first.value?.id).toBe('k1')
|
||||
expect(first.value?.plaintext.toString('utf8')).toBe('sk-x')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
// NOTICE:
|
||||
// The Valibot schemas in `services/config-kv.ts` are the single source of
|
||||
// truth for the router config tree. We re-export inferred types so downstream
|
||||
// modules don't redeclare the shape. New fields belong in config-kv.ts, not
|
||||
// here.
|
||||
// Source: server/apps/api/src/services/config-kv.ts (llmRouterConfigSchema).
|
||||
import type {
|
||||
asrModelSchema,
|
||||
asrUpstreamSchema,
|
||||
fallbackTriggersSchema,
|
||||
keyEntrySchema,
|
||||
llmModelSchema,
|
||||
llmRouterConfigSchema,
|
||||
llmRouterDefaultsSchema,
|
||||
llmRoutingGroupSchema,
|
||||
llmRoutingSchema,
|
||||
llmUpstreamSchema,
|
||||
routeFailureTriggersSchema,
|
||||
ttsModelSchema,
|
||||
ttsRoutingGroupSchema,
|
||||
ttsRoutingSchema,
|
||||
ttsUpstreamSchema,
|
||||
} from '../../adapters/config-kv'
|
||||
|
||||
/**
|
||||
* Composite router config (the value at `LLM_ROUTER_CONFIG` in configKV).
|
||||
*/
|
||||
export type RouterConfig = InferOutput<typeof llmRouterConfigSchema>
|
||||
|
||||
/**
|
||||
* Top-level routing defaults (per-attempt + full-chain timeouts and
|
||||
* fallback HTTP codes).
|
||||
*/
|
||||
export type RouterDefaults = InferOutput<typeof llmRouterDefaultsSchema>
|
||||
|
||||
/**
|
||||
* LLM upstream — one candidate endpoint with its ordered key list.
|
||||
*/
|
||||
export type LlmUpstream = InferOutput<typeof llmUpstreamSchema>
|
||||
|
||||
/**
|
||||
* LLM model entry — upstream candidates plus an optional grouped route.
|
||||
*/
|
||||
export type LlmModel = InferOutput<typeof llmModelSchema>
|
||||
|
||||
/**
|
||||
* LLM route composed from ordered candidate groups.
|
||||
*/
|
||||
export type LlmRouting = InferOutput<typeof llmRoutingSchema>
|
||||
|
||||
/**
|
||||
* One ordered group of interchangeable LLM candidates.
|
||||
*/
|
||||
export type LlmRoutingGroup = InferOutput<typeof llmRoutingGroupSchema>
|
||||
|
||||
/**
|
||||
* TTS upstream — one candidate endpoint with adapter params + key list.
|
||||
*/
|
||||
export type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
|
||||
|
||||
/**
|
||||
* TTS model entry — provider tag, upstream candidates, and optional grouped route.
|
||||
*/
|
||||
export type TtsModel = InferOutput<typeof ttsModelSchema>
|
||||
|
||||
/**
|
||||
* TTS route composed from ordered candidate groups.
|
||||
*/
|
||||
export type TtsRouting = InferOutput<typeof ttsRoutingSchema>
|
||||
|
||||
/**
|
||||
* One group of interchangeable TTS candidates.
|
||||
*/
|
||||
export type TtsRoutingGroup = InferOutput<typeof ttsRoutingGroupSchema>
|
||||
|
||||
/**
|
||||
* ASR model entry — provider tag + ordered upstreams for realtime transcription.
|
||||
*/
|
||||
export type AsrModel = InferOutput<typeof asrModelSchema>
|
||||
|
||||
/**
|
||||
* ASR upstream — one provider credential set plus adapter params.
|
||||
*/
|
||||
export type AsrUpstream = InferOutput<typeof asrUpstreamSchema>
|
||||
|
||||
/**
|
||||
* Per-(upstream) fallback trigger config: which upstream HTTP codes should
|
||||
* cause the router to move on to the next key/upstream.
|
||||
*/
|
||||
export type FallbackTriggers = InferOutput<typeof fallbackTriggersSchema>
|
||||
|
||||
/**
|
||||
* Failure allow-list that authorizes a routing transition.
|
||||
*/
|
||||
export type RouteFailureTriggers = InferOutput<typeof routeFailureTriggersSchema>
|
||||
|
||||
/**
|
||||
* One entry in `upstream.keys`: stable id + at-rest envelope ciphertext.
|
||||
* The plaintext key is only produced lazily by the key-rotator at call time.
|
||||
*/
|
||||
export type KeyEntry = InferOutput<typeof keyEntrySchema>
|
||||
|
||||
/**
|
||||
* Which surface the router orchestrates for a given route call.
|
||||
*/
|
||||
export type ModelKind = 'llm' | 'tts'
|
||||
|
||||
/**
|
||||
* A single inbound request the router knows how to dispatch.
|
||||
*
|
||||
* The body is **already-parsed JSON** (not a Buffer). The router clones it
|
||||
* per attempt and injects `model` + the auth header before forwarding to the
|
||||
* chosen upstream.
|
||||
*/
|
||||
export interface LlmRouteRequest {
|
||||
/**
|
||||
* Model name from the caller (e.g. `openai/gpt-5-mini`). Used to look up
|
||||
* the per-model upstream list in `LLM_ROUTER_CONFIG`.
|
||||
*/
|
||||
modelName: string
|
||||
/** Already-parsed JSON body (OpenAI-shaped chat-completions payload). */
|
||||
body: Record<string, unknown>
|
||||
/**
|
||||
* Caller-supplied headers to forward. The router overwrites `authorization`
|
||||
* and `content-type`; everything else passes through.
|
||||
*/
|
||||
headers?: Record<string, string>
|
||||
/**
|
||||
* Caller-side abort signal (typically the client disconnect signal). When
|
||||
* fired mid-flight, the active upstream fetch is aborted and the router
|
||||
* stops without trying further keys/upstreams.
|
||||
*/
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Auxiliary context shape kept alongside one `route()` invocation. Not part
|
||||
* of the public input — exists so future billing-attribution work can thread
|
||||
* userId / billing tags through without changing the call signature.
|
||||
*
|
||||
* Per SEC-5: upstream response bodies must never enter this shape. Only
|
||||
* status codes (or `'timeout'`) and counts are safe to carry.
|
||||
*/
|
||||
export interface LlmRouteContext {
|
||||
/** Provider tag for OTel labels (e.g. `openrouter`). */
|
||||
provider: string
|
||||
/** Actual model id sent to the winning upstream after `overrideModel` rewrites. */
|
||||
upstreamModel?: string
|
||||
/** Number of upstreams attempted so far. */
|
||||
triedUpstreams: number
|
||||
/** Number of keys attempted across all upstreams so far. */
|
||||
triedKeys: number
|
||||
/** Most recent upstream failure status or `'timeout'`. */
|
||||
lastStatus: number | 'timeout' | null
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { startChatGeneration, startTtsGeneration } from '.'
|
||||
|
||||
// Mock the Langfuse SDK so tests assert what the module sends to it without a
|
||||
// real exporter. `startObservation` returns a stub generation whose methods are
|
||||
// spies; `otelSpan.setAttribute` captures trace-identity attributes.
|
||||
const generationStub = {
|
||||
otelSpan: { setAttribute: vi.fn() },
|
||||
update: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}
|
||||
const startObservation = vi.fn((_name: string, _attributes: unknown, _options: unknown) => generationStub)
|
||||
vi.mock('@langfuse/tracing', () => ({
|
||||
startObservation: (name: string, attributes: unknown, options: unknown) => startObservation(name, attributes, options),
|
||||
}))
|
||||
|
||||
const BASE_INPUT = {
|
||||
input: [{ role: 'user', content: 'hi' }],
|
||||
model: 'openai/gpt-5-mini',
|
||||
requestId: 'req-1',
|
||||
stream: false,
|
||||
userId: 'user-1',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
startObservation.mockClear()
|
||||
generationStub.otelSpan.setAttribute.mockClear()
|
||||
generationStub.update.mockClear()
|
||||
generationStub.end.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('startChatGeneration', () => {
|
||||
describe('when LANGFUSE_TRACING_ACTIVE is not "1"', () => {
|
||||
it('returns a no-op trace and never calls the SDK', () => {
|
||||
// @example disabled deployment: no env set
|
||||
const trace = startChatGeneration(BASE_INPUT)
|
||||
trace.appendStreamChunk('data: {"choices":[{"delta":{"content":"x"}}]}\n')
|
||||
trace.succeed({ output: 'x', promptTokens: 1, completionTokens: 1 })
|
||||
trace.fail('should be ignored')
|
||||
|
||||
expect(startObservation).not.toHaveBeenCalled()
|
||||
expect(generationStub.update).not.toHaveBeenCalled()
|
||||
expect(generationStub.end).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when LANGFUSE_TRACING_ACTIVE is "1"', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('LANGFUSE_TRACING_ACTIVE', '1')
|
||||
})
|
||||
|
||||
it('creates a generation with input/model/metadata and trace identity', () => {
|
||||
// @example a request with a client conversation id
|
||||
startChatGeneration({ ...BASE_INPUT, sessionId: 'sess-9', stream: true })
|
||||
|
||||
expect(startObservation).toHaveBeenCalledWith(
|
||||
'chat.completion',
|
||||
{
|
||||
input: BASE_INPUT.input,
|
||||
model: BASE_INPUT.model,
|
||||
metadata: { requestId: 'req-1', stream: true },
|
||||
},
|
||||
{ asType: 'generation' },
|
||||
)
|
||||
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.user.id', 'user-1')
|
||||
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.session.id', 'sess-9')
|
||||
})
|
||||
|
||||
it('omits session attribute when no sessionId is supplied', () => {
|
||||
// @example a request without x-airi-session-id → user-only attribution
|
||||
startChatGeneration(BASE_INPUT)
|
||||
|
||||
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.user.id', 'user-1')
|
||||
expect(generationStub.otelSpan.setAttribute).not.toHaveBeenCalledWith('langfuse.session.id', expect.anything())
|
||||
})
|
||||
|
||||
it('records explicit output + usage + flux on succeed (non-streaming)', () => {
|
||||
// @example non-streaming completion passes the parsed response body
|
||||
const trace = startChatGeneration(BASE_INPUT)
|
||||
trace.succeed({ output: { ok: true }, promptTokens: 12, completionTokens: 34, fluxConsumed: 5 })
|
||||
|
||||
expect(generationStub.update).toHaveBeenCalledWith({
|
||||
output: { ok: true },
|
||||
usageDetails: { input: 12, output: 34 },
|
||||
metadata: { requestId: 'req-1', stream: false, fluxConsumed: 5 },
|
||||
})
|
||||
expect(generationStub.end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('assembles streamed assistant text across chunk boundaries for succeed output', () => {
|
||||
// @example a delta whose JSON is split mid-key across two chunks
|
||||
const trace = startChatGeneration({ ...BASE_INPUT, stream: true })
|
||||
trace.appendStreamChunk('data: {"choices":[{"delta":{"role":"assistant"}}]}\n')
|
||||
trace.appendStreamChunk('data: {"choices":[{"delta":{"con')
|
||||
trace.appendStreamChunk('tent":"Hel"}}]}\ndata: {"choices":[{"delta":{"content":"lo"}}]}\n')
|
||||
trace.appendStreamChunk('data: [DONE]\n')
|
||||
trace.succeed({ promptTokens: 2, completionTokens: 1, fluxConsumed: 1 })
|
||||
|
||||
expect(generationStub.update).toHaveBeenCalledWith({
|
||||
output: 'Hello',
|
||||
usageDetails: { input: 2, output: 1 },
|
||||
metadata: { requestId: 'req-1', stream: true, fluxConsumed: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores malformed and non-text SSE lines when assembling output', () => {
|
||||
// @example usage-only chunk, blank line, and broken JSON contribute nothing
|
||||
const trace = startChatGeneration({ ...BASE_INPUT, stream: true })
|
||||
trace.appendStreamChunk('\n')
|
||||
trace.appendStreamChunk('data: {bad json\n')
|
||||
trace.appendStreamChunk('data: {"choices":[],"usage":{"prompt_tokens":5}}\n')
|
||||
trace.appendStreamChunk('data: {"choices":[{"delta":{"content":"A"}}]}\n')
|
||||
trace.succeed({})
|
||||
|
||||
expect(generationStub.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ output: 'A' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('records ERROR level + message on fail', () => {
|
||||
// @example router exhaustion / upstream non-2xx
|
||||
const trace = startChatGeneration(BASE_INPUT)
|
||||
trace.fail('Gateway 502')
|
||||
|
||||
expect(generationStub.update).toHaveBeenCalledWith({
|
||||
level: 'ERROR',
|
||||
statusMessage: 'Gateway 502',
|
||||
metadata: { requestId: 'req-1', stream: false },
|
||||
})
|
||||
expect(generationStub.end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ends only once even if succeed/fail are called repeatedly', () => {
|
||||
// @example defensive: overlapping transport exit branches
|
||||
const trace = startChatGeneration(BASE_INPUT)
|
||||
trace.succeed({ output: 'first' })
|
||||
trace.succeed({ output: 'second' })
|
||||
trace.fail('late failure')
|
||||
|
||||
expect(generationStub.end).toHaveBeenCalledTimes(1)
|
||||
expect(generationStub.update).toHaveBeenCalledTimes(1)
|
||||
expect(generationStub.update).toHaveBeenCalledWith(expect.objectContaining({ output: 'first' }))
|
||||
})
|
||||
|
||||
it('hard-caps streamed assistant text even when one SSE delta exceeds the remaining space', () => {
|
||||
// @example a single very large provider delta should not overflow the buffer cap
|
||||
const trace = startChatGeneration({ ...BASE_INPUT, stream: true })
|
||||
trace.appendStreamChunk(`data: ${JSON.stringify({ choices: [{ delta: { content: 'x'.repeat(1_100_000) } }] })}\n`)
|
||||
trace.succeed({})
|
||||
|
||||
const output = generationStub.update.mock.calls[0][0].output
|
||||
expect(output).toHaveLength(1_000_000)
|
||||
})
|
||||
|
||||
it('creates a TTS generation and records character usage without buffering audio', () => {
|
||||
// @example /audio/speech request: text in, content-type metadata out
|
||||
const trace = startTtsGeneration({
|
||||
input: { text: 'hello', voice: 'alloy', responseFormat: 'mp3' },
|
||||
model: 'tts-1',
|
||||
requestId: 'tts-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'sess-1',
|
||||
})
|
||||
trace.succeed({
|
||||
inputChars: 5,
|
||||
fluxConsumed: 2,
|
||||
output: { contentType: 'audio/mpeg' },
|
||||
})
|
||||
|
||||
expect(startObservation).toHaveBeenCalledWith(
|
||||
'tts.speech',
|
||||
{
|
||||
input: { text: 'hello', voice: 'alloy', responseFormat: 'mp3' },
|
||||
model: 'tts-1',
|
||||
metadata: {
|
||||
requestId: 'tts-1',
|
||||
inputChars: 5,
|
||||
voice: 'alloy',
|
||||
speed: undefined,
|
||||
responseFormat: 'mp3',
|
||||
},
|
||||
},
|
||||
{ asType: 'generation' },
|
||||
)
|
||||
expect(generationStub.update).toHaveBeenCalledWith({
|
||||
output: { contentType: 'audio/mpeg' },
|
||||
usageDetails: { input: 5 },
|
||||
metadata: {
|
||||
requestId: 'tts-1',
|
||||
inputChars: 5,
|
||||
voice: 'alloy',
|
||||
speed: undefined,
|
||||
responseFormat: 'mp3',
|
||||
fluxConsumed: 2,
|
||||
},
|
||||
})
|
||||
expect(generationStub.otelSpan.setAttribute).toHaveBeenCalledWith('langfuse.session.id', 'sess-1')
|
||||
expect(generationStub.end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { startObservation } from '@langfuse/tracing'
|
||||
|
||||
/**
|
||||
* Upper bound on the assistant text buffered for a streaming generation's
|
||||
* `output`. Without a cap a pathological long completion under concurrency would
|
||||
* pin `N × completion_size` in memory; Langfuse truncates large payloads
|
||||
* server-side anyway, so a generous cap loses nothing useful.
|
||||
*/
|
||||
const STREAM_OUTPUT_CHAR_CAP = 1_000_000
|
||||
|
||||
/**
|
||||
* Whether per-request Langfuse generations should be created.
|
||||
*
|
||||
* Gated on the `LANGFUSE_TRACING_ACTIVE` sentinel that `instrumentation.ts` sets
|
||||
* ONLY after `setLangfuseTracerProvider()` succeeds — not on a raw key check.
|
||||
* Why: if the isolated Langfuse provider is not actually wired, `startObservation`
|
||||
* falls back to the GLOBAL OTel TracerProvider, which would ship prompt/completion
|
||||
* text to the OTLP/Grafana exporter. Binding to the real provider state (single
|
||||
* source of truth in instrumentation.ts) keeps a future change to the enable
|
||||
* condition there from silently desyncing this gate and leaking PII to the wrong
|
||||
* backend. Read per call (cheap; the value is process-constant after the preload
|
||||
* sets it) so the boundary stays self-contained and trivially testable.
|
||||
*/
|
||||
function tracingActive(): boolean {
|
||||
return process.env.LANGFUSE_TRACING_ACTIVE === '1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the assistant text delta from a single OpenAI streaming SSE line.
|
||||
*
|
||||
* Before:
|
||||
* - `data: {"choices":[{"delta":{"content":"Hel"}}]}`
|
||||
*
|
||||
* After:
|
||||
* - `"Hel"`
|
||||
*
|
||||
* Returns `''` for lines that carry no assistant text — `[DONE]`, role-only
|
||||
* deltas, usage-only chunks, blank/comment lines, or malformed JSON. The upstream
|
||||
* SSE is an external boundary, so per-line JSON parsing is tolerated and a parse
|
||||
* failure degrades to "this line added no text" rather than aborting capture:
|
||||
* output is best-effort trace data, not billing.
|
||||
*/
|
||||
function extractSseDeltaText(sseLine: string): string {
|
||||
const trimmed = sseLine.trimStart()
|
||||
if (!trimmed.startsWith('data:'))
|
||||
return ''
|
||||
const payload = trimmed.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]')
|
||||
return ''
|
||||
try {
|
||||
const json = JSON.parse(payload)
|
||||
const content = json?.choices?.[0]?.delta?.content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Parameters identifying a request a Langfuse generation traces. */
|
||||
interface GenerationInput {
|
||||
/** Provider-domain input payload, recorded verbatim as trace input. */
|
||||
input: unknown
|
||||
/** Resolved upstream model id (after `auto` aliases are replaced). */
|
||||
model: string
|
||||
/** Correlation id shared with billing / request-log rows. */
|
||||
requestId: string
|
||||
/** Generation name shown in Langfuse. */
|
||||
name: string
|
||||
/** Extra observation metadata. */
|
||||
metadata?: Record<string, unknown>
|
||||
/** Billing/identity owner of the request. Lifted to trace-level `userId`. */
|
||||
userId: string
|
||||
/** Client-supplied conversation id (`x-airi-session-id`). Absent → user-only attribution. */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
/** Parameters identifying the chat request a generation traces. */
|
||||
export interface ChatGenerationInput extends Omit<GenerationInput, 'name' | 'metadata'> {
|
||||
/** OpenAI chat `messages` array (the prompt), recorded verbatim as trace input. */
|
||||
input: unknown
|
||||
/** Whether the response is streamed (affects how output is captured). */
|
||||
stream: boolean
|
||||
}
|
||||
|
||||
/** Parameters identifying the TTS request a generation traces. */
|
||||
export interface TtsGenerationInput extends Omit<GenerationInput, 'name' | 'metadata'> {
|
||||
/** Adapter-neutral TTS request payload, recorded as trace input. */
|
||||
input: {
|
||||
text: string
|
||||
voice?: string
|
||||
speed?: number
|
||||
responseFormat?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal usage/cost figures recorded when a generation completes successfully. */
|
||||
interface GenerationResult {
|
||||
/**
|
||||
* Explicit completion to record. Omit for streaming requests to use the
|
||||
* assistant text assembled from the streamed SSE deltas.
|
||||
*/
|
||||
output?: unknown
|
||||
/** Usage dimensions for Langfuse. For chat this is token counts; for TTS this is character count. */
|
||||
usageDetails?: Record<string, number>
|
||||
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
|
||||
fluxConsumed?: number
|
||||
/** Additional terminal metadata to merge with request metadata. */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Terminal usage/cost figures recorded when a chat generation completes successfully. */
|
||||
export interface ChatGenerationResult {
|
||||
/**
|
||||
* Explicit completion to record. Omit for streaming requests to use the
|
||||
* assistant text assembled from the streamed SSE deltas.
|
||||
*/
|
||||
output?: unknown
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
|
||||
fluxConsumed?: number
|
||||
}
|
||||
|
||||
/** Terminal usage/cost figures recorded when a TTS generation completes successfully. */
|
||||
export interface TtsGenerationResult {
|
||||
/** Output metadata only; binary audio is not buffered into Langfuse. */
|
||||
output?: unknown
|
||||
/** Input character count charged by the TTS flux meter. */
|
||||
inputChars: number
|
||||
/** AIRI business cost (flux). Stored in generation metadata, not `costDetails`. */
|
||||
fluxConsumed?: number
|
||||
/** Additional terminal metadata to merge with request metadata. */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle handle for one chat completion's Langfuse generation.
|
||||
*
|
||||
* Hides whether Langfuse is enabled (no-op when off), the SDK call shape, the
|
||||
* trace field mapping, and the streamed-output assembly. The owning route only
|
||||
* drives the domain lifecycle: feed stream chunks, then end with success or
|
||||
* failure exactly once (subsequent calls are ignored, so every transport exit
|
||||
* branch can call defensively without double-ending).
|
||||
*/
|
||||
export interface ChatGenerationTrace {
|
||||
/**
|
||||
* Feed one decoded chunk of streamed SSE text. Accumulates the assistant
|
||||
* completion for the trace `output`, bounded by the char cap. No-op for
|
||||
* non-streaming requests (which pass `output` to {@link ChatGenerationTrace.succeed}).
|
||||
*/
|
||||
appendStreamChunk: (decodedChunk: string) => void
|
||||
/** Record a successful completion with usage/cost and end the generation. */
|
||||
succeed: (result: ChatGenerationResult) => void
|
||||
/** Record a failure (`level: ERROR` + message) and end the generation. */
|
||||
fail: (statusMessage: string) => void
|
||||
}
|
||||
|
||||
/** Lifecycle handle for one TTS Langfuse generation. */
|
||||
export interface TtsGenerationTrace {
|
||||
/** Record a successful speech generation with character usage/cost and end the generation. */
|
||||
succeed: (result: TtsGenerationResult) => void
|
||||
/** Record a failure (`level: ERROR` + message) and end the generation. */
|
||||
fail: (statusMessage: string) => void
|
||||
}
|
||||
|
||||
const NOOP_CHAT_TRACE: ChatGenerationTrace = {
|
||||
appendStreamChunk() {},
|
||||
succeed() {},
|
||||
fail() {},
|
||||
}
|
||||
|
||||
const NOOP_TTS_TRACE: TtsGenerationTrace = {
|
||||
succeed() {},
|
||||
fail() {},
|
||||
}
|
||||
|
||||
function startGeneration(input: GenerationInput): {
|
||||
succeed: (result: GenerationResult) => void
|
||||
fail: (statusMessage: string) => void
|
||||
} | null {
|
||||
if (!tracingActive())
|
||||
return null
|
||||
|
||||
const baseMetadata = { requestId: input.requestId, ...input.metadata }
|
||||
const generation = startObservation(input.name, {
|
||||
input: input.input,
|
||||
model: input.model,
|
||||
metadata: baseMetadata,
|
||||
}, { asType: 'generation' })
|
||||
// Trace-level identity via Langfuse compat attributes, lifted to the trace by
|
||||
// the platform — enables per-user / per-session cost attribution.
|
||||
generation.otelSpan.setAttribute('langfuse.user.id', input.userId)
|
||||
if (input.sessionId)
|
||||
generation.otelSpan.setAttribute('langfuse.session.id', input.sessionId)
|
||||
|
||||
let ended = false
|
||||
|
||||
return {
|
||||
succeed(result) {
|
||||
if (ended)
|
||||
return
|
||||
ended = true
|
||||
generation.update({
|
||||
output: result.output,
|
||||
usageDetails: result.usageDetails,
|
||||
metadata: { ...baseMetadata, ...result.metadata, fluxConsumed: result.fluxConsumed ?? 0 },
|
||||
})
|
||||
generation.end()
|
||||
},
|
||||
fail(statusMessage) {
|
||||
if (ended)
|
||||
return
|
||||
ended = true
|
||||
generation.update({ level: 'ERROR', statusMessage, metadata: baseMetadata })
|
||||
generation.end()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a Langfuse generation for a chat completion, or a no-op handle when
|
||||
* Langfuse tracing is disabled.
|
||||
*
|
||||
* Use when:
|
||||
* - Entering a chat completion handler that should be traced for prompt/eval/cost.
|
||||
*
|
||||
* Expects:
|
||||
* - Called once per request. `instrumentation.ts` has already wired the isolated
|
||||
* Langfuse TracerProvider when tracing is active.
|
||||
*
|
||||
* Returns:
|
||||
* - A {@link ChatGenerationTrace} whose `succeed`/`fail` are idempotent; the
|
||||
* first call ends the generation and later calls are ignored.
|
||||
*/
|
||||
export function startChatGeneration(input: ChatGenerationInput): ChatGenerationTrace {
|
||||
const generation = startGeneration({
|
||||
input: input.input,
|
||||
model: input.model,
|
||||
requestId: input.requestId,
|
||||
name: 'chat.completion',
|
||||
metadata: { stream: input.stream },
|
||||
userId: input.userId,
|
||||
sessionId: input.sessionId,
|
||||
})
|
||||
if (!generation)
|
||||
return NOOP_CHAT_TRACE
|
||||
|
||||
let assistantText = ''
|
||||
let sseLineBuffer = ''
|
||||
|
||||
return {
|
||||
appendStreamChunk(decodedChunk) {
|
||||
if (assistantText.length >= STREAM_OUTPUT_CHAR_CAP)
|
||||
return
|
||||
// Split on newlines, parse complete lines, keep the partial trailing line
|
||||
// for the next chunk so a delta split across a chunk boundary still parses.
|
||||
sseLineBuffer += decodedChunk
|
||||
const lines = sseLineBuffer.split('\n')
|
||||
sseLineBuffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const deltaText = extractSseDeltaText(line)
|
||||
const remainingChars = STREAM_OUTPUT_CHAR_CAP - assistantText.length
|
||||
if (remainingChars <= 0)
|
||||
break
|
||||
assistantText += deltaText.slice(0, remainingChars)
|
||||
if (assistantText.length >= STREAM_OUTPUT_CHAR_CAP)
|
||||
break
|
||||
}
|
||||
},
|
||||
succeed(result) {
|
||||
generation.succeed({
|
||||
output: result.output ?? assistantText,
|
||||
usageDetails: { input: result.promptTokens ?? 0, output: result.completionTokens ?? 0 },
|
||||
fluxConsumed: result.fluxConsumed,
|
||||
})
|
||||
},
|
||||
fail(statusMessage) {
|
||||
generation.fail(statusMessage)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a Langfuse generation for a TTS request, or a no-op handle when
|
||||
* Langfuse tracing is disabled.
|
||||
*
|
||||
* Use when:
|
||||
* - Entering the OpenAI-compatible `/audio/speech` handler.
|
||||
*
|
||||
* Expects:
|
||||
* - Binary audio is not buffered into Langfuse; callers pass output metadata
|
||||
* such as content type instead.
|
||||
*
|
||||
* Returns:
|
||||
* - A {@link TtsGenerationTrace} whose `succeed`/`fail` are idempotent.
|
||||
*/
|
||||
export function startTtsGeneration(input: TtsGenerationInput): TtsGenerationTrace {
|
||||
const generation = startGeneration({
|
||||
input: input.input,
|
||||
model: input.model,
|
||||
requestId: input.requestId,
|
||||
name: 'tts.speech',
|
||||
metadata: {
|
||||
inputChars: input.input.text.length,
|
||||
voice: input.input.voice,
|
||||
speed: input.input.speed,
|
||||
responseFormat: input.input.responseFormat,
|
||||
},
|
||||
userId: input.userId,
|
||||
sessionId: input.sessionId,
|
||||
})
|
||||
if (!generation)
|
||||
return NOOP_TTS_TRACE
|
||||
|
||||
return {
|
||||
succeed(result) {
|
||||
generation.succeed({
|
||||
output: result.output,
|
||||
usageDetails: { input: result.inputChars },
|
||||
fluxConsumed: result.fluxConsumed,
|
||||
metadata: { inputChars: result.inputChars, ...result.metadata },
|
||||
})
|
||||
},
|
||||
fail(statusMessage) {
|
||||
generation.fail(statusMessage)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import type { GenAiMetrics } from '../../../otel'
|
||||
import type { ConfigKVService } from '../../adapters/config-kv'
|
||||
import type { FluxMeter } from '../billing/flux-meter'
|
||||
import type { FluxService } from '../flux'
|
||||
import type { LlmRouterService } from '../llm-router'
|
||||
import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing'
|
||||
import type { ProductEventService } from '../product-events'
|
||||
import type { ProviderCatalogService } from '../provider-catalog'
|
||||
import type { RequestLogService } from '../request-log'
|
||||
import type { VoicePackService } from '../voice-packs'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
|
||||
|
||||
import { ApiError, createBadRequestError, createPaymentRequiredError } from '../../../utils/error'
|
||||
import { nanoid } from '../../../utils/id'
|
||||
import {
|
||||
AIRI_ATTR_BILLING_FLUX_CONSUMED,
|
||||
AIRI_ATTR_GEN_AI_OPERATION_KIND,
|
||||
GEN_AI_ATTR_REQUEST_MODEL,
|
||||
} from '../../../utils/observability'
|
||||
import { fluxBalanceBucket } from '../flux-balance'
|
||||
|
||||
const tracer = trace.getTracer('v1-completions')
|
||||
|
||||
const SAFE_RESPONSE_HEADERS = new Set([
|
||||
'content-type',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'cache-control',
|
||||
])
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== 'object' || value == null || Array.isArray(value))
|
||||
return undefined
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function readOptionalNumber(record: Record<string, unknown> | undefined, key: string): number | undefined {
|
||||
const value = record?.[key]
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
||||
export interface OpenAiSpeechServiceDeps {
|
||||
fluxService: FluxService
|
||||
configKV: ConfigKVService
|
||||
requestLogService: RequestLogService
|
||||
ttsMeter: FluxMeter
|
||||
llmRouter: LlmRouterService
|
||||
voicePackService: VoicePackService
|
||||
providerCatalogService: ProviderCatalogService
|
||||
productEventService: ProductEventService
|
||||
genAi?: GenAiMetrics | null
|
||||
llmTracing: {
|
||||
startTtsGeneration: (input: Parameters<typeof startTtsGeneration>[0]) => TtsGenerationTrace
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenAiSpeechRequest {
|
||||
userId: string
|
||||
body: Record<string, unknown>
|
||||
sessionId?: string
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
type TtsTrigger = 'auto' | 'manual'
|
||||
type TtsVoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
|
||||
|
||||
interface TtsAnalyticsContext {
|
||||
trigger: TtsTrigger
|
||||
source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
|
||||
voiceType: TtsVoiceType
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the OpenAI-shaped text-to-speech gateway flow.
|
||||
*
|
||||
* Use when:
|
||||
* - The HTTP route has parsed an authenticated `/audio/speech` request and
|
||||
* needs domain orchestration for billing, routing, tracing, and logging.
|
||||
*
|
||||
* Expects:
|
||||
* - `body` is the parsed JSON request body.
|
||||
* - Auth and route guards have already run.
|
||||
*
|
||||
* Returns:
|
||||
* - A gateway `Response` with safe upstream headers and audio body.
|
||||
*/
|
||||
export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
|
||||
const logger = useLogger('v1-completions').useGlobalConfig()
|
||||
|
||||
async function handleSpeechRequest(input: OpenAiSpeechRequest): Promise<Response> {
|
||||
const requestId = nanoid()
|
||||
const requestedModel = typeof input.body.model === 'string' ? input.body.model : 'auto'
|
||||
let requestModel = requestedModel
|
||||
const requestVoice = typeof input.body.voice === 'string' ? input.body.voice : undefined
|
||||
const inputText = typeof input.body.input === 'string' ? input.body.input : ''
|
||||
const analytics = ttsAnalyticsContext(input.body)
|
||||
|
||||
const voicePackRequest = await voicePackRequestOptions(input.body, {
|
||||
requestedModel,
|
||||
voice: requestVoice,
|
||||
voicePackService: deps.voicePackService,
|
||||
})
|
||||
requestModel = voicePackRequest.model ?? requestModel
|
||||
if (requestModel === 'auto')
|
||||
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
|
||||
const routedVoice = voicePackRequest.voice ?? requestVoice
|
||||
await deps.providerCatalogService.assertTtsModelEnabled(requestModel)
|
||||
if (!voicePackRequest.voicePackId && routedVoice)
|
||||
await deps.providerCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice)
|
||||
|
||||
const voiceMetadata = ttsVoiceMetadata({
|
||||
voice: requestVoice,
|
||||
voicePackId: voicePackRequest.voicePackId,
|
||||
voiceType: analytics.voiceType,
|
||||
})
|
||||
const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier)
|
||||
|
||||
logger.withFields({
|
||||
requestId,
|
||||
userId: input.userId,
|
||||
model: requestModel,
|
||||
inputChars: inputText.length,
|
||||
voice: requestVoice,
|
||||
}).log('tts speech request')
|
||||
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_requested',
|
||||
status: 'started',
|
||||
source: analytics.source,
|
||||
model: requestModel,
|
||||
metadata: {
|
||||
input_chars: inputText.length,
|
||||
trigger: analytics.trigger,
|
||||
...voiceMetadata,
|
||||
},
|
||||
})
|
||||
|
||||
const flux = await deps.fluxService.getFlux(input.userId)
|
||||
try {
|
||||
await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux)
|
||||
}
|
||||
catch (err) {
|
||||
if (!(err instanceof ApiError) || err.statusCode !== 402)
|
||||
throw err
|
||||
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_blocked',
|
||||
status: 'blocked',
|
||||
source: analytics.source,
|
||||
model: requestModel,
|
||||
reason: 'insufficient_balance',
|
||||
metadata: {
|
||||
input_chars: inputText.length,
|
||||
billing_units: billingUnits,
|
||||
block_reason: 'insufficient_balance',
|
||||
balance_state: 'insufficient',
|
||||
flux_balance_bucket: fluxBalanceBucket(flux.flux),
|
||||
trigger: analytics.trigger,
|
||||
...voiceMetadata,
|
||||
},
|
||||
})
|
||||
logger.withError(err).withFields({
|
||||
requestId,
|
||||
userId: input.userId,
|
||||
model: requestModel,
|
||||
trigger: analytics.trigger,
|
||||
source: analytics.source,
|
||||
}).warn('tts speech blocked by pre-flight balance check')
|
||||
|
||||
if (analytics.trigger === 'auto')
|
||||
return new Response(null, { status: 204 })
|
||||
|
||||
throw createPaymentRequiredError('Insufficient flux')
|
||||
}
|
||||
|
||||
const ttsInput = {
|
||||
text: inputText,
|
||||
voice: routedVoice,
|
||||
speed: voicePackRequest.speed ?? (typeof input.body.speed === 'number' ? input.body.speed : undefined),
|
||||
responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined,
|
||||
extraOptions: voicePackRequest.extraOptions,
|
||||
}
|
||||
|
||||
const generationTrace = deps.llmTracing.startTtsGeneration({
|
||||
input: ttsInput,
|
||||
model: requestModel,
|
||||
requestId,
|
||||
userId: input.userId,
|
||||
sessionId: input.sessionId,
|
||||
})
|
||||
|
||||
const span = tracer.startSpan('llm.gateway.tts', {
|
||||
attributes: {
|
||||
[GEN_AI_ATTR_REQUEST_MODEL]: requestModel,
|
||||
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech',
|
||||
},
|
||||
})
|
||||
|
||||
const startedAt = Date.now()
|
||||
const routeCtx = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null }
|
||||
let response: Response
|
||||
try {
|
||||
response = await context.with(trace.setSpan(context.active(), span), () =>
|
||||
deps.llmRouter.routeTts({
|
||||
modelName: requestModel,
|
||||
input: ttsInput,
|
||||
abortSignal: input.abortSignal,
|
||||
}, routeCtx))
|
||||
}
|
||||
catch (err) {
|
||||
const failure = routerFailure(err)
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: failure.message })
|
||||
span.end()
|
||||
generationTrace.fail(failure.message)
|
||||
recordMetrics({
|
||||
durationMs: Date.now() - startedAt,
|
||||
fluxConsumed: 0,
|
||||
model: requestModel,
|
||||
provider: routeCtx.provider,
|
||||
status: failure.status,
|
||||
})
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_failed',
|
||||
status: 'failed',
|
||||
source: analytics.source,
|
||||
model: requestModel,
|
||||
provider: routeCtx.provider,
|
||||
reason: failure.reason,
|
||||
metadata: {
|
||||
http_status: failure.status,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
failure_reason: failure.reason,
|
||||
trigger: analytics.trigger,
|
||||
...voiceMetadata,
|
||||
},
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
span.setAttribute('http.response.status_code', response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` })
|
||||
span.end()
|
||||
generationTrace.fail(`Gateway ${response.status}`)
|
||||
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed: 0 })
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_failed',
|
||||
status: 'failed',
|
||||
source: analytics.source,
|
||||
model: requestModel,
|
||||
provider: routeCtx.provider,
|
||||
reason: 'upstream_error',
|
||||
metadata: {
|
||||
http_status: response.status,
|
||||
duration_ms: durationMs,
|
||||
failure_reason: 'upstream_error',
|
||||
trigger: analytics.trigger,
|
||||
...voiceMetadata,
|
||||
},
|
||||
})
|
||||
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
|
||||
.warn('tts speech delivered with upstream error status')
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: buildSafeResponseHeaders(response),
|
||||
})
|
||||
}
|
||||
|
||||
let fluxConsumed = 0
|
||||
try {
|
||||
const result = await deps.ttsMeter.accumulate({
|
||||
userId: input.userId,
|
||||
units: billingUnits,
|
||||
currentBalance: flux.flux,
|
||||
requestId,
|
||||
metadata: { model: requestModel, costMultiplier: voicePackRequest.costMultiplier },
|
||||
})
|
||||
fluxConsumed = result.fluxDebited
|
||||
span.setAttribute(AIRI_ATTR_BILLING_FLUX_CONSUMED, fluxConsumed)
|
||||
generationTrace.succeed({
|
||||
inputChars: inputText.length,
|
||||
fluxConsumed,
|
||||
output: { contentType: response.headers.get('content-type') },
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
generationTrace.fail('TTS billing failed')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
span.end()
|
||||
}
|
||||
|
||||
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed })
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_succeeded',
|
||||
status: 'succeeded',
|
||||
source: analytics.source,
|
||||
model: requestModel,
|
||||
provider: routeCtx.provider,
|
||||
metadata: {
|
||||
http_status: response.status,
|
||||
input_chars: inputText.length,
|
||||
billing_units: billingUnits,
|
||||
cost_multiplier: voicePackRequest.costMultiplier,
|
||||
duration_ms: durationMs,
|
||||
flux_consumed: fluxConsumed,
|
||||
trigger: analytics.trigger,
|
||||
...voiceMetadata,
|
||||
},
|
||||
})
|
||||
deps.requestLogService.logRequest({
|
||||
userId: input.userId,
|
||||
model: requestModel,
|
||||
status: response.status,
|
||||
durationMs,
|
||||
fluxConsumed,
|
||||
}).catch(err => logger.withError(err).warn('Failed to write llm_request_log row'))
|
||||
|
||||
logger.withFields({
|
||||
requestId,
|
||||
userId: input.userId,
|
||||
model: requestModel,
|
||||
status: response.status,
|
||||
durationMs,
|
||||
inputChars: inputText.length,
|
||||
fluxConsumed,
|
||||
}).log('tts speech delivered')
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: buildSafeResponseHeaders(response),
|
||||
})
|
||||
}
|
||||
|
||||
function recordMetrics(input: {
|
||||
model: string
|
||||
status: number
|
||||
provider: string
|
||||
durationMs: number
|
||||
fluxConsumed: number
|
||||
}): void {
|
||||
const attrs = {
|
||||
[GEN_AI_ATTR_REQUEST_MODEL]: input.model,
|
||||
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'tts',
|
||||
'http.response.status_code': input.status,
|
||||
'provider': input.provider,
|
||||
}
|
||||
deps.genAi?.operationCount.add(1, attrs)
|
||||
deps.genAi?.operationDuration.record(input.durationMs / 1000, attrs)
|
||||
deps.genAi?.fluxConsumed.add(input.fluxConsumed, attrs)
|
||||
}
|
||||
|
||||
return { handleSpeechRequest }
|
||||
}
|
||||
|
||||
function ttsAnalyticsContext(body: Record<string, unknown>): TtsAnalyticsContext {
|
||||
const extraBody = asRecord(body.extra_body)
|
||||
const analytics = asRecord(extraBody?.airi_analytics)
|
||||
const trigger = analytics?.trigger === 'auto' ? 'auto' : 'manual'
|
||||
const rawSource = analytics?.source
|
||||
const source = rawSource === 'chat_auto_tts'
|
||||
|| rawSource === 'manual_preview'
|
||||
|| rawSource === 'settings_test'
|
||||
? rawSource
|
||||
: 'audio.speech'
|
||||
const voiceType = normalizeVoiceType(analytics?.voice_type)
|
||||
|
||||
return { trigger, source, voiceType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes client-provided TTS voice type into bounded analytics values.
|
||||
*/
|
||||
function normalizeVoiceType(value: unknown): TtsVoiceType {
|
||||
switch (value) {
|
||||
case 'official_default':
|
||||
case 'official_selected':
|
||||
case 'custom_configured':
|
||||
case 'voice_pack':
|
||||
return value
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds reusable low-cardinality voice metadata for every TTS product event.
|
||||
*/
|
||||
function ttsVoiceMetadata(input: {
|
||||
voice?: string
|
||||
voicePackId?: string
|
||||
voiceType: TtsVoiceType
|
||||
}): Record<string, unknown> {
|
||||
const voiceType = input.voicePackId ? 'voice_pack' : input.voiceType
|
||||
return {
|
||||
...(input.voice ? { voice_id: input.voice } : {}),
|
||||
voice_type: voiceType,
|
||||
...(input.voicePackId ? { voice_pack_id: input.voicePackId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function voicePackRequestOptions(
|
||||
body: Record<string, unknown>,
|
||||
context: {
|
||||
requestedModel: string
|
||||
voice?: string
|
||||
voicePackService: VoicePackService
|
||||
},
|
||||
): Promise<{
|
||||
extraOptions: Record<string, unknown> | undefined
|
||||
costMultiplier: number
|
||||
voicePackId?: string
|
||||
model?: string
|
||||
voice?: string
|
||||
speed?: number
|
||||
}> {
|
||||
const extraBody = asRecord(body.extra_body)
|
||||
const voicePackOptions = asRecord(extraBody?.voice_pack)
|
||||
const pitch = readOptionalNumber(voicePackOptions, 'pitch')
|
||||
const volume = readOptionalNumber(voicePackOptions, 'volume')
|
||||
const voicePack = await resolveVoicePackRequest(voicePackOptions, context)
|
||||
const extraOptions: Record<string, unknown> = {}
|
||||
const resolvedPitch = voicePack?.params.pitch ?? pitch
|
||||
const resolvedVolume = voicePack?.params.volume ?? volume
|
||||
if (resolvedPitch != null)
|
||||
extraOptions.pitch = resolvedPitch
|
||||
if (resolvedVolume != null)
|
||||
extraOptions.volume = resolvedVolume
|
||||
|
||||
return {
|
||||
extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined,
|
||||
costMultiplier: voicePack?.costMultiplier ?? 1,
|
||||
voicePackId: voicePack?.id,
|
||||
model: voicePack?.ttsModelId,
|
||||
voice: voicePack?.upstreamVoiceId,
|
||||
speed: voicePack?.params.rate,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveVoicePackRequest(
|
||||
voicePackOptions: Record<string, unknown> | undefined,
|
||||
context: {
|
||||
requestedModel: string
|
||||
voice?: string
|
||||
voicePackService: VoicePackService
|
||||
},
|
||||
): Promise<Awaited<ReturnType<VoicePackService['findById']>> | null> {
|
||||
const packId = voicePackOptions?.pack_id
|
||||
const requestedVoice = context.voice?.trim()
|
||||
if (voicePackOptions?.cost_multiplier != null) {
|
||||
throw createBadRequestError('voice_pack.cost_multiplier is server-managed', 'INVALID_VOICE_PACK', {
|
||||
field: 'voice_pack.cost_multiplier',
|
||||
})
|
||||
}
|
||||
if (packId != null && (typeof packId !== 'string' || !packId.trim()))
|
||||
throw createBadRequestError('voice_pack.pack_id is required when Voice Pack billing metadata is provided', 'INVALID_VOICE_PACK')
|
||||
|
||||
const pack = typeof packId === 'string'
|
||||
? await context.voicePackService.findById(packId)
|
||||
: requestedVoice
|
||||
? await context.voicePackService.findEnabledByVoiceId(requestedVoice)
|
||||
: null
|
||||
if (!pack && packId == null)
|
||||
return null
|
||||
|
||||
if (!pack)
|
||||
throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId })
|
||||
if (!pack.enabled)
|
||||
throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId })
|
||||
|
||||
return pack
|
||||
}
|
||||
|
||||
function routerFailure(error: unknown): { status: number, reason: string, message: string } {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
status: error.statusCode,
|
||||
reason: error.errorCode,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 502,
|
||||
reason: 'router_exhausted',
|
||||
message: 'TTS router exhausted or unknown model',
|
||||
}
|
||||
}
|
||||
|
||||
function buildSafeResponseHeaders(response: Response): Headers {
|
||||
const headers = new Headers()
|
||||
response.headers.forEach((value, key) => {
|
||||
if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase()))
|
||||
headers.set(key, value)
|
||||
})
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { ProductMetrics } from '../../otel'
|
||||
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createProductEventService } from './product-events'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('productEventService', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.productEvents)
|
||||
})
|
||||
|
||||
it('writes first-party events and increments only low-cardinality metric labels', async () => {
|
||||
const events = { add: vi.fn() }
|
||||
const service = createProductEventService(db, { events } as unknown as ProductMetrics)
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'openai.chat.completions',
|
||||
model: 'openrouter/anthropic/claude-sonnet-4',
|
||||
provider: 'openrouter',
|
||||
metadata: {
|
||||
stream: false,
|
||||
flux_consumed: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'openai.chat.completions',
|
||||
model: 'openrouter/anthropic/claude-sonnet-4',
|
||||
provider: 'openrouter',
|
||||
})
|
||||
|
||||
expect(events.add).toHaveBeenCalledWith(1, {
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'openai.chat.completions',
|
||||
})
|
||||
})
|
||||
|
||||
it('aggregates event volume and distinct users by feature/action/status', async () => {
|
||||
const service = createProductEventService(db)
|
||||
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'tts',
|
||||
action: 'speech_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'audio.speech',
|
||||
createdAt,
|
||||
})
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'tts',
|
||||
action: 'speech_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'audio.speech.ws',
|
||||
createdAt,
|
||||
})
|
||||
await service.track({
|
||||
userId: 'user-2',
|
||||
feature: 'tts',
|
||||
action: 'speech_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'audio.speech',
|
||||
createdAt,
|
||||
})
|
||||
|
||||
const rows = await service.countDistinctUsersByFeature({
|
||||
from: new Date('2026-06-02T00:00:00.000Z'),
|
||||
to: new Date('2026-06-04T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(rows).toEqual([{
|
||||
feature: 'tts',
|
||||
action: 'speech_succeeded',
|
||||
status: 'succeeded',
|
||||
eventCount: 3,
|
||||
distinctUsers: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('writes blocked TTS events for server-side preflight decisions', async () => {
|
||||
const events = { add: vi.fn() }
|
||||
const service = createProductEventService(db, { events } as unknown as ProductMetrics)
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'tts',
|
||||
action: 'speech_blocked',
|
||||
status: 'blocked',
|
||||
source: 'chat_auto_tts',
|
||||
reason: 'insufficient_balance',
|
||||
metadata: {
|
||||
trigger: 'auto',
|
||||
balance_state: 'insufficient',
|
||||
flux_balance_bucket: 'zero',
|
||||
},
|
||||
})
|
||||
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
feature: 'tts',
|
||||
action: 'speech_blocked',
|
||||
status: 'blocked',
|
||||
source: 'chat_auto_tts',
|
||||
reason: 'insufficient_balance',
|
||||
})
|
||||
expect(events.add).toHaveBeenCalledWith(1, {
|
||||
feature: 'tts',
|
||||
action: 'speech_blocked',
|
||||
status: 'blocked',
|
||||
source: 'chat_auto_tts',
|
||||
reason: 'insufficient_balance',
|
||||
flux_balance_bucket: 'zero',
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards allowlisted business facts to PostHog keyed by user id, mapping user_signed_up to signup_completed', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const sink = { capture, shutdown: vi.fn(async () => {}) }
|
||||
const service = createProductEventService(db, null, sink)
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
metadata: { amount_minor_unit: 990, currency: 'usd' },
|
||||
})
|
||||
await service.track({
|
||||
userId: 'user-2',
|
||||
feature: 'auth',
|
||||
action: 'user_signed_up',
|
||||
status: 'succeeded',
|
||||
})
|
||||
|
||||
expect(capture).toHaveBeenNthCalledWith(1, {
|
||||
distinctId: 'user-1',
|
||||
event: 'payment_completed',
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-1',
|
||||
feature: 'billing',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
amount_minor_unit: 990,
|
||||
currency: 'usd',
|
||||
},
|
||||
})
|
||||
expect(capture).toHaveBeenNthCalledWith(2, {
|
||||
distinctId: 'user-2',
|
||||
event: 'signup_completed',
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-2',
|
||||
feature: 'auth',
|
||||
status: 'succeeded',
|
||||
},
|
||||
})
|
||||
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('merges Stripe webhook conversions with the browser PostHog person when a distinct id is present', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const sink = { capture, shutdown: vi.fn(async () => {}) }
|
||||
const service = createProductEventService(db, null, sink)
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
metadata: {
|
||||
posthog_distinct_id: 'anon-browser-1',
|
||||
posthog_session_id: 'ph-session-1',
|
||||
stripe_checkout_session_id: 'cs_1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(capture).toHaveBeenNthCalledWith(1, {
|
||||
distinctId: 'user-1',
|
||||
event: '$identify',
|
||||
properties: {
|
||||
$anon_distinct_id: 'anon-browser-1',
|
||||
$session_id: 'ph-session-1',
|
||||
airi_user_id: 'user-1',
|
||||
},
|
||||
})
|
||||
expect(capture).toHaveBeenNthCalledWith(2, {
|
||||
distinctId: 'user-1',
|
||||
event: 'payment_completed',
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-1',
|
||||
posthog_distinct_id: 'anon-browser-1',
|
||||
$session_id: 'ph-session-1',
|
||||
feature: 'billing',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
posthog_session_id: 'ph-session-1',
|
||||
stripe_checkout_session_id: 'cs_1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not forward high-volume per-request actions to PostHog', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const sink = { capture, shutdown: vi.fn(async () => {}) }
|
||||
const service = createProductEventService(db, null, sink)
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_succeeded',
|
||||
status: 'succeeded',
|
||||
})
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'checkout_started',
|
||||
status: 'started',
|
||||
})
|
||||
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('captures an LLM generation as a PostHog AI fact without storing prompts or responses', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const captureQueued = vi.fn()
|
||||
const sink = { capture, captureQueued, shutdown: vi.fn(async () => {}) }
|
||||
const service = createProductEventService(db, null, sink)
|
||||
|
||||
service.trackGeneration({
|
||||
userId: 'user-1',
|
||||
traceId: 'session-1',
|
||||
generationId: 'round-1',
|
||||
model: 'openai/gpt-5-mini',
|
||||
provider: 'openai',
|
||||
providerType: 'official',
|
||||
usageSource: 'reported',
|
||||
inputTokens: 12,
|
||||
outputTokens: 8,
|
||||
totalTokens: 20,
|
||||
conversationId: 'session-1',
|
||||
conversationIdSource: 'client_header',
|
||||
roundId: 'round-1',
|
||||
appSurface: 'electron',
|
||||
captureSurface: 'server',
|
||||
})
|
||||
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
expect(captureQueued).toHaveBeenCalledWith({
|
||||
distinctId: 'user-1',
|
||||
event: '$ai_generation',
|
||||
properties: {
|
||||
$ai_trace_id: 'session-1',
|
||||
$ai_session_id: 'session-1',
|
||||
$ai_span_id: 'round-1',
|
||||
$ai_model: 'openai/gpt-5-mini',
|
||||
$ai_provider: 'openai',
|
||||
$ai_input_tokens: 12,
|
||||
$ai_output_tokens: 8,
|
||||
$ai_total_tokens: 20,
|
||||
$insert_id: 'ai-generation:round-1',
|
||||
airi_user_id: 'user-1',
|
||||
provider_type: 'official',
|
||||
usage_source: 'reported',
|
||||
token_usage_available: true,
|
||||
cost_usd_source: 'unavailable',
|
||||
cost_usd_known: false,
|
||||
conversation_id: 'session-1',
|
||||
conversation_id_source: 'client_header',
|
||||
round_id: 'round-1',
|
||||
app_surface: 'electron',
|
||||
capture_surface: 'server',
|
||||
},
|
||||
})
|
||||
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not forward to PostHog when the DB write fails', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// track() swallows DB insert errors to protect the caller, but the
|
||||
// PostHog forwarding block ran unconditionally afterwards — a Postgres
|
||||
// outage during a Stripe webhook would mint `payment_completed` in
|
||||
// PostHog with no `product_events` row backing it, breaking the
|
||||
// "Postgres is the fact of record" invariant and later reconciliation.
|
||||
// Found by PR #2038 review.
|
||||
//
|
||||
// Fixed by gating forwarding on a `persisted` flag set only after the
|
||||
// insert resolves.
|
||||
const capture = vi.fn(async () => {})
|
||||
const sink = { capture, shutdown: vi.fn(async () => {}) }
|
||||
const service = createProductEventService(db, null, sink)
|
||||
|
||||
// Simulate a DB outage by renaming the table out from under the insert.
|
||||
await db.execute(sql`ALTER TABLE product_events RENAME TO product_events_outage`)
|
||||
try {
|
||||
await expect(service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
})).resolves.toBeUndefined()
|
||||
}
|
||||
finally {
|
||||
await db.execute(sql`ALTER TABLE product_events_outage RENAME TO product_events`)
|
||||
}
|
||||
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('persists the product event even when a misbehaving sink throws', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The PosthogSink contract says implementations swallow transport
|
||||
// errors, but the forwarding call sits on the Stripe webhook path —
|
||||
// if a sink ever throws, an unguarded `await` would fail the webhook
|
||||
// after the fact was already persisted, causing Stripe to retry and
|
||||
// (before the idempotency guard) double-process the payment.
|
||||
//
|
||||
// track() therefore wraps forwarding in its own try/catch: the DB row
|
||||
// must survive and track() must resolve regardless of sink behavior.
|
||||
const capture = vi.fn(async () => {
|
||||
throw new Error('posthog exploded')
|
||||
})
|
||||
const sink = { capture, shutdown: vi.fn(async () => {}) }
|
||||
const service = createProductEventService(db, null, sink)
|
||||
|
||||
await expect(service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
})).resolves.toBeUndefined()
|
||||
|
||||
const rows = await db.select().from(schema.productEvents)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ action: 'payment_completed', status: 'succeeded' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { ProductMetrics } from '../../otel'
|
||||
import type { ProductEventMetadata } from '../../schemas/product-events'
|
||||
import type { PosthogSink } from '../adapters/posthog'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, asc, count, gte, lt, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../../schemas/product-events'
|
||||
|
||||
const logger = useLogger('product-events')
|
||||
|
||||
export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack'
|
||||
|
||||
export type ProductEventStatus = 'started' | 'succeeded' | 'failed' | 'blocked'
|
||||
|
||||
export type ProductAction
|
||||
= | 'user_signed_up'
|
||||
| 'session_started'
|
||||
| 'message_pushed'
|
||||
| 'completion_requested'
|
||||
| 'completion_succeeded'
|
||||
| 'completion_failed'
|
||||
| 'speech_requested'
|
||||
| 'speech_succeeded'
|
||||
| 'speech_failed'
|
||||
| 'speech_blocked'
|
||||
| 'voice_pack_created'
|
||||
| 'voice_pack_updated'
|
||||
| 'voice_pack_disabled'
|
||||
| 'checkout_started'
|
||||
| 'payment_completed'
|
||||
| 'subscription_started'
|
||||
| 'subscription_renewed'
|
||||
| 'subscription_cancelled'
|
||||
| 'topic_classified'
|
||||
|
||||
/**
|
||||
* Product event fact written to AIRI's own Postgres analytics table.
|
||||
*/
|
||||
export interface ProductEventInput {
|
||||
/** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */
|
||||
userId: string
|
||||
/** Bounded product area used for product dashboards and funnels. */
|
||||
feature: ProductFeature
|
||||
/** Bounded user/business action within the feature. */
|
||||
action: ProductAction
|
||||
/** Lifecycle state for the action. */
|
||||
status: ProductEventStatus
|
||||
/** Optional bounded route/surface label such as `openai.chat.completions`. */
|
||||
source?: string
|
||||
/** Optional model alias for DB-side drilldown. Do not expose as a Prometheus label. */
|
||||
model?: string
|
||||
/** Optional provider name for DB-side drilldown. */
|
||||
provider?: string
|
||||
/** Optional bounded failure reason or business outcome. */
|
||||
reason?: string
|
||||
/** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */
|
||||
metadata?: ProductEventMetadata
|
||||
/** Override for tests/backfills. Defaults to database/server current time. */
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
export interface ProductEventAggregateInput {
|
||||
/** Inclusive lower time bound. */
|
||||
from: Date
|
||||
/** Exclusive upper time bound. Omit for open-ended queries. */
|
||||
to?: Date
|
||||
}
|
||||
|
||||
export interface ProductEventAggregateRow {
|
||||
feature: string
|
||||
action: string
|
||||
status: string
|
||||
eventCount: number
|
||||
distinctUsers: number
|
||||
}
|
||||
|
||||
/** Product runtime where the user initiated the AI generation. */
|
||||
export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron'
|
||||
|
||||
/** Runtime that captured the `$ai_generation` fact. */
|
||||
export type AiGenerationCaptureSurface = 'server' | 'client'
|
||||
|
||||
/** Explains whether `conversation_id` is an app conversation or a server fallback. */
|
||||
export type AiGenerationConversationIdSource = 'client_header' | 'server_request'
|
||||
|
||||
/** Explains whether AIRI supplied a trustworthy USD cost for this generation. */
|
||||
export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable'
|
||||
|
||||
/** Content-free PostHog AI generation fact keyed to the authenticated user. */
|
||||
export interface AiGenerationEventInput {
|
||||
userId: string
|
||||
traceId: string
|
||||
generationId: string
|
||||
model: string
|
||||
provider: string
|
||||
providerType: 'official' | 'custom' | 'unknown'
|
||||
usageSource: 'reported' | 'estimated' | 'unavailable'
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
totalTokens?: number
|
||||
totalCostUsd?: number
|
||||
costUsdSource?: AiGenerationCostUsdSource
|
||||
/** Always present for joins; `conversationIdSource` tells whether it is request-level fallback. */
|
||||
conversationId: string
|
||||
/** Distinguishes real client conversation ids from server-generated request fallbacks. */
|
||||
conversationIdSource: AiGenerationConversationIdSource
|
||||
roundId?: string
|
||||
/** Omitted when the server cannot determine the user's product runtime. */
|
||||
appSurface?: AiGenerationAppSurface
|
||||
/** Defaults to `server` because this service runs in the API process. */
|
||||
captureSurface?: AiGenerationCaptureSurface
|
||||
latencySeconds?: number
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side actions worth a PostHog copy, mapped to the event name the
|
||||
* client-side funnels expect. Only business facts that terminate or anchor
|
||||
* a funnel are forwarded — per-request LLM/TTS volume stays in Postgres and
|
||||
* Grafana where it belongs (see `docs/ai-context/metrics-ownership.md`).
|
||||
*
|
||||
* `user_signed_up` maps to `signup_completed` because the identified server
|
||||
* hook is the canonical registration fact for every signup method. Anonymous
|
||||
* auth UI progress uses `signup_form_completed` and never reuses this name.
|
||||
*/
|
||||
const POSTHOG_FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
|
||||
user_signed_up: 'signup_completed',
|
||||
payment_completed: 'payment_completed',
|
||||
subscription_started: 'subscription_started',
|
||||
subscription_renewed: 'subscription_renewed',
|
||||
subscription_cancelled: 'subscription_cancelled',
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds bounded Prometheus labels from product event inputs.
|
||||
*/
|
||||
function metricLabels(input: ProductEventInput): Record<string, string> {
|
||||
const attrs: Record<string, string> = {
|
||||
feature: input.feature,
|
||||
action: input.action,
|
||||
status: input.status,
|
||||
}
|
||||
if (input.source)
|
||||
attrs.source = input.source
|
||||
if (input.reason)
|
||||
attrs.reason = input.reason
|
||||
|
||||
const fluxBalanceBucket = input.metadata?.flux_balance_bucket
|
||||
if (typeof fluxBalanceBucket === 'string')
|
||||
attrs.flux_balance_bucket = fluxBalanceBucket
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
function stringMetadata(input: ProductEventInput, key: string): string | undefined {
|
||||
const value = input.metadata?.[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates AIRI's first-party product analytics event writer.
|
||||
*
|
||||
* Use when:
|
||||
* - Server-side product behavior has a user id and should be queryable by
|
||||
* distinct users, funnels, or retention windows.
|
||||
* - Grafana needs low-cardinality event volume while Postgres keeps user-level
|
||||
* detail.
|
||||
*
|
||||
* Expects:
|
||||
* - Callers pass only bounded `feature` / `action` / `status` values.
|
||||
* - PII, prompts, request ids, sessions, and user ids are not written into
|
||||
* Prometheus labels. User id is stored only in the DB row.
|
||||
*
|
||||
* Returns:
|
||||
* - Best-effort event writer plus a DB aggregation helper for analytics jobs.
|
||||
*/
|
||||
export function createProductEventService(db: Database, metrics?: ProductMetrics | null, posthog?: PosthogSink | null) {
|
||||
return {
|
||||
trackGeneration(input: AiGenerationEventInput): void {
|
||||
if (!posthog)
|
||||
return
|
||||
|
||||
const event = {
|
||||
distinctId: input.userId,
|
||||
event: '$ai_generation',
|
||||
properties: {
|
||||
$ai_trace_id: input.traceId,
|
||||
$ai_session_id: input.conversationId,
|
||||
$ai_span_id: input.generationId,
|
||||
$ai_model: input.model,
|
||||
$ai_provider: input.provider,
|
||||
...(input.inputTokens != null && { $ai_input_tokens: input.inputTokens }),
|
||||
...(input.outputTokens != null && { $ai_output_tokens: input.outputTokens }),
|
||||
...(input.totalTokens != null && { $ai_total_tokens: input.totalTokens }),
|
||||
...(input.totalCostUsd != null && { $ai_total_cost_usd: input.totalCostUsd }),
|
||||
...(input.latencySeconds != null && { $ai_latency: input.latencySeconds }),
|
||||
...(input.stream != null && { $ai_stream: input.stream }),
|
||||
$insert_id: `ai-generation:${input.generationId}`,
|
||||
airi_user_id: input.userId,
|
||||
provider_type: input.providerType,
|
||||
usage_source: input.usageSource,
|
||||
token_usage_available: input.usageSource !== 'unavailable',
|
||||
cost_usd_source: input.costUsdSource ?? 'unavailable',
|
||||
cost_usd_known: input.totalCostUsd != null,
|
||||
conversation_id: input.conversationId,
|
||||
conversation_id_source: input.conversationIdSource,
|
||||
...(input.roundId && { round_id: input.roundId }),
|
||||
...(input.appSurface && { app_surface: input.appSurface }),
|
||||
capture_surface: input.captureSurface ?? 'server',
|
||||
},
|
||||
}
|
||||
|
||||
if (posthog.captureQueued) {
|
||||
posthog.captureQueued(event)
|
||||
return
|
||||
}
|
||||
|
||||
void posthog.capture(event)
|
||||
.catch(err => logger.withError(err).withFields({ generationId: input.generationId }).warn('Failed to capture PostHog AI generation'))
|
||||
},
|
||||
|
||||
async track(input: ProductEventInput): Promise<void> {
|
||||
// Postgres is the fact of record, so forwarding is gated both ways:
|
||||
// the DB write comes first (a PostHog outage can't lose the row) and
|
||||
// forwarding only runs when the row actually landed (a DB outage
|
||||
// can't mint PostHog events with no DB backing).
|
||||
let persisted = false
|
||||
try {
|
||||
await db.insert(schema.productEvents).values({
|
||||
userId: input.userId,
|
||||
feature: input.feature,
|
||||
action: input.action,
|
||||
status: input.status,
|
||||
source: input.source,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
reason: input.reason,
|
||||
metadata: input.metadata,
|
||||
createdAt: input.createdAt,
|
||||
})
|
||||
persisted = true
|
||||
|
||||
metrics?.events.add(1, metricLabels(input))
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({
|
||||
userId: input.userId,
|
||||
feature: input.feature,
|
||||
action: input.action,
|
||||
status: input.status,
|
||||
}).warn('Failed to write product event; swallowing to protect caller')
|
||||
}
|
||||
|
||||
// PostHog copy so browser funnels (identified by the same Better Auth
|
||||
// user id) get their server-side terminator events.
|
||||
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action]
|
||||
if (persisted && posthog && forwardedEvent) {
|
||||
try {
|
||||
const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id')
|
||||
const posthogSessionId = stringMetadata(input, 'posthog_session_id')
|
||||
if (posthogDistinctId && posthogDistinctId !== input.userId) {
|
||||
await posthog.capture({
|
||||
distinctId: input.userId,
|
||||
event: '$identify',
|
||||
properties: {
|
||||
$anon_distinct_id: posthogDistinctId,
|
||||
airi_user_id: input.userId,
|
||||
...(posthogSessionId && { $session_id: posthogSessionId }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await posthog.capture({
|
||||
distinctId: input.userId,
|
||||
event: forwardedEvent,
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: input.userId,
|
||||
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
|
||||
...(posthogSessionId && { $session_id: posthogSessionId }),
|
||||
feature: input.feature,
|
||||
status: input.status,
|
||||
...(input.source && { source: input.source }),
|
||||
...(input.reason && { reason: input.reason }),
|
||||
...input.metadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
// The sink contract already swallows transport errors; this guard
|
||||
// is the last line so a misbehaving sink can never fail the
|
||||
// webhook/auth flow that produced the business fact.
|
||||
logger.withError(err).withFields({ action: input.action }).warn('PostHog forwarding threw; product event already persisted')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise<ProductEventAggregateRow[]> {
|
||||
const where = input.to
|
||||
? and(gte(schema.productEvents.createdAt, input.from), lt(schema.productEvents.createdAt, input.to))
|
||||
: gte(schema.productEvents.createdAt, input.from)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
feature: schema.productEvents.feature,
|
||||
action: schema.productEvents.action,
|
||||
status: schema.productEvents.status,
|
||||
eventCount: count(),
|
||||
distinctUsers: sql<number>`count(distinct ${schema.productEvents.userId})::int`,
|
||||
})
|
||||
.from(schema.productEvents)
|
||||
.where(where)
|
||||
.groupBy(schema.productEvents.feature, schema.productEvents.action, schema.productEvents.status)
|
||||
.orderBy(asc(schema.productEvents.feature), asc(schema.productEvents.action), asc(schema.productEvents.status))
|
||||
|
||||
return rows.map(row => ({
|
||||
feature: row.feature,
|
||||
action: row.action,
|
||||
status: row.status,
|
||||
eventCount: Number(row.eventCount),
|
||||
distinctUsers: Number(row.distinctUsers),
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductEventService = ReturnType<typeof createProductEventService>
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Database } from '../../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createProviderCatalogService } from '.'
|
||||
import { mockDB } from '../../../libs/mock-db'
|
||||
import { capabilityAliases, capabilityAliasRoutes, providerCatalogTtsModels, providerCatalogTtsVoices } from '../../../schemas/provider-catalog'
|
||||
import { ApiError } from '../../../utils/error'
|
||||
|
||||
import * as schema from '../../../schemas'
|
||||
|
||||
describe('providerCatalogService', () => {
|
||||
let db: Database
|
||||
let service: ReturnType<typeof createProviderCatalogService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
service = createProviderCatalogService(db)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(capabilityAliasRoutes)
|
||||
await db.delete(capabilityAliases)
|
||||
await db.delete(providerCatalogTtsVoices)
|
||||
await db.delete(providerCatalogTtsModels)
|
||||
})
|
||||
|
||||
it('syncs the default LLM auto alias and runtime model routes as enabled', async () => {
|
||||
const aliases = await service.syncAliasesFromRouterConfig({
|
||||
surface: 'llm',
|
||||
modelIds: ['chat-b', 'chat-a'],
|
||||
})
|
||||
|
||||
expect(aliases).toHaveLength(1)
|
||||
expect(aliases[0]).toMatchObject({
|
||||
surface: 'llm',
|
||||
aliasId: 'auto',
|
||||
displayName: 'Auto',
|
||||
enabled: true,
|
||||
fallbackEnabled: true,
|
||||
loadBalancingEnabled: false,
|
||||
})
|
||||
|
||||
const resolved = await service.resolveEnabledAlias('llm', 'auto')
|
||||
expect(resolved.routes.map(route => route.routerModelId)).toEqual(['chat-b', 'chat-a'])
|
||||
expect(resolved.routes.every(route => route.enabled)).toBe(true)
|
||||
expect(resolved.routes.every(route => route.pool === 'primary')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves alias and route curation across repeated syncs', async () => {
|
||||
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
|
||||
const [alias] = await db.select().from(capabilityAliases)
|
||||
const [route] = await db.select().from(capabilityAliasRoutes)
|
||||
|
||||
await db.update(capabilityAliases)
|
||||
.set({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
|
||||
.where(eq(capabilityAliases.id, alias.id))
|
||||
await db.update(capabilityAliasRoutes)
|
||||
.set({ enabled: false, displayOrder: 9 })
|
||||
.where(eq(capabilityAliasRoutes.id, route.id))
|
||||
|
||||
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a', 'chat-b'] })
|
||||
const aliases = await service.listAliases('llm')
|
||||
const preservedRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-a')
|
||||
const newRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-b')
|
||||
|
||||
expect(aliases[0]).toMatchObject({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
|
||||
expect(preservedRoute).toMatchObject({ enabled: false, displayOrder: 9 })
|
||||
expect(newRoute).toMatchObject({ enabled: true, displayOrder: 1 })
|
||||
})
|
||||
|
||||
it('syncs runtime TTS models as enabled but preserves admin display fields', async () => {
|
||||
const first = await service.syncTtsModelsFromRouterConfig({
|
||||
models: {
|
||||
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
|
||||
},
|
||||
})
|
||||
await db.update(providerCatalogTtsModels)
|
||||
.set({ enabled: false, displayName: 'Curated CosyVoice', displayOrder: 7 })
|
||||
.where(eq(providerCatalogTtsModels.id, first[0].id))
|
||||
|
||||
await service.syncTtsModelsFromRouterConfig({
|
||||
models: {
|
||||
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
|
||||
'microsoft/v1': { provider: 'azure' },
|
||||
},
|
||||
})
|
||||
|
||||
const models = await service.listTtsModels()
|
||||
expect(models.map(model => model.routerModelId)).toEqual(['alibaba/cosyvoice-v2', 'microsoft/v1'])
|
||||
expect(models.find(model => model.routerModelId === 'alibaba/cosyvoice-v2')).toMatchObject({
|
||||
enabled: false,
|
||||
displayName: 'Curated CosyVoice',
|
||||
displayOrder: 7,
|
||||
provider: 'dashscope-cosyvoice',
|
||||
})
|
||||
expect(models.find(model => model.routerModelId === 'microsoft/v1')).toMatchObject({
|
||||
enabled: true,
|
||||
displayName: 'microsoft/v1',
|
||||
provider: 'azure',
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs provider voices as disabled by default and preserves curation on resync', async () => {
|
||||
await service.syncTtsModelsFromRouterConfig({
|
||||
models: { 'microsoft/v1': { provider: 'azure' } },
|
||||
})
|
||||
|
||||
const first = await service.syncTtsVoices({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava',
|
||||
languages: [{ code: 'en-US', title: 'English' }],
|
||||
labels: { gender: 'female' },
|
||||
previewAudioUrl: 'https://example.com/ava.mp3',
|
||||
}],
|
||||
})
|
||||
expect(first[0]).toMatchObject({
|
||||
providerVoiceId: 'en-US-AvaMultilingualNeural',
|
||||
displayName: 'Ava',
|
||||
enabled: false,
|
||||
previewAudioUrl: 'https://example.com/ava.mp3',
|
||||
})
|
||||
|
||||
await db.update(providerCatalogTtsVoices)
|
||||
.set({
|
||||
enabled: true,
|
||||
displayName: 'Curated Ava',
|
||||
displayOrder: 3,
|
||||
previewAudioUrl: 'https://example.com/manual.mp3',
|
||||
})
|
||||
.where(eq(providerCatalogTtsVoices.id, first[0].id))
|
||||
|
||||
await service.syncTtsVoices({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava from provider',
|
||||
languages: [{ code: 'en-US', title: 'English US' }],
|
||||
labels: { gender: 'Female' },
|
||||
previewAudioUrl: 'https://example.com/provider-new.mp3',
|
||||
}],
|
||||
})
|
||||
|
||||
const voices = await service.listTtsVoices('microsoft/v1')
|
||||
expect(voices[0]).toMatchObject({
|
||||
enabled: true,
|
||||
displayName: 'Curated Ava',
|
||||
displayOrder: 3,
|
||||
previewAudioUrl: 'https://example.com/manual.mp3',
|
||||
labels: { gender: 'Female' },
|
||||
languages: [{ code: 'en-US', title: 'English US' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('lists and gates only enabled TTS models and voices', async () => {
|
||||
const [model] = await service.syncTtsModelsFromRouterConfig({
|
||||
models: { 'microsoft/v1': { provider: 'azure' } },
|
||||
})
|
||||
const [voice] = await service.syncTtsVoices({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
|
||||
})
|
||||
|
||||
expect(await service.listEnabledTtsModels()).toHaveLength(1)
|
||||
expect(await service.listEnabledTtsVoices('microsoft/v1')).toEqual([])
|
||||
|
||||
await db.update(providerCatalogTtsVoices)
|
||||
.set({ enabled: true })
|
||||
.where(eq(providerCatalogTtsVoices.id, voice.id))
|
||||
expect((await service.listEnabledTtsVoices('microsoft/v1')).map(item => item.providerVoiceId)).toEqual(['en-US-AvaMultilingualNeural'])
|
||||
|
||||
await db.update(providerCatalogTtsModels)
|
||||
.set({ enabled: false })
|
||||
.where(eq(providerCatalogTtsModels.id, model.id))
|
||||
|
||||
await expect(service.assertTtsModelEnabled('microsoft/v1')).rejects.toMatchObject({
|
||||
errorCode: 'PROVIDER_CATALOG_TTS_MODEL_DISABLED',
|
||||
})
|
||||
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'en-US-AvaMultilingualNeural')).rejects.toMatchObject({
|
||||
errorCode: 'PROVIDER_CATALOG_TTS_MODEL_DISABLED',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws structured errors for missing or disabled aliases and voices', async () => {
|
||||
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
|
||||
errorCode: 'CAPABILITY_ALIAS_NOT_FOUND',
|
||||
})
|
||||
|
||||
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
|
||||
const [alias] = await db.select().from(capabilityAliases)
|
||||
await db.update(capabilityAliases)
|
||||
.set({ enabled: false })
|
||||
.where(eq(capabilityAliases.id, alias.id))
|
||||
|
||||
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
|
||||
errorCode: 'CAPABILITY_ALIAS_DISABLED',
|
||||
})
|
||||
|
||||
await service.syncTtsModelsFromRouterConfig({ models: { 'microsoft/v1': { provider: 'azure' } } })
|
||||
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toMatchObject({
|
||||
errorCode: 'PROVIDER_CATALOG_TTS_VOICE_NOT_FOUND',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,444 @@
|
||||
import type { Database } from '../../../libs/db'
|
||||
import type {
|
||||
CapabilityAlias,
|
||||
CapabilityAliasRoute,
|
||||
CapabilityAliasRoutePool,
|
||||
CapabilityAliasSurface,
|
||||
ProviderCatalogTtsModel,
|
||||
ProviderCatalogTtsVoice,
|
||||
ProviderCatalogTtsVoiceLabels,
|
||||
ProviderCatalogTtsVoiceLanguage,
|
||||
} from '../../../schemas/provider-catalog'
|
||||
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
||||
|
||||
import {
|
||||
capabilityAliases,
|
||||
capabilityAliasRoutes,
|
||||
providerCatalogTtsModels,
|
||||
providerCatalogTtsVoices,
|
||||
} from '../../../schemas/provider-catalog'
|
||||
import { createBadRequestError } from '../../../utils/error'
|
||||
|
||||
const DEFAULT_ALIAS_ID = 'auto'
|
||||
|
||||
export interface ProviderCatalogTtsModelSyncInput {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface ProviderCatalogTtsVoiceSyncInput {
|
||||
id: string
|
||||
name?: string
|
||||
languages?: ProviderCatalogTtsVoiceLanguage[]
|
||||
labels?: ProviderCatalogTtsVoiceLabels
|
||||
previewAudioUrl?: string | null
|
||||
}
|
||||
|
||||
export interface CapabilityAliasWithRoutes extends CapabilityAlias {
|
||||
routes: CapabilityAliasRoute[]
|
||||
}
|
||||
|
||||
export interface ProviderCatalogTtsVoiceWithModel {
|
||||
model: ProviderCatalogTtsModel
|
||||
voice: ProviderCatalogTtsVoice
|
||||
}
|
||||
|
||||
export interface CapabilityAliasUpdateInput {
|
||||
displayName?: string
|
||||
enabled?: boolean
|
||||
displayOrder?: number
|
||||
fallbackEnabled?: boolean
|
||||
loadBalancingEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface CapabilityAliasRouteUpdateInput {
|
||||
enabled?: boolean
|
||||
pool?: CapabilityAliasRoutePool
|
||||
weight?: number
|
||||
displayOrder?: number
|
||||
}
|
||||
|
||||
export interface ProviderCatalogTtsModelUpdateInput {
|
||||
displayName?: string
|
||||
enabled?: boolean
|
||||
displayOrder?: number
|
||||
}
|
||||
|
||||
export interface ProviderCatalogTtsVoiceUpdateInput {
|
||||
displayName?: string
|
||||
enabled?: boolean
|
||||
displayOrder?: number
|
||||
languages?: ProviderCatalogTtsVoiceLanguage[]
|
||||
labels?: ProviderCatalogTtsVoiceLabels
|
||||
previewAudioUrl?: string | null
|
||||
}
|
||||
|
||||
function defaultAliasDisplayName(surface: CapabilityAliasSurface, aliasId: string): string {
|
||||
if (aliasId !== DEFAULT_ALIAS_ID)
|
||||
return aliasId
|
||||
return surface === 'llm' ? 'Auto' : 'Auto Transcription'
|
||||
}
|
||||
|
||||
function nextOrder(rows: Array<{ displayOrder: number }>): number {
|
||||
if (rows.length === 0)
|
||||
return 0
|
||||
return Math.max(...rows.map(row => row.displayOrder)) + 1
|
||||
}
|
||||
|
||||
function catalogError(message: string, errorCode: string, details?: unknown) {
|
||||
return createBadRequestError(message, errorCode, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns AIRI's provider catalog curation state.
|
||||
*
|
||||
* The router config still owns real provider URLs, keys, and fallback
|
||||
* mechanics. Capability aliases and provider model or voice rows decide what
|
||||
* users can see and what gateway requests may use. Public list endpoints and
|
||||
* gateway request gates should both call this service so UI hiding and
|
||||
* handwritten request validation cannot drift.
|
||||
*/
|
||||
export function createProviderCatalogService(db: Database) {
|
||||
async function findAlias(surface: CapabilityAliasSurface, aliasId: string) {
|
||||
return await db.query.capabilityAliases.findFirst({
|
||||
where: and(
|
||||
eq(capabilityAliases.surface, surface),
|
||||
eq(capabilityAliases.aliasId, aliasId),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureAlias(surface: CapabilityAliasSurface, aliasId: string) {
|
||||
const existing = await findAlias(surface, aliasId)
|
||||
if (existing)
|
||||
return existing
|
||||
|
||||
const existingAliases = await db.query.capabilityAliases.findMany({
|
||||
where: eq(capabilityAliases.surface, surface),
|
||||
})
|
||||
const [created] = await db.insert(capabilityAliases).values({
|
||||
surface,
|
||||
aliasId,
|
||||
displayName: defaultAliasDisplayName(surface, aliasId),
|
||||
enabled: true,
|
||||
displayOrder: nextOrder(existingAliases),
|
||||
fallbackEnabled: true,
|
||||
loadBalancingEnabled: false,
|
||||
}).onConflictDoNothing({
|
||||
target: [capabilityAliases.surface, capabilityAliases.aliasId],
|
||||
}).returning()
|
||||
const alias = created ?? await findAlias(surface, aliasId)
|
||||
if (!alias)
|
||||
throw catalogError('Capability alias could not be synced', 'CAPABILITY_ALIAS_SYNC_FAILED', { surface, aliasId })
|
||||
return alias
|
||||
}
|
||||
|
||||
async function syncAliasRoute(input: {
|
||||
aliasRowId: string
|
||||
routerModelId: string
|
||||
pool: CapabilityAliasRoutePool
|
||||
order: number
|
||||
}) {
|
||||
const existing = await db.query.capabilityAliasRoutes.findFirst({
|
||||
where: and(
|
||||
eq(capabilityAliasRoutes.aliasId, input.aliasRowId),
|
||||
eq(capabilityAliasRoutes.routerModelId, input.routerModelId),
|
||||
eq(capabilityAliasRoutes.pool, input.pool),
|
||||
),
|
||||
})
|
||||
|
||||
if (existing)
|
||||
return existing
|
||||
|
||||
const [created] = await db.insert(capabilityAliasRoutes).values({
|
||||
aliasId: input.aliasRowId,
|
||||
routerModelId: input.routerModelId,
|
||||
pool: input.pool,
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
displayOrder: input.order,
|
||||
}).onConflictDoNothing({
|
||||
target: [
|
||||
capabilityAliasRoutes.aliasId,
|
||||
capabilityAliasRoutes.routerModelId,
|
||||
capabilityAliasRoutes.pool,
|
||||
],
|
||||
}).returning()
|
||||
const route = created ?? await db.query.capabilityAliasRoutes.findFirst({
|
||||
where: and(
|
||||
eq(capabilityAliasRoutes.aliasId, input.aliasRowId),
|
||||
eq(capabilityAliasRoutes.routerModelId, input.routerModelId),
|
||||
eq(capabilityAliasRoutes.pool, input.pool),
|
||||
),
|
||||
})
|
||||
if (!route) {
|
||||
throw catalogError('Capability alias route could not be synced', 'CAPABILITY_ALIAS_ROUTE_SYNC_FAILED', {
|
||||
routerModelId: input.routerModelId,
|
||||
pool: input.pool,
|
||||
})
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
return {
|
||||
async syncAliasesFromRouterConfig(input: {
|
||||
surface: CapabilityAliasSurface
|
||||
modelIds: string[]
|
||||
}) {
|
||||
const alias = await ensureAlias(input.surface, DEFAULT_ALIAS_ID)
|
||||
const uniqueModelIds = Array.from(new Set(input.modelIds))
|
||||
for (const [index, routerModelId] of uniqueModelIds.entries()) {
|
||||
await syncAliasRoute({
|
||||
aliasRowId: alias.id,
|
||||
routerModelId,
|
||||
pool: 'primary',
|
||||
order: index,
|
||||
})
|
||||
}
|
||||
|
||||
return await db.query.capabilityAliases.findMany({
|
||||
where: eq(capabilityAliases.surface, input.surface),
|
||||
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
|
||||
})
|
||||
},
|
||||
|
||||
async listAliases(surface?: CapabilityAliasSurface): Promise<CapabilityAliasWithRoutes[]> {
|
||||
const aliases = await db.query.capabilityAliases.findMany({
|
||||
where: surface ? eq(capabilityAliases.surface, surface) : undefined,
|
||||
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
|
||||
})
|
||||
if (aliases.length === 0)
|
||||
return []
|
||||
|
||||
const routes = await db.query.capabilityAliasRoutes.findMany({
|
||||
where: inArray(capabilityAliasRoutes.aliasId, aliases.map(alias => alias.id)),
|
||||
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
|
||||
})
|
||||
return aliases.map(alias => ({
|
||||
...alias,
|
||||
routes: routes.filter(route => route.aliasId === alias.id),
|
||||
}))
|
||||
},
|
||||
|
||||
async updateAlias(id: string, input: CapabilityAliasUpdateInput): Promise<CapabilityAlias | null> {
|
||||
const [updated] = await db.update(capabilityAliases)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(capabilityAliases.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async updateAliasRoute(id: string, input: CapabilityAliasRouteUpdateInput): Promise<CapabilityAliasRoute | null> {
|
||||
const [updated] = await db.update(capabilityAliasRoutes)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(capabilityAliasRoutes.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async resolveEnabledAlias(surface: CapabilityAliasSurface, aliasId: string): Promise<CapabilityAliasWithRoutes> {
|
||||
const alias = await findAlias(surface, aliasId)
|
||||
if (!alias) {
|
||||
throw catalogError('Capability alias is not configured', 'CAPABILITY_ALIAS_NOT_FOUND', { surface, aliasId })
|
||||
}
|
||||
if (!alias.enabled) {
|
||||
throw catalogError('Capability alias is disabled', 'CAPABILITY_ALIAS_DISABLED', { surface, aliasId })
|
||||
}
|
||||
|
||||
const routes = await db.query.capabilityAliasRoutes.findMany({
|
||||
where: and(
|
||||
eq(capabilityAliasRoutes.aliasId, alias.id),
|
||||
eq(capabilityAliasRoutes.enabled, true),
|
||||
),
|
||||
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
|
||||
})
|
||||
if (routes.length === 0) {
|
||||
throw catalogError('Capability alias has no enabled route', 'CAPABILITY_ALIAS_ROUTE_NOT_FOUND', { surface, aliasId })
|
||||
}
|
||||
|
||||
return { ...alias, routes }
|
||||
},
|
||||
|
||||
async syncTtsModelsFromRouterConfig(input: {
|
||||
models: Record<string, ProviderCatalogTtsModelSyncInput>
|
||||
}) {
|
||||
const existingModels = await db.query.providerCatalogTtsModels.findMany()
|
||||
const synced: ProviderCatalogTtsModel[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (const [routerModelId, model] of Object.entries(input.models).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const [syncedModel] = await db.insert(providerCatalogTtsModels).values({
|
||||
routerModelId,
|
||||
provider: model.provider,
|
||||
displayName: routerModelId,
|
||||
enabled: true,
|
||||
displayOrder: nextOrder([...existingModels, ...synced]),
|
||||
lastSyncedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: providerCatalogTtsModels.routerModelId,
|
||||
set: {
|
||||
provider: model.provider,
|
||||
lastSyncedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
}).returning()
|
||||
synced.push(syncedModel)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
|
||||
async listTtsModels(): Promise<ProviderCatalogTtsModel[]> {
|
||||
return await db.query.providerCatalogTtsModels.findMany({
|
||||
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
|
||||
})
|
||||
},
|
||||
|
||||
async updateTtsModel(id: string, input: ProviderCatalogTtsModelUpdateInput): Promise<ProviderCatalogTtsModel | null> {
|
||||
const [updated] = await db.update(providerCatalogTtsModels)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(providerCatalogTtsModels.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async listEnabledTtsModels(): Promise<ProviderCatalogTtsModel[]> {
|
||||
return await db.query.providerCatalogTtsModels.findMany({
|
||||
where: eq(providerCatalogTtsModels.enabled, true),
|
||||
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
|
||||
})
|
||||
},
|
||||
|
||||
async assertTtsModelEnabled(routerModelId: string): Promise<ProviderCatalogTtsModel> {
|
||||
const model = await db.query.providerCatalogTtsModels.findFirst({
|
||||
where: eq(providerCatalogTtsModels.routerModelId, routerModelId),
|
||||
})
|
||||
if (!model) {
|
||||
throw catalogError('Provider catalog TTS model is not configured', 'PROVIDER_CATALOG_TTS_MODEL_NOT_FOUND', { model: routerModelId })
|
||||
}
|
||||
if (!model.enabled) {
|
||||
throw catalogError('Provider catalog TTS model is disabled', 'PROVIDER_CATALOG_TTS_MODEL_DISABLED', { model: routerModelId })
|
||||
}
|
||||
return model
|
||||
},
|
||||
|
||||
async syncTtsVoices(input: {
|
||||
routerModelId: string
|
||||
voices: ProviderCatalogTtsVoiceSyncInput[]
|
||||
}) {
|
||||
const model = await db.query.providerCatalogTtsModels.findFirst({
|
||||
where: eq(providerCatalogTtsModels.routerModelId, input.routerModelId),
|
||||
})
|
||||
if (!model) {
|
||||
throw catalogError('Provider catalog TTS model is not configured', 'PROVIDER_CATALOG_TTS_MODEL_NOT_FOUND', { model: input.routerModelId })
|
||||
}
|
||||
const existingVoices = await db.query.providerCatalogTtsVoices.findMany({
|
||||
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
|
||||
})
|
||||
const existingByVoiceId = new Map(existingVoices.map(voice => [voice.providerVoiceId, voice]))
|
||||
const synced: ProviderCatalogTtsVoice[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (const voice of input.voices) {
|
||||
const existing = existingByVoiceId.get(voice.id)
|
||||
|
||||
const [syncedVoice] = await db.insert(providerCatalogTtsVoices).values({
|
||||
ttsModelId: model.id,
|
||||
providerVoiceId: voice.id,
|
||||
displayName: voice.name ?? voice.id,
|
||||
enabled: false,
|
||||
displayOrder: nextOrder([...existingVoices, ...synced]),
|
||||
languages: voice.languages ?? [],
|
||||
labels: voice.labels ?? {},
|
||||
previewAudioUrl: voice.previewAudioUrl ?? null,
|
||||
source: 'provider-sync',
|
||||
lastSyncedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: [providerCatalogTtsVoices.ttsModelId, providerCatalogTtsVoices.providerVoiceId],
|
||||
set: {
|
||||
languages: voice.languages ?? existing?.languages ?? [],
|
||||
labels: voice.labels ?? existing?.labels ?? {},
|
||||
lastSyncedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
}).returning()
|
||||
synced.push(syncedVoice)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
|
||||
async listTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
|
||||
const model = await db.query.providerCatalogTtsModels.findFirst({
|
||||
where: eq(providerCatalogTtsModels.routerModelId, routerModelId),
|
||||
})
|
||||
if (!model)
|
||||
return []
|
||||
|
||||
return await db.query.providerCatalogTtsVoices.findMany({
|
||||
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
|
||||
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
|
||||
})
|
||||
},
|
||||
|
||||
async getTtsVoiceWithModel(id: string): Promise<ProviderCatalogTtsVoiceWithModel | null> {
|
||||
const voice = await db.query.providerCatalogTtsVoices.findFirst({
|
||||
where: eq(providerCatalogTtsVoices.id, id),
|
||||
})
|
||||
if (!voice)
|
||||
return null
|
||||
|
||||
const model = await db.query.providerCatalogTtsModels.findFirst({
|
||||
where: eq(providerCatalogTtsModels.id, voice.ttsModelId),
|
||||
})
|
||||
if (!model)
|
||||
return null
|
||||
|
||||
return { model, voice }
|
||||
},
|
||||
|
||||
async updateTtsVoice(id: string, input: ProviderCatalogTtsVoiceUpdateInput): Promise<ProviderCatalogTtsVoice | null> {
|
||||
const [updated] = await db.update(providerCatalogTtsVoices)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(providerCatalogTtsVoices.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async listEnabledTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
|
||||
const model = await this.assertTtsModelEnabled(routerModelId)
|
||||
return await db.query.providerCatalogTtsVoices.findMany({
|
||||
where: and(
|
||||
eq(providerCatalogTtsVoices.ttsModelId, model.id),
|
||||
eq(providerCatalogTtsVoices.enabled, true),
|
||||
),
|
||||
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
|
||||
})
|
||||
},
|
||||
|
||||
async assertTtsVoiceEnabled(routerModelId: string, providerVoiceId: string): Promise<ProviderCatalogTtsVoice> {
|
||||
const model = await this.assertTtsModelEnabled(routerModelId)
|
||||
const voice = await db.query.providerCatalogTtsVoices.findFirst({
|
||||
where: and(
|
||||
eq(providerCatalogTtsVoices.ttsModelId, model.id),
|
||||
eq(providerCatalogTtsVoices.providerVoiceId, providerVoiceId),
|
||||
),
|
||||
})
|
||||
if (!voice) {
|
||||
throw catalogError('Provider catalog TTS voice is not configured for this model', 'PROVIDER_CATALOG_TTS_VOICE_NOT_FOUND', {
|
||||
model: routerModelId,
|
||||
voice: providerVoiceId,
|
||||
})
|
||||
}
|
||||
if (!voice.enabled) {
|
||||
throw catalogError('Provider catalog TTS voice is disabled', 'PROVIDER_CATALOG_TTS_VOICE_DISABLED', {
|
||||
model: routerModelId,
|
||||
voice: providerVoiceId,
|
||||
})
|
||||
}
|
||||
return voice
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderCatalogService = ReturnType<typeof createProviderCatalogService>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ProviderCatalogTtsVoice, ProviderCatalogTtsVoiceLabels, ProviderCatalogTtsVoiceLanguage } from '../../../schemas/provider-catalog'
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== 'object' || value == null || Array.isArray(value))
|
||||
return undefined
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function asLanguageList(value: unknown): ProviderCatalogTtsVoiceLanguage[] | undefined {
|
||||
if (!Array.isArray(value))
|
||||
return undefined
|
||||
|
||||
const languages = value.flatMap((item) => {
|
||||
const record = asRecord(item)
|
||||
const code = asOptionalString(record?.code)
|
||||
if (!code)
|
||||
return []
|
||||
const title = asOptionalString(record?.title)
|
||||
return [{ code, ...(title ? { title } : {}) }]
|
||||
})
|
||||
return languages.length > 0 ? languages : undefined
|
||||
}
|
||||
|
||||
function asLabels(value: unknown): ProviderCatalogTtsVoiceLabels | undefined {
|
||||
const record = asRecord(value)
|
||||
return record ? { ...record } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a provider-specific voice object into the provider catalog sync shape.
|
||||
*
|
||||
* Before:
|
||||
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewUrl: "https://..." }`
|
||||
*
|
||||
* After:
|
||||
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewAudioUrl: "https://..." }`
|
||||
*/
|
||||
export function normalizeProviderVoiceForCatalog(value: unknown) {
|
||||
const record = asRecord(value)
|
||||
const id = asOptionalString(record?.id)
|
||||
if (!id)
|
||||
return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: asOptionalString(record?.name),
|
||||
languages: asLanguageList(record?.languages),
|
||||
labels: asLabels(record?.labels),
|
||||
previewAudioUrl: asOptionalString(record?.previewAudioUrl) ?? asOptionalString(record?.previewUrl) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function catalogVoiceResponse(voice: ProviderCatalogTtsVoice) {
|
||||
// NOTICE: Admin-generated previews may temporarily live as data URIs until
|
||||
// object storage is wired. Public voice catalogs stay lightweight and only
|
||||
// expose provider or storage URLs.
|
||||
const previewAudioUrl = voice.previewAudioUrl?.startsWith('data:') ? undefined : voice.previewAudioUrl
|
||||
|
||||
return {
|
||||
id: voice.providerVoiceId,
|
||||
name: voice.displayName,
|
||||
languages: voice.languages,
|
||||
labels: voice.labels,
|
||||
preview_audio_url: previewAudioUrl ?? undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createProviderService } from './providers'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('providerService', () => {
|
||||
let db: Database
|
||||
let service: ReturnType<typeof createProviderService>
|
||||
let testUser: any
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
service = createProviderService(db)
|
||||
|
||||
// Create a test user for foreign key constraints
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
}).returning()
|
||||
testUser = user
|
||||
})
|
||||
|
||||
it('createUserConfig should handle provider config creation', async () => {
|
||||
const providerData = {
|
||||
id: 'prov-1',
|
||||
ownerId: testUser.id,
|
||||
definitionId: 'openai',
|
||||
name: 'My OpenAI',
|
||||
config: { apiKey: 'sk-123' },
|
||||
validated: true,
|
||||
validationBypassed: false,
|
||||
}
|
||||
|
||||
const result = await service.createUserConfig(providerData)
|
||||
expect(result.id).toBe('prov-1')
|
||||
expect(result.name).toBe('My OpenAI')
|
||||
|
||||
const found = await service.findUserConfigById('prov-1')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.definitionId).toBe('openai')
|
||||
expect((found!.config as Record<string, string>).apiKey).toBe('sk-123')
|
||||
})
|
||||
|
||||
it('findUserConfigsByOwnerId should return providers for the user', async () => {
|
||||
const result = await service.findUserConfigsByOwnerId(testUser.id)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].ownerId).toBe(testUser.id)
|
||||
})
|
||||
|
||||
it('findAll should return both user and system configs', async () => {
|
||||
// Create a system config
|
||||
await db.insert(schema.systemProviderConfigs).values({
|
||||
id: 'sys-1',
|
||||
definitionId: 'anthropic',
|
||||
name: 'System Anthropic',
|
||||
config: { apiKey: 'sys-sk' },
|
||||
})
|
||||
|
||||
const result = await service.findAll(testUser.id)
|
||||
expect(result.length).toBe(2)
|
||||
|
||||
const userConfig = result.find(r => r.id === 'prov-1')
|
||||
const systemConfig = result.find(r => r.id === 'sys-1')
|
||||
|
||||
expect(userConfig?.isSystem).toBe(false)
|
||||
expect(systemConfig?.isSystem).toBe(true)
|
||||
expect(systemConfig?.name).toBe('System Anthropic')
|
||||
})
|
||||
|
||||
it('findById should find both user and system configs', async () => {
|
||||
const userFound = await service.findById('prov-1', testUser.id)
|
||||
expect(userFound?.isSystem).toBe(false)
|
||||
|
||||
const sysFound = await service.findById('sys-1', testUser.id)
|
||||
expect(sysFound?.isSystem).toBe(true)
|
||||
})
|
||||
|
||||
it('updateUserConfig should update provider fields', async () => {
|
||||
await service.updateUserConfig('prov-1', { name: 'Updated OpenAI' })
|
||||
const prov = await service.findUserConfigById('prov-1')
|
||||
expect(prov?.name).toBe('Updated OpenAI')
|
||||
})
|
||||
|
||||
it('deleteUserConfig should soft delete provider', async () => {
|
||||
await service.deleteUserConfig('prov-1')
|
||||
const prov = await service.findUserConfigById('prov-1')
|
||||
expect(prov).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../../schemas/providers'
|
||||
|
||||
const logger = useLogger('providers')
|
||||
|
||||
export function createProviderService(db: Database) {
|
||||
return {
|
||||
async findAll(ownerId: string) {
|
||||
const userConfigs = db
|
||||
.select({
|
||||
id: schema.userProviderConfigs.id,
|
||||
definitionId: schema.userProviderConfigs.definitionId,
|
||||
name: schema.userProviderConfigs.name,
|
||||
config: schema.userProviderConfigs.config,
|
||||
validated: schema.userProviderConfigs.validated,
|
||||
validationBypassed: schema.userProviderConfigs.validationBypassed,
|
||||
createdAt: schema.userProviderConfigs.createdAt,
|
||||
updatedAt: schema.userProviderConfigs.updatedAt,
|
||||
isSystem: sql<boolean>`false`.as('is_system'),
|
||||
})
|
||||
.from(schema.userProviderConfigs)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userProviderConfigs.ownerId, ownerId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
)
|
||||
|
||||
const systemConfigs = db
|
||||
.select({
|
||||
id: schema.systemProviderConfigs.id,
|
||||
definitionId: schema.systemProviderConfigs.definitionId,
|
||||
name: schema.systemProviderConfigs.name,
|
||||
config: schema.systemProviderConfigs.config,
|
||||
validated: schema.systemProviderConfigs.validated,
|
||||
validationBypassed: schema.systemProviderConfigs.validationBypassed,
|
||||
createdAt: schema.systemProviderConfigs.createdAt,
|
||||
updatedAt: schema.systemProviderConfigs.updatedAt,
|
||||
isSystem: sql<boolean>`true`.as('is_system'),
|
||||
})
|
||||
.from(schema.systemProviderConfigs)
|
||||
.where(isNull(schema.systemProviderConfigs.deletedAt))
|
||||
|
||||
return await userConfigs.unionAll(systemConfigs)
|
||||
},
|
||||
|
||||
async findUserConfigsByOwnerId(ownerId: string) {
|
||||
return await db.query.userProviderConfigs.findMany({
|
||||
where: and(
|
||||
eq(schema.userProviderConfigs.ownerId, ownerId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async findById(id: string, ownerId: string) {
|
||||
const userConfig = await db.query.userProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
eq(schema.userProviderConfigs.ownerId, ownerId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (userConfig) {
|
||||
return { ...userConfig, isSystem: false }
|
||||
}
|
||||
|
||||
const systemConfig = await db.query.systemProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (systemConfig) {
|
||||
return { ...systemConfig, isSystem: true }
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
async findUserConfigById(id: string) {
|
||||
return await db.query.userProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async createUserConfig(data: schema.NewUserProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.userProviderConfigs).values(data).returning()
|
||||
logger.withFields({ id: inserted.id, ownerId: data.ownerId, definitionId: data.definitionId }).log('Created user provider config')
|
||||
return inserted
|
||||
},
|
||||
|
||||
async updateUserConfig(id: string, data: Partial<schema.NewUserProviderConfig>) {
|
||||
const [updated] = await db.update(schema.userProviderConfigs)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Updated user provider config')
|
||||
return updated
|
||||
},
|
||||
|
||||
async deleteUserConfig(id: string) {
|
||||
const result = await db.update(schema.userProviderConfigs)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Deleted user provider config')
|
||||
return result
|
||||
},
|
||||
|
||||
// System Provider Configs
|
||||
async findSystemConfigs() {
|
||||
return await db.query.systemProviderConfigs.findMany({
|
||||
where: isNull(schema.systemProviderConfigs.deletedAt),
|
||||
})
|
||||
},
|
||||
|
||||
async findSystemConfigById(id: string) {
|
||||
return await db.query.systemProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async createSystemConfig(data: schema.NewSystemProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.systemProviderConfigs).values(data).returning()
|
||||
logger.withFields({ id: inserted.id, definitionId: data.definitionId }).log('Created system provider config')
|
||||
return inserted
|
||||
},
|
||||
|
||||
async updateSystemConfig(id: string, data: Partial<schema.NewSystemProviderConfig>) {
|
||||
const [updated] = await db.update(schema.systemProviderConfigs)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Updated system provider config')
|
||||
return updated
|
||||
},
|
||||
|
||||
async deleteSystemConfig(id: string) {
|
||||
const result = await db.update(schema.systemProviderConfigs)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Deleted system provider config')
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* Soft-delete every `user_provider_configs` row owned by the user.
|
||||
* Called from the user-deletion pipeline. System configs are not
|
||||
* touched (they are not user-scoped).
|
||||
*
|
||||
* Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows.
|
||||
*/
|
||||
async deleteAllForUser(userId: string) {
|
||||
const now = new Date()
|
||||
|
||||
const result = await db.update(schema.userProviderConfigs)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.userProviderConfigs.ownerId, userId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning({ id: schema.userProviderConfigs.id })
|
||||
|
||||
logger.withFields({ userId, count: result.length }).log('Provider configs soft-deleted for user')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderService = ReturnType<typeof createProviderService>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import * as schema from '../../schemas/llm-request-log'
|
||||
|
||||
export interface RequestLogEntry {
|
||||
userId: string
|
||||
model: string
|
||||
status: number
|
||||
durationMs: number
|
||||
fluxConsumed: number
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
}
|
||||
|
||||
export function createRequestLogService(db: Database) {
|
||||
return {
|
||||
async logRequest(entry: RequestLogEntry) {
|
||||
await db.insert(schema.llmRequestLog).values(entry)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestLogService = ReturnType<typeof createRequestLogService>
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { Database } from '../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createStripeService } from './stripe'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('stripeService', () => {
|
||||
let db: Database
|
||||
let stripeService: ReturnType<typeof createStripeService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'user-stripe-1', name: 'Stripe User 1', email: 'stripe1@example.com' },
|
||||
{ id: 'user-stripe-2', name: 'Stripe User 2', email: 'stripe2@example.com' },
|
||||
])
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
stripeService = createStripeService(db, null)
|
||||
|
||||
// Clean all stripe tables between tests
|
||||
await db.delete(schema.stripeInvoice)
|
||||
await db.delete(schema.stripeSubscription)
|
||||
await db.delete(schema.stripeCheckoutSession)
|
||||
await db.delete(schema.stripeCustomer)
|
||||
})
|
||||
|
||||
// ---- Customer ----
|
||||
|
||||
describe('upsertCustomer', () => {
|
||||
it('inserts a new customer', async () => {
|
||||
const result = await stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_new_1',
|
||||
email: 'stripe1@example.com',
|
||||
})
|
||||
|
||||
expect(result.userId).toBe('user-stripe-1')
|
||||
expect(result.stripeCustomerId).toBe('cus_new_1')
|
||||
expect(result.email).toBe('stripe1@example.com')
|
||||
})
|
||||
|
||||
it('updates an existing customer on conflict (atomic upsert)', async () => {
|
||||
await stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_dup_1',
|
||||
email: 'old@example.com',
|
||||
})
|
||||
|
||||
const updated = await stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_dup_1',
|
||||
email: 'new@example.com',
|
||||
name: 'Updated Name',
|
||||
})
|
||||
|
||||
expect(updated.email).toBe('new@example.com')
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
|
||||
// Verify only one record exists
|
||||
const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_dup_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles concurrent upserts for the same customer without error', async () => {
|
||||
// Simulate two webhook events arriving at the same time for the same customer
|
||||
const results = await Promise.all([
|
||||
stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_race_1',
|
||||
email: 'a@example.com',
|
||||
}),
|
||||
stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_race_1',
|
||||
email: 'b@example.com',
|
||||
}),
|
||||
])
|
||||
|
||||
// Both should succeed (no unique constraint violation)
|
||||
expect(results).toHaveLength(2)
|
||||
results.forEach(r => expect(r.stripeCustomerId).toBe('cus_race_1'))
|
||||
|
||||
// Only one record should exist
|
||||
const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_race_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCustomerByUserId', () => {
|
||||
it('returns the customer for a given userId', async () => {
|
||||
await stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_lookup_1',
|
||||
})
|
||||
|
||||
const found = await stripeService.getCustomerByUserId('user-stripe-1')
|
||||
expect(found?.stripeCustomerId).toBe('cus_lookup_1')
|
||||
})
|
||||
|
||||
it('returns undefined when no customer exists', async () => {
|
||||
const found = await stripeService.getCustomerByUserId('user-nonexistent')
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCustomerByStripeId', () => {
|
||||
it('returns the customer for a given stripeCustomerId', async () => {
|
||||
await stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_sid_1',
|
||||
})
|
||||
|
||||
const found = await stripeService.getCustomerByStripeId('cus_sid_1')
|
||||
expect(found?.userId).toBe('user-stripe-1')
|
||||
})
|
||||
|
||||
it('returns undefined when no customer exists', async () => {
|
||||
const found = await stripeService.getCustomerByStripeId('cus_nonexistent')
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Checkout Session ----
|
||||
|
||||
describe('upsertCheckoutSession', () => {
|
||||
it('inserts a new checkout session', async () => {
|
||||
const result = await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_new_1',
|
||||
mode: 'payment',
|
||||
status: 'open',
|
||||
paymentStatus: 'unpaid',
|
||||
amountTotal: 1000,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
expect(result.stripeSessionId).toBe('cs_new_1')
|
||||
expect(result.amountTotal).toBe(1000)
|
||||
expect(result.fluxCredited).toBe(false)
|
||||
})
|
||||
|
||||
it('updates an existing checkout session on conflict', async () => {
|
||||
await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_upd_1',
|
||||
mode: 'payment',
|
||||
status: 'open',
|
||||
paymentStatus: 'unpaid',
|
||||
amountTotal: 1000,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
const updated = await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_upd_1',
|
||||
mode: 'payment',
|
||||
status: 'complete',
|
||||
paymentStatus: 'paid',
|
||||
amountTotal: 1000,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
expect(updated.status).toBe('complete')
|
||||
expect(updated.paymentStatus).toBe('paid')
|
||||
|
||||
const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_upd_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles concurrent upserts without error', async () => {
|
||||
const results = await Promise.all([
|
||||
stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_race_1',
|
||||
mode: 'payment',
|
||||
status: 'open',
|
||||
paymentStatus: 'unpaid',
|
||||
amountTotal: 500,
|
||||
currency: 'usd',
|
||||
}),
|
||||
stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_race_1',
|
||||
mode: 'payment',
|
||||
status: 'complete',
|
||||
paymentStatus: 'paid',
|
||||
amountTotal: 500,
|
||||
currency: 'usd',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
|
||||
const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_race_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCheckoutSessionsByUserId', () => {
|
||||
it('returns all sessions for the user', async () => {
|
||||
await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_list_1',
|
||||
mode: 'payment',
|
||||
amountTotal: 100,
|
||||
currency: 'usd',
|
||||
})
|
||||
await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_list_2',
|
||||
mode: 'payment',
|
||||
amountTotal: 200,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
|
||||
expect(sessions).toHaveLength(2)
|
||||
const ids = sessions.map(s => s.stripeSessionId)
|
||||
expect(ids).toContain('cs_list_1')
|
||||
expect(ids).toContain('cs_list_2')
|
||||
})
|
||||
|
||||
it('does not return sessions from other users', async () => {
|
||||
await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSessionId: 'cs_iso_1',
|
||||
mode: 'payment',
|
||||
})
|
||||
await stripeService.upsertCheckoutSession({
|
||||
userId: 'user-stripe-2',
|
||||
stripeSessionId: 'cs_iso_2',
|
||||
mode: 'payment',
|
||||
})
|
||||
|
||||
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]?.stripeSessionId).toBe('cs_iso_1')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Subscription ----
|
||||
|
||||
describe('upsertSubscription', () => {
|
||||
it('inserts a new subscription', async () => {
|
||||
await stripeService.upsertCustomer({
|
||||
userId: 'user-stripe-1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
})
|
||||
|
||||
const result = await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_new_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
expect(result.stripeSubscriptionId).toBe('sub_new_1')
|
||||
expect(result.status).toBe('active')
|
||||
})
|
||||
|
||||
it('updates an existing subscription on conflict', async () => {
|
||||
await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_upd_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const updated = await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_upd_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'canceled',
|
||||
})
|
||||
|
||||
expect(updated.status).toBe('canceled')
|
||||
|
||||
const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_upd_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles concurrent upserts without error', async () => {
|
||||
const results = await Promise.all([
|
||||
stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_race_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'active',
|
||||
}),
|
||||
stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_race_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'past_due',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
|
||||
const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_race_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveSubscription', () => {
|
||||
it('returns only the active subscription', async () => {
|
||||
await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_active_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'canceled',
|
||||
})
|
||||
await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_active_2',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const active = await stripeService.getActiveSubscription('user-stripe-1')
|
||||
expect(active?.stripeSubscriptionId).toBe('sub_active_2')
|
||||
expect(active?.status).toBe('active')
|
||||
})
|
||||
|
||||
it('returns undefined when no active subscription exists', async () => {
|
||||
await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-1',
|
||||
stripeSubscriptionId: 'sub_none_1',
|
||||
stripeCustomerId: 'cus_sub_1',
|
||||
status: 'canceled',
|
||||
})
|
||||
|
||||
const active = await stripeService.getActiveSubscription('user-stripe-1')
|
||||
expect(active).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not return subscriptions from other users', async () => {
|
||||
await stripeService.upsertSubscription({
|
||||
userId: 'user-stripe-2',
|
||||
stripeSubscriptionId: 'sub_other_1',
|
||||
stripeCustomerId: 'cus_other_1',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const active = await stripeService.getActiveSubscription('user-stripe-1')
|
||||
expect(active).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Invoice ----
|
||||
|
||||
describe('upsertInvoice', () => {
|
||||
it('inserts a new invoice', async () => {
|
||||
const result = await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_new_1',
|
||||
stripeCustomerId: 'cus_inv_1',
|
||||
status: 'open',
|
||||
amountDue: 2000,
|
||||
amountPaid: 0,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
expect(result.stripeInvoiceId).toBe('inv_new_1')
|
||||
expect(result.status).toBe('open')
|
||||
expect(result.fluxCredited).toBe(false)
|
||||
})
|
||||
|
||||
it('updates an existing invoice on conflict', async () => {
|
||||
await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_upd_1',
|
||||
status: 'open',
|
||||
amountDue: 2000,
|
||||
amountPaid: 0,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
const updated = await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_upd_1',
|
||||
status: 'paid',
|
||||
amountDue: 2000,
|
||||
amountPaid: 2000,
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
expect(updated.status).toBe('paid')
|
||||
expect(updated.amountPaid).toBe(2000)
|
||||
|
||||
const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_upd_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles concurrent upserts without error', async () => {
|
||||
const results = await Promise.all([
|
||||
stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_race_1',
|
||||
status: 'open',
|
||||
amountDue: 1000,
|
||||
currency: 'usd',
|
||||
}),
|
||||
stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_race_1',
|
||||
status: 'paid',
|
||||
amountPaid: 1000,
|
||||
currency: 'usd',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
|
||||
const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_race_1'))
|
||||
expect(all).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInvoicesByUserId', () => {
|
||||
it('returns all invoices for the user', async () => {
|
||||
await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_list_1',
|
||||
status: 'paid',
|
||||
currency: 'usd',
|
||||
})
|
||||
await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_list_2',
|
||||
status: 'open',
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
|
||||
expect(invoices).toHaveLength(2)
|
||||
const ids = invoices.map(i => i.stripeInvoiceId)
|
||||
expect(ids).toContain('inv_list_1')
|
||||
expect(ids).toContain('inv_list_2')
|
||||
})
|
||||
|
||||
it('does not return invoices from other users', async () => {
|
||||
await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-1',
|
||||
stripeInvoiceId: 'inv_iso_1',
|
||||
status: 'paid',
|
||||
currency: 'usd',
|
||||
})
|
||||
await stripeService.upsertInvoice({
|
||||
userId: 'user-stripe-2',
|
||||
stripeInvoiceId: 'inv_iso_2',
|
||||
status: 'paid',
|
||||
currency: 'usd',
|
||||
})
|
||||
|
||||
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
|
||||
expect(invoices).toHaveLength(1)
|
||||
expect(invoices[0]?.stripeInvoiceId).toBe('inv_iso_1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../../schemas/stripe'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, isNull, notInArray } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../../schemas/stripe'
|
||||
|
||||
const logger = useLogger('stripe-service')
|
||||
|
||||
// NOTICE:
|
||||
// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose
|
||||
// stripe_* rows persist for billing audit) are invisible to user-facing
|
||||
// API. Webhooks that arrive after deletion still match by stripeCustomerId
|
||||
// and re-upsert into the soft-deleted row — that's by design (the row
|
||||
// remains deletedAt-set, but we capture the late event for accurate audit).
|
||||
// See `server/apps/api/docs/ai-context/account-deletion.md`.
|
||||
export function createStripeService(db: Database, stripe: Stripe | null) {
|
||||
return {
|
||||
// ---- Customer ----
|
||||
|
||||
async upsertCustomer(data: NewStripeCustomer) {
|
||||
const [row] = await db.insert(schema.stripeCustomer)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.stripeCustomer.stripeCustomerId,
|
||||
set: { ...data, updatedAt: new Date() },
|
||||
})
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Upserted Stripe customer')
|
||||
return row
|
||||
},
|
||||
|
||||
async getCustomerByUserId(userId: string) {
|
||||
return db.query.stripeCustomer.findFirst({
|
||||
where: and(
|
||||
eq(schema.stripeCustomer.userId, userId),
|
||||
isNull(schema.stripeCustomer.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async getCustomerByStripeId(stripeCustomerId: string) {
|
||||
// NOTICE: NOT filtering by deletedAt — this lookup is by external
|
||||
// Stripe id and is used by webhook handlers that need to reach
|
||||
// soft-deleted archive rows for late events (cancellation receipts,
|
||||
// final invoices arriving after account deletion). User-facing reads
|
||||
// use getCustomerByUserId which DOES filter.
|
||||
return db.query.stripeCustomer.findFirst({
|
||||
where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId),
|
||||
})
|
||||
},
|
||||
|
||||
// ---- Checkout Session ----
|
||||
|
||||
async upsertCheckoutSession(data: NewStripeCheckoutSession) {
|
||||
const [row] = await db.insert(schema.stripeCheckoutSession)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.stripeCheckoutSession.stripeSessionId,
|
||||
set: { ...data, updatedAt: new Date() },
|
||||
})
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Upserted checkout session')
|
||||
return row
|
||||
},
|
||||
|
||||
async getCheckoutSessionsByUserId(userId: string) {
|
||||
return db.query.stripeCheckoutSession.findMany({
|
||||
where: and(
|
||||
eq(schema.stripeCheckoutSession.userId, userId),
|
||||
isNull(schema.stripeCheckoutSession.deletedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
// ---- Subscription ----
|
||||
|
||||
async upsertSubscription(data: NewStripeSubscription) {
|
||||
const [row] = await db.insert(schema.stripeSubscription)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.stripeSubscription.stripeSubscriptionId,
|
||||
set: { ...data, updatedAt: new Date() },
|
||||
})
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Upserted subscription')
|
||||
return row
|
||||
},
|
||||
|
||||
async getActiveSubscription(userId: string) {
|
||||
return db.query.stripeSubscription.findFirst({
|
||||
where: and(
|
||||
eq(schema.stripeSubscription.userId, userId),
|
||||
eq(schema.stripeSubscription.status, 'active'),
|
||||
isNull(schema.stripeSubscription.deletedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
// ---- Invoice ----
|
||||
|
||||
async upsertInvoice(data: NewStripeInvoice) {
|
||||
const [row] = await db.insert(schema.stripeInvoice)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.stripeInvoice.stripeInvoiceId,
|
||||
set: { ...data, updatedAt: new Date() },
|
||||
})
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Upserted invoice')
|
||||
return row
|
||||
},
|
||||
|
||||
async getInvoicesByUserId(userId: string) {
|
||||
return db.query.stripeInvoice.findMany({
|
||||
where: and(
|
||||
eq(schema.stripeInvoice.userId, userId),
|
||||
isNull(schema.stripeInvoice.deletedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel the user's active Stripe subscription via the API and stamp every
|
||||
* `stripe_*` row with `deletedAt`. Called from the user-deletion pipeline
|
||||
* (priority 10 — runs first because Stripe API cancellation has no
|
||||
* rollback path).
|
||||
*
|
||||
* Idempotent on retry: subsequent calls find no `active` subs to cancel
|
||||
* and the `WHERE deletedAt IS NULL` guard skips already-stamped rows.
|
||||
* Stripe `subscriptions.cancel` itself is also idempotent per spec —
|
||||
* cancelling an already-canceled sub returns 200.
|
||||
*
|
||||
* Cancellation is immediate, no proration, no refund — see
|
||||
* `server/apps/api/docs/ai-context/account-deletion.md`.
|
||||
*/
|
||||
async deleteAllForUser(userId: string) {
|
||||
// Cancel every subscription that is NOT already in a terminal state.
|
||||
// Stripe's terminal statuses are `canceled` and `incomplete_expired`;
|
||||
// anything else (`active`, `trialing`, `past_due`, `unpaid`,
|
||||
// `incomplete`, `paused`) can still bill or transition into billing,
|
||||
// so leaving them uncancelled would charge a deleted account.
|
||||
// Stripe `subscriptions.cancel` is idempotent per spec — safe to
|
||||
// call on any non-terminal status.
|
||||
const cancellableSubs = await db.query.stripeSubscription.findMany({
|
||||
where: and(
|
||||
eq(schema.stripeSubscription.userId, userId),
|
||||
notInArray(schema.stripeSubscription.status, ['canceled', 'incomplete_expired']),
|
||||
isNull(schema.stripeSubscription.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (stripe && cancellableSubs.length > 0) {
|
||||
for (const sub of cancellableSubs) {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(sub.stripeSubscriptionId, {
|
||||
prorate: false,
|
||||
})
|
||||
logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription')
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).error('Failed to cancel Stripe subscription')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!stripe && cancellableSubs.length > 0) {
|
||||
logger.withFields({ userId, cancellableSubCount: cancellableSubs.length }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.update(schema.stripeSubscription)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeSubscription.userId, userId),
|
||||
isNull(schema.stripeSubscription.deletedAt),
|
||||
))
|
||||
|
||||
await db.update(schema.stripeCheckoutSession)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeCheckoutSession.userId, userId),
|
||||
isNull(schema.stripeCheckoutSession.deletedAt),
|
||||
))
|
||||
|
||||
await db.update(schema.stripeInvoice)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeInvoice.userId, userId),
|
||||
isNull(schema.stripeInvoice.deletedAt),
|
||||
))
|
||||
|
||||
await db.update(schema.stripeCustomer)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.stripeCustomer.userId, userId),
|
||||
isNull(schema.stripeCustomer.deletedAt),
|
||||
))
|
||||
|
||||
logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type StripeService = ReturnType<typeof createStripeService>
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types'
|
||||
|
||||
/**
|
||||
* Build an empty deletion-service registry.
|
||||
*
|
||||
* Use when:
|
||||
* - Composing the server in `app.ts` — wire one instance and `register()`
|
||||
* each business handler at composition time.
|
||||
*
|
||||
* Returns:
|
||||
* - A registry whose `softDeleteAll` walks handlers in ascending `priority`
|
||||
* and aborts on the first throw. Successful handlers are NOT rolled back —
|
||||
* each handler's writes must be idempotent.
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* better-auth `/delete-user/callback`
|
||||
* -> `user.deleteUser.beforeDelete` (libs/auth.ts)
|
||||
* -> {@link UserDeletionService.softDeleteAll}
|
||||
* -> handler.softDelete (per registered module)
|
||||
*
|
||||
* Failure model: a thrown error from any handler aborts before
|
||||
* `internalAdapter.deleteUser`, leaving the user row intact. The next retry
|
||||
* is expected to re-run already-completed handlers as no-ops.
|
||||
*/
|
||||
export function createUserDeletionService(): UserDeletionService {
|
||||
const handlers: UserDeletionHandler[] = []
|
||||
const names = new Set<string>()
|
||||
|
||||
const logger = useLogger('user-deletion').useGlobalConfig()
|
||||
|
||||
return {
|
||||
register(handler) {
|
||||
if (names.has(handler.name))
|
||||
throw new Error(`Duplicate user-deletion handler name: ${handler.name}`)
|
||||
|
||||
names.add(handler.name)
|
||||
handlers.push(handler)
|
||||
// Resort on every insert so post-boot registrations stay ordered.
|
||||
handlers.sort((a, b) => a.priority - b.priority)
|
||||
},
|
||||
|
||||
async softDeleteAll({ userId, reason }) {
|
||||
const ctx = {
|
||||
userId,
|
||||
reason: reason as UserDeletionReason,
|
||||
logger,
|
||||
}
|
||||
|
||||
logger.withFields({ userId, reason, handlerCount: handlers.length }).log('starting user deletion')
|
||||
|
||||
for (const handler of handlers) {
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
await handler.softDelete(ctx)
|
||||
logger
|
||||
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
|
||||
.log('handler completed')
|
||||
}
|
||||
catch (err) {
|
||||
logger
|
||||
.withError(err)
|
||||
.withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt })
|
||||
.error('handler failed; aborting deletion pipeline')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
logger.withFields({ userId, reason }).log('user deletion handlers completed')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { UserDeletionHandler } from '../types'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createUserDeletionService } from '../index'
|
||||
|
||||
function makeHandler(name: string, priority: number, body?: () => Promise<void> | void): UserDeletionHandler {
|
||||
return {
|
||||
name,
|
||||
priority,
|
||||
softDelete: vi.fn(async () => {
|
||||
await body?.()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('createUserDeletionService', () => {
|
||||
describe('register', () => {
|
||||
it('rejects duplicate handler names', () => {
|
||||
const service = createUserDeletionService()
|
||||
service.register(makeHandler('flux', 20))
|
||||
|
||||
expect(() => service.register(makeHandler('flux', 30))).toThrow(/Duplicate user-deletion handler name: flux/)
|
||||
})
|
||||
|
||||
it('keeps handlers in ascending priority regardless of registration order', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const calls: string[] = []
|
||||
service.register(makeHandler('characters', 30, () => {
|
||||
calls.push('characters')
|
||||
}))
|
||||
service.register(makeHandler('stripe', 10, () => {
|
||||
calls.push('stripe')
|
||||
}))
|
||||
service.register(makeHandler('flux', 20, () => {
|
||||
calls.push('flux')
|
||||
}))
|
||||
|
||||
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
|
||||
|
||||
// @example
|
||||
// register order: characters(30) -> stripe(10) -> flux(20)
|
||||
// execution order: stripe(10) -> flux(20) -> characters(30)
|
||||
expect(calls).toEqual(['stripe', 'flux', 'characters'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('softDeleteAll', () => {
|
||||
it('passes the user id and reason to every handler', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const a = makeHandler('a', 10)
|
||||
const b = makeHandler('b', 20)
|
||||
service.register(a)
|
||||
service.register(b)
|
||||
|
||||
await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' })
|
||||
|
||||
expect(a.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
|
||||
expect(b.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
|
||||
})
|
||||
|
||||
it('aborts on first handler error and skips later handlers', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const earlyOk = makeHandler('a', 10)
|
||||
const failing = makeHandler('b', 20, () => {
|
||||
throw new Error('stripe API down')
|
||||
})
|
||||
const lateNeverRuns = makeHandler('c', 30)
|
||||
|
||||
service.register(earlyOk)
|
||||
service.register(failing)
|
||||
service.register(lateNeverRuns)
|
||||
|
||||
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }))
|
||||
.rejects
|
||||
.toThrow('stripe API down')
|
||||
|
||||
expect(earlyOk.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(failing.softDelete).toHaveBeenCalledTimes(1)
|
||||
expect(lateNeverRuns.softDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs handlers serially (next starts only after previous resolves)', async () => {
|
||||
const service = createUserDeletionService()
|
||||
const order: string[] = []
|
||||
|
||||
service.register({
|
||||
name: 'slow',
|
||||
priority: 10,
|
||||
softDelete: async () => {
|
||||
order.push('slow:start')
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
order.push('slow:end')
|
||||
},
|
||||
})
|
||||
service.register({
|
||||
name: 'fast',
|
||||
priority: 20,
|
||||
softDelete: async () => {
|
||||
order.push('fast:start')
|
||||
order.push('fast:end')
|
||||
},
|
||||
})
|
||||
|
||||
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
|
||||
|
||||
// @example
|
||||
// serial execution: slow:start -> slow:end -> fast:start -> fast:end
|
||||
// (NOT slow:start -> fast:start -> slow:end -> fast:end which would
|
||||
// indicate parallelism)
|
||||
expect(order).toEqual(['slow:start', 'slow:end', 'fast:start', 'fast:end'])
|
||||
})
|
||||
|
||||
it('runs no handlers gracefully when registry is empty', async () => {
|
||||
const service = createUserDeletionService()
|
||||
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { Database } from '../../../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../../../libs/mock-db'
|
||||
import { createCharacterService } from '../../characters'
|
||||
import { createChatService } from '../../chats'
|
||||
import { createFluxService } from '../../flux'
|
||||
import { createProviderService } from '../../providers'
|
||||
|
||||
import * as schema from '../../../../schemas'
|
||||
|
||||
function fakeRedis() {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (k: string) => map.get(k) ?? null),
|
||||
set: vi.fn(async (k: string, v: string) => {
|
||||
map.set(k, v)
|
||||
return 'OK'
|
||||
}),
|
||||
del: vi.fn(async (k: string) => {
|
||||
const had = map.has(k)
|
||||
map.delete(k)
|
||||
return had ? 1 : 0
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
function fakeConfigKV() {
|
||||
return {
|
||||
get: vi.fn(async () => undefined),
|
||||
getOrThrow: vi.fn(async () => 0),
|
||||
set: vi.fn(async () => {}),
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('fluxService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('marks userFlux.deletedAt and invalidates Redis cache', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-flux-1', name: 'A', email: 'a@example.com' })
|
||||
await db.insert(schema.userFlux).values({ userId: 'u-flux-1', flux: 100 })
|
||||
|
||||
const redis = fakeRedis()
|
||||
const service = createFluxService(db, redis, fakeConfigKV())
|
||||
await service.deleteAllForUser('u-flux-1')
|
||||
|
||||
const row = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-1') })
|
||||
expect(row?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(redis.del).toHaveBeenCalledTimes(1)
|
||||
expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1'))
|
||||
})
|
||||
|
||||
it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-flux-2', name: 'B', email: 'b@example.com' })
|
||||
await db.insert(schema.userFlux).values({ userId: 'u-flux-2', flux: 50 })
|
||||
|
||||
const redis = fakeRedis()
|
||||
const service = createFluxService(db, redis, fakeConfigKV())
|
||||
|
||||
await service.deleteAllForUser('u-flux-2')
|
||||
const firstStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
|
||||
|
||||
// Second invocation: WHERE deletedAt IS NULL filters out the
|
||||
// already-stamped row, so deletedAt does not change.
|
||||
await service.deleteAllForUser('u-flux-2')
|
||||
const secondStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
|
||||
|
||||
expect(secondStamp).toEqual(firstStamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('marks every userProviderConfigs row owned by the user', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' })
|
||||
await db.insert(schema.userProviderConfigs).values([
|
||||
{ ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' },
|
||||
{ ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' },
|
||||
])
|
||||
|
||||
const service = createProviderService(db)
|
||||
await service.deleteAllForUser('u-prov-1')
|
||||
|
||||
const rows = await db.query.userProviderConfigs.findMany({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-1') })
|
||||
expect(rows).toHaveLength(2)
|
||||
rows.forEach(r => expect(r.deletedAt).toBeInstanceOf(Date))
|
||||
})
|
||||
|
||||
it('does not touch other users rows', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-prov-other', name: 'O', email: 'o@example.com' })
|
||||
await db.insert(schema.userProviderConfigs).values({ ownerId: 'u-prov-other', definitionId: 'openai', name: 'kept' })
|
||||
|
||||
const service = createProviderService(db)
|
||||
await service.deleteAllForUser('u-prov-1')
|
||||
|
||||
const otherRow = await db.query.userProviderConfigs.findFirst({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-other') })
|
||||
expect(otherRow?.deletedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('characterService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('soft-deletes characters where the user is owner OR creator', async () => {
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-char-1', name: 'C1', email: 'c1@example.com' },
|
||||
{ id: 'u-char-2', name: 'C2', email: 'c2@example.com' },
|
||||
])
|
||||
await db.insert(schema.character).values([
|
||||
{ id: 'char-owner', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-1', characterId: 'cid-1' },
|
||||
{ id: 'char-creator', version: '1', coverUrl: '', creatorId: 'u-char-1', ownerId: 'u-char-2', characterId: 'cid-2' },
|
||||
{ id: 'char-other', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-2', characterId: 'cid-3' },
|
||||
])
|
||||
|
||||
const service = createCharacterService(db)
|
||||
await service.deleteAllForUser('u-char-1')
|
||||
|
||||
const owner = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-owner') })
|
||||
const creator = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-creator') })
|
||||
const other = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-other') })
|
||||
|
||||
expect(owner?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(creator?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(other?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('decrements character engagement counters for soft-deleted likes and bookmarks', async () => {
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-char-counts', name: 'Counts', email: 'counts@example.com' },
|
||||
{ id: 'u-char-owner', name: 'Owner', email: 'owner@example.com' },
|
||||
])
|
||||
await db.insert(schema.character).values({
|
||||
id: 'char-counts',
|
||||
version: '1',
|
||||
coverUrl: '',
|
||||
creatorId: 'u-char-owner',
|
||||
ownerId: 'u-char-owner',
|
||||
characterId: 'cid-counts',
|
||||
likesCount: 1,
|
||||
bookmarksCount: 1,
|
||||
})
|
||||
await db.insert(schema.characterLikes).values({ userId: 'u-char-counts', characterId: 'char-counts' })
|
||||
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-counts', characterId: 'char-counts' })
|
||||
|
||||
const service = createCharacterService(db)
|
||||
await service.deleteAllForUser('u-char-counts')
|
||||
|
||||
const character = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-counts') })
|
||||
expect(character?.likesCount).toBe(0)
|
||||
expect(character?.bookmarksCount).toBe(0)
|
||||
|
||||
await service.deleteAllForUser('u-char-counts')
|
||||
|
||||
const afterRetry = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-counts') })
|
||||
expect(afterRetry?.likesCount).toBe(0)
|
||||
expect(afterRetry?.bookmarksCount).toBe(0)
|
||||
})
|
||||
|
||||
it('soft-deletes the user likes and bookmarks', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' })
|
||||
await db.insert(schema.character).values({
|
||||
id: 'char-z',
|
||||
version: '1',
|
||||
coverUrl: '',
|
||||
creatorId: 'u-char-3',
|
||||
ownerId: 'u-char-3',
|
||||
characterId: 'cid-z',
|
||||
})
|
||||
await db.insert(schema.characterLikes).values({ userId: 'u-char-3', characterId: 'char-z' })
|
||||
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-3', characterId: 'char-z' })
|
||||
|
||||
const service = createCharacterService(db)
|
||||
await service.deleteAllForUser('u-char-3')
|
||||
|
||||
const like = await db.query.characterLikes.findFirst({ where: eq(schema.characterLikes.userId, 'u-char-3') })
|
||||
const bookmark = await db.query.characterBookmarks.findFirst({ where: eq(schema.characterBookmarks.userId, 'u-char-3') })
|
||||
|
||||
expect(like?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(bookmark?.deletedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatService.deleteAllForUser', () => {
|
||||
let db: Database
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
})
|
||||
|
||||
it('soft-deletes chats the user is a member of', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-chat-1', name: 'C', email: 'chat@example.com' })
|
||||
await db.insert(schema.chats).values([
|
||||
{ id: 'chat-mine', type: 'private', title: 'mine' },
|
||||
{ id: 'chat-other', type: 'private', title: 'other' },
|
||||
])
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'chat-mine', memberType: 'user', userId: 'u-chat-1' })
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-chat-1')
|
||||
|
||||
const mine = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-mine') })
|
||||
const other = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-other') })
|
||||
|
||||
expect(mine?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(other?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('drops chat_members for shared (group/channel) chats but keeps the chat alive', async () => {
|
||||
// Two users in a shared group chat. When user A is deleted, the chat
|
||||
// row must survive for user B; only A's chat_members row goes.
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-grp-a', name: 'A', email: 'grpa@example.com' },
|
||||
{ id: 'u-grp-b', name: 'B', email: 'grpb@example.com' },
|
||||
])
|
||||
await db.insert(schema.chats).values({ id: 'chat-grp', type: 'group', title: 'team' })
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-a' },
|
||||
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-b' },
|
||||
])
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-grp-a')
|
||||
|
||||
const chatRow = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-grp') })
|
||||
expect(chatRow?.deletedAt).toBeNull() // chat survives
|
||||
|
||||
const remainingMembers = await db.query.chatMembers.findMany({ where: eq(schema.chatMembers.chatId, 'chat-grp') })
|
||||
expect(remainingMembers).toHaveLength(1)
|
||||
expect(remainingMembers[0]?.userId).toBe('u-grp-b')
|
||||
})
|
||||
|
||||
it('preserves the user messages inside group chats so other members keep conversation context', async () => {
|
||||
// Anonymization-by-design: in a group chat, user A's messages must NOT
|
||||
// be soft-deleted on account deletion — that would corrupt B's history.
|
||||
// The senderId stays as the (now-orphan) user.id string; the UI renders
|
||||
// it as "Deleted User" once it cannot resolve the id to a real user.
|
||||
await db.insert(schema.user).values([
|
||||
{ id: 'u-anon-a', name: 'A', email: 'anona@example.com' },
|
||||
{ id: 'u-anon-b', name: 'B', email: 'anonb@example.com' },
|
||||
])
|
||||
await db.insert(schema.chats).values({ id: 'chat-anon-grp', type: 'group', title: 'team' })
|
||||
await db.insert(schema.chatMembers).values([
|
||||
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-a' },
|
||||
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-b' },
|
||||
])
|
||||
await db.insert(schema.messages).values([
|
||||
{ id: 'm-a-1', chatId: 'chat-anon-grp', senderId: 'u-anon-a', role: 'user', content: 'hi from A', mediaIds: [], stickerIds: [] },
|
||||
{ id: 'm-b-1', chatId: 'chat-anon-grp', senderId: 'u-anon-b', role: 'user', content: 'hi from B', mediaIds: [], stickerIds: [] },
|
||||
])
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-anon-a')
|
||||
|
||||
// A's message stays alive; senderId still points at the now-orphan user.id string.
|
||||
const aMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-a-1') })
|
||||
expect(aMsg?.deletedAt).toBeNull()
|
||||
expect(aMsg?.senderId).toBe('u-anon-a')
|
||||
expect(aMsg?.content).toBe('hi from A')
|
||||
|
||||
// B's message obviously untouched.
|
||||
const bMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-b-1') })
|
||||
expect(bMsg?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('soft-deletes messages the user sent in private/bot chats', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-chat-2', name: 'M', email: 'msg@example.com' })
|
||||
await db.insert(schema.chats).values({ id: 'chat-msg', type: 'private', title: 't' })
|
||||
await db.insert(schema.chatMembers).values({ chatId: 'chat-msg', memberType: 'user', userId: 'u-chat-2' })
|
||||
await db.insert(schema.messages).values([
|
||||
{
|
||||
id: 'msg-mine',
|
||||
chatId: 'chat-msg',
|
||||
senderId: 'u-chat-2',
|
||||
role: 'user',
|
||||
content: 'hi',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
},
|
||||
{
|
||||
id: 'msg-other',
|
||||
chatId: 'chat-msg',
|
||||
senderId: 'someone-else',
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
mediaIds: [],
|
||||
stickerIds: [],
|
||||
},
|
||||
])
|
||||
|
||||
const service = createChatService(db)
|
||||
await service.deleteAllForUser('u-chat-2')
|
||||
|
||||
const mine = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-mine') })
|
||||
const other = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-other') })
|
||||
|
||||
expect(mine?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(other?.deletedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Logger } from '@guiiai/logg'
|
||||
|
||||
/**
|
||||
* Reason a user deletion is being requested. Recorded in logs and surfaced
|
||||
* to handlers so they can branch (e.g. compliance erase vs. user-initiated).
|
||||
*
|
||||
* - `user-requested`: triggered by the user via better-auth `/delete-user/callback`.
|
||||
* - `admin`: triggered by an admin tool (not yet implemented).
|
||||
* - `compliance`: triggered by automated GDPR / data-retention workflow (not yet implemented).
|
||||
*/
|
||||
export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance'
|
||||
|
||||
/**
|
||||
* Context passed to every {@link UserDeletionHandler} invocation.
|
||||
*
|
||||
* Use when:
|
||||
* - Implementing a new business handler — read `userId` to scope your soft-delete writes.
|
||||
* - Logging within a handler — use the provided `logger` so entries share the deletion correlation context.
|
||||
*/
|
||||
export interface UserDeletionContext {
|
||||
/** The user being deleted. Handlers MUST scope their writes to this id. */
|
||||
userId: string
|
||||
/** Why the deletion was triggered. */
|
||||
reason: UserDeletionReason
|
||||
/** Pre-scoped logger for handler diagnostics. */
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* A registered participant in the account-deletion pipeline.
|
||||
*
|
||||
* Each business module that owns user-scoped tables registers one of these
|
||||
* with the {@link UserDeletionService}. Handlers run sequentially in
|
||||
* ascending `priority` order; a thrown error aborts the whole pipeline so
|
||||
* better-auth's hard-delete of the user row never runs (the user is left
|
||||
* intact and the operation can be retried idempotently).
|
||||
*
|
||||
* @example
|
||||
* createUserDeletionService().register({
|
||||
* name: 'flux',
|
||||
* priority: 20,
|
||||
* async softDelete({ userId }) {
|
||||
* await db.update(userFlux).set({ deletedAt: new Date() }).where(eq(userFlux.userId, userId))
|
||||
* },
|
||||
* })
|
||||
*/
|
||||
export interface UserDeletionHandler {
|
||||
/** Stable identifier used for logs, metrics, and duplicate-registration checks. */
|
||||
name: string
|
||||
/**
|
||||
* Lower runs first. Conventions:
|
||||
* - 10: external side-effects without rollback (Stripe API cancel)
|
||||
* - 20: financial / cache state (Flux balance, Redis invalidation)
|
||||
* - 30: pure DB soft-delete (providers, characters, chats)
|
||||
*
|
||||
* @default 30
|
||||
*/
|
||||
priority: number
|
||||
/**
|
||||
* Mark business records as deleted. MUST be idempotent — the deletion
|
||||
* pipeline retries by re-issuing the entire request, and Stripe / Postgres
|
||||
* already deduplicate on subsequent calls. Throw to abort the pipeline.
|
||||
*/
|
||||
softDelete: (ctx: UserDeletionContext) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator for account deletion across business modules.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring better-auth's `user.deleteUser.beforeDelete` hook in `libs/auth.ts`.
|
||||
* - Implementing an admin-triggered deletion path (future).
|
||||
*
|
||||
* Expects:
|
||||
* - All handlers are registered at app-composition time before the first
|
||||
* request hits `beforeDelete`. Late registration is allowed but discouraged.
|
||||
*/
|
||||
export interface UserDeletionService {
|
||||
/**
|
||||
* Register a handler. Throws if `handler.name` is already registered —
|
||||
* names must be unique so logs and metrics can attribute work cleanly.
|
||||
*/
|
||||
register: (handler: UserDeletionHandler) => void
|
||||
/**
|
||||
* Run every registered handler in priority order. Returns when all
|
||||
* handlers complete, or throws the first handler error and stops.
|
||||
*/
|
||||
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Database } from '../../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
import { createNotFoundError } from '../../../utils/error'
|
||||
|
||||
import * as accountsSchema from '../../../schemas/accounts'
|
||||
|
||||
export interface UserSelector {
|
||||
/** Select by user id. Exactly one of `userId` / `email` must be set. */
|
||||
userId?: string
|
||||
/** Select by email (case-insensitive). Exactly one of `userId` / `email` must be set. */
|
||||
email?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an admin-supplied selector to a concrete user row.
|
||||
*
|
||||
* Use when:
|
||||
* - An admin operation targets a single user by id or email and needs the
|
||||
* canonical `{ id, email }` before mutating balance, bans, etc.
|
||||
*
|
||||
* Expects:
|
||||
* - Exactly one of `userId` / `email` is set. `email` is matched lowercased,
|
||||
* which is safe because better-auth stores emails lowercased and the unique
|
||||
* index is on the raw column (wrapping in `LOWER()` would seq-scan).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ id, email }` of the matching user.
|
||||
*
|
||||
* Throws:
|
||||
* - 404 when no user matches the selector.
|
||||
*/
|
||||
export async function resolveUserByIdOrEmail(
|
||||
db: Database,
|
||||
selector: UserSelector,
|
||||
): Promise<{ id: string, email: string }> {
|
||||
if (selector.userId != null) {
|
||||
const [row] = await db
|
||||
.select({ id: accountsSchema.user.id, email: accountsSchema.user.email })
|
||||
.from(accountsSchema.user)
|
||||
.where(eq(accountsSchema.user.id, selector.userId))
|
||||
.limit(1)
|
||||
if (!row)
|
||||
throw createNotFoundError(`No user with id ${selector.userId}`)
|
||||
return row
|
||||
}
|
||||
|
||||
const email = selector.email!.toLowerCase()
|
||||
const [row] = await db
|
||||
.select({ id: accountsSchema.user.id, email: accountsSchema.user.email })
|
||||
.from(accountsSchema.user)
|
||||
.where(eq(accountsSchema.user.email, email))
|
||||
.limit(1)
|
||||
if (!row)
|
||||
throw createNotFoundError(`No user with email ${email}`)
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Database } from '../../../libs/db'
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createVoicePackService } from '.'
|
||||
import { mockDB } from '../../../libs/mock-db'
|
||||
|
||||
import * as schema from '../../../schemas'
|
||||
|
||||
describe('voicePackService', () => {
|
||||
let db: Database
|
||||
let service: ReturnType<typeof createVoicePackService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
service = createVoicePackService(db)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.voicePacks)
|
||||
})
|
||||
|
||||
it('creates a Voice Pack with provider, model, voice, params, cost multiplier, and tts model pin', async () => {
|
||||
// @example create one curated cloud voice -> row stores the resolved routing pin.
|
||||
const pack = await service.create({
|
||||
name: 'Neuro Sama',
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-neuro',
|
||||
upstreamVoiceId: 'voice-neuro-upstream',
|
||||
ttsModelId: 'volcengine/neuro-pool',
|
||||
params: { pitch: 20, volume: 5 },
|
||||
costMultiplier: 1.5,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
expect(pack.name).toBe('Neuro Sama')
|
||||
expect(pack.provider).toBe('volcengine')
|
||||
expect(pack.model).toBe('seed-tts-2.0')
|
||||
expect(pack.voiceId).toBe('voice-neuro')
|
||||
expect(pack.upstreamVoiceId).toBe('voice-neuro-upstream')
|
||||
expect(pack.ttsModelId).toBe('volcengine/neuro-pool')
|
||||
expect(pack.params).toEqual({ pitch: 20, volume: 5 })
|
||||
expect(pack.costMultiplier).toBe(1.5)
|
||||
expect(pack.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps parameter variants as separate packs', async () => {
|
||||
// @example same provider/model/voice with different params -> two library entries.
|
||||
await service.create({
|
||||
name: 'Base',
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-a',
|
||||
upstreamVoiceId: 'voice-a-upstream',
|
||||
ttsModelId: 'volcengine/pool',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
enabled: true,
|
||||
})
|
||||
await service.create({
|
||||
name: 'Pitched',
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-a',
|
||||
upstreamVoiceId: 'voice-a-upstream',
|
||||
ttsModelId: 'volcengine/pool',
|
||||
params: { pitch: 20 },
|
||||
costMultiplier: 1,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const packs = await service.list()
|
||||
expect(packs).toHaveLength(2)
|
||||
expect(packs.map(p => p.name).sort()).toEqual(['Base', 'Pitched'])
|
||||
})
|
||||
|
||||
it('updates mutable fields without replacing the row', async () => {
|
||||
// @example edit curation metadata/params -> same id, updated values.
|
||||
const pack = await service.create({
|
||||
name: 'Old',
|
||||
provider: 'azure',
|
||||
model: 'v1',
|
||||
voiceId: 'en-US-AvaMultilingualNeural',
|
||||
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
|
||||
ttsModelId: 'microsoft/v1',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const updated = await service.update(pack.id, {
|
||||
name: 'New',
|
||||
params: { rate: 1.1 },
|
||||
costMultiplier: 2,
|
||||
})
|
||||
|
||||
expect(updated?.id).toBe(pack.id)
|
||||
expect(updated?.name).toBe('New')
|
||||
expect(updated?.params).toEqual({ rate: 1.1 })
|
||||
expect(updated?.costMultiplier).toBe(2)
|
||||
})
|
||||
|
||||
it('soft-disables a pack and excludes it from listEnabled', async () => {
|
||||
// @example disabled packs remain in admin list but disappear from user list.
|
||||
const pack = await service.create({
|
||||
name: 'Disable me',
|
||||
provider: 'dashscope-cosyvoice',
|
||||
model: 'cosyvoice-v2',
|
||||
voiceId: 'longxiaochun_v2',
|
||||
upstreamVoiceId: 'longxiaochun_v2',
|
||||
ttsModelId: 'alibaba/cosyvoice-v2',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const disabled = await service.disable(pack.id)
|
||||
const all = await service.list()
|
||||
const enabled = await service.listEnabled()
|
||||
|
||||
expect(disabled?.enabled).toBe(false)
|
||||
expect(all).toHaveLength(1)
|
||||
expect(enabled).toEqual([])
|
||||
})
|
||||
|
||||
it('finds only enabled packs by product-facing voice alias', async () => {
|
||||
// @example TTS request voice="narrator" -> enabled Voice Pack row resolves server-side.
|
||||
await service.create({
|
||||
name: 'Disabled narrator',
|
||||
provider: 'azure',
|
||||
model: 'v1',
|
||||
voiceId: 'narrator',
|
||||
upstreamVoiceId: 'disabled-upstream',
|
||||
ttsModelId: 'microsoft/v1',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
enabled: false,
|
||||
})
|
||||
const enabled = await service.create({
|
||||
name: 'Enabled narrator',
|
||||
provider: 'azure',
|
||||
model: 'v1',
|
||||
voiceId: 'narrator',
|
||||
upstreamVoiceId: 'enabled-upstream',
|
||||
ttsModelId: 'microsoft/v1',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
expect(await service.findEnabledByVoiceId('narrator')).toMatchObject({
|
||||
id: enabled.id,
|
||||
upstreamVoiceId: 'enabled-upstream',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when updating or disabling a missing pack', async () => {
|
||||
// @example unknown id -> null so routes can map to 404.
|
||||
expect(await service.update('missing', { name: 'Nope' })).toBeNull()
|
||||
expect(await service.disable('missing')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import type { Database } from '../../../libs/db'
|
||||
import type { VoicePack } from '../../../schemas/voice-packs'
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { boolean, maxLength, minValue, nonEmpty, number, object, optional, pipe, string } from 'valibot'
|
||||
|
||||
import * as schema from '../../../schemas/voice-packs'
|
||||
|
||||
export const VoicePackParamsSchema = object({
|
||||
pitch: optional(number()),
|
||||
volume: optional(number()),
|
||||
rate: optional(pipe(number(), minValue(0.01, 'rate must be positive'))),
|
||||
})
|
||||
|
||||
export const VoicePackCostMultiplierSchema = pipe(
|
||||
number(),
|
||||
minValue(0, 'costMultiplier must not be negative'),
|
||||
)
|
||||
|
||||
export const CreateVoicePackInputSchema = object({
|
||||
name: pipe(string(), nonEmpty('name is required'), maxLength(120)),
|
||||
description: optional(pipe(string(), maxLength(500))),
|
||||
provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)),
|
||||
model: pipe(string(), nonEmpty('model is required'), maxLength(200)),
|
||||
voiceId: pipe(string(), nonEmpty('voiceId is required'), maxLength(200)),
|
||||
upstreamVoiceId: pipe(string(), nonEmpty('upstreamVoiceId is required'), maxLength(200)),
|
||||
ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)),
|
||||
params: optional(VoicePackParamsSchema, {}),
|
||||
costMultiplier: VoicePackCostMultiplierSchema,
|
||||
enabled: optional(boolean(), true),
|
||||
})
|
||||
|
||||
export const UpdateVoicePackInputSchema = object({
|
||||
name: optional(pipe(string(), nonEmpty('name must not be empty'), maxLength(120))),
|
||||
description: optional(pipe(string(), maxLength(500))),
|
||||
provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))),
|
||||
model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))),
|
||||
voiceId: optional(pipe(string(), nonEmpty('voiceId must not be empty'), maxLength(200))),
|
||||
upstreamVoiceId: optional(pipe(string(), nonEmpty('upstreamVoiceId must not be empty'), maxLength(200))),
|
||||
ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))),
|
||||
params: optional(VoicePackParamsSchema),
|
||||
costMultiplier: optional(VoicePackCostMultiplierSchema),
|
||||
enabled: optional(boolean()),
|
||||
})
|
||||
|
||||
/**
|
||||
* Voice Pack creation input accepted by the admin service.
|
||||
*/
|
||||
export type CreateVoicePackInput = InferOutput<typeof CreateVoicePackInputSchema>
|
||||
|
||||
/**
|
||||
* Voice Pack update input accepted by the admin service.
|
||||
*/
|
||||
export type UpdateVoicePackInput = InferOutput<typeof UpdateVoicePackInputSchema>
|
||||
|
||||
/**
|
||||
* Handles the curated server-side Voice Pack library.
|
||||
*
|
||||
* Use when:
|
||||
* - Admin routes create, update, disable, or list curated cloud-provider voices.
|
||||
* - Client routes need the enabled-only market list for binding.
|
||||
*
|
||||
* Expects:
|
||||
* - HTTP routes validate input with the exported Valibot schemas before calling.
|
||||
*
|
||||
* Returns:
|
||||
* - CRUD methods that preserve rows and use `enabled=false` as soft disable.
|
||||
*/
|
||||
export function createVoicePackService(db: Database) {
|
||||
return {
|
||||
async create(input: CreateVoicePackInput) {
|
||||
const [inserted] = await db.insert(schema.voicePacks).values({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
voiceId: input.voiceId,
|
||||
upstreamVoiceId: input.upstreamVoiceId,
|
||||
ttsModelId: input.ttsModelId,
|
||||
params: input.params,
|
||||
costMultiplier: input.costMultiplier,
|
||||
enabled: input.enabled,
|
||||
}).returning()
|
||||
|
||||
return inserted
|
||||
},
|
||||
|
||||
async list() {
|
||||
return await db.query.voicePacks.findMany({
|
||||
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
async listEnabled() {
|
||||
return await db.query.voicePacks.findMany({
|
||||
where: eq(schema.voicePacks.enabled, true),
|
||||
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
return await db.query.voicePacks.findFirst({
|
||||
where: eq(schema.voicePacks.id, id),
|
||||
})
|
||||
},
|
||||
|
||||
async findEnabledByVoiceId(voiceId: string) {
|
||||
return await db.query.voicePacks.findFirst({
|
||||
where: and(
|
||||
eq(schema.voicePacks.voiceId, voiceId),
|
||||
eq(schema.voicePacks.enabled, true),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async update(id: string, input: UpdateVoicePackInput): Promise<VoicePack | null> {
|
||||
const [updated] = await db.update(schema.voicePacks)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(schema.voicePacks.id, id))
|
||||
.returning()
|
||||
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async disable(id: string): Promise<VoicePack | null> {
|
||||
const [updated] = await db.update(schema.voicePacks)
|
||||
.set({ enabled: false, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.voicePacks.id, id),
|
||||
eq(schema.voicePacks.enabled, true),
|
||||
))
|
||||
.returning()
|
||||
|
||||
return updated ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type VoicePackService = ReturnType<typeof createVoicePackService>
|
||||
Reference in New Issue
Block a user