feat: 新增模型管理功能(供应商 + 模型 CRUD)

- 新增 providers/models 数据库表、迁移和数据访问层
- 新增 15 个后端 API 路由(供应商/模型 CRUD + 连通性测试)
- 新增 AI 服务层(registry.ts: buildProviderRegistry + testProviderConnection)
- 新增前端模型管理页面(Tabs: 供应商/模型,含表格、表单、工具栏)
- 新增前端 hooks(use-providers, use-models)
- 新增共享类型和 MODEL_CAPABILITIES 常量
- 新增 10 个测试文件(66 个测试用例,4 个因 bun test ESM 兼容问题待修复)
- 更新开发文档(architecture, backend, frontend)
- 附带 apply-review 修复:统一错误响应、提取共享常量、清理重复测试

注意:registry.test.ts 中 4 个测试因 bun test 无法解析
createProviderRegistry ESM 导出而失败,详情见 context.md
This commit is contained in:
2026-05-29 12:40:10 +08:00
parent 2ea4bd4410
commit 933c2133f0
56 changed files with 4706 additions and 9 deletions

74
src/server/ai/registry.ts Normal file
View File

@@ -0,0 +1,74 @@
import type Database from "bun:sqlite";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createProviderRegistry, generateText } from "ai";
import type { AIProviderConfig } from "./types";
export function buildProviderRegistry(db: Database) {
const enabledProviders = getEnabledProviders(db);
const providerEntries: Record<string, ReturnType<typeof createProvider>> = {};
for (const p of enabledProviders) {
providerEntries[p.id] = createProvider({
apiKey: p.api_key,
baseUrl: p.base_url,
name: p.name,
type: p.type,
});
}
return createProviderRegistry(providerEntries);
}
export async function testProviderConnection(config: AIProviderConfig): Promise<{ message: string; ok: boolean }> {
try {
const provider = createProvider(config);
const model = provider.languageModel("test");
await generateText({
maxOutputTokens: 1,
model,
prompt: "Hi",
});
return { message: "连接成功", ok: true };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { message: `连接失败: ${msg}`, ok: false };
}
}
function createProvider(config: AIProviderConfig) {
switch (config.type) {
case "anthropic":
return createAnthropic({ apiKey: config.apiKey, baseURL: config.baseUrl });
case "openai":
return createOpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
case "openai-compatible":
return createOpenAICompatible({
apiKey: config.apiKey,
baseURL: config.baseUrl,
name: config.name,
});
}
}
function getEnabledProviders(db: Database): Array<{
api_key: string;
base_url: string;
id: string;
name: string;
type: "anthropic" | "openai" | "openai-compatible";
}> {
const stmt = db.prepare("SELECT id, name, type, base_url, api_key FROM providers WHERE enabled = 1");
return stmt.all() as Array<{
api_key: string;
base_url: string;
id: string;
name: string;
type: "anthropic" | "openai" | "openai-compatible";
}>;
}

21
src/server/ai/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { ModelCapability, ProviderType } from "../../shared/api";
export type { ModelCapability, ProviderType };
export interface AIModelConfig {
capabilities: ModelCapability[];
modelId: string;
providerId: string;
}
export interface AIProviderConfig {
apiKey: string;
baseUrl: string;
name: string;
type: ProviderType;
}
export interface AIRegistryConfig {
models: AIModelConfig[];
providers: AIProviderConfig[];
}

237
src/server/db/models.ts Normal file
View File

@@ -0,0 +1,237 @@
import type Database from "bun:sqlite";
import { and, desc, eq, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
import { models, providers } from "./schema";
export function createModel(
raw: Database,
request: CreateModelRequest,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
if (!provider) return { error: "供应商不存在", status: 400 };
const name = request.name.trim();
if (!name) return { error: "模型名称不能为空", status: 400 };
const modelId = request.modelId.trim();
if (!modelId) return { error: "模型 ID 不能为空", status: 400 };
const capabilities = request.capabilities;
if (!capabilities || capabilities.length === 0) {
return { error: "至少选择一个能力标签", status: 400 };
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
try {
db.insert(models)
.values({
capabilities: JSON.stringify(capabilities),
contextLength: request.contextLength ?? null,
createdAt: now,
enabled: true,
id,
maxOutputTokens: request.maxOutputTokens ?? null,
modelId,
name,
providerId: request.providerId,
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
throw e;
}
const row = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(row!) };
}
export function deleteModel(raw: Database, id: string): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
db.delete(models).where(eq(models.id, id)).run();
return { success: true };
}
export function disableModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
if (!existing.enabled) return { error: "模型已禁用", status: 409 };
const now = new Date().toISOString();
db.update(models).set({ enabled: false, updatedAt: now }).where(eq(models.id, id)).run();
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
export function enableModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
if (existing.enabled) return { error: "模型已启用", status: 409 };
const now = new Date().toISOString();
db.update(models).set({ enabled: true, updatedAt: now }).where(eq(models.id, id)).run();
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const row = db.select().from(models).where(eq(models.id, id)).get();
if (!row) return { error: "模型不存在", status: 404 };
return { model: toModel(row) };
}
export function getModelsByProviderId(raw: Database, providerId: string): number {
const db = wrap(raw);
const result = db
.select({ count: sql<number>`count(*)` })
.from(models)
.where(eq(models.providerId, providerId))
.get();
return Number(result?.count ?? 0);
}
export function listModels(
raw: Database,
options: { keyword?: string; page: number; pageSize: number; providerId?: string },
): { items: Model[]; page: number; pageSize: number; total: number } {
const db = wrap(raw);
const conditions = [];
if (options.providerId) {
conditions.push(eq(models.providerId, options.providerId));
}
if (options.keyword) {
const pattern = `%${options.keyword}%`;
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const countResult = db
.select({ count: sql<number>`count(*)` })
.from(models)
.where(where)
.get();
const total = Number(countResult?.count ?? 0);
const rows = db
.select()
.from(models)
.where(where)
.orderBy(desc(models.createdAt))
.limit(options.pageSize)
.offset((options.page - 1) * options.pageSize)
.all();
return {
items: rows.map(toModel),
page: options.page,
pageSize: options.pageSize,
total,
};
}
export function updateModel(
raw: Database,
id: string,
request: UpdateModelRequest,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
const updates: Partial<typeof models.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
const name = request.name?.trim();
if (name === "") return { error: "模型名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) {
updates.name = name;
}
const modelId = request.modelId?.trim();
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 };
if (modelId !== undefined) {
updates.modelId = modelId;
}
if (request.providerId !== undefined) {
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
if (!provider) return { error: "供应商不存在", status: 400 };
updates.providerId = request.providerId;
}
if (request.capabilities !== undefined) {
if (request.capabilities.length === 0) {
return { error: "至少选择一个能力标签", status: 400 };
}
updates.capabilities = JSON.stringify(request.capabilities);
}
if (request.contextLength !== undefined) {
updates.contextLength = request.contextLength;
}
if (request.maxOutputTokens !== undefined) {
updates.maxOutputTokens = request.maxOutputTokens;
}
if (Object.keys(updates).length === 1 && updates.updatedAt) {
return { model: toModel(existing) };
}
try {
db.update(models).set(updates).where(eq(models.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
throw e;
}
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
function toModel(row: typeof models.$inferSelect): Model {
return {
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
contextLength: row.contextLength,
createdAt: row.createdAt,
enabled: row.enabled,
id: row.id,
maxOutputTokens: row.maxOutputTokens,
modelId: row.modelId,
name: row.name,
providerId: row.providerId,
updatedAt: row.updatedAt,
};
}
function wrap(raw: Database) {
return drizzle(raw);
}

202
src/server/db/providers.ts Normal file
View File

@@ -0,0 +1,202 @@
import type Database from "bun:sqlite";
import { and, desc, eq, like, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import type { CreateProviderRequest, Provider, UpdateProviderRequest } from "../../shared/api";
import { providers } from "./schema";
export function createProvider(
raw: Database,
request: CreateProviderRequest,
): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const name = request.name.trim();
if (!name) return { error: "供应商名称不能为空", status: 400 };
const baseUrl = request.baseUrl.trim();
if (!baseUrl) return { error: "Base URL 不能为空", status: 400 };
const apiKey = request.apiKey.trim();
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
const id = crypto.randomUUID();
const now = new Date().toISOString();
try {
db.insert(providers)
.values({
apiKey,
baseUrl,
createdAt: now,
enabled: true,
id,
name,
type: request.type,
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
throw e;
}
const row = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(row!) };
}
export function deleteProvider(raw: Database, id: string): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
db.delete(providers).where(eq(providers.id, id)).run();
return { success: true };
}
export function disableProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
if (!existing.enabled) return { error: "供应商已禁用", status: 409 };
const now = new Date().toISOString();
db.update(providers).set({ enabled: false, updatedAt: now }).where(eq(providers.id, id)).run();
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) };
}
export function enableProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
if (existing.enabled) return { error: "供应商已启用", status: 409 };
const now = new Date().toISOString();
db.update(providers).set({ enabled: true, updatedAt: now }).where(eq(providers.id, id)).run();
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) };
}
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const row = db.select().from(providers).where(eq(providers.id, id)).get();
if (!row) return { error: "供应商不存在", status: 404 };
return { provider: toProvider(row) };
}
export function listProviders(
raw: Database,
options: { keyword?: string; page: number; pageSize: number },
): { items: Provider[]; page: number; pageSize: number; total: number } {
const db = wrap(raw);
const conditions = [];
if (options.keyword) {
const pattern = `%${options.keyword}%`;
conditions.push(like(providers.name, pattern));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const countResult = db
.select({ count: sql<number>`count(*)` })
.from(providers)
.where(where)
.get();
const total = Number(countResult?.count ?? 0);
const rows = db
.select()
.from(providers)
.where(where)
.orderBy(desc(providers.createdAt))
.limit(options.pageSize)
.offset((options.page - 1) * options.pageSize)
.all();
return {
items: rows.map(toProvider),
page: options.page,
pageSize: options.pageSize,
total,
};
}
export function updateProvider(
raw: Database,
id: string,
request: UpdateProviderRequest,
): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
const updates: Partial<typeof providers.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
const name = request.name?.trim();
if (name === "") return { error: "供应商名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) {
updates.name = name;
}
const baseUrl = request.baseUrl?.trim();
if (baseUrl === "") return { error: "Base URL 不能为空", status: 400 };
if (baseUrl !== undefined) {
updates.baseUrl = baseUrl;
}
const apiKey = request.apiKey?.trim();
if (apiKey === "") return { error: "API Key 不能为空", status: 400 };
if (apiKey !== undefined) {
updates.apiKey = apiKey;
}
if (request.type !== undefined) {
updates.type = request.type;
}
if (Object.keys(updates).length === 1 && updates.updatedAt) {
return { provider: toProvider(existing) };
}
try {
db.update(providers).set(updates).where(eq(providers.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
throw e;
}
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) };
}
function toProvider(row: typeof providers.$inferSelect): Provider {
return {
apiKey: row.apiKey,
baseUrl: row.baseUrl,
createdAt: row.createdAt,
enabled: row.enabled,
id: row.id,
name: row.name,
type: row.type,
updatedAt: row.updatedAt,
};
}
function wrap(raw: Database) {
return drizzle(raw);
}

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", {
archivedAt: text("archived_at"),
@@ -12,6 +12,41 @@ export const projects = sqliteTable("projects", {
updatedAt: text("updated_at").notNull(),
});
export const providers = sqliteTable("providers", {
apiKey: text("api_key").notNull(),
baseUrl: text("base_url").notNull(),
createdAt: text("created_at").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
.notNull()
.default("openai-compatible"),
updatedAt: text("updated_at").notNull(),
});
export const models = sqliteTable(
"models",
{
capabilities: text("capabilities").notNull(),
contextLength: integer("context_length"),
createdAt: text("created_at").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
id: text("id").primaryKey(),
maxOutputTokens: integer("max_output_tokens"),
modelId: text("model_id").notNull(),
name: text("name").notNull(),
providerId: text("provider_id")
.notNull()
.references(() => providers.id),
updatedAt: text("updated_at").notNull(),
},
(table) => [
uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId),
index("models_provider_id_idx").on(table.providerId),
],
);
export const schemaMigrations = sqliteTable("schema_migrations", {
appliedAt: text("applied_at").notNull(),
checksum: text("checksum").notNull(),

View File

@@ -0,0 +1,47 @@
import type Database from "bun:sqlite";
import type { CreateModelRequest, RuntimeMode } from "../../../shared/api";
import { MODEL_CAPABILITIES } from "../../../shared/api";
import { createModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleCreateModel(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
let body: CreateModelRequest;
try {
body = (await req.json()) as CreateModelRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.name || typeof body.name !== "string") {
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
if (!body.modelId || typeof body.modelId !== "string") {
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
}
if (!body.providerId || typeof body.providerId !== "string") {
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
}
if (!Array.isArray(body.capabilities) || body.capabilities.length === 0) {
return jsonResponse(createApiError("capabilities is required and must be a non-empty array", 400), {
mode,
status: 400,
});
}
const invalidCaps = body.capabilities.filter((c) => !MODEL_CAPABILITIES.includes(c));
if (invalidCaps.length > 0) {
return jsonResponse(createApiError(`Invalid capabilities: ${invalidCaps.join(", ")}`, 400), { mode, status: 400 });
}
const result = createModel(db, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { deleteModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteModel(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = deleteModel(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { disableModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDisableModel(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = disableModel(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { enableModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleEnableModel(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = enableModel(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { getModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetModel(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = getModel(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,27 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { listModels } from "../../db/models";
import { jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
export function handleListModels(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const keyword = url.searchParams.get("keyword");
const providerId = url.searchParams.get("providerId");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const result = listModels(db, {
keyword: keyword ?? undefined,
page: pagination.page,
pageSize: pagination.pageSize,
providerId: providerId ?? undefined,
});
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,43 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateModelRequest } from "../../../shared/api";
import { MODEL_CAPABILITIES } from "../../../shared/api";
import { updateModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateModel(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
let body: UpdateModelRequest;
try {
body = (await req.json()) as UpdateModelRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (body.capabilities !== undefined) {
if (!Array.isArray(body.capabilities) || body.capabilities.length === 0) {
return jsonResponse(createApiError("capabilities must be a non-empty array", 400), { mode, status: 400 });
}
const invalidCaps = body.capabilities.filter((c) => !MODEL_CAPABILITIES.includes(c));
if (invalidCaps.length > 0) {
return jsonResponse(createApiError(`Invalid capabilities: ${invalidCaps.join(", ")}`, 400), {
mode,
status: 400,
});
}
}
const result = updateModel(db, validated.id, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,41 @@
import type Database from "bun:sqlite";
import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api";
import { createProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleCreateProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
let body: CreateProviderRequest;
try {
body = (await req.json()) as CreateProviderRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.name || typeof body.name !== "string") {
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
if (!body.baseUrl || typeof body.baseUrl !== "string") {
return jsonResponse(createApiError("baseUrl is required", 400), { mode, status: 400 });
}
if (!body.apiKey || typeof body.apiKey !== "string") {
return jsonResponse(createApiError("apiKey is required", 400), { mode, status: 400 });
}
if (!body.type || !["anthropic", "openai", "openai-compatible"].includes(body.type)) {
return jsonResponse(createApiError("type must be one of: openai, anthropic, openai-compatible", 400), {
mode,
status: 400,
});
}
const result = createProvider(db, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,28 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { getModelsByProviderId } from "../../db/models";
import { deleteProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const modelCount = getModelsByProviderId(db, validated.id);
if (modelCount > 0) {
return jsonResponse(createApiError("该供应商下存在模型,无法删除", 409), { mode, status: 409 });
}
const result = deleteProvider(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { disableProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDisableProvider(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = disableProvider(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { enableProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleEnableProvider(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = enableProvider(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { getProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetProvider(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = getProvider(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,25 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { listProviders } from "../../db/providers";
import { jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const keyword = url.searchParams.get("keyword");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const result = listProviders(db, {
keyword: keyword ?? undefined,
page: pagination.page,
pageSize: pagination.pageSize,
});
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,34 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { testProviderConnection } from "../../ai/registry";
import { getProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleTestProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const providerResult = getProvider(db, validated.id);
if ("error" in providerResult) {
return jsonResponse(createApiError(providerResult.error, providerResult.status), {
mode,
status: providerResult.status,
});
}
const provider = providerResult.provider;
const testResult = await testProviderConnection({
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
name: provider.name,
type: provider.type,
});
return jsonResponse({ providerTestResponse: testResult }, { mode });
}

View File

@@ -0,0 +1,36 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateProviderRequest } from "../../../shared/api";
import { updateProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
let body: UpdateProviderRequest;
try {
body = (await req.json()) as UpdateProviderRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (body.type !== undefined && !["anthropic", "openai", "openai-compatible"].includes(body.type)) {
return jsonResponse(createApiError("type must be one of: openai, anthropic, openai-compatible", 400), {
mode,
status: 400,
});
}
const result = updateProvider(db, validated.id, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -43,6 +43,42 @@ export function startServer(options: StartServerOptions) {
return handleMeta(mode, resolvedVersion);
},
},
"/api/models": {
GET: async (req) => {
const { handleListModels } = await import("./routes/models/list");
return handleListModels(req, db, mode);
},
POST: async (req) => {
const { handleCreateModel } = await import("./routes/models/create");
return handleCreateModel(req, db, mode);
},
},
"/api/models/:id": {
DELETE: async (req) => {
const { handleDeleteModel } = await import("./routes/models/delete");
return handleDeleteModel(req, db, mode);
},
GET: async (req) => {
const { handleGetModel } = await import("./routes/models/get");
return handleGetModel(req, db, mode);
},
PATCH: async (req) => {
const { handleUpdateModel } = await import("./routes/models/update");
return handleUpdateModel(req, db, mode);
},
},
"/api/models/:id/disable": {
POST: async (req) => {
const { handleDisableModel } = await import("./routes/models/disable");
return handleDisableModel(req, db, mode);
},
},
"/api/models/:id/enable": {
POST: async (req) => {
const { handleEnableModel } = await import("./routes/models/enable");
return handleEnableModel(req, db, mode);
},
},
"/api/projects": {
GET: async (req) => {
const { handleListProjects } = await import("./routes/projects/list");
@@ -79,6 +115,48 @@ export function startServer(options: StartServerOptions) {
return handleRestoreProject(req, db, mode);
},
},
"/api/providers": {
GET: async (req) => {
const { handleListProviders } = await import("./routes/providers/list");
return handleListProviders(req, db, mode);
},
POST: async (req) => {
const { handleCreateProvider } = await import("./routes/providers/create");
return handleCreateProvider(req, db, mode);
},
},
"/api/providers/:id": {
DELETE: async (req) => {
const { handleDeleteProvider } = await import("./routes/providers/delete");
return handleDeleteProvider(req, db, mode);
},
GET: async (req) => {
const { handleGetProvider } = await import("./routes/providers/get");
return handleGetProvider(req, db, mode);
},
PATCH: async (req) => {
const { handleUpdateProvider } = await import("./routes/providers/update");
return handleUpdateProvider(req, db, mode);
},
},
"/api/providers/:id/disable": {
POST: async (req) => {
const { handleDisableProvider } = await import("./routes/providers/disable");
return handleDisableProvider(req, db, mode);
},
},
"/api/providers/:id/enable": {
POST: async (req) => {
const { handleEnableProvider } = await import("./routes/providers/enable");
return handleEnableProvider(req, db, mode);
},
},
"/api/providers/:id/test": {
POST: async (req) => {
const { handleTestProvider } = await import("./routes/providers/test");
return handleTestProvider(req, db, mode);
},
},
},
});