refactor(server): redesign server routes structure

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent a515d20091
commit 750e48ad95
27 changed files with 209 additions and 180 deletions
@@ -137,7 +137,7 @@ CLI 入口在 `src/bin/run.ts`,支持两种角色:
### LLM 网关代理而不是本地 provider 编排
`/api/v1` 并不直接调具体模型 provider,而是转发到 `config: GATEWAY_BASE_URL`。因此:
`/api/v1/openai` 并不直接调具体模型 provider,而是转发到 `config: GATEWAY_BASE_URL`。因此:
- 服务端关心的是鉴权、限流、计费、日志、观测
- 具体模型执行和 usage 返回格式由 gateway 决定
@@ -157,4 +157,4 @@ Redis 在这里同时承担:
- `src/services/request-log.ts``src/services/llm-request-log.ts` 职责重复,当前实际注入的是前者。
- `src/schemas/accounts.ts``src/schemas/auth.ts` 内容重复,`createAuth()` 使用的是 `accounts.ts`
- `v1completions.ts` 已实现 `handleTTS` / `handleTranscription`,但路由仍被注释掉,当前只开放 chat completions。
- `src/routes/openai/v1/index.ts` 已实现 `handleTTS` / `handleTranscription`,但路由仍被注释掉,当前只开放 chat completions。
@@ -130,8 +130,8 @@ lock:{domain}:{id}
推荐:
```txt
/api/users/me/flux
/api/users/me/flux/history
/api/v1/flux
/api/v1/flux/history
```
不推荐:
@@ -147,15 +147,20 @@ lock:{domain}:{id}
```txt
/api/v1/...
/api/openai/v1/...
/api/v1/openai/...
```
不要让“部分资源版本化、部分资源裸挂”长期并存而没有说明。
## TODO
- Replace DB-derived HTTP request schemas for characters/providers/chats with explicit DTO schemas.
- Move ownership and membership authorization rules behind actor-aware service APIs instead of splitting them across routes and services.
- Stabilize HTTP response shapes so services no longer leak raw Drizzle returning arrays to routes.
- Encode chat member invariants in schema validation and map those failures to 4xx API errors.
- Split the OpenAI compat route and chat WebSocket handler into smaller modules so transport code stops owning orchestration complexity.
-`apps/server` 中现有 Redis key / channel 继续向 helper 收口,避免业务代码里散落模板字符串。
- 统一把旧式 key 命名迁移到分段命名风格,优先处理 Flux cache、chat broadcast、lock key。
-`configKV` 增补一份“哪些配置属于 infra、哪些属于运营策略”的清单,避免继续模糊放置位置。
- 把所有 `configKV.getOptional(...) ?? defaultValue` 模式清理掉,默认值统一回到 `ConfigEntrySchemas`
- 评估是否 `/api/v1` 收口为兼容层 API,并明确业务资源是否也需要统一版本化
- 评估是否继续沿用统一 `/api/v1/*` 版本树,还是为兼容 API 与业务 API 引入更明确的子域分隔
@@ -6,12 +6,12 @@
- `GET /health`
- `/api/auth/*`
- `/api/characters`
- `/api/providers`
- `/api/chats`
- `/api/v1`
- `/api/users/me/flux`
- `/api/stripe`
- `/api/v1/characters`
- `/api/v1/providers`
- `/api/v1/chats`
- `/api/v1/openai`
- `/api/v1/flux`
- `/api/v1/stripe`
- `GET /ws/chat`
## 鉴权链路
@@ -52,11 +52,11 @@
- Bearer plugin 已启用
- `/api/auth/*` 有独立 IP 限流,每分钟 20 次
### `/api/characters`
### `/api/v1/characters`
实现位置:
- route: `src/routes/characters.ts`
- route: `src/routes/characters/index.ts`
- service: `src/services/characters.ts`
主要能力:
@@ -77,11 +77,11 @@
- 更新和删除会额外校验 `ownerId === user.id`
- 点赞和收藏是 toggle 语义
### `/api/providers`
### `/api/v1/providers`
实现位置:
- route: `src/routes/providers.ts`
- route: `src/routes/providers/index.ts`
- service: `src/services/providers.ts`
主要能力:
@@ -96,11 +96,11 @@
- `findAll(ownerId)` 通过 `unionAll` 合并系统配置和用户配置
- 用户只能改自己的 user config,不能改 system config
### `/api/chats`
### `/api/v1/chats`
实现位置:
- route: `src/routes/chats.ts`
- route: `src/routes/chats/index.ts`
- service: `src/services/chats.ts`
主要能力:
@@ -119,7 +119,7 @@
实现位置:
- route 注册:`src/app.ts`
- handler factory: `src/routes/chat-ws.ts`
- handler factory: `src/routes/chat-ws/index.ts`
- 底层事件适配:`src/libs/eventa-hono-adapter.ts`
主要 RPC
@@ -141,11 +141,11 @@
- key / channel 与 payload 边界应集中收口,不要在调用点散落模板字符串和裸 `JSON.parse`
- 具体规范见 `redis-boundaries-and-pubsub.md`
### `/api/v1`
### `/api/v1/openai`
实现位置:
- route: `src/routes/v1completions.ts`
- route: `src/routes/openai/v1/index.ts`
- 依赖服务:
- `fluxService`
- `billingService`
@@ -154,10 +154,10 @@
当前已开放:
- `POST /api/v1/chat/completions`
- `POST /api/v1/chat/completion`
- `POST /api/v1/audio/speech`
- `POST /api/v1/audio/transcriptions`
- `POST /api/v1/openai/chat/completions`
- `POST /api/v1/openai/chat/completion`
- `POST /api/v1/openai/audio/speech`
- `POST /api/v1/openai/audio/transcriptions`
请求流程:
@@ -181,27 +181,27 @@
- 流结束后再 best-effort 扣费
- 扣费失败只打 error log,不回滚给客户端
### `/api/users/me/flux`
### `/api/v1/flux`
实现位置:
- route: `src/routes/flux.ts`
- route: `src/routes/flux/index.ts`
- services:
- `fluxService`
- `fluxAuditService`
主要能力:
- `GET /api/users/me/flux`
- `GET /api/v1/flux`
- 读取当前用户余额
- `GET /api/users/me/flux/history`
- `GET /api/v1/flux/history`
- 读取用户可见流水
### `/api/stripe`
### `/api/v1/stripe`
实现位置:
- route: `src/routes/stripe.ts`
- route: `src/routes/stripe/index.ts`
- services:
- `fluxService`
- `stripeService`
@@ -226,7 +226,7 @@
## 参数校验方式
输入 schema 位于 `src/api/*.schema.ts`
输入 schema 位于各资源路由目录下的 `schema.ts`
- `characters.schema.ts`
- `chats.schema.ts`
+8 -8
View File
@@ -38,9 +38,9 @@ import { createCharacterRoutes } from './routes/characters'
import { createChatWsHandlers } from './routes/chat-ws'
import { createChatRoutes } from './routes/chats'
import { createFluxRoutes } from './routes/flux'
import { createV1CompletionsRoutes } from './routes/openai/v1'
import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createV1CompletionsRoutes } from './routes/v1completions'
import { createBillingMq } from './services/billing/billing-events'
import { createBillingService } from './services/billing/billing-service'
import { createCharacterService } from './services/characters'
@@ -109,7 +109,7 @@ function buildApp(deps: AppDeps) {
.use('*', sessionMiddleware(deps.auth))
.use('*', async (c, next) => {
// Skip global body limit for ASR transcription route (has its own 25MB limit)
if (c.req.path === '/api/v1/audio/transcriptions') {
if (c.req.path === '/api/v1/openai/audio/transcriptions') {
return next()
}
return bodyLimit({ maxSize: 1024 * 1024 })(c, next)
@@ -153,32 +153,32 @@ function buildApp(deps: AppDeps) {
/**
* Character routes are handled by the character service.
*/
.route('/api/characters', createCharacterRoutes(deps.characterService))
.route('/api/v1/characters', createCharacterRoutes(deps.characterService))
/**
* Provider routes are handled by the provider service.
*/
.route('/api/providers', createProviderRoutes(deps.providerService))
.route('/api/v1/providers', createProviderRoutes(deps.providerService))
/**
* Chat routes are handled by the chat service.
*/
.route('/api/chats', createChatRoutes(deps.chatService))
.route('/api/v1/chats', createChatRoutes(deps.chatService))
/**
* V1 routes for official provider.
*/
.route('/api/v1', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMq, deps.otel?.llm))
.route('/api/v1/openai', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMq, deps.otel?.llm))
/**
* Flux routes.
*/
.route('/api/flux', createFluxRoutes(deps.fluxService, deps.fluxAuditService))
.route('/api/v1/flux', createFluxRoutes(deps.fluxService, deps.fluxAuditService))
/**
* Stripe routes.
*/
.route('/api/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.otel?.revenue))
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.otel?.revenue))
return { app: builtApp, injectWebSocket }
}
@@ -1,12 +1,12 @@
import type { CharacterService } from '../services/characters'
import type { HonoEnv } from '../types/hono'
import type { CharacterService } from '../../services/characters'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { CreateCharacterSchema, UpdateCharacterSchema } from '../api/characters.schema'
import { authGuard } from '../middlewares/auth'
import { createBadRequestError, createForbiddenError, createNotFoundError } from '../utils/error'
import { authGuard } from '../../middlewares/auth'
import { createBadRequestError, createForbiddenError, createNotFoundError } from '../../utils/error'
import { CreateCharacterSchema, UpdateCharacterSchema } from './schema'
export function createCharacterRoutes(characterService: CharacterService) {
return new Hono<HonoEnv>()
@@ -65,6 +65,7 @@ export function createCharacterRoutes(characterService: CharacterService) {
throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)
}
// TODO: Move ownership checks into the service layer with an actor-aware API such as updateByOwner(user.id, id, input).
const existing = await characterService.findById(id)
if (!existing)
throw createNotFoundError()
@@ -79,6 +80,7 @@ export function createCharacterRoutes(characterService: CharacterService) {
const user = c.get('user')!
const id = c.req.param('id')
// TODO: Move ownership checks into the service layer with an actor-aware API such as deleteByOwner(user.id, id).
const existing = await characterService.findById(id)
if (!existing)
throw createNotFoundError()
@@ -4,10 +4,10 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { beforeAll, describe, expect, it } from 'vitest'
import { createCharacterRoutes } from '.'
import { mockDB } from '../../libs/mock-db'
import { createCharacterService } from '../../services/characters'
import { ApiError } from '../../utils/error'
import { createCharacterRoutes } from '../characters'
import * as schema from '../../schemas'
@@ -1,7 +1,7 @@
import { createInsertSchema, createSelectSchema } from 'drizzle-valibot'
import { array, literal, number, object, optional, pipe, string, transform, union } from 'valibot'
import * as schema from '../schemas/characters'
import * as schema from '../../schemas/characters'
export const AvatarModelConfigSchema = object({
vrm: optional(object({
@@ -69,6 +69,8 @@ const DateSchema = pipe(
)
export const CreateCharacterSchema = object({
// TODO: Replace createInsertSchema-derived request bodies with explicit HTTP DTO schemas.
// The current shape still leaks persistence fields such as ownerId/creatorId into the API boundary.
character: createInsertSchema(schema.character, {
creatorId: optional(string()),
ownerId: optional(string()),
@@ -98,6 +100,8 @@ export const CreateCharacterSchema = object({
}))),
})
// TODO: Split update request schema from DB insert schema.
// This route should reject server-managed fields like id/ownerId/creatorId/timestamps instead of allowing them here.
export const UpdateCharacterSchema = createInsertSchema(schema.character, {
id: optional(string()),
version: optional(string()),
@@ -1,16 +1,16 @@
import type Redis from 'ioredis'
import type { HonoWsInvocableEventContext } from '../libs/eventa-hono-adapter'
import type { EngagementMetrics } from '../libs/otel'
import type { ChatService } from '../services/chats'
import type { HonoWsInvocableEventContext } from '../../libs/eventa-hono-adapter'
import type { EngagementMetrics } from '../../libs/otel'
import type { ChatService } from '../../services/chats'
import { useLogger } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { newMessages, pullMessages, sendMessages } from '@proj-airi/server-sdk-shared'
import { createPeerHooks, wsDisconnectedEvent } from '../libs/eventa-hono-adapter'
import { createChatBroadcastMessage, parseChatBroadcastMessage } from '../utils/chat-broadcast'
import { userChatBroadcastRedisKey } from '../utils/redis-keys'
import { createPeerHooks, wsDisconnectedEvent } from '../../libs/eventa-hono-adapter'
import { createChatBroadcastMessage, parseChatBroadcastMessage } from '../../utils/chat-broadcast'
import { userChatBroadcastRedisKey } from '../../utils/redis-keys'
const log = useLogger('chat-ws').useGlobalConfig()
@@ -54,6 +54,8 @@ export function createChatWsHandlers(
redis: Redis,
metrics?: EngagementMetrics | null,
) {
// TODO: Separate connection lifecycle, cross-instance broadcast, and RPC orchestration into smaller modules.
// This file is still acting as both transport adapter and chat delivery coordinator.
// Dedicated subscriber connection (ioredis requires a separate connection for subscribe mode)
const sub = redis.duplicate()
@@ -1,12 +1,12 @@
import type { ChatService } from '../services/chats'
import type { HonoEnv } from '../types/hono'
import type { ChatService } from '../../services/chats'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { AddMemberSchema, CreateChatSchema, UpdateChatSchema } from '../api/chats.schema'
import { authGuard } from '../middlewares/auth'
import { createBadRequestError } from '../utils/error'
import { authGuard } from '../../middlewares/auth'
import { createBadRequestError } from '../../utils/error'
import { AddMemberSchema, CreateChatSchema, UpdateChatSchema } from './schema'
export function createChatRoutes(chatService: ChatService) {
return new Hono<HonoEnv>()
@@ -13,6 +13,9 @@ const ChatMemberTypeSchema = union([
literal('bot'),
])
// TODO: Encode member invariants directly in schema:
// - type === 'user' requires userId
// - non-user member types require characterId
export const CreateChatSchema = object({
id: optional(pipe(string(), minLength(1), maxLength(30))),
type: optional(ChatTypeSchema),
@@ -28,6 +31,7 @@ export const UpdateChatSchema = object({
title: optional(string()),
})
// TODO: Promote the same discriminated validation rules to AddMemberSchema so invalid combinations fail as 4xx at the HTTP boundary.
export const AddMemberSchema = object({
type: ChatMemberTypeSchema,
userId: optional(string()),
@@ -1,12 +1,12 @@
import type { FluxService } from '../services/flux'
import type { FluxAuditService } from '../services/flux-audit'
import type { HonoEnv } from '../types/hono'
import type { FluxService } from '../../services/flux'
import type { FluxAuditService } from '../../services/flux-audit'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { parse } from 'valibot'
import { authGuard } from '../middlewares/auth'
import { LimitOffsetPaginationQuerySchema } from '../utils/http-query'
import { authGuard } from '../../middlewares/auth'
import { LimitOffsetPaginationQuerySchema } from '../../utils/http-query'
export function createFluxRoutes(fluxService: FluxService, fluxAuditService: FluxAuditService) {
return new Hono<HonoEnv>()
@@ -5,8 +5,8 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createFluxRoutes } from '.'
import { ApiError } from '../../utils/error'
import { createFluxRoutes } from '../flux'
function createMockFluxService(): FluxService {
return {
@@ -59,19 +59,19 @@ function createTestApp(fluxService: FluxService, fluxAuditService: FluxAuditServ
await next()
})
app.route('/api/users/me/flux', routes)
app.route('/api/v1/flux', routes)
return app
}
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
describe('fluxRoutes', () => {
it('get /api/users/me/flux should return the current user balance', async () => {
it('get /api/v1/flux should return the current user balance', async () => {
const fluxService = createMockFluxService()
const app = createTestApp(fluxService, createMockFluxAuditService())
const res = await app.fetch(
new Request('http://localhost/api/users/me/flux'),
new Request('http://localhost/api/v1/flux'),
{ user: testUser } as any,
)
@@ -80,12 +80,12 @@ describe('fluxRoutes', () => {
expect(fluxService.getFlux).toHaveBeenCalledWith('user-1')
})
it('get /api/users/me/flux/history should clamp pagination query values', async () => {
it('get /api/v1/flux/history should clamp pagination query values', async () => {
const fluxAuditService = createMockFluxAuditService()
const app = createTestApp(createMockFluxService(), fluxAuditService)
const res = await app.fetch(
new Request('http://localhost/api/users/me/flux/history?limit=999&offset=-12'),
new Request('http://localhost/api/v1/flux/history?limit=999&offset=-12'),
{ user: testUser } as any,
)
@@ -1,25 +1,25 @@
import type { Context } from 'hono'
import type { MqService } from '../libs/mq'
import type { LlmMetrics } from '../libs/otel'
import type { UsageInfo } from '../services/billing/billing'
import type { BillingEvent } from '../services/billing/billing-events'
import type { BillingService } from '../services/billing/billing-service'
import type { ConfigKVService } from '../services/config-kv'
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'
import type { MqService } from '../../../libs/mq'
import type { LlmMetrics } from '../../../libs/otel'
import type { UsageInfo } from '../../../services/billing/billing'
import type { BillingEvent } from '../../../services/billing/billing-events'
import type { BillingService } from '../../../services/billing/billing-service'
import type { ConfigKVService } from '../../../services/config-kv'
import type { FluxService } from '../../../services/flux'
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 { rateLimiter } from '../middlewares/rate-limit'
import { calculateFluxFromUsage, extractUsageFromBody } from '../services/billing/billing'
import { createPaymentRequiredError } from '../utils/error'
import { nanoid } from '../utils/id'
import { authGuard } from '../../../middlewares/auth'
import { configGuard } from '../../../middlewares/config-guard'
import { rateLimiter } from '../../../middlewares/rate-limit'
import { calculateFluxFromUsage, extractUsageFromBody } from '../../../services/billing/billing'
import { createPaymentRequiredError } from '../../../utils/error'
import { nanoid } from '../../../utils/id'
import {
AIRI_ATTR_BILLING_FLUX_CONSUMED,
AIRI_ATTR_GEN_AI_OPERATION_KIND,
@@ -30,7 +30,7 @@ import {
GEN_AI_ATTR_USAGE_INPUT_TOKENS,
GEN_AI_ATTR_USAGE_OUTPUT_TOKENS,
getServerConnectionAttributes,
} from '../utils/observability'
} from '../../../utils/observability'
const tracer = trace.getTracer('v1-completions')
@@ -72,6 +72,8 @@ function getLlmMetricAttributes(opts: { model: string, type: string, status: num
export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, billingMq: MqService<BillingEvent>, llm?: LlmMetrics | null) {
const logger = useLogger('v1-completions').useGlobalConfig()
// TODO: Extract this compat route into smaller facades/modules.
// It currently mixes auth, rate limiting, proxying, billing, telemetry, and event publishing in one transport layer entrypoint.
function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) {
if (!llm)
@@ -1,16 +1,16 @@
import type { MqService } from '../../libs/mq'
import type { BillingEvent } from '../../services/billing/billing-events'
import type { BillingService } from '../../services/billing/billing-service'
import type { ConfigKVService } from '../../services/config-kv'
import type { FluxService } from '../../services/flux'
import type { HonoEnv } from '../../types/hono'
import type { MqService } from '../../../libs/mq'
import type { BillingEvent } from '../../../services/billing/billing-events'
import type { BillingService } from '../../../services/billing/billing-service'
import type { ConfigKVService } from '../../../services/config-kv'
import type { FluxService } from '../../../services/flux'
import type { HonoEnv } from '../../../types/hono'
import { Hono } from 'hono'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { ApiError } from '../../utils/error'
import { DEFAULT_BILLING_EVENTS_STREAM } from '../../utils/redis-keys'
import { createV1CompletionsRoutes } from '../v1completions'
import { createV1CompletionsRoutes } from '.'
import { ApiError } from '../../../utils/error'
import { DEFAULT_BILLING_EVENTS_STREAM } from '../../../utils/redis-keys'
// --- Mock helpers ---
@@ -95,7 +95,7 @@ function createTestApp(
await next()
})
app.route('/api/v1', routes)
app.route('/api/v1/openai', routes)
return app
}
@@ -110,14 +110,14 @@ describe('v1CompletionsRoutes', () => {
globalThis.fetch = originalFetch
})
describe('pOST /api/v1/chat/completions', () => {
describe('pOST /api/v1/openai/chat/completions', () => {
it('should return 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
)
const res = await app.request('/api/v1/chat/completions', {
const res = await app.request('/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }),
@@ -132,7 +132,7 @@ describe('v1CompletionsRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }),
@@ -155,7 +155,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(fluxService, configKV, billingService)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }),
@@ -194,7 +194,7 @@ describe('v1CompletionsRoutes', () => {
)
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
@@ -219,7 +219,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'openai/gpt-5-mini', messages: [] }),
@@ -245,7 +245,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(100), createMockConfigKV(), billingService)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
@@ -266,7 +266,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(), configKV)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
@@ -286,7 +286,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV(), undefined, billingMq)
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-4', messages: [] }),
@@ -332,7 +332,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(100), createMockConfigKV(), billingService, billingMq)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
@@ -350,7 +350,7 @@ describe('v1CompletionsRoutes', () => {
})
})
describe.skip('pOST /api/v1/audio/speech', () => {
describe.skip('pOST /api/v1/openai/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, {
@@ -361,7 +361,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
new Request('http://localhost/api/v1/openai/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'tts-1', input: 'hello', voice: 'alloy' }),
@@ -377,7 +377,7 @@ describe('v1CompletionsRoutes', () => {
})
})
describe.skip('pOST /api/v1/audio/transcriptions', () => {
describe.skip('pOST /api/v1/openai/audio/transcriptions', () => {
it('should proxy transcription request to upstream', async () => {
globalThis.fetch = vi.fn(async () => new Response('{"text":"hello"}', {
status: 200,
@@ -391,7 +391,7 @@ describe('v1CompletionsRoutes', () => {
formData.append('model', 'whisper-1')
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/transcriptions', {
new Request('http://localhost/api/v1/openai/audio/transcriptions', {
method: 'POST',
body: formData,
}),
@@ -407,17 +407,17 @@ describe('v1CompletionsRoutes', () => {
})
describe('route matching', () => {
it('gET /api/v1/chat/completions should return 404', async () => {
it('gET /api/v1/openai/chat/completions should return 404', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', { method: 'GET' }),
new Request('http://localhost/api/v1/openai/chat/completions', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(404)
})
it('pOST /api/v1/chat/completion (singular) should also work', async () => {
it('pOST /api/v1/openai/chat/completion (singular) should also work', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
@@ -426,7 +426,7 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completion', {
new Request('http://localhost/api/v1/openai/chat/completion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', messages: [] }),
@@ -1,12 +1,12 @@
import type { ProviderService } from '../services/providers'
import type { HonoEnv } from '../types/hono'
import type { ProviderService } from '../../services/providers'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { CreateProviderConfigSchema, UpdateProviderConfigSchema } from '../api/providers.schema'
import { authGuard } from '../middlewares/auth'
import { createBadRequestError, createForbiddenError, createNotFoundError } from '../utils/error'
import { authGuard } from '../../middlewares/auth'
import { createBadRequestError, createForbiddenError, createNotFoundError } from '../../utils/error'
import { CreateProviderConfigSchema, UpdateProviderConfigSchema } from './schema'
export function createProviderRoutes(providerService: ProviderService) {
return new Hono<HonoEnv>()
@@ -55,6 +55,7 @@ export function createProviderRoutes(providerService: ProviderService) {
throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)
}
// TODO: Move ownership checks into the service layer with an actor-aware API such as updateUserConfigByOwner(user.id, id, input).
const existing = await providerService.findUserConfigById(id)
if (!existing)
throw createNotFoundError()
@@ -69,6 +70,7 @@ export function createProviderRoutes(providerService: ProviderService) {
const user = c.get('user')!
const id = c.req.param('id')
// TODO: Move ownership checks into the service layer with an actor-aware API such as deleteUserConfigByOwner(user.id, id).
const existing = await providerService.findUserConfigById(id)
if (!existing)
throw createNotFoundError()
@@ -4,10 +4,10 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { beforeAll, describe, expect, it } from 'vitest'
import { createProviderRoutes } from '.'
import { mockDB } from '../../libs/mock-db'
import { createProviderService } from '../../services/providers'
import { ApiError } from '../../utils/error'
import { createProviderRoutes } from '../providers'
import * as schema from '../../schemas'
@@ -1,7 +1,7 @@
import { createInsertSchema, createSelectSchema } from 'drizzle-valibot'
import { boolean, object, optional, record, string } from 'valibot'
import * as schema from '../schemas/providers'
import * as schema from '../../schemas/providers'
export const UserProviderConfigSchema = createSelectSchema(schema.userProviderConfigs)
export const InsertUserProviderConfigSchema = createInsertSchema(schema.userProviderConfigs)
@@ -9,6 +9,8 @@ export const InsertUserProviderConfigSchema = createInsertSchema(schema.userProv
export const SystemProviderConfigSchema = createSelectSchema(schema.systemProviderConfigs)
export const InsertSystemProviderConfigSchema = createInsertSchema(schema.systemProviderConfigs)
// TODO: Replace these schemas with explicit HTTP request DTOs.
// validated/validationBypassed are server-managed state and should not be client-writable.
export const CreateProviderConfigSchema = object({
id: optional(string()),
definitionId: string(),
@@ -18,6 +20,7 @@ export const CreateProviderConfigSchema = object({
validationBypassed: optional(boolean()),
})
// TODO: Restrict updates to user-editable fields only.
export const UpdateProviderConfigSchema = object({
name: optional(string()),
config: optional(record(string(), string())),
@@ -1,30 +1,27 @@
import type { Env } from '../libs/env'
import type { RevenueMetrics } from '../libs/otel'
import type { BillingService } from '../services/billing/billing-service'
import type { ConfigKVService } from '../services/config-kv'
import type { FluxService } from '../services/flux'
import type { StripeService } from '../services/stripe'
import type { HonoEnv } from '../types/hono'
import type { Env } from '../../libs/env'
import type { RevenueMetrics } from '../../libs/otel'
import type { BillingService } from '../../services/billing/billing-service'
import type { ConfigKVService } from '../../services/config-kv'
import type { FluxService } from '../../services/flux'
import type { StripeService } from '../../services/stripe'
import type { HonoEnv } from '../../types/hono'
import Stripe from 'stripe'
import { useLogger } from '@guiiai/logg'
import { Hono } from 'hono'
import { integer, minValue, number, object, pipe, safeParse } from 'valibot'
import { safeParse } from 'valibot'
import { authGuard } from '../middlewares/auth'
import { configGuard } from '../middlewares/config-guard'
import { rateLimiter } from '../middlewares/rate-limit'
import { createBadRequestError, createServiceUnavailableError } from '../utils/error'
import { errorMessageFromUnknown } from '../utils/error-message'
import { resolveTrustedRequestOrigin } from '../utils/origin'
import { authGuard } from '../../middlewares/auth'
import { configGuard } from '../../middlewares/config-guard'
import { rateLimiter } from '../../middlewares/rate-limit'
import { createBadRequestError, createServiceUnavailableError } from '../../utils/error'
import { errorMessageFromUnknown } from '../../utils/error-message'
import { resolveTrustedRequestOrigin } from '../../utils/origin'
import { CheckoutBodySchema } from './schema'
const logger = useLogger('stripe')
const CheckoutBodySchema = object({
amount: pipe(number(), integer(), minValue(1)),
})
export function createStripeRoutes(
fluxService: FluxService,
stripeService: StripeService,
@@ -8,8 +8,8 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createStripeRoutes } from '.'
import { ApiError } from '../../utils/error'
import { createStripeRoutes } from '../stripe'
// --- Mock helpers ---
@@ -148,14 +148,14 @@ function createTestApp(
await next()
})
app.route('/api/stripe', routes)
app.route('/api/v1/stripe', routes)
return app
}
// --- Tests ---
describe('stripeRoutes', () => {
describe('gET /api/stripe/packages', () => {
describe('gET /api/v1/stripe/packages', () => {
it('returns configured packages', async () => {
const app = createTestApp(
createMockFluxService(),
@@ -164,7 +164,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/packages')
const res = await app.request('/api/v1/stripe/packages')
expect(res.status).toBe(200)
const data = await res.json()
@@ -180,13 +180,13 @@ describe('stripeRoutes', () => {
configKV,
)
const res = await app.request('/api/stripe/packages')
const res = await app.request('/api/v1/stripe/packages')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
})
describe('pOST /api/stripe/checkout', () => {
describe('pOST /api/v1/stripe/checkout', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
@@ -195,7 +195,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/checkout', {
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 500 }),
@@ -212,7 +212,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/checkout', {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 0 }),
@@ -231,7 +231,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/checkout', {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: -100 }),
@@ -250,7 +250,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/checkout', {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1_000_001 }),
@@ -269,7 +269,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/checkout', {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 501 }),
@@ -289,7 +289,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/checkout', {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 9.99 }),
@@ -317,9 +317,9 @@ describe('stripeRoutes', () => {
c.set('user', testUser as any)
await next()
})
app.route('/api/stripe', routes)
app.route('/api/v1/stripe', routes)
const res = await app.request('/api/stripe/checkout', {
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 500 }),
@@ -328,7 +328,7 @@ describe('stripeRoutes', () => {
})
})
describe('gET /api/stripe/orders', () => {
describe('gET /api/v1/stripe/orders', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
@@ -337,7 +337,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/orders')
const res = await app.request('/api/v1/stripe/orders')
expect(res.status).toBe(401)
})
@@ -357,7 +357,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/orders'),
new Request('http://localhost/api/v1/stripe/orders'),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
@@ -368,7 +368,7 @@ describe('stripeRoutes', () => {
})
})
describe('gET /api/stripe/invoices', () => {
describe('gET /api/v1/stripe/invoices', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
@@ -377,7 +377,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/invoices')
const res = await app.request('/api/v1/stripe/invoices')
expect(res.status).toBe(401)
})
@@ -394,7 +394,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/invoices'),
new Request('http://localhost/api/v1/stripe/invoices'),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
@@ -405,7 +405,7 @@ describe('stripeRoutes', () => {
})
})
describe('pOST /api/stripe/portal', () => {
describe('pOST /api/v1/stripe/portal', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
@@ -414,7 +414,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/portal', { method: 'POST' })
const res = await app.request('/api/v1/stripe/portal', { method: 'POST' })
expect(res.status).toBe(401)
})
@@ -430,7 +430,7 @@ describe('stripeRoutes', () => {
)
const res = await app.fetch(
new Request('http://localhost/api/stripe/portal', { method: 'POST' }),
new Request('http://localhost/api/v1/stripe/portal', { method: 'POST' }),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
@@ -440,7 +440,7 @@ describe('stripeRoutes', () => {
})
})
describe('pOST /api/stripe/webhook', () => {
describe('pOST /api/v1/stripe/webhook', () => {
it('returns 400 when signature is missing', async () => {
const app = createTestApp(
createMockFluxService(),
@@ -449,7 +449,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/webhook', {
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
body: '{}',
})
@@ -467,7 +467,7 @@ describe('stripeRoutes', () => {
createMockConfigKV(),
)
const res = await app.request('/api/stripe/webhook', {
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'invalid_sig' },
body: '{}',
@@ -492,9 +492,9 @@ describe('stripeRoutes', () => {
return c.json({ error: err.errorCode }, err.statusCode)
return c.json({ error: 'Internal Server Error' }, 500)
})
app.route('/api/stripe', routes)
app.route('/api/v1/stripe', routes)
const res = await app.request('/api/stripe/webhook', {
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'test_sig' },
body: '{}',
+5
View File
@@ -0,0 +1,5 @@
import { integer, minValue, number, object, pipe } from 'valibot'
export const CheckoutBodySchema = object({
amount: pipe(number(), integer(), minValue(1)),
})
+2
View File
@@ -191,6 +191,8 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics
},
async update(id: string, data: Partial<schema.NewCharacter>) {
// TODO: Return a stable single-object response shape for HTTP callers.
// leaking Drizzle returning() arrays across the service boundary makes route contracts drift.
const result = await db.update(schema.character)
.set({ ...data, updatedAt: new Date() })
.where(and(
+1
View File
@@ -173,6 +173,7 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
},
async addMember(userId: string, chatId: string, member: { type: ChatMemberType, userId?: string, characterId?: string }) {
// TODO: Push these invariants up into the HTTP schema and convert failures to API errors instead of generic Error.
// Validate that user-type members have a userId and non-user members have a characterId
if (member.type === 'user' && !member.userId) {
throw new Error('userId is required for user-type members')
+2 -2
View File
@@ -12,7 +12,7 @@ describe('origin utils', () => {
})
it('prefers a trusted referer origin', () => {
const request = new Request('http://localhost/api/stripe/checkout', {
const request = new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
referer: 'https://airi.moeru.ai/settings/flux',
origin: 'https://example.com',
@@ -23,7 +23,7 @@ describe('origin utils', () => {
})
it('falls back to a trusted origin header when referer is missing', () => {
const request = new Request('http://localhost/api/stripe/checkout', {
const request = new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
origin: 'http://localhost:5173',
},