feat(server): official provider router (#1117)

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
parent c78ac2ccf1
commit 0f9ea751e5
30 changed files with 3795 additions and 235 deletions
+1 -3
View File
@@ -10,9 +10,7 @@ AUTH_GITHUB_CLIENT_SECRET="change-me"
STRIPE_SECRET_KEY="change-me"
STRIPE_WEBHOOK_SECRET="change-me"
BACKEND_LLM_API_KEY="change-me"
BACKEND_LLM_BASE_URL="change-me"
CLIENT_URL="change-me"
API_SERVER_URL="change-me"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "llm_request_log" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"model" text NOT NULL,
"status" integer NOT NULL,
"duration_ms" integer NOT NULL,
"flux_consumed" integer NOT NULL,
"prompt_tokens" integer,
"completion_tokens" integer,
"settled" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "llm_request_log" ADD CONSTRAINT "llm_request_log_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1772634952890,
"tag": "0002_mean_tigra",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1773229668722,
"tag": "0003_old_titania",
"breakpoints": true
}
]
}
+1
View File
@@ -8,6 +8,7 @@
"auth:generate": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/accounts.ts -y",
"dev": "pnpm run apply:env -- tsx --watch src/app.ts",
"start": "tsx src/app.ts",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:push": "pnpm run apply:env -- drizzle-kit push"
},
+36 -6
View File
@@ -28,7 +28,9 @@ import { createCharacterService } from './services/characters'
import { createChatService } from './services/chats'
import { createConfigKVService } from './services/config-kv'
import { createFluxService } from './services/flux'
import { createFluxWriteBack } from './services/flux-write-back'
import { createProviderService } from './services/providers'
import { createRequestLogService } from './services/request-log'
import { createStripeService } from './services/stripe'
import { ApiError, createInternalError } from './utils/error'
import { getTrustedOrigin } from './utils/origin'
@@ -39,6 +41,7 @@ type ChatService = ReturnType<typeof createChatService>
type ProviderService = ReturnType<typeof createProviderService>
type FluxService = ReturnType<typeof createFluxService>
type ConfigKVService = ReturnType<typeof createConfigKVService>
type RequestLogService = ReturnType<typeof createRequestLogService>
type StripeDBService = ReturnType<typeof createStripeService>
type OtelMetrics = ReturnType<typeof initOtel>
@@ -49,6 +52,7 @@ interface AppDeps {
chatService: ChatService
providerService: ProviderService
fluxService: FluxService
requestLogService: RequestLogService
stripeService: StripeDBService
configKV: ConfigKVService
env: Env
@@ -61,6 +65,7 @@ function buildApp({
chatService,
providerService,
fluxService,
requestLogService,
stripeService,
configKV,
env,
@@ -133,7 +138,7 @@ function buildApp({
/**
* V1 routes for official provider.
*/
.route('/v1', createV1CompletionsRoutes(fluxService, configKV, env))
.route('/api/v1', createV1CompletionsRoutes(fluxService, configKV, requestLogService, otel))
/**
* Flux routes.
@@ -220,8 +225,26 @@ async function createApp() {
})
const fluxService = injeca.provide('services:flux', {
dependsOn: { db, configKV },
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.configKV),
dependsOn: { db, redis, configKV },
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV),
})
const requestLogService = injeca.provide('services:requestLog', {
dependsOn: { db },
build: ({ dependsOn }) => createRequestLogService(dependsOn.db),
})
const fluxWriteBack = injeca.provide('services:fluxWriteBack', {
dependsOn: { db, lifecycle },
build: ({ dependsOn }) => {
const wb = createFluxWriteBack(dependsOn.db)
wb.start()
dependsOn.lifecycle.appHooks.onStop(async () => {
wb.stop()
await wb.flush()
})
return wb
},
})
await injeca.start()
@@ -231,10 +254,12 @@ async function createApp() {
chatService,
providerService,
fluxService,
requestLogService,
stripeService,
configKV,
otel,
env: parsedEnv,
otel,
fluxWriteBack,
})
const app = buildApp({
auth: resolved.auth,
@@ -242,15 +267,20 @@ async function createApp() {
chatService: resolved.chatService,
providerService: resolved.providerService,
fluxService: resolved.fluxService,
requestLogService: resolved.requestLogService,
stripeService: resolved.stripeService,
configKV: resolved.configKV,
env: resolved.env,
otel: resolved.otel,
})
logger.withFields({ port: 3000 }).log('Server started')
logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started')
return app
return {
...app,
port: Number(resolved.env.PORT),
hostname: resolved.env.HOST,
} satisfies Parameters<typeof serve>[0]
}
// eslint-disable-next-line antfu/no-top-level-await
+3 -3
View File
@@ -7,6 +7,9 @@ import { injeca } from 'injeca'
import { nonEmpty, object, optional, parse, pipe, string } from 'valibot'
const EnvSchema = object({
HOST: optional(string(), '0.0.0.0'),
PORT: optional(string(), '3000'),
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
CLIENT_URL: optional(string(), 'https://airi.moerui.ai'),
@@ -21,9 +24,6 @@ const EnvSchema = object({
STRIPE_SECRET_KEY: optional(string()),
STRIPE_WEBHOOK_SECRET: optional(string()),
BACKEND_LLM_BASE_URL: optional(string()),
BACKEND_LLM_API_KEY: optional(string()),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),
+27
View File
@@ -141,6 +141,28 @@ export function initOtel(env: Env) {
description: 'Number of Stripe webhook events processed',
})
// LLM / Gateway metrics
const llmRequestDuration = meter.createHistogram('llm.request.duration', {
description: 'LLM gateway request duration in milliseconds',
unit: 'ms',
})
const llmRequestCount = meter.createCounter('llm.request.count', {
description: 'Number of LLM gateway requests',
})
const llmTokensPrompt = meter.createCounter('llm.tokens.prompt', {
description: 'Total prompt tokens consumed',
})
const llmTokensCompletion = meter.createCounter('llm.tokens.completion', {
description: 'Total completion tokens consumed',
})
const fluxConsumed = meter.createCounter('flux.consumed', {
description: 'Total flux consumed',
})
// Graceful shutdown
const shutdown = async () => {
try {
@@ -162,6 +184,11 @@ export function initOtel(env: Env) {
authAttempts,
authFailures,
stripeEvents,
llmRequestDuration,
llmRequestCount,
llmTokensPrompt,
llmTokensCompletion,
fluxConsumed,
shutdown,
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { createServiceUnavailableError } from '../utils/error'
*/
export function configGuard(
configKV: ConfigKVService,
keys: Parameters<ConfigKVService['get']>[0][],
keys: Parameters<ConfigKVService['getOrThrow']>[0][],
message = 'Service is not available yet',
): MiddlewareHandler<HonoEnv> {
return async (_c, next) => {
@@ -0,0 +1,376 @@
import type { ConfigKVService } from '../../services/config-kv'
import type { FluxService } from '../../services/flux'
import type { RequestLogService } from '../../services/request-log'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { ApiError } from '../../utils/error'
import { createV1CompletionsRoutes } from '../v1completions'
// --- Mock helpers ---
function createMockFluxService(flux = 100): FluxService {
return {
getFlux: vi.fn(async () => ({ userId: 'user-1', flux })),
consumeFlux: vi.fn(async (_userId: string, amount: number) => ({ userId: 'user-1', flux: flux - amount })),
addFlux: vi.fn(async (_userId: string, amount: number) => ({ userId: 'user-1', flux: flux + amount })),
updateStripeCustomerId: vi.fn(),
} as any
}
function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVService {
const defaults: Record<string, any> = {
FLUX_PER_REQUEST: 1,
FLUX_PER_REQUEST_TTS: 1,
FLUX_PER_REQUEST_ASR: 1,
GATEWAY_BASE_URL: 'http://mock-gateway/',
DEFAULT_CHAT_MODEL: 'openai/gpt-5-mini',
...overrides,
}
return {
getOrThrow: vi.fn(async (key: string) => {
if (defaults[key] === undefined)
throw new Error(`Config key "${key}" is not set`)
return defaults[key]
}),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
get: vi.fn(async (key: string) => defaults[key]),
set: vi.fn(),
} as any
}
function createMockRequestLogService(): RequestLogService {
return {
logRequest: vi.fn(async () => {}),
} as any
}
function createTestApp(
fluxService: FluxService,
configKV: ConfigKVService,
requestLogService: RequestLogService,
) {
const routes = createV1CompletionsRoutes(fluxService, configKV, requestLogService, null)
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
})
// Inject user from env (simulates sessionMiddleware)
app.use('*', async (c, next) => {
const user = (c.env as any)?.user
if (user) {
c.set('user', user)
}
await next()
})
app.route('/api/v1', routes)
return app
}
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
// --- Tests ---
describe('v1CompletionsRoutes', () => {
const originalFetch = globalThis.fetch
afterAll(() => {
globalThis.fetch = originalFetch
})
describe('pOST /api/v1/chat/completions', () => {
it('should return 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
createMockRequestLogService(),
)
const res = await app.request('/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }),
})
expect(res.status).toBe(401)
})
it('should return 402 when flux is insufficient', async () => {
const app = createTestApp(
createMockFluxService(0),
createMockConfigKV(),
createMockRequestLogService(),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(402)
})
it('should proxy upstream response on success', async () => {
const upstreamBody = JSON.stringify({ id: 'chatcmpl-1', choices: [{ message: { content: 'hello' } }] })
globalThis.fetch = vi.fn(async () => new Response(upstreamBody, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const fluxService = createMockFluxService(100)
const configKV = createMockConfigKV({ GATEWAY_BASE_URL: 'http://mock-gateway/' })
const requestLogService = createMockRequestLogService()
const app = createTestApp(fluxService, configKV, requestLogService)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.id).toBe('chatcmpl-1')
// Verify flux was consumed
expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1)
// Verify upstream was called with correct URL and resolved model
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://mock-gateway/chat/completions',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"model":"openai/gpt-5-mini"'),
}),
)
})
it('should resolve "auto" model to DEFAULT_CHAT_MODEL from config', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({ DEFAULT_CHAT_MODEL: 'anthropic/claude-sonnet' }),
createMockRequestLogService(),
)
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
}),
{ user: testUser } as any,
)
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://mock-gateway/chat/completions',
expect.objectContaining({
body: expect.stringContaining('"model":"anthropic/claude-sonnet"'),
}),
)
})
it('should pass through non-auto model as-is', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'openai/gpt-5-mini', messages: [] }),
}),
{ user: testUser } as any,
)
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://mock-gateway/chat/completions',
expect.objectContaining({
body: expect.stringContaining('"model":"openai/gpt-5-mini"'),
}),
)
})
it('should not charge flux when upstream returns error', async () => {
globalThis.fetch = vi.fn(async () => new Response('{"error":"bad"}', {
status: 500,
headers: { 'Content-Type': 'application/json' },
}))
const fluxService = createMockFluxService(100)
const app = createTestApp(fluxService, createMockConfigKV(), createMockRequestLogService())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(500)
// Post-billing: no charge on failed requests, no refund needed
expect(fluxService.consumeFlux).not.toHaveBeenCalled()
expect(fluxService.addFlux).not.toHaveBeenCalled()
})
it('should return 503 when config keys are missing', async () => {
const configKV = createMockConfigKV()
// Override getOptional to return null for required keys
configKV.getOptional = vi.fn(async () => null)
const app = createTestApp(createMockFluxService(), configKV, createMockRequestLogService())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(503)
})
it('should log the request', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const requestLogService = createMockRequestLogService()
const app = createTestApp(createMockFluxService(), createMockConfigKV(), requestLogService)
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-4', messages: [] }),
}),
{ user: testUser } as any,
)
expect(requestLogService.logRequest).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
model: 'gpt-4',
status: 200,
fluxConsumed: 1,
}),
)
})
})
describe('pOST /api/v1/audio/speech', () => {
it('should proxy TTS request to upstream', async () => {
const audioData = new Uint8Array([1, 2, 3, 4])
globalThis.fetch = vi.fn(async () => new Response(audioData, {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'tts-1', input: 'hello', voice: 'alloy' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://mock-gateway/audio/speech',
expect.objectContaining({ method: 'POST' }),
)
})
})
describe('pOST /api/v1/audio/transcriptions', () => {
it('should proxy transcription request to upstream', async () => {
globalThis.fetch = vi.fn(async () => new Response('{"text":"hello"}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const formData = new FormData()
formData.append('file', new Blob(['audio']), 'test.wav')
formData.append('model', 'whisper-1')
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/transcriptions', {
method: 'POST',
body: formData,
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://mock-gateway/audio/transcriptions',
expect.objectContaining({ method: 'POST' }),
)
})
})
describe('route matching', () => {
it('gET /api/v1/chat/completions should return 404', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(404)
})
it('pOST /api/v1/chat/completion (singular) should also work', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
})
})
})
+320 -19
View File
@@ -1,17 +1,23 @@
import type { Context } from 'hono'
import type { Env } from '../libs/env'
import type { initOtel } from '../libs/otel'
import type { ConfigKVService } from '../services/config-kv'
import type { FluxService } from '../services/flux'
import type { RequestLogService } from '../services/request-log'
import type { HonoEnv } from '../types/hono'
import { useLogger } from '@guiiai/logg'
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { authGuard } from '../middlewares/auth'
import { configGuard } from '../middlewares/config-guard'
import { createPaymentRequiredError } from '../utils/error'
// Only forward these headers from the upstream LLM response
type OtelMetrics = ReturnType<typeof initOtel>
const tracer = trace.getTracer('v1-completions')
const SAFE_RESPONSE_HEADERS = new Set([
'content-type',
'content-length',
@@ -19,7 +25,59 @@ const SAFE_RESPONSE_HEADERS = new Set([
'cache-control',
])
export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, env: Env) {
function buildSafeResponseHeaders(response: Response): Headers {
const headers = new Headers()
for (const [key, value] of response.headers) {
if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase()))
headers.set(key, value)
}
return headers
}
function normalizeBaseUrl(gatewayBaseUrl: string): string {
return gatewayBaseUrl.endsWith('/') ? gatewayBaseUrl : `${gatewayBaseUrl}/`
}
interface UsageInfo {
promptTokens?: number
completionTokens?: number
}
function extractUsageFromBody(body: any): UsageInfo {
const usage = body?.usage
if (!usage)
return {}
return {
promptTokens: usage.prompt_tokens ?? undefined,
completionTokens: usage.completion_tokens ?? undefined,
}
}
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
}
export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, requestLogService: RequestLogService, otel: OtelMetrics | null) {
const logger = useLogger('v1-completions').useGlobalConfig()
function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) {
if (!otel)
return
const attrs = { model: opts.model, type: opts.type, status: opts.status }
otel.llmRequestCount.add(1, attrs)
otel.llmRequestDuration.record(opts.durationMs, attrs)
otel.fluxConsumed.add(opts.fluxConsumed, { model: opts.model, type: opts.type })
if (opts.promptTokens != null)
otel.llmTokensPrompt.add(opts.promptTokens, { model: opts.model })
if (opts.completionTokens != null)
otel.llmTokensCompletion.add(opts.completionTokens, { model: opts.model })
}
async function handleCompletion(c: Context<HonoEnv>) {
const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
@@ -28,33 +86,276 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co
}
const body = await c.req.json()
const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL')
const baseUrl = normalizeBaseUrl(gatewayBaseUrl)
let requestModel = body.model || 'auto'
const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST')
await fluxService.consumeFlux(user.id, fluxPerRequest)
if (requestModel === 'auto') {
requestModel = await configKV.getOrThrow('DEFAULT_CHAT_MODEL')
}
const response = await fetch(`${env.BACKEND_LLM_BASE_URL}chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.BACKEND_LLM_API_KEY}`,
const span = tracer.startSpan('llm.gateway.chat', {
attributes: {
'llm.model': requestModel,
'llm.stream': !!body.stream,
},
body: JSON.stringify(body),
})
const headers = new Headers()
for (const [key, value] of response.headers) {
if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase()))
headers.set(key, value)
const startedAt = Date.now()
const response = await context.with(trace.setSpan(context.active(), span), () =>
fetch(`${baseUrl}chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, model: requestModel }),
}))
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()
recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed: 0 })
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
// Post-billing: parse usage and charge after successful response
const fallbackRate = await configKV.getOrThrow('FLUX_PER_REQUEST')
const fluxPer1kTokens = (await configKV.getOptional('FLUX_PER_1K_TOKENS')) ?? 1
if (body.stream) {
// Streaming: return response immediately, bill after stream ends
const { readable, writable } = new TransformStream()
const reader = response.body!.getReader()
const writer = writable.getWriter()
// Buffer last 2KB to handle chunk boundary splits for usage extraction
let tailBuffer = ''
// Process stream in background
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done)
break
await writer.write(value)
const text = new TextDecoder().decode(value)
tailBuffer = (tailBuffer + text).slice(-2048)
}
}
finally {
await writer.close()
// Extract usage from final SSE data lines
let usage: UsageInfo = {}
try {
const lines = tailBuffer.split('\n').filter(l => l.startsWith('data: ') && !l.includes('[DONE]'))
const lastDataLine = lines[lines.length - 1]
if (lastDataLine) {
const json = JSON.parse(lastDataLine.slice(6))
usage = extractUsageFromBody(json)
}
}
catch (err) { logger.withError(err).warn('Failed to extract usage from stream, falling back to flat rate') }
const fluxConsumed = calculateFluxFromUsage(usage, fluxPer1kTokens, fallbackRate)
span.setAttributes({
'llm.tokens.prompt': usage.promptTokens ?? 0,
'llm.tokens.completion': usage.completionTokens ?? 0,
'llm.flux_consumed': fluxConsumed,
})
span.end()
recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed, ...usage })
// Best-effort billing — don't throw on insufficient flux during streaming
try {
await fluxService.consumeFlux(user.id, fluxConsumed)
}
catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux after streaming') }
requestLogService.logRequest({
userId: user.id,
model: requestModel,
status: response.status,
durationMs,
fluxConsumed,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
}).catch(err => logger.withError(err).warn('Failed to log streaming request'))
}
})()
return new Response(readable, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
// Non-streaming: parse response, bill, then return
const responseBody = await response.json()
const usage = extractUsageFromBody(responseBody)
const fluxConsumed = calculateFluxFromUsage(usage, fluxPer1kTokens, fallbackRate)
span.setAttributes({
'llm.tokens.prompt': usage.promptTokens ?? 0,
'llm.tokens.completion': usage.completionTokens ?? 0,
'llm.flux_consumed': fluxConsumed,
})
span.end()
recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed, ...usage })
// Best-effort billing — gateway already processed the request,
// don't return 402 after work is done
try {
await fluxService.consumeFlux(user.id, fluxConsumed)
}
catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux') }
requestLogService.logRequest({
userId: user.id,
model: requestModel,
status: response.status,
durationMs,
fluxConsumed,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
}).catch(err => logger.withError(err).warn('Failed to log request'))
return c.json(responseBody)
}
async function handleTTS(c: Context<HonoEnv>) {
const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
if (flux.flux <= 0) {
throw createPaymentRequiredError('Insufficient flux')
}
const body = await c.req.json()
const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL')
const baseUrl = normalizeBaseUrl(gatewayBaseUrl)
const requestModel = body.model || 'auto'
const span = tracer.startSpan('llm.gateway.tts', {
attributes: { 'llm.model': requestModel },
})
const startedAt = Date.now()
const response = await context.with(trace.setSpan(context.active(), span), () =>
fetch(`${baseUrl}audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}))
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()
recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: 0 })
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST_TTS')
await fluxService.consumeFlux(user.id, fluxPerRequest)
span.setAttribute('llm.flux_consumed', fluxPerRequest)
span.end()
recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: fluxPerRequest })
requestLogService.logRequest({
userId: user.id,
model: requestModel,
status: response.status,
durationMs,
fluxConsumed: fluxPerRequest,
}).catch(err => logger.withError(err).warn('Failed to log TTS request'))
return new Response(response.body, {
status: response.status,
headers,
headers: buildSafeResponseHeaders(response),
})
}
async function handleTranscription(c: Context<HonoEnv>) {
const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
if (flux.flux <= 0) {
throw createPaymentRequiredError('Insufficient flux')
}
const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL')
const baseUrl = normalizeBaseUrl(gatewayBaseUrl)
const span = tracer.startSpan('llm.gateway.asr', {
attributes: { 'llm.model': 'auto' },
})
const startedAt = Date.now()
const rawBody = await c.req.arrayBuffer()
const contentType = c.req.header('content-type') || 'multipart/form-data'
const response = await context.with(trace.setSpan(context.active(), span), () =>
fetch(`${baseUrl}audio/transcriptions`, {
method: 'POST',
headers: { 'Content-Type': contentType },
body: rawBody,
}))
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()
recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: 0 })
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST_ASR')
await fluxService.consumeFlux(user.id, fluxPerRequest)
span.setAttribute('llm.flux_consumed', fluxPerRequest)
span.end()
recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: fluxPerRequest })
requestLogService.logRequest({
userId: user.id,
model: 'auto',
status: response.status,
durationMs,
fluxConsumed: fluxPerRequest,
}).catch(err => logger.withError(err).warn('Failed to log ASR request'))
return new Response(response.body, {
status: response.status,
headers: buildSafeResponseHeaders(response),
})
}
const chatGuard = configGuard(configKV, ['FLUX_PER_REQUEST', 'GATEWAY_BASE_URL', 'DEFAULT_CHAT_MODEL'], 'Service is not available yet')
const ttsGuard = configGuard(configKV, ['FLUX_PER_REQUEST_TTS', 'GATEWAY_BASE_URL'], 'TTS service is not available yet')
const asrGuard = configGuard(configKV, ['FLUX_PER_REQUEST_ASR', 'GATEWAY_BASE_URL'], 'ASR service is not available yet')
return new Hono<HonoEnv>()
.use('*', authGuard, configGuard(configKV, ['FLUX_PER_REQUEST'], 'Service is not available yet'))
.post('/chat/completions', handleCompletion)
.post('/chat/completion', handleCompletion)
.use('*', authGuard)
.post('/chat/completions', chatGuard, handleCompletion)
.post('/chat/completion', chatGuard, handleCompletion)
.post('/audio/speech', ttsGuard, handleTTS)
.post('/audio/transcriptions', bodyLimit({ maxSize: 25 * 1024 * 1024 }), asrGuard, handleTranscription)
}
+1
View File
@@ -2,6 +2,7 @@ export * from './accounts'
export * from './characters'
export * from './chats'
export * from './flux'
export * from './llm-request-log'
export * from './providers'
export * from './stripe'
export * from './user-character'
@@ -0,0 +1,17 @@
import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
import { user } from './accounts'
export const llmRequestLog = pgTable('llm_request_log', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
model: text('model').notNull(),
status: integer('status').notNull(),
durationMs: integer('duration_ms').notNull(),
fluxConsumed: integer('flux_consumed').notNull(),
promptTokens: integer('prompt_tokens'),
completionTokens: integer('completion_tokens'),
settled: boolean('settled').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
@@ -0,0 +1,105 @@
import { eq } from 'drizzle-orm'
import { beforeAll, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createFluxWriteBack } from '../flux-write-back'
import * as schema from '../../schemas'
describe('fluxWriteBack', () => {
let db: any
let testUser: any
let writeBack: ReturnType<typeof createFluxWriteBack>
beforeAll(async () => {
db = await mockDB(schema)
const [user] = await db.insert(schema.user).values({
id: 'user-wb-1',
name: 'Write-back User',
email: 'wb@example.com',
}).returning()
testUser = user
await db.insert(schema.userFlux).values({
userId: testUser.id,
flux: 1000,
})
writeBack = createFluxWriteBack(db)
})
it('should aggregate unsettled logs and deduct from user_flux', async () => {
await db.insert(schema.llmRequestLog).values([
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 10, settled: false },
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 200, fluxConsumed: 20, settled: false },
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 150, fluxConsumed: 30, settled: false },
])
await writeBack.flush()
const record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, testUser.id),
})
expect(record.flux).toBe(940)
const unsettled = await db.query.llmRequestLog.findMany({
where: eq(schema.llmRequestLog.settled, false),
})
expect(unsettled).toHaveLength(0)
})
it('should not re-settle already settled logs', async () => {
await db.insert(schema.llmRequestLog).values({
userId: testUser.id,
model: 'gpt-4',
status: 200,
durationMs: 100,
fluxConsumed: 5,
settled: false,
})
await writeBack.flush()
const record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, testUser.id),
})
expect(record.flux).toBe(935)
})
it('should be a no-op when there are no unsettled logs', async () => {
await writeBack.flush()
const record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, testUser.id),
})
expect(record.flux).toBe(935)
})
it('should aggregate across multiple users correctly', async () => {
const [user2] = await db.insert(schema.user).values({
id: 'user-wb-2',
name: 'Write-back User 2',
email: 'wb2@example.com',
}).returning()
await db.insert(schema.userFlux).values({ userId: user2.id, flux: 500 })
await db.insert(schema.llmRequestLog).values([
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 15, settled: false },
{ userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 25, settled: false },
{ userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 35, settled: false },
])
await writeBack.flush()
const record1 = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, testUser.id),
})
expect(record1.flux).toBe(920)
const record2 = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, user2.id),
})
expect(record2.flux).toBe(440)
})
})
+88 -95
View File
@@ -1,6 +1,8 @@
import type Redis from 'ioredis'
import type { createConfigKVService } from '../config-kv'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createFluxService } from '../flux'
@@ -11,21 +13,41 @@ function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, 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
}
describe('fluxService', () => {
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' }),
decrby: vi.fn(async (key: string, amount: number) => {
const current = Number.parseInt(store.get(key) ?? '0', 10)
const next = current - amount
store.set(key, String(next))
return next
}),
incrby: vi.fn(async (key: string, amount: number) => {
const current = Number.parseInt(store.get(key) ?? '0', 10)
const next = current + amount
store.set(key, String(next))
return next
}),
} as unknown as Redis
}
describe('fluxService (Redis-backed)', () => {
let db: any
let redis: Redis
let service: ReturnType<typeof createFluxService>
let testUser: any
beforeAll(async () => {
db = await mockDB(schema)
service = createFluxService(db, createMockConfigKV())
// Create a test user for foreign key constraints
const [user] = await db.insert(schema.user).values({
id: 'user-1',
name: 'Test User',
@@ -34,133 +56,104 @@ describe('fluxService', () => {
testUser = user
})
// --- getFlux ---
beforeEach(() => {
redis = createMockRedis()
service = createFluxService(db, redis, createMockConfigKV())
})
it('getFlux should create a new record with 100 default flux for a new user', async () => {
it('getFlux should load from DB on cache miss and populate Redis', async () => {
const record = await service.getFlux(testUser.id)
expect(record).toBeDefined()
expect(record.userId).toBe(testUser.id)
expect(record.flux).toBe(100)
expect(redis.set).toHaveBeenCalledWith(`flux:${testUser.id}`, '100')
})
it('getFlux should return existing record on subsequent calls', async () => {
const first = await service.getFlux(testUser.id)
const second = await service.getFlux(testUser.id)
// Same record, no duplicate insert
expect(second.userId).toBe(first.userId)
expect(second.flux).toBe(first.flux)
it('getFlux should return cached value on subsequent calls', async () => {
await service.getFlux(testUser.id)
await service.getFlux(testUser.id)
expect(redis.get).toHaveBeenCalledTimes(2)
})
// --- consumeFlux ---
it('consumeFlux should deduct flux correctly', async () => {
it('consumeFlux should deduct via Redis DECRBY', async () => {
await service.getFlux(testUser.id)
const result = await service.consumeFlux(testUser.id, 10)
expect(result.flux).toBe(90)
expect(redis.decrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 10)
})
// Started at 100, consumed 10
it('consumeFlux should throw and rollback when insufficient', async () => {
await service.getFlux(testUser.id)
await expect(service.consumeFlux(testUser.id, 101))
.rejects
.toThrow('Insufficient flux')
expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 101)
})
it('addFlux should update both DB and Redis', async () => {
await service.getFlux(testUser.id)
const result = await service.addFlux(testUser.id, 50)
expect(result.flux).toBe(150)
expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 50)
})
it('consumeFlux should lazy-load cache if not preloaded', async () => {
const [user2] = await db.insert(schema.user).values({
id: 'user-lazy',
name: 'Lazy User',
email: 'lazy@example.com',
}).returning()
const result = await service.consumeFlux(user2.id, 10)
expect(result.flux).toBe(90)
})
it('consumeFlux should throw when balance is insufficient', async () => {
// Current balance is 90 after previous test; consuming 91 should fail
await expect(service.consumeFlux(testUser.id, 91))
.rejects
.toThrow('Insufficient flux')
it('getFlux should return updated value after consumeFlux', async () => {
const [user] = await db.insert(schema.user).values({
id: 'user-consume-then-get',
name: 'Consume Then Get',
email: 'consume-then-get@example.com',
}).returning()
await service.getFlux(user.id)
await service.consumeFlux(user.id, 25)
const record = await service.getFlux(user.id)
expect(record.flux).toBe(75)
})
it('consumeFlux should throw when trying to consume more than available', async () => {
await expect(service.consumeFlux(testUser.id, 999))
.rejects
.toThrow('Insufficient flux')
})
// --- addFlux ---
it('addFlux should add flux correctly', async () => {
// Balance is 90 from previous consume test
const result = await service.addFlux(testUser.id, 50)
expect(result.flux).toBe(140)
})
it('addFlux should accumulate across multiple calls', async () => {
// Balance is 140; add 10 three times
await service.addFlux(testUser.id, 10)
await service.addFlux(testUser.id, 10)
const result = await service.addFlux(testUser.id, 10)
expect(result.flux).toBe(170)
})
// --- updateStripeCustomerId ---
it('updateStripeCustomerId should update the stripe customer ID', async () => {
it('updateStripeCustomerId should update DB only', async () => {
await service.getFlux(testUser.id)
const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123')
expect(result.stripeCustomerId).toBe('cus_abc123')
// Verify it persists via getFlux
const record = await service.getFlux(testUser.id)
expect(record.stripeCustomerId).toBe('cus_abc123')
expect(result!.stripeCustomerId).toBe('cus_abc123')
})
// --- Concurrent consumeFlux ---
it('concurrent consumeFlux should not over-deduct flux', async () => {
// Set up a fresh user to isolate this test from previous state
const [user2] = await db.insert(schema.user).values({
const [user3] = await db.insert(schema.user).values({
id: 'user-concurrent-consume',
name: 'Concurrent Consumer',
email: 'concurrent-consume@example.com',
}).returning()
// Initialize flux record (100 default)
await service.getFlux(user2.id)
// Fire 10 concurrent consume calls of 10 each (total 100, exactly the balance)
await service.getFlux(user3.id)
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => service.consumeFlux(user2.id, 10)),
Array.from({ length: 10 }, () => service.consumeFlux(user3.id, 10)),
)
const fulfilled = results.filter(r => r.status === 'fulfilled')
const rejected = results.filter(r => r.status === 'rejected')
// All 10 should succeed since total equals balance, but under concurrency
// some may fail if the atomic check-and-deduct fires after balance drops.
// The key invariant: final balance must never go negative.
const finalRecord = await service.getFlux(user2.id)
expect(finalRecord.flux).toBeGreaterThanOrEqual(0)
// Total consumed must equal (fulfilled count * 10)
expect(finalRecord.flux).toBe(100 - fulfilled.length * 10)
// Every rejection should be 'Insufficient flux'
const final = await service.getFlux(user3.id)
expect(final.flux).toBeGreaterThanOrEqual(0)
expect(final.flux).toBe(100 - fulfilled.length * 10)
for (const r of rejected) {
expect((r as PromiseRejectedResult).reason.message).toBe('Insufficient flux')
}
})
// --- Concurrent addFlux ---
it('concurrent addFlux should accumulate correctly without lost updates', async () => {
// Set up a fresh user to isolate this test
const [user3] = await db.insert(schema.user).values({
it('concurrent addFlux should accumulate correctly', async () => {
const [user4] = await db.insert(schema.user).values({
id: 'user-concurrent-add',
name: 'Concurrent Adder',
email: 'concurrent-add@example.com',
}).returning()
// Initialize flux record (100 default)
await service.getFlux(user3.id)
// Fire 10 concurrent add calls of 5 each (expect +50 total)
await service.getFlux(user4.id)
await Promise.all(
Array.from({ length: 10 }, () => service.addFlux(user3.id, 5)),
Array.from({ length: 10 }, () => service.addFlux(user4.id, 5)),
)
const finalRecord = await service.getFlux(user3.id)
// 100 initial + 10 * 5 = 150
expect(finalRecord.flux).toBe(150)
const final = await service.getFlux(user4.id)
expect(final.flux).toBe(150)
})
})
+18 -1
View File
@@ -14,16 +14,25 @@ export interface FluxPackage {
interface ConfigDefinitions {
FLUX_PER_CENT: number
FLUX_PER_REQUEST: number
FLUX_PER_REQUEST_TTS: number
FLUX_PER_REQUEST_ASR: number
INITIAL_USER_FLUX: number
FLUX_PACKAGES: FluxPackage[]
FLUX_PER_1K_TOKENS: number
GATEWAY_BASE_URL: string
DEFAULT_CHAT_MODEL: string
}
const NUMERIC_KEYS = new Set<string>(['FLUX_PER_CENT', 'FLUX_PER_REQUEST', 'FLUX_PER_REQUEST_TTS', 'FLUX_PER_REQUEST_ASR', 'INITIAL_USER_FLUX', 'FLUX_PER_1K_TOKENS'])
const KEY_PREFIX = 'config:'
function parseValue<K extends keyof ConfigDefinitions>(key: K, raw: string): ConfigDefinitions[K] {
if (key === 'FLUX_PACKAGES')
return JSON.parse(raw) as ConfigDefinitions[K]
return Number(raw) as ConfigDefinitions[K]
if (NUMERIC_KEYS.has(key))
return Number(raw) as ConfigDefinitions[K]
return raw as ConfigDefinitions[K]
}
function serializeValue<K extends keyof ConfigDefinitions>(key: K, value: ConfigDefinitions[K]): string {
@@ -50,6 +59,14 @@ export function createConfigKVService(redis: Redis) {
return value
},
async get<K extends keyof ConfigDefinitions>(key: K): Promise<ConfigDefinitions[K]> {
const value = await this.getOptional(key)
if (value === null)
throw createServiceUnavailableError(`Config key "${key}" is not set in Redis`, 'CONFIG_NOT_SET')
return value
},
async set<K extends keyof ConfigDefinitions>(key: K, value: ConfigDefinitions[K]): Promise<void> {
await redis.set(`${KEY_PREFIX}${key}`, serializeValue(key, value))
},
@@ -0,0 +1,74 @@
import type { Database } from '../libs/db'
import { useLogger } from '@guiiai/logg'
import { and, eq, lte, sql } from 'drizzle-orm'
import * as fluxSchema from '../schemas/flux'
import * as logSchema from '../schemas/llm-request-log'
/**
* NOTE: Flux balances are deducted in real-time via Redis (DECRBY) in FluxService.consumeFlux().
* This write-back service only syncs the DB — it does NOT touch Redis.
* It periodically aggregates unsettled request logs and batch-updates the DB's user_flux table
* so that the persistent balance stays consistent with the Redis cache.
*/
export function createFluxWriteBack(db: Database) {
const logger = useLogger('flux-write-back').useGlobalConfig()
let timer: ReturnType<typeof setInterval> | null = null
async function flush() {
const snapshotTime = new Date()
// 1. Aggregate unsettled logs inserted before (or at) this tick
const totals = await db
.select({
userId: logSchema.llmRequestLog.userId,
total: sql<number>`SUM(${logSchema.llmRequestLog.fluxConsumed})`.as('total'),
})
.from(logSchema.llmRequestLog)
.where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime)))
.groupBy(logSchema.llmRequestLog.userId)
if (totals.length === 0)
return
// 2. Batch update in transaction
await db.transaction(async (tx) => {
for (const { userId, total } of totals) {
await tx.update(fluxSchema.userFlux)
.set({
flux: sql`${fluxSchema.userFlux.flux} - ${total}`,
updatedAt: new Date(),
})
.where(eq(fluxSchema.userFlux.userId, userId))
}
await tx.update(logSchema.llmRequestLog)
.set({ settled: true })
.where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime)))
})
logger.withFields({ userCount: totals.length }).log('Write-back completed')
}
return {
flush,
start(intervalMs = 60_000) {
timer = setInterval(() => {
flush().catch((err) => {
logger.withError(err).error('Write-back failed')
})
}, intervalMs)
},
stop() {
if (timer) {
clearInterval(timer)
timer = null
}
},
}
}
export type FluxWriteBack = ReturnType<typeof createFluxWriteBack>
+37 -22
View File
@@ -1,15 +1,28 @@
import type Redis from 'ioredis'
import type { Database } from '../libs/db'
import type { ConfigKVService } from './config-kv'
import { and, eq, gte, sql } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { createPaymentRequiredError } from '../utils/error'
import * as schema from '../schemas/flux'
export function createFluxService(db: Database, configKV: ConfigKVService) {
function redisKey(userId: string): string {
return `flux:${userId}`
}
export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) {
return {
async getFlux(userId: string) {
// 1. Try Redis cache
const cached = await redis.get(redisKey(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: eq(schema.userFlux.userId, userId),
})
@@ -22,46 +35,48 @@ export function createFluxService(db: Database, configKV: ConfigKVService) {
}).returning()
}
// 3. Populate Redis cache
await redis.set(redisKey(userId), String(record.flux))
return record
},
async consumeFlux(userId: string, amount: number) {
// Ensure the user has a flux record
// Ensure Redis key exists before DECRBY
// (DECRBY on a nonexistent key creates it at 0, giving wrong balance)
await this.getFlux(userId)
// Atomic check-and-deduct to prevent race conditions
const result = await db.update(schema.userFlux)
.set({
flux: sql`${schema.userFlux.flux} - ${amount}`,
updatedAt: new Date(),
})
.where(and(
eq(schema.userFlux.userId, userId),
gte(schema.userFlux.flux, amount),
))
.returning()
if (result.length === 0) {
// Atomic decrement — check result.
// Note: there is a small race window between DECRBY returning negative
// and INCRBY rolling back, during which another concurrent request could
// see the negative balance and also attempt rollback. We accept this
// trade-off — the initial balance check is the real guard, and this
// DECRBY+rollback is a safety net, not a guarantee.
const newBalance = await redis.decrby(redisKey(userId), amount)
if (newBalance < 0) {
await redis.incrby(redisKey(userId), amount)
throw createPaymentRequiredError('Insufficient flux')
}
return result[0]
return { userId, flux: newBalance }
},
async addFlux(userId: string, amount: number) {
// Ensure the user has a flux record
// Ensure user record exists in DB
await this.getFlux(userId)
// Atomic addition to prevent race conditions
const [updated] = await db.update(schema.userFlux)
// DB update (persistence for Stripe payments)
await db.update(schema.userFlux)
.set({
flux: sql`${schema.userFlux.flux} + ${amount}`,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.returning()
return updated
// Sync Redis cache
const newBalance = await redis.incrby(redisKey(userId), amount)
return { userId, flux: newBalance }
},
async updateStripeCustomerId(userId: string, stripeCustomerId: string) {
+23
View File
@@ -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>
@@ -874,6 +874,10 @@ pages:
official:
title: Official Provider
description: Official AI provider by AIRI.
speech-title: Official Speech Provider
speech-description: Official text-to-speech provider by AIRI.
transcription-title: Official Transcription Provider
transcription-description: Official speech-to-text provider by AIRI.
transcriptions:
playground:
title: Transcription Playground
@@ -835,6 +835,13 @@ pages:
aliyun-nls:
description: Aliyun 智能语音服务
title: Aliyun 智能语音服务
official:
title: 官方服务
description: 由 AIRI 提供的官方 AI 服务。
speech-title: 官方语音合成服务
speech-description: 由 AIRI 提供的官方文字转语音服务。
transcription-title: 官方语音识别服务
transcription-description: 由 AIRI 提供的官方语音转文字服务。
browser-web-speech-api:
description: 浏览器原生STT (需要 Chrome/Edge/Safari)
title: Web 语音API
@@ -0,0 +1,98 @@
<script setup lang="ts">
import {
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, isLoginOpen } = storeToRefs(authStore)
const providerId = 'official-provider-speech'
const providerMetadata = providersStore.getProviderMetadata(providerId)
watch(isAuthenticated, (val) => {
if (val) {
providersStore.forceProviderConfigured(providerId)
}
}, { immediate: true })
function handleLogin() {
isLoginOpen.value = true
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<div v-if="!isAuthenticated" flex flex-col gap-4>
<Callout theme="primary">
<template #label>
{{ t('settings.pages.providers.provider.official.speech-title') }}
</template>
<div flex flex-col gap-3>
<p>{{ t('settings.dialogs.onboarding.loginPrompt') }}</p>
<button
type="button"
class="w-fit rounded-lg bg-primary-500 px-4 py-2 text-white transition-colors active:scale-95 hover:bg-primary-600"
@click="handleLogin"
>
{{ t('settings.dialogs.onboarding.loginAction') }}
</button>
</div>
</Callout>
</div>
<div v-else flex flex-col gap-6>
<div class="rounded-xl bg-neutral-100/50 p-6 backdrop-blur-sm dark:bg-neutral-800/50">
<div flex items-center justify-between>
<div flex flex-col gap-1>
<span text="sm neutral-500 dark:neutral-400 font-medium uppercase tracking-wider">
{{ t('settings.dialogs.onboarding.flux') }}
</span>
<span text="3xl font-bold text-primary-600 dark:text-primary-400">
{{ credits }}
</span>
</div>
<button
type="button"
class="rounded-full bg-primary-500/10 px-6 py-2 text-sm text-primary-600 font-semibold transition-all dark:bg-primary-400/10 hover:bg-primary-500 dark:text-primary-400 hover:text-white dark:hover:bg-primary-400 dark:hover:text-neutral-900"
@click="router.push('/settings/flux')"
>
{{ t('settings.dialogs.onboarding.buyFlux') }}
</button>
</div>
</div>
<div class="border border-neutral-200/50 rounded-xl p-4 dark:border-neutral-700/50">
<div flex items-center gap-3>
<div class="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<span text="sm neutral-600 dark:neutral-300">
{{ t('settings.pages.providers.provider.common.status.valid') }}
</span>
</div>
</div>
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import {
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, isLoginOpen } = storeToRefs(authStore)
const providerId = 'official-provider-transcription'
const providerMetadata = providersStore.getProviderMetadata(providerId)
watch(isAuthenticated, (val) => {
if (val) {
providersStore.forceProviderConfigured(providerId)
}
}, { immediate: true })
function handleLogin() {
isLoginOpen.value = true
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<div v-if="!isAuthenticated" flex flex-col gap-4>
<Callout theme="primary">
<template #label>
{{ t('settings.pages.providers.provider.official.transcription-title') }}
</template>
<div flex flex-col gap-3>
<p>{{ t('settings.dialogs.onboarding.loginPrompt') }}</p>
<button
type="button"
class="w-fit rounded-lg bg-primary-500 px-4 py-2 text-white transition-colors active:scale-95 hover:bg-primary-600"
@click="handleLogin"
>
{{ t('settings.dialogs.onboarding.loginAction') }}
</button>
</div>
</Callout>
</div>
<div v-else flex flex-col gap-6>
<div class="rounded-xl bg-neutral-100/50 p-6 backdrop-blur-sm dark:bg-neutral-800/50">
<div flex items-center justify-between>
<div flex flex-col gap-1>
<span text="sm neutral-500 dark:neutral-400 font-medium uppercase tracking-wider">
{{ t('settings.dialogs.onboarding.flux') }}
</span>
<span text="3xl font-bold text-primary-600 dark:text-primary-400">
{{ credits }}
</span>
</div>
<button
type="button"
class="rounded-full bg-primary-500/10 px-6 py-2 text-sm text-primary-600 font-semibold transition-all dark:bg-primary-400/10 hover:bg-primary-500 dark:text-primary-400 hover:text-white dark:hover:bg-primary-400 dark:hover:text-neutral-900"
@click="router.push('/settings/flux')"
>
{{ t('settings.dialogs.onboarding.buyFlux') }}
</button>
</div>
</div>
<div class="border border-neutral-200/50 rounded-xl p-4 dark:border-neutral-700/50">
<div flex items-center gap-3>
<div class="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<span text="sm neutral-600 dark:neutral-300">
{{ t('settings.pages.providers.provider.common.status.valid') }}
</span>
</div>
</div>
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -8,12 +8,14 @@ import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.avif'
import { useAuthStore } from '../../../../stores/auth'
import { useOnboardingStore } from '../../../../stores/onboarding'
import { useSettingsGeneral } from '../../../../stores/settings'
import { OnboardingContextKey } from './utils'
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const settingsStore = useSettingsGeneral()
const { language } = storeToRefs(settingsStore)
@@ -22,6 +24,7 @@ const languages = computed(() => {
})
function handleLogin() {
onboardingStore.shouldShowSetup = false
authStore.isLoginOpen = true
}
@@ -25,6 +25,7 @@ import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useDelayMessageQueue, useEmotionsMessageQueue } from '../../composables/queues'
import { useAuthProviderSync } from '../../composables/use-auth-provider-sync'
import { llmInferenceEndToken } from '../../constants'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
@@ -78,6 +79,7 @@ const chatHookCleanups: Array<() => void> = []
// cross-window broadcast wiring.
const providersStore = useProvidersStore()
useAuthProviderSync()
const live2dStore = useLive2d()
const showStage = ref(true)
const viewUpdateCleanups: Array<() => void> = []
@@ -0,0 +1,59 @@
import { nextTick, watch } from 'vue'
import { initializeAuth } from '../libs/auth'
import { useAuthStore } from '../stores/auth'
import { useConsciousnessStore } from '../stores/modules/consciousness'
import { useHearingStore } from '../stores/modules/hearing'
import { useSpeechStore } from '../stores/modules/speech'
import { useProvidersStore } from '../stores/providers'
/**
* Coordinates auth state with provider/module stores.
*
* When the user becomes authenticated, this composable automatically enables
* the official providers and sets them as active across consciousness, speech,
* and hearing modules.
*
* Call once at the app root (e.g. Stage.vue).
*/
export function useAuthProviderSync() {
initializeAuth()
const authState = useAuthStore()
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const hearingStore = useHearingStore()
watch(() => authState.isAuthenticated, async (val) => {
if (!val)
return
const officialProviderId = 'official-provider'
const officialSpeechId = 'official-provider-speech'
const officialTranscriptionId = 'official-provider-transcription'
providersStore.forceProviderConfigured(officialProviderId)
providersStore.forceProviderConfigured(officialSpeechId)
providersStore.forceProviderConfigured(officialTranscriptionId)
consciousnessStore.activeProvider = officialProviderId
consciousnessStore.activeModel = 'auto'
speechStore.activeSpeechProvider = officialSpeechId
speechStore.activeSpeechModel = 'auto'
hearingStore.activeTranscriptionProvider = officialTranscriptionId
hearingStore.activeTranscriptionModel = 'auto'
await nextTick()
try {
await Promise.all([
consciousnessStore.loadModelsForProvider(officialProviderId),
providersStore.fetchModelsForProvider(officialSpeechId),
providersStore.fetchModelsForProvider(officialTranscriptionId),
])
}
catch (err) {
console.error('error loading models for official providers', err)
}
}, { immediate: true })
}
+10 -1
View File
@@ -10,11 +10,20 @@ export const authClient = createAuthClient({
credentials: 'include',
})
let initialized = false
export function initializeAuth() {
if (initialized)
return
fetchSession().catch(() => {})
initialized = true
}
export async function fetchSession() {
const { data } = await authClient.getSession()
if (data) {
const authStore = useAuthStore()
authStore.user = data.user
authStore.session = data.session
return true
+7 -32
View File
@@ -1,13 +1,16 @@
import type { Session, User } from 'better-auth'
import { defineStore } from 'pinia'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { client } from '../composables/api'
import { fetchSession } from '../libs/auth'
import { useConsciousnessStore } from './modules/consciousness'
import { useProvidersStore } from './providers'
/**
* Auth store holds identity state and credits.
*
* This store has no dependency on `stores/providers`, which allows
* `providers` to safely depend on it without creating a circular import.
*/
export const useAuthStore = defineStore('auth', () => {
const user = ref<User>()
const session = ref<Session>()
@@ -18,16 +21,6 @@ export const useAuthStore = defineStore('auth', () => {
const isLoginOpen = ref(false)
const initialized = ref(false)
const initialize = () => {
if (initialized.value)
return
fetchSession().catch(() => {})
initialized.value = true
}
const updateCredits = async () => {
if (!isAuthenticated.value)
return
@@ -38,33 +31,15 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Get store references once
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
watch(isAuthenticated, async (val) => {
if (val) {
updateCredits()
// Automatically enable official provider when authenticated
const officialProviderId = 'official-provider'
providersStore.forceProviderConfigured(officialProviderId)
consciousnessStore.activeProvider = officialProviderId
await nextTick()
try {
await consciousnessStore.loadModelsForProvider(officialProviderId)
}
catch (err) {
console.error('error loading models for official provider', err)
}
}
else {
credits.value = 0
}
}, { immediate: true })
initialize()
return {
user,
userId,
+49 -52
View File
@@ -48,13 +48,14 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { listProviders as listDefinedProviders } from '../libs/providers'
import { SERVER_URL } from '../libs/server'
import { useAuthStore } from '../stores/auth'
import { getProviderValidationIntervalMs } from '../libs/providers/validators/run'
import { getKokoroWorker } from '../workers/kokoro'
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
import { useAuthStore } from './auth'
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
import { convertProviderDefinitionsToMetadata } from './providers/converters'
import { models as elevenLabsModels } from './providers/elevenlabs/list-models'
import { createOfficialProviders, OFFICIAL_PROVIDER_IDS } from './providers/official'
import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder'
import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech'
import { createWebSpeechAPIProvider } from './providers/web-speech-api'
@@ -159,6 +160,11 @@ export interface ProviderMetadata {
valid: boolean
}>
}
/**
* If true, the provider does not require user-provided credentials (e.g. API keys).
* Used for official/built-in providers that authenticate via session.
*/
requiresCredentials?: boolean
transcriptionFeatures?: {
supportsGenerate: boolean
supportsStreamOutput: boolean
@@ -249,44 +255,35 @@ export const useProvidersStore = defineStore('providers', () => {
}
// Centralized provider metadata with provider factory functions
const authState = useAuthStore()
const providerMetadata: Record<string, ProviderMetadata> = {
'official-provider': {
id: 'official-provider',
order: -1,
category: 'chat',
tasks: ['text-generation'],
nameKey: 'settings.pages.providers.provider.official.title',
name: 'Official Provider',
descriptionKey: 'settings.pages.providers.provider.official.description',
description: 'Official AI provider by AIRI.',
icon: 'i-solar:star-bold-duotone',
createProvider: async (_config) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
throw new Error('User is not authenticated')
}
return createOpenAI('', `${SERVER_URL}/v1/`)
},
...createOfficialProviders(() => authState.isAuthenticated),
'speech-noop': {
id: 'speech-noop',
category: 'speech',
tasks: ['text-to-speech', 'tts'],
nameKey: 'settings.pages.providers.provider.speech-noop.title',
name: 'None',
descriptionKey: 'settings.pages.providers.provider.speech-noop.description',
description: 'No speech output.',
icon: 'i-solar:volume-cross-bold-duotone',
defaultOptions: () => ({}),
createProvider: async () => ({
speech: () => ({
baseURL: 'http://speech-noop.invalid/v1/',
model: 'noop',
}),
}),
capabilities: {
listModels: async () => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
provider: 'official-provider',
},
]
},
listModels: async () => [],
listVoices: async () => [],
},
validators: {
validateProviderConfig: () => {
const authStore = useAuthStore()
return {
errors: [],
reason: '',
valid: authStore.isAuthenticated,
}
},
validateProviderConfig: () => ({
errors: [],
reason: '',
valid: true,
}),
},
},
'app-local-audio-speech': buildOpenAICompatibleProvider({
@@ -1737,10 +1734,11 @@ export const useProvidersStore = defineStore('providers', () => {
}
}
// Keep only legacy ASR/TTS providers as hand-written metadata.
// Keep only legacy ASR/TTS providers and official providers as hand-written metadata.
// All other categories are sourced from unified definitions in libs/providers.
for (const [providerId, existing] of Object.entries(providerMetadata)) {
if (existing.category !== 'speech' && existing.category !== 'transcription') {
if (existing.category !== 'speech' && existing.category !== 'transcription'
&& !(OFFICIAL_PROVIDER_IDS as readonly string[]).includes(providerId)) {
delete providerMetadata[providerId]
}
}
@@ -1867,9 +1865,8 @@ export const useProvidersStore = defineStore('providers', () => {
}
// Must run AFTER runtime state is created so forceProviderConfigured can set isConfigured
if (providerId === 'official-provider') {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
if ((OFFICIAL_PROVIDER_IDS as readonly string[]).includes(providerId)) {
if (authState.isAuthenticated) {
forceProviderConfigured(providerId)
}
}
@@ -1919,8 +1916,7 @@ export const useProvidersStore = defineStore('providers', () => {
watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true })
startPeriodicRuntimeValidation()
const authStore = useAuthStore()
watch(() => authStore.isAuthenticated, updateConfigurationStatus)
watch(() => authState.isAuthenticated, updateConfigurationStatus)
// Available providers (only those that are properly configured)
const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured))
@@ -1979,14 +1975,14 @@ export const useProvidersStore = defineStore('providers', () => {
// Function to fetch models for a specific provider
async function fetchModelsForProvider(providerId: string) {
const config = providerCredentials.value[providerId]
if (!config)
return []
const metadata = providerMetadata[providerId]
if (!metadata)
return []
const config = providerCredentials.value[providerId]
if (!config && metadata.requiresCredentials !== false)
return []
const runtimeState = providerRuntimeState.value[providerId]
if (runtimeState) {
runtimeState.isLoadingModels = true
@@ -1994,7 +1990,7 @@ export const useProvidersStore = defineStore('providers', () => {
}
try {
const models = metadata.capabilities.listModels ? await metadata.capabilities.listModels(config) : []
const models = metadata.capabilities.listModels ? await metadata.capabilities.listModels(config || {}) : []
// Transform and store the models
if (runtimeState) {
@@ -2129,14 +2125,15 @@ export const useProvidersStore = defineStore('providers', () => {
if (!metadata)
throw new Error(`Provider metadata for ${providerId} not found`)
// Web Speech API doesn't require credentials - use empty config
// Providers that don't require credentials use empty config
let config = providerCredentials.value[providerId]
if (!config && providerId === 'browser-web-speech-api') {
config = getDefaultProviderConfig(providerId)
const noCredentials = metadata.requiresCredentials === false || providerId === 'browser-web-speech-api'
if (!config && noCredentials) {
config = getDefaultProviderConfig(providerId) || {}
providerCredentials.value[providerId] = config
}
if (!config && providerId !== 'browser-web-speech-api')
if (!config && !noCredentials)
throw new Error(`Provider credentials for ${providerId} not found`)
try {
@@ -0,0 +1,179 @@
import type { ProviderMetadata } from '../providers'
import { createOpenAI } from '@xsai-ext/providers/create'
import { SERVER_URL } from '../../libs/server'
const OFFICIAL_ICON = 'i-solar:star-bold-duotone'
function withCredentials() {
return (input: RequestInfo | URL, init?: RequestInit) => {
return globalThis.fetch(input, {
...init,
credentials: 'include',
})
}
}
function createOfficialOpenAIProvider() {
return createOpenAI('', `${SERVER_URL}/api/v1/`)
}
export const OFFICIAL_PROVIDER_IDS = [
'official-provider',
'official-provider-speech',
'official-provider-transcription',
] as const
/**
* Factory that creates official provider metadata.
* Accepts a lazy auth getter to avoid circular dependency:
* official.ts -> auth.ts -> providers.ts -> official.ts
*/
export function createOfficialProviders(getIsAuthenticated: () => boolean): Record<string, ProviderMetadata> {
function assertAuthenticated() {
if (!getIsAuthenticated()) {
throw new Error('User is not authenticated')
}
}
function validateAuth() {
return {
errors: [],
reason: '',
valid: getIsAuthenticated(),
}
}
return {
'official-provider': {
id: 'official-provider',
order: -1,
category: 'chat',
tasks: ['text-generation'],
nameKey: 'settings.pages.providers.provider.official.title',
name: 'Official Provider',
descriptionKey: 'settings.pages.providers.provider.official.description',
description: 'Official AI provider by AIRI.',
icon: OFFICIAL_ICON,
requiresCredentials: false,
createProvider: async (_config) => {
assertAuthenticated()
const provider = createOfficialOpenAIProvider()
const originalChat = provider.chat.bind(provider)
provider.chat = (model: string) => {
const result = originalChat(model)
result.fetch = withCredentials()
return result
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider',
description: 'Automatically routed by AI Gateway',
},
],
},
validators: {
validateProviderConfig: () => validateAuth(),
},
},
'official-provider-speech': {
id: 'official-provider-speech',
order: -1,
category: 'speech',
tasks: ['text-to-speech'],
nameKey: 'settings.pages.providers.provider.official.speech-title',
name: 'Official Speech Provider',
descriptionKey: 'settings.pages.providers.provider.official.speech-description',
description: 'Official text-to-speech provider by AIRI.',
icon: OFFICIAL_ICON,
requiresCredentials: false,
createProvider: async (_config) => {
assertAuthenticated()
const provider = createOfficialOpenAIProvider()
const originalSpeech = provider.speech.bind(provider)
provider.speech = (model: string) => {
const result = originalSpeech(model)
result.fetch = withCredentials()
return result
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider-speech',
description: 'Automatically routed by AI Gateway',
},
],
listVoices: async () => [
{ id: 'alloy', name: 'Alloy', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'echo', name: 'Echo', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'fable', name: 'Fable', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'onyx', name: 'Onyx', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'nova', name: 'Nova', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'shimmer', name: 'Shimmer', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
],
},
validators: {
validateProviderConfig: () => validateAuth(),
},
},
'official-provider-transcription': {
id: 'official-provider-transcription',
order: -1,
category: 'transcription',
tasks: ['speech-to-text', 'asr'],
nameKey: 'settings.pages.providers.provider.official.transcription-title',
name: 'Official Transcription Provider',
descriptionKey: 'settings.pages.providers.provider.official.transcription-description',
description: 'Official speech-to-text provider by AIRI.',
icon: OFFICIAL_ICON,
requiresCredentials: false,
transcriptionFeatures: {
supportsGenerate: true,
supportsStreamOutput: false,
supportsStreamInput: false,
},
createProvider: async (_config) => {
assertAuthenticated()
const provider = createOfficialOpenAIProvider()
const originalTranscription = provider.transcription.bind(provider)
provider.transcription = (model: string) => {
const result = originalTranscription(model)
result.fetch = withCredentials()
return result
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider-transcription',
description: 'Automatically routed by AI Gateway',
},
],
},
validators: {
validateProviderConfig: () => validateAuth(),
},
},
}
}