feat(server): official provider router (#1117)
This commit is contained in:
+1
-3
@@ -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"
|
||||
|
||||
@@ -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
@@ -22,6 +22,13 @@
|
||||
"when": 1772634952890,
|
||||
"tag": "0002_mean_tigra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1773229668722,
|
||||
"tag": "0003_old_titania",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
+98
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user