refactor(server): redesign server routes structure
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+5
-1
@@ -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>()
|
||||
+6
-6
@@ -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,
|
||||
)
|
||||
|
||||
+17
-15
@@ -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)
|
||||
+28
-28
@@ -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()
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+4
-1
@@ -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,
|
||||
+28
-28
@@ -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: '{}',
|
||||
@@ -0,0 +1,5 @@
|
||||
import { integer, minValue, number, object, pipe } from 'valibot'
|
||||
|
||||
export const CheckoutBodySchema = object({
|
||||
amount: pipe(number(), integer(), minValue(1)),
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -14,5 +14,5 @@ export function withCredentials() {
|
||||
}
|
||||
|
||||
export function createOfficialOpenAIProvider() {
|
||||
return createOpenAI('', `${SERVER_URL}/api/v1/`)
|
||||
return createOpenAI('', `${SERVER_URL}/api/v1/openai/`)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const updateCredits = async () => {
|
||||
if (!isAuthenticated.value)
|
||||
return
|
||||
const res = await client.api.flux.$get()
|
||||
const res = await client.api.v1.flux.$get()
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
credits.value = data.flux
|
||||
|
||||
@@ -88,7 +88,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
}
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.characters.$get({
|
||||
const res = await client.api.v1.characters.$get({
|
||||
query: { all: String(all) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -118,7 +118,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
return cached
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.characters[':id'].$get({
|
||||
const res = await client.api.v1.characters[':id'].$get({
|
||||
param: { id },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -144,7 +144,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
return localCharacter
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.characters.$post({
|
||||
const res = await client.api.v1.characters.$post({
|
||||
json: payload,
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -182,7 +182,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
return character
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await (client.api.characters[':id'].$patch)({
|
||||
const res = await (client.api.v1.characters[':id'].$patch)({
|
||||
param: { id },
|
||||
// @ts-expect-error FIXME: hono client typing misses json option for this route
|
||||
json: payload,
|
||||
@@ -207,7 +207,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
await charactersRepo.remove(id)
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.characters[':id'].$delete({
|
||||
const res = await client.api.v1.characters[':id'].$delete({
|
||||
param: { id },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -235,7 +235,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
}
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.characters[':id'].like.$post({
|
||||
const res = await client.api.v1.characters[':id'].like.$post({
|
||||
param: { id },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -268,7 +268,7 @@ export const useCharacterStore = defineStore('characters', () => {
|
||||
}
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.characters[':id'].bookmark.$post({
|
||||
const res = await client.api.v1.characters[':id'].bookmark.$post({
|
||||
param: { id },
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
|
||||
}
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.providers.$get()
|
||||
const res = await client.api.v1.providers.$get()
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch providers')
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
|
||||
return provider
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.providers.$post({
|
||||
const res = await client.api.v1.providers.$post({
|
||||
json: {
|
||||
id,
|
||||
definitionId,
|
||||
@@ -109,7 +109,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
|
||||
await providersRepo.remove(providerId)
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.providers[':id'].$delete({
|
||||
const res = await client.api.v1.providers[':id'].$delete({
|
||||
param: { id: providerId },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -134,7 +134,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
|
||||
return provider
|
||||
},
|
||||
remote: async () => {
|
||||
const res = await client.api.providers[':id'].$patch({
|
||||
const res = await client.api.v1.providers[':id'].$patch({
|
||||
param: { id: providerId },
|
||||
// @ts-expect-error hono client typing misses json option for this route
|
||||
json: {
|
||||
|
||||
Reference in New Issue
Block a user