refactor(server): openai route gateway

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
RainbowBird
2026-06-03 00:21:25 +08:00
co-authored by Neko
parent 88fac74702
commit 6f6fe01b3e
14 changed files with 533 additions and 196 deletions
+11 -1
View File
@@ -216,7 +216,17 @@ export async function buildApp(deps: AppDeps) {
// Built once so the OpenAI-compat and audio routers share the same closure
// (helpers like recordMetrics / recordRequestLog cross both surfaces) but
// mount at different prefixes — see the `.route` calls below.
const v1Routes = createV1Routes(deps.fluxService, deps.billingService, deps.configKV, deps.requestLogService, deps.ttsMeter, deps.llmRouter, deps.otel?.genAi, deps.otel?.revenue, deps.otel?.rateLimit)
const v1Routes = createV1Routes({
fluxService: deps.fluxService,
billingService: deps.billingService,
configKV: deps.configKV,
requestLogService: deps.requestLogService,
ttsMeter: deps.ttsMeter,
llmRouter: deps.llmRouter,
genAi: deps.otel?.genAi,
revenue: deps.otel?.revenue,
rateLimitMetrics: deps.otel?.rateLimit,
})
const builtApp = app
.use('*', sessionMiddleware(deps.auth, deps.env))
+218
View File
@@ -0,0 +1,218 @@
import type { Context, Handler, MiddlewareHandler } from 'hono'
import type { HonoEnv } from '../../../types/hono'
import type { ChatCompletionsOperationRequest } from './operations/chat-completions'
import type { SpeechGenerationOperationRequest } from './operations/speech-generation'
import type { V1RouteDeps } from './types'
import { Hono } from 'hono'
export type GatewayCallback<Name extends V1GatewayOperationName> = (
context: V1GatewayContext<Name>,
) => Promise<Response>
export type GatewayMiddleware<Name extends V1GatewayOperationName> = (
context: V1GatewayContext<Name>,
next: () => Promise<Response>,
) => Promise<Response>
export type V1HttpSurface = 'audio' | 'openai'
export interface V1GatewayOperationInput {
'chat.completions': ChatCompletionsOperationRequest
'speech.generate': SpeechGenerationOperationRequest
}
export type V1GatewayOperationName = keyof V1GatewayOperationInput
export type V1GatewayPlugin = (gateway: V1GatewayRuntime) => void
export interface V1GatewayContext<Name extends V1GatewayOperationName> {
deps: V1RouteDeps
hono: Context<HonoEnv>
input: V1GatewayOperationInput[Name]
}
export interface V1GatewayRuntime {
deps: V1RouteDeps
handler: <Name extends V1GatewayOperationName>(
name: Name,
parse: (hono: Context<HonoEnv>) => V1GatewayOperationInput[Name] | Promise<V1GatewayOperationInput[Name]>,
callback: GatewayCallback<Name>,
) => Handler<HonoEnv>
route: (surface: V1HttpSurface) => V1GatewayRoute
use: {
(plugin: V1GatewayPlugin): V1GatewayRuntime
<Name extends V1GatewayOperationName>(name: Name, middleware: GatewayMiddleware<Name>): V1GatewayRuntime
}
useHono: (surface: V1HttpSurface | '*', path: string, middleware: MiddlewareHandler<HonoEnv>) => V1GatewayRuntime
}
export interface V1GatewayRoute {
deps: V1RouteDeps
get: (path: string, handler: Handler<HonoEnv> | V1GatewayRouteHandler) => V1GatewayRoute
handler: V1GatewayRuntime['handler']
post: (path: string, handler: Handler<HonoEnv> | V1GatewayRouteHandler) => V1GatewayRoute
route: Hono<HonoEnv>
use: <Name extends V1GatewayOperationName>(name: Name, middleware: GatewayMiddleware<Name>) => V1GatewayRoute
useHono: (path: string, middleware: MiddlewareHandler<HonoEnv>) => V1GatewayRoute
}
const routeHandlerMarker = Symbol('v1-gateway-route-handler')
export interface V1GatewayRouteHandler {
(scope: Pick<V1GatewayRoute, 'deps' | 'handler'>): Handler<HonoEnv>
[routeHandlerMarker]: true
}
export function routeHandler(handler: (scope: Pick<V1GatewayRoute, 'deps' | 'handler'>) => Handler<HonoEnv>): V1GatewayRouteHandler {
return Object.assign(handler, { [routeHandlerMarker]: true as const })
}
interface RegisteredHttpMiddleware {
middleware: MiddlewareHandler<HonoEnv>
path: string
surface: V1HttpSurface | '*'
}
type OperationMiddlewares = {
[Name in V1GatewayOperationName]: GatewayMiddleware<Name>[]
}
function cloneOperationMiddlewares(input: OperationMiddlewares): OperationMiddlewares {
return {
'chat.completions': [...input['chat.completions']],
'speech.generate': [...input['speech.generate']],
}
}
/**
* Runs an OpenAI gateway callback through operation-scoped middleware.
*
* Use when:
* - The middleware needs parsed gateway input such as user id, model, body,
* session id, or abort signal.
* - The behavior is not a generic HTTP concern and should not receive Hono
* `Context`.
*
* Expects:
* - `callback` is the concrete gateway business callback.
* - `middlewares` are ordered from outermost to innermost.
*
* Returns:
* - A response produced by the gateway callback chain.
*/
export function runGatewayMiddlewares<Name extends V1GatewayOperationName>(
context: V1GatewayContext<Name>,
callback: GatewayCallback<Name>,
middlewares: GatewayMiddleware<Name>[],
): Promise<Response> {
const runnable = middlewares.reduceRight<GatewayCallback<Name>>(
(next, middleware) => ctx => middleware(ctx, () => next(ctx)),
callback,
)
return runnable(context)
}
export function createV1Gateway(deps: V1RouteDeps): V1GatewayRuntime {
const httpMiddlewares: RegisteredHttpMiddleware[] = []
const operationMiddlewares: OperationMiddlewares = {
'chat.completions': [],
'speech.generate': [],
}
let gateway: V1GatewayRuntime
function use(plugin: V1GatewayPlugin): V1GatewayRuntime
function use<Name extends V1GatewayOperationName>(name: Name, middleware: GatewayMiddleware<Name>): V1GatewayRuntime
function use<Name extends V1GatewayOperationName>(
arg1: V1GatewayPlugin | Name,
arg2?: GatewayMiddleware<Name>,
): V1GatewayRuntime {
if (typeof arg1 === 'function') {
arg1(gateway)
}
else if (arg2) {
operationMiddlewares[arg1].push(arg2)
}
return gateway
}
function handlerWithMiddlewares<Name extends V1GatewayOperationName>(
middlewares: OperationMiddlewares,
name: Name,
parse: (hono: Context<HonoEnv>) => V1GatewayOperationInput[Name] | Promise<V1GatewayOperationInput[Name]>,
callback: GatewayCallback<Name>,
): Handler<HonoEnv> {
return async (hono) => {
const input = await parse(hono)
return runGatewayMiddlewares(
{ deps, hono, input },
callback,
middlewares[name],
)
}
}
function createRoute(surface: V1HttpSurface): V1GatewayRoute {
const route = new Hono<HonoEnv>()
for (const registered of httpMiddlewares) {
if (registered.surface === '*' || registered.surface === surface)
route.use(registered.path, registered.middleware)
}
function makeBuilder(scopedOperationMiddlewares: OperationMiddlewares): V1GatewayRoute {
let builder: V1GatewayRoute
function register(method: 'get' | 'post', path: string, handler: Handler<HonoEnv> | V1GatewayRouteHandler): V1GatewayRoute {
route[method](path, resolveRouteHandler(builder, handler))
return builder
}
builder = {
deps,
get: (path, handler) => register('get', path, handler),
handler(name, parse, callback) {
return handlerWithMiddlewares(scopedOperationMiddlewares, name, parse, callback)
},
post: (path, handler) => register('post', path, handler),
route,
use(name, middleware) {
const nextMiddlewares = cloneOperationMiddlewares(scopedOperationMiddlewares)
nextMiddlewares[name].push(middleware)
return makeBuilder(nextMiddlewares)
},
useHono(path, middleware) {
route.use(path, middleware)
return builder
},
}
return builder
}
return makeBuilder(cloneOperationMiddlewares(operationMiddlewares))
}
gateway = {
deps,
handler(name, parse, callback) {
return handlerWithMiddlewares(operationMiddlewares, name, parse, callback)
},
route: createRoute,
use,
useHono(surface, path, middleware) {
httpMiddlewares.push({ surface, path, middleware })
return gateway
},
}
return gateway
}
function resolveRouteHandler(scope: V1GatewayRoute, handler: Handler<HonoEnv> | V1GatewayRouteHandler): Handler<HonoEnv> {
if (routeHandlerMarker in handler)
return (handler as V1GatewayRouteHandler)(scope)
return handler as Handler<HonoEnv>
}
@@ -1,19 +0,0 @@
import type { V1RouteDeps } from './types'
import { authGuard } from '../../../middlewares/auth'
import { configGuard } from '../../../middlewares/config-guard'
import { rateLimiter } from '../../../middlewares/rate-limit'
export function createV1RouteGuards(deps: V1RouteDeps) {
return {
authGuard,
chatGuard: configGuard(deps.configKV, ['FLUX_PER_REQUEST'], 'Service is not available yet'),
completionsRateLimit: rateLimiter({
max: 60,
windowSec: 60,
metrics: deps.rateLimitMetrics,
routeLabel: 'openai.completions',
}),
ttsGuard: configGuard(deps.configKV, ['FLUX_PER_1K_CHARS_TTS'], 'TTS service is not available yet'),
}
}
+66 -61
View File
@@ -1,58 +1,27 @@
import type { PostHog } from 'posthog-node'
import type { Context } from 'hono'
import type { GenAiMetrics, RateLimitMetrics, RevenueMetrics } from '../../../otel'
import type { ConfigKVService } from '../../../services/adapters/config-kv'
import type { BillingService } from '../../../services/domain/billing/billing-service'
import type { FluxMeter } from '../../../services/domain/billing/flux-meter'
import type { FluxService } from '../../../services/domain/flux'
import type { LlmRouterService } from '../../../services/domain/llm-router'
import type { RequestLogService } from '../../../services/domain/request-log'
import type { HonoEnv } from '../../../types/hono'
import type { LlmTracingDeps } from './types'
import type { LlmTracingDeps, V1RouteDeps } from './types'
import { Hono } from 'hono'
import { createAudioCatalogHandlers } from './catalog'
import { createChatCompletionHandler } from './chat'
import { createV1RouteGuards } from './guards'
import { createSpeechHandler } from './speech'
import { authGuard } from '../../../middlewares/auth'
import { configGuard } from '../../../middlewares/config-guard'
import { createV1Gateway } from './gateway'
import { chatCompletionsRateLimit } from './middlewares'
import { chatCompletions } from './operations/chat-completions'
import { createSpeechCatalogOperation } from './operations/speech-catalog'
import { speechGeneration } from './operations/speech-generation'
import { defaultLlmTracing } from './types'
export function createV1Routes(
fluxService: FluxService,
billingService: BillingService,
configKV: ConfigKVService,
requestLogService: RequestLogService,
ttsMeter: FluxMeter,
llmRouter: LlmRouterService,
genAi?: GenAiMetrics | null,
revenue?: RevenueMetrics | null,
rateLimitMetrics?: RateLimitMetrics | null,
posthog?: PostHog | null,
llmTracing: LlmTracingDeps = defaultLlmTracing,
) {
const deps = {
fluxService,
billingService,
configKV,
requestLogService,
ttsMeter,
llmRouter,
genAi,
revenue,
rateLimitMetrics,
posthog,
llmTracing,
}
const guards = createV1RouteGuards(deps)
const handleCompletion = createChatCompletionHandler(deps)
const handleTTS = createSpeechHandler(deps)
const {
handleListStreamingTTSModels,
handleListStreamingVoices,
handleListTTSModels,
handleListVoices,
} = createAudioCatalogHandlers(deps)
export interface CreateV1RoutesDeps extends Omit<V1RouteDeps, 'llmTracing'> {
llmTracing?: LlmTracingDeps
}
export function createV1Routes(input: CreateV1RoutesDeps) {
const deps: V1RouteDeps = { ...input, llmTracing: input.llmTracing ?? defaultLlmTracing }
const gateway = createV1Gateway(deps)
.useHono('*', '*', authGuard)
.useHono('openai', '/chat/*', configGuard(deps.configKV, ['FLUX_PER_REQUEST'], 'Service is not available yet'))
.useHono('audio', '/speech', configGuard(deps.configKV, ['FLUX_PER_1K_CHARS_TTS'], 'TTS service is not available yet'))
// OpenAI-compatible surface (mounted at /api/v1/openai). Only routes that
// mirror an actual OpenAI public endpoint belong here. Audio used to live
@@ -60,22 +29,58 @@ export function createV1Routes(
// real OpenAI route and the streaming TTS protocol has nothing to do with
// OpenAI — keeping them here mislabelled the surface, so audio now mounts
// at /api/v1/audio (see `audioRoutes` below).
const openaiRoutes = new Hono<HonoEnv>()
.use('*', guards.authGuard)
.post('/chat/completions', guards.completionsRateLimit, guards.chatGuard, handleCompletion)
.post('/chat/completion', guards.completionsRateLimit, guards.chatGuard, handleCompletion)
const openai = gateway.route('openai')
.use('chat.completions', chatCompletionsRateLimit({ metrics: deps.rateLimitMetrics }))
const openaiRoutes = openai
.post('/chat/completions', openai.handler(
'chat.completions',
async (c) => {
const user = c.get('user')!
const body = await c.req.json() as Record<string, unknown>
return {
userId: user.id,
body,
sessionId: c.req.header('x-airi-session-id'),
abortSignal: c.req.raw.signal,
}
},
chatCompletions(deps),
))
.route
const audio = gateway.route('audio')
const speechCatalog = createSpeechCatalogOperation(deps)
// AIRI audio surface (mounted at /api/v1/audio). Lives outside /openai/ so
// the `/voices`, `/voices/streaming`, and `/models` extensions aren't
// misread as OpenAI-compatible. `/audio/speech/ws` is registered
// separately in app.ts because it needs the WebSocket upgrade middleware.
const audioRoutes = new Hono<HonoEnv>()
.use('*', guards.authGuard)
.post('/speech', guards.ttsGuard, handleTTS)
.get('/voices', handleListVoices)
.get('/voices/streaming', handleListStreamingVoices)
.get('/models', handleListTTSModels)
.get('/models/streaming', handleListStreamingTTSModels)
const audioRoutes = audio
.post('/speech', audio.handler(
'speech.generate',
async (c) => {
const user = c.get('user')!
const body = await c.req.json() as Record<string, unknown>
return {
userId: user.id,
body,
sessionId: c.req.header('x-airi-session-id'),
abortSignal: c.req.raw.signal,
}
},
speechGeneration(deps),
))
.get('/voices', (c: Context<HonoEnv>) => speechCatalog.listVoices({
requestedModel: c.req.query('model'),
}))
.get('/voices/streaming', (c: Context<HonoEnv>) => speechCatalog.listStreamingVoices({
model: c.req.query('model'),
}))
.get('/models', () => speechCatalog.listSpeechModels())
.get('/models/streaming', () => speechCatalog.listStreamingSpeechModels())
.route
return { openaiRoutes, audioRoutes }
}
@@ -1,13 +1,13 @@
import type { RevenueMetrics } from '../../../otel'
import type { ConfigKVService } from '../../../services/adapters/config-kv'
import type { UsageInfo } from '../../../services/domain/billing/billing'
import type { BillingService } from '../../../services/domain/billing/billing-service'
import type { FluxMeter } from '../../../services/domain/billing/flux-meter'
import type { FluxService } from '../../../services/domain/flux'
import type { RevenueMetrics } from '../../../../otel'
import type { ConfigKVService } from '../../../../services/adapters/config-kv'
import type { UsageInfo } from '../../../../services/domain/billing/billing'
import type { BillingService } from '../../../../services/domain/billing/billing-service'
import type { FluxMeter } from '../../../../services/domain/billing/flux-meter'
import type { FluxService } from '../../../../services/domain/flux'
import { calculateFluxFromUsage } from '../../../services/domain/billing/billing'
import { createPaymentRequiredError } from '../../../utils/error'
import { GEN_AI_ATTR_REQUEST_MODEL } from '../../../utils/observability'
import { calculateFluxFromUsage } from '../../../../services/domain/billing/billing'
import { createPaymentRequiredError } from '../../../../utils/error'
import { GEN_AI_ATTR_REQUEST_MODEL } from '../../../../utils/observability'
export interface ChatFluxDebitInput extends UsageInfo {
billingService: BillingService
@@ -0,0 +1,3 @@
export * from './billing'
export * from './telemetry'
export * from './traffic-control'
@@ -1,7 +1,7 @@
import type { GenAiMetrics } from '../../../otel'
import type { UsageInfo } from '../../../services/domain/billing/billing'
import type { LlmRouteContext } from '../../../services/domain/llm-router'
import type { RequestLogService } from '../../../services/domain/request-log'
import type { GenAiMetrics } from '../../../../otel'
import type { UsageInfo } from '../../../../services/domain/billing/billing'
import type { LlmRouteContext } from '../../../../services/domain/llm-router'
import type { RequestLogService } from '../../../../services/domain/request-log'
import { useLogger } from '@guiiai/logg'
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
@@ -15,7 +15,7 @@ import {
GEN_AI_ATTR_REQUEST_MODEL,
GEN_AI_ATTR_USAGE_INPUT_TOKENS,
GEN_AI_ATTR_USAGE_OUTPUT_TOKENS,
} from '../../../utils/observability'
} from '../../../../utils/observability'
export const tracer = trace.getTracer('v1-completions')
@@ -0,0 +1,78 @@
import type { RateLimitMetrics } from '../../../../otel'
import type { GatewayMiddleware, V1GatewayContext, V1GatewayOperationName } from '../gateway'
type RateLimitKeyType = 'ip' | 'model' | 'user'
interface GatewayRateLimitClassification {
key: string
keyType: RateLimitKeyType
model?: string
}
interface GatewayRateLimitOptions<Name extends V1GatewayOperationName> {
classify: (context: V1GatewayContext<Name>) => GatewayRateLimitClassification
max: number
metrics?: RateLimitMetrics | null
routeLabel: string
windowSec: number
}
interface RateLimitBucket {
count: number
resetAt: number
}
export function chatCompletionsRateLimit(input: {
metrics?: RateLimitMetrics | null
}): GatewayMiddleware<'chat.completions'> {
return createGatewayRateLimiter({
classify: context => ({
key: context.input.userId,
keyType: 'user',
model: typeof context.input.body.model === 'string' ? context.input.body.model : 'auto',
}),
max: 60,
metrics: input.metrics,
routeLabel: 'openai.completions',
windowSec: 60,
})
}
function createGatewayRateLimiter<Name extends V1GatewayOperationName>(opts: GatewayRateLimitOptions<Name>): GatewayMiddleware<Name> {
const buckets = new Map<string, RateLimitBucket>()
return async function limitGatewayOperation(context, next) {
const now = Date.now()
const classification = opts.classify(context)
const bucketKey = `${opts.routeLabel}:${classification.keyType}:${classification.key}`
const existing = buckets.get(bucketKey)
const bucket = existing && existing.resetAt > now
? existing
: { count: 0, resetAt: now + opts.windowSec * 1000 }
if (bucket.count >= opts.max) {
opts.metrics?.blocked.add(1, {
route: opts.routeLabel,
key_type: classification.keyType,
limit: String(opts.max),
})
const retryAfterSec = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
return Response.json(
{ error: 'TOO_MANY_REQUESTS', message: 'Too many requests' },
{
status: 429,
headers: {
'RateLimit-Limit': String(opts.max),
'RateLimit-Remaining': '0',
'RateLimit-Reset': String(retryAfterSec),
'Retry-After': String(retryAfterSec),
},
},
)
}
bucket.count += 1
buckets.set(bucketKey, bucket)
return next()
}
}
@@ -1,23 +1,28 @@
import type { Context, Handler } from 'hono'
import type { UsageInfo } from '../../../services/domain/billing/billing'
import type { HonoEnv } from '../../../types/hono'
import type { V1RouteDeps } from './types'
import type { UsageInfo } from '../../../../../services/domain/billing/billing'
import type { GatewayCallback } from '../../gateway'
import type { V1RouteDeps } from '../../types'
import { useLogger } from '@guiiai/logg'
import { captureSafe } from '../../../services/adapters/posthog'
import { extractUsageFromBody } from '../../../services/domain/billing/billing'
import { nanoid } from '../../../utils/id'
import { createOpenAiRouteBilling } from './billing'
import { buildSafeResponseHeaders } from './response'
import { createRouteTelemetry, newRouteContext } from './telemetry'
import { captureSafe } from '../../../../../services/adapters/posthog'
import { extractUsageFromBody } from '../../../../../services/domain/billing/billing'
import { nanoid } from '../../../../../utils/id'
import { buildSafeResponseHeaders } from '../../http/response'
import { createOpenAiRouteBilling } from '../../middlewares/billing'
import { createRouteTelemetry, newRouteContext } from '../../middlewares/telemetry'
type ChatBilling = ReturnType<typeof createOpenAiRouteBilling>
type ChatBillingPolicy = Awaited<ReturnType<ChatBilling['authorizeChat']>>
type RouteTelemetry = ReturnType<typeof createRouteTelemetry>
export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv> {
export interface ChatCompletionsOperationRequest {
userId: string
body: Record<string, unknown>
sessionId?: string
abortSignal?: AbortSignal
}
export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.completions'> {
const logger = useLogger('v1-completions').useGlobalConfig()
const telemetry = createRouteTelemetry({
genAi: deps.genAi,
@@ -25,18 +30,18 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
})
const billing = createOpenAiRouteBilling(deps)
return async function handleCompletion(c: Context<HonoEnv>) {
const user = c.get('user')!
return async (context) => {
const input = context.input
// Generated up-front so incoming, completion, partial-debit, debit-failure,
// and request-log entries all carry the same correlation id. Re-used as
// the billing requestId (both streaming and non-streaming branches) for
// DB-level idempotency.
const requestId = nanoid()
const billingPolicy = await billing.authorizeChat(user.id)
const billingPolicy = await billing.authorizeChat(input.userId)
const body = await c.req.json()
let requestModel = body.model || 'auto'
const body = input.body
let requestModel = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto'
if (requestModel === 'auto') {
requestModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL')
@@ -45,7 +50,7 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
const stream = !!body.stream
logger.withFields({
requestId,
userId: user.id,
userId: input.userId,
model: requestModel,
stream,
messageCount: Array.isArray(body.messages) ? body.messages.length : undefined,
@@ -67,7 +72,7 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
// caller hangs up. Without this the streaming-cancel path records
// fluxConsumed: 0 while real cost was incurred — a silent revenue leak.
// Source: codex review 2026-05-15 HIGH #1.
const clientAbort = c.req.raw.signal
const clientAbort = input.abortSignal
const routeCtx = newRouteContext()
let response: Response
try {
@@ -81,8 +86,8 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
model: routeCtx.upstreamModel ?? requestModel,
requestId,
stream,
userId: user.id,
sessionId: c.req.header('x-airi-session-id'),
userId: input.userId,
sessionId: input.sessionId,
}).fail('Router exhausted or unknown model')
telemetry.recordMetrics({ model: requestModel, status: 502, type: 'chat', provider: routeCtx.provider, durationMs: Date.now() - startedAt, fluxConsumed: 0 })
throw err
@@ -102,8 +107,8 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
model: langfuseModel,
requestId,
stream,
userId: user.id,
sessionId: c.req.header('x-airi-session-id'),
userId: input.userId,
sessionId: input.sessionId,
})
if (!response.ok) {
@@ -113,7 +118,7 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
// Emit server-side so funnels see real HTTP status — the client only
// ever observes "stream closed" and cannot tell 401 / 429 / 5xx apart.
void captureSafe(deps.posthog ?? null, {
distinctId: user.id,
distinctId: input.userId,
event: 'llm_request_failed',
properties: {
model: requestModel,
@@ -123,7 +128,7 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
},
})
logger.withFields({ requestId, userId: user.id, model: requestModel, status: response.status, durationMs })
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
.warn('chat completion delivered with upstream error status')
return new Response(response.body, {
@@ -141,7 +146,7 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
startedAt,
durationMs,
requestId,
userId: user.id,
userId: input.userId,
requestModel,
routeCtxProvider: routeCtx.provider,
billing,
@@ -152,14 +157,13 @@ export function createChatCompletionHandler(deps: V1RouteDeps): Handler<HonoEnv>
}
return completeNonStreamingChat({
c,
deps,
response,
generationTrace,
span,
durationMs,
requestId,
userId: user.id,
userId: input.userId,
requestModel,
routeCtxProvider: routeCtx.provider,
billing,
@@ -360,7 +364,6 @@ function streamChatCompletion(input: {
}
async function completeNonStreamingChat(input: {
c: Context<HonoEnv>
deps: V1RouteDeps
response: Response
generationTrace: ReturnType<V1RouteDeps['llmTracing']['startChatGeneration']>
@@ -452,5 +455,5 @@ async function completeNonStreamingChat(input: {
stream: false,
}).log('chat completion delivered')
return input.c.json(responseBody)
return Response.json(responseBody)
}
@@ -1,24 +1,29 @@
import type { Context, Handler } from 'hono'
import type { HonoEnv } from '../../../types/hono'
import type { V1RouteDeps } from './types'
import type { V1RouteDeps } from '../../types'
import { useLogger } from '@guiiai/logg'
import { ofetch } from 'ofetch'
import { createBadGatewayError, createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { createBadGatewayError, createBadRequestError, createServiceUnavailableError } from '../../../../../utils/error'
export interface AudioCatalogHandlers {
handleListStreamingTTSModels: Handler<HonoEnv>
handleListStreamingVoices: Handler<HonoEnv>
handleListTTSModels: Handler<HonoEnv>
handleListVoices: Handler<HonoEnv>
export interface SpeechCatalogOperation {
listSpeechModels: () => Promise<Response>
listStreamingSpeechModels: () => Promise<Response>
listStreamingVoices: (input: ListStreamingVoicesInput) => Promise<Response>
listVoices: (input: ListVoicesInput) => Promise<Response>
}
export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandlers {
export interface ListStreamingVoicesInput {
model?: string
}
export interface ListVoicesInput {
requestedModel?: string
}
export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOperation {
const logger = useLogger('v1-completions').useGlobalConfig()
async function handleListVoices(c: Context<HonoEnv>) {
async function listVoices(input: ListVoicesInput) {
// Voice catalogs are per-model. Live providers (Azure) call upstream
// via unspeech; static providers (cosyvoice, volcengine) return their
// bundled JSON. The Redis cache + invalidation lives one layer down
@@ -29,7 +34,7 @@ export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandl
// No implicit fallback: an empty `?model=` is a client bug (the UI is
// expected to pass either an explicit model id or the `auto` alias) and
// returns 400 instead of silently resolving to DEFAULT_TTS_MODEL.
const requested = c.req.query('model')
const requested = input.requestedModel
if (requested === undefined || requested === '')
throw createBadRequestError('audio voices: ?model= is required (use `auto` to defer to DEFAULT_TTS_MODEL)', 'MISSING_MODEL')
@@ -49,11 +54,11 @@ export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandl
/**
* Voice catalog for the streaming TTS provider (`/audio/speech/ws`).
*
* Errors propagate verbatim: missing config 503, malformed upstream
* URL 502, unspeech network failure 502, unspeech non-2xx 502.
* No empty-array fallback the UI surfaces a real failure state.
* Errors propagate verbatim: missing config -> 503, malformed upstream
* URL -> 502, unspeech network failure -> 502, unspeech non-2xx -> 502.
* No empty-array fallback: the UI surfaces a real failure state.
*/
async function handleListStreamingVoices(c: Context<HonoEnv>) {
async function listStreamingVoices(input: ListStreamingVoicesInput) {
const unspeech = await deps.configKV.getOptional('UNSPEECH_UPSTREAM')
if (!unspeech?.streaming?.baseURL)
throw createServiceUnavailableError('streaming tts upstream not configured', 'STREAMING_TTS_NOT_CONFIGURED')
@@ -61,7 +66,7 @@ export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandl
// Pass through the api_resource_id (e.g. `seed-tts-2.0`). unspeech
// filters the embedded Volcengine catalogue server-side; absent model
// means "return everything streaming-safe".
const model = c.req.query('model')
const model = input.model
let voicesURL: string
try {
@@ -117,7 +122,7 @@ export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandl
return Response.json({ voices: data.voices, recommended })
}
async function handleListTTSModels(_c: Context<HonoEnv>) {
async function listSpeechModels() {
// Surface the concrete TTS models the operator has configured. The UI
// should select an explicit model id so voice catalog requests stay
// model-scoped instead of hiding behind DEFAULT_TTS_MODEL.
@@ -132,7 +137,7 @@ export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandl
})
}
async function handleListStreamingTTSModels(_c: Context<HonoEnv>) {
async function listStreamingSpeechModels() {
const unspeech = await deps.configKV.getOptional('UNSPEECH_UPSTREAM')
const models = unspeech?.streaming?.models ?? []
// `available` is the operator-controlled visibility switch the client gates
@@ -152,9 +157,9 @@ export function createAudioCatalogHandlers(deps: V1RouteDeps): AudioCatalogHandl
}
return {
handleListStreamingTTSModels,
handleListStreamingVoices,
handleListTTSModels,
handleListVoices,
listSpeechModels,
listStreamingSpeechModels,
listStreamingVoices,
listVoices,
}
}
@@ -0,0 +1,25 @@
import type { GatewayCallback } from '../../gateway'
import type { V1RouteDeps } from '../../types'
import { createOpenAiSpeechService } from '../../../../../services/domain/openai-speech'
export interface SpeechGenerationOperationRequest {
userId: string
body: Record<string, unknown>
sessionId?: string
abortSignal?: AbortSignal
}
export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.generate'> {
const speechService = createOpenAiSpeechService({
configKV: deps.configKV,
fluxService: deps.fluxService,
genAi: deps.genAi,
llmRouter: deps.llmRouter,
llmTracing: deps.llmTracing,
requestLogService: deps.requestLogService,
ttsMeter: deps.ttsMeter,
})
return context => speechService.handleSpeechRequest(context.input)
}
+56 -17
View File
@@ -137,19 +137,19 @@ function createTestApp(
llmRouter?: LlmRouterService,
llmTracing = createMockLlmTracing(),
) {
const { openaiRoutes, audioRoutes } = createV1Routes(
const { openaiRoutes, audioRoutes } = createV1Routes({
fluxService,
billingService ?? createMockBillingService(),
billingService: billingService ?? createMockBillingService(),
configKV,
requestLogService ?? createMockRequestLogService(),
ttsMeter ?? createMockTtsMeter(),
llmRouter ?? createMockLlmRouter(),
null,
null,
null,
null,
requestLogService: requestLogService ?? createMockRequestLogService(),
ttsMeter: ttsMeter ?? createMockTtsMeter(),
llmRouter: llmRouter ?? createMockLlmRouter(),
genAi: null,
revenue: null,
rateLimitMetrics: null,
posthog: null,
llmTracing,
)
})
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
@@ -263,6 +263,50 @@ describe('v1CompletionsRoutes', () => {
expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled()
})
it('rate-limits chat completions at the gateway operation boundary', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({
id: 'chatcmpl-test',
choices: [{ message: { role: 'assistant', content: 'ok' } }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
})) as any
const llmRouter = createMockLlmRouter()
const app = createTestApp(
createMockFluxService(1000),
createMockConfigKV(),
createMockBillingService(1000),
undefined,
undefined,
llmRouter,
)
for (let i = 0; i < 60; i += 1) {
const res = await app.fetch(
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: `hi ${i}` }] }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
}
const limited = await app.fetch(
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'blocked' }] }),
}),
{ user: testUser } as any,
)
const body = await limited.json()
expect(limited.status).toBe(429)
expect(body).toEqual({ error: 'TOO_MANY_REQUESTS', message: 'Too many requests' })
expect(llmRouter.route).toHaveBeenCalledTimes(60)
})
// ROOT CAUSE:
//
// Before: when usage arrived and `fluxConsumed > balance`, debitFlux
@@ -1213,12 +1257,7 @@ describe('v1CompletionsRoutes', () => {
expect(res.status).toBe(404)
})
it('pOST /api/v1/openai/chat/completion (singular) should also work', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
it('pOST /api/v1/openai/chat/completion (singular) should return 404', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
@@ -1229,7 +1268,7 @@ describe('v1CompletionsRoutes', () => {
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(res.status).toBe(404)
})
})
})
@@ -1,30 +0,0 @@
import type { Context, Handler } from 'hono'
import type { HonoEnv } from '../../../types/hono'
import type { V1RouteDeps } from './types'
import { createOpenAiSpeechService } from '../../../services/domain/openai-speech'
export function createSpeechHandler(deps: V1RouteDeps): Handler<HonoEnv> {
const speechService = createOpenAiSpeechService({
configKV: deps.configKV,
fluxService: deps.fluxService,
genAi: deps.genAi,
llmRouter: deps.llmRouter,
llmTracing: deps.llmTracing,
requestLogService: deps.requestLogService,
ttsMeter: deps.ttsMeter,
})
return async function handleTTS(c: Context<HonoEnv>) {
const user = c.get('user')!
const body = await c.req.json() as Record<string, unknown>
return speechService.handleSpeechRequest({
userId: user.id,
body,
sessionId: c.req.header('x-airi-session-id'),
abortSignal: c.req.raw.signal,
})
}
}