Browse Source

feat(llm): add CRUD operations for providers and models

- Implemented GET, POST, PUT, DELETE endpoints for LLM providers and models.
- Added validation for provider and model IDs.
- Introduced functions to list, create, update, and delete providers and models in the service layer.
- Enhanced error handling for invalid inputs and non-existent resources.
- Updated database schema to include new provider and model management features.
acas
npmrun 1 week ago
parent
commit
7e04425f7d
  1. 1
      AGENTS.md
  2. 1
      app/pages/admin.vue
  3. 1446
      app/pages/admin/llm-config/index.vue
  4. BIN
      packages/drizzle-pkg/db.sqlite
  5. 58
      packages/drizzle-pkg/lib/schema/llm.ts
  6. 33
      packages/drizzle-pkg/migrations/0012_funny_santa_claus.sql
  7. 1659
      packages/drizzle-pkg/migrations/meta/0012_snapshot.json
  8. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  9. 14
      server/api/admin/llm/models/[providerId].get.ts
  10. 29
      server/api/admin/llm/models/[providerId].post.ts
  11. 14
      server/api/admin/llm/models/detail/[id].delete.ts
  12. 24
      server/api/admin/llm/models/detail/[id].put.ts
  13. 14
      server/api/admin/llm/providers/[id].delete.ts
  14. 17
      server/api/admin/llm/providers/[id].get.ts
  15. 50
      server/api/admin/llm/providers/[id].models.get.ts
  16. 25
      server/api/admin/llm/providers/[id].put.ts
  17. 15
      server/api/admin/llm/providers/index.get.ts
  18. 24
      server/api/admin/llm/providers/index.post.ts
  19. 228
      server/service/llm/index.ts

1
AGENTS.md

@ -5,6 +5,7 @@
- 新建的mono包必须在根目录安装
- 使用nuxt时遇到不清楚的,必须先加载nuxt-remote查询文档再进行分析
- 能够用toast提示的尽量用toast
- 数据库schema编写完成之后,必须使用命令进行迁移,不能自己生成
## 代码抽离与复用

1
app/pages/admin.vue

@ -25,6 +25,7 @@ const adminNav: NavItem[] = [
children: [
{ label: '全局配置', to: '/admin/global-config' },
{ label: '用户管理', to: '/admin/users' },
{ label: '模型配置', to: '/admin/llm-config' },
]
},
]

1446
app/pages/admin/llm-config/index.vue

File diff suppressed because it is too large

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

58
packages/drizzle-pkg/lib/schema/llm.ts

@ -0,0 +1,58 @@
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const LlmProviderStatuses = ["active", "disabled"] as const;
export type LlmProviderStatus = (typeof LlmProviderStatuses)[number];
export const LlmParseModes = ["openai", "anthropic"] as const;
export type LlmParseMode = (typeof LlmParseModes)[number];
export const LlmModelTypes = ["text", "vision", "multimodal"] as const;
export type LlmModelType = (typeof LlmModelTypes)[number];
export const llmProviders = sqliteTable(
"llm_providers",
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name", { length: 100 }).notNull().unique(),
slug: text("slug", { length: 100 }).notNull().unique(),
baseUrl: text("base_url", { length: 500 }),
parseMode: text("parse_mode", { enum: LlmParseModes }).notNull().default("openai"),
apiKey: text("api_key"),
status: text("status", { enum: LlmProviderStatuses }).notNull().default("active"),
description: text("description"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
index("idx_llm_provider_slug").on(table.slug),
index("idx_llm_provider_status").on(table.status),
],
);
export const llmModels = sqliteTable(
"llm_models",
{
id: integer("id").primaryKey({ autoIncrement: true }),
providerId: integer("provider_id")
.notNull()
.references(() => llmProviders.id, { onDelete: "cascade" }),
name: text("name", { length: 200 }).notNull(),
modelId: text("model_id", { length: 200 }).notNull(),
type: text("type", { enum: LlmModelTypes }).notNull().default("text"),
enabled: integer("enabled").default(1).notNull(),
description: text("description"),
maxTokens: integer("max_tokens"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
index("idx_llm_model_provider").on(table.providerId),
index("idx_llm_model_type").on(table.type),
],
);

33
packages/drizzle-pkg/migrations/0012_funny_santa_claus.sql

@ -0,0 +1,33 @@
CREATE TABLE `llm_models` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`provider_id` integer NOT NULL,
`name` text(200) NOT NULL,
`model_id` text(200) NOT NULL,
`type` text DEFAULT 'text' NOT NULL,
`enabled` integer DEFAULT 1 NOT NULL,
`description` text,
`max_tokens` integer,
`created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
FOREIGN KEY (`provider_id`) REFERENCES `llm_providers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_llm_model_provider` ON `llm_models` (`provider_id`);--> statement-breakpoint
CREATE INDEX `idx_llm_model_type` ON `llm_models` (`type`);--> statement-breakpoint
CREATE TABLE `llm_providers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text(100) NOT NULL,
`slug` text(100) NOT NULL,
`base_url` text(500),
`parse_mode` text DEFAULT 'openai' NOT NULL,
`api_key` text,
`status` text DEFAULT 'active' NOT NULL,
`description` text,
`created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `llm_providers_name_unique` ON `llm_providers` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `llm_providers_slug_unique` ON `llm_providers` (`slug`);--> statement-breakpoint
CREATE INDEX `idx_llm_provider_slug` ON `llm_providers` (`slug`);--> statement-breakpoint
CREATE INDEX `idx_llm_provider_status` ON `llm_providers` (`status`);

1659
packages/drizzle-pkg/migrations/meta/0012_snapshot.json

File diff suppressed because it is too large

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -85,6 +85,13 @@
"when": 1782825462705,
"tag": "0011_add_projects_table",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1782963564430,
"tag": "0012_funny_santa_claus",
"breakpoints": true
}
]
}

14
server/api/admin/llm/models/[providerId].get.ts

@ -0,0 +1,14 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { listModelsByProvider } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const providerId = Number(getRouterParam(event, "providerId"));
if (!providerId) {
return R.throwError(400, "无效的供应商ID", null);
}
const result = await listModelsByProvider(providerId);
return R.success(result);
});

29
server/api/admin/llm/models/[providerId].post.ts

@ -0,0 +1,29 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { createModel } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const providerId = Number(getRouterParam(event, "providerId"));
if (!providerId) {
return R.throwError(400, "无效的供应商ID", null);
}
const body = await readBody(event);
const { name, modelId, type, enabled, description, maxTokens } = body;
if (!name || !modelId) {
return R.throwError(422, "模型名称和模型ID不能为空", null);
}
const result = await createModel({
providerId,
name,
modelId,
type: type || "text",
enabled: enabled ?? 1,
description,
maxTokens,
});
return R.success(result);
});

14
server/api/admin/llm/models/detail/[id].delete.ts

@ -0,0 +1,14 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { deleteModel } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(getRouterParam(event, "id"));
if (!id) {
return R.throwError(400, "无效的模型ID", null);
}
await deleteModel(id);
return R.success(null);
});

24
server/api/admin/llm/models/detail/[id].put.ts

@ -0,0 +1,24 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { updateModel } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(getRouterParam(event, "id"));
if (!id) {
return R.throwError(400, "无效的模型ID", null);
}
const body = await readBody(event);
const { name, modelId, type, enabled, description, maxTokens } = body;
await updateModel(id, {
name,
modelId,
type,
enabled,
description,
maxTokens,
});
return R.success(null);
});

14
server/api/admin/llm/providers/[id].delete.ts

@ -0,0 +1,14 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { deleteProvider } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(getRouterParam(event, "id"));
if (!id) {
return R.throwError(400, "无效的供应商ID", null);
}
await deleteProvider(id);
return R.success(null);
});

17
server/api/admin/llm/providers/[id].get.ts

@ -0,0 +1,17 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getProviderWithModels } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(getRouterParam(event, "id"));
if (!id) {
return R.throwError(400, "无效的供应商ID", null);
}
const result = await getProviderWithModels(id);
if (!result) {
return R.throwError(404, "供应商不存在", null);
}
return R.success(result);
});

50
server/api/admin/llm/providers/[id].models.get.ts

@ -0,0 +1,50 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getProviderById } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(getRouterParam(event, "id"));
if (!id) {
return R.throwError(400, "无效的供应商 ID", null);
}
const provider = await getProviderById(id);
if (!provider) {
return R.throwError(404, "供应商不存在", null);
}
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || "";
if (!baseUrl) {
return R.success([]);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (provider.parseMode === "anthropic") {
if (provider.apiKey) {
headers["x-api-key"] = provider.apiKey;
}
headers["anthropic-version"] = "2023-06-01";
} else {
if (provider.apiKey) {
headers["Authorization"] = `Bearer ${provider.apiKey}`;
}
}
try {
const res = await $fetch<{ data?: { id: string; name?: string }[] }>(
`${baseUrl}/models`,
{ headers, timeout: 8000 },
);
const models = (res?.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
}));
return R.success(models);
} catch {
return R.success([]);
}
});

25
server/api/admin/llm/providers/[id].put.ts

@ -0,0 +1,25 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { updateProvider } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(getRouterParam(event, "id"));
if (!id) {
return R.throwError(400, "无效的供应商ID", null);
}
const body = await readBody(event);
const { name, slug, baseUrl, parseMode, apiKey, status, description } = body;
await updateProvider(id, {
name,
slug,
baseUrl,
parseMode,
apiKey,
status,
description,
});
return R.success(null);
});

15
server/api/admin/llm/providers/index.get.ts

@ -0,0 +1,15 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { listProviders } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const query = getQuery(event);
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const search = query.search as string | undefined;
const status = query.status as string | undefined;
const result = await listProviders(page, pageSize, search, status as any);
return R.success(result);
});

24
server/api/admin/llm/providers/index.post.ts

@ -0,0 +1,24 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { createProvider } from "#server/service/llm";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody(event);
const { name, slug, baseUrl, parseMode, apiKey, status, description } = body;
if (!name || !slug) {
return R.throwError(422, "供应商名称和标识不能为空", null);
}
const result = await createProvider({
name,
slug,
baseUrl,
parseMode: parseMode || "openai",
apiKey,
status: status || "active",
description,
});
return R.success(result);
});

228
server/service/llm/index.ts

@ -0,0 +1,228 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { llmProviders, llmModels } from "drizzle-pkg/lib/schema/llm";
import { eq, desc, like, and, sql } from "drizzle-orm";
import type { LlmParseMode, LlmProviderStatus, LlmModelType } from "drizzle-pkg/lib/schema/llm";
export interface LlmProviderRow {
id: number;
name: string;
slug: string;
baseUrl: string | null;
parseMode: LlmParseMode;
apiKey: string | null;
status: LlmProviderStatus;
description: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface LlmModelRow {
id: number;
providerId: number;
name: string;
modelId: string;
type: LlmModelType;
enabled: number;
description: string | null;
maxTokens: number | null;
createdAt: Date;
updatedAt: Date;
}
export interface LlmProviderWithModels extends Omit<LlmProviderRow, "apiKey"> {
apiKeySet: boolean;
models: LlmModelRow[];
}
export async function listProviders(
page = 1,
pageSize = 20,
search?: string,
status?: LlmProviderStatus,
) {
const conditions = [];
if (search) {
conditions.push(like(llmProviders.name, `%${search}%`));
}
if (status) {
conditions.push(eq(llmProviders.status, status));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [countResult] = await dbGlobal
.select({ count: sql<number>`count(*)` })
.from(llmProviders)
.where(where);
const total = countResult?.count ?? 0;
const totalPages = Math.ceil(total / pageSize);
const rows = await dbGlobal
.select()
.from(llmProviders)
.where(where)
.orderBy(desc(llmProviders.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
const sanitized = rows.map(({ apiKey, ...rest }) => ({
...rest,
apiKeySet: !!apiKey,
}));
return { list: sanitized, total, page, pageSize, totalPages };
}
export async function getProviderById(id: number) {
const [row] = await dbGlobal
.select()
.from(llmProviders)
.where(eq(llmProviders.id, id))
.limit(1);
return row ?? null;
}
export async function getProviderWithModels(id: number): Promise<LlmProviderWithModels | null> {
const provider = await getProviderById(id);
if (!provider) return null;
const models = await dbGlobal
.select()
.from(llmModels)
.where(eq(llmModels.providerId, id))
.orderBy(desc(llmModels.createdAt));
const { apiKey, ...rest } = provider;
return {
...rest,
apiKeySet: !!apiKey,
models,
};
}
export async function createProvider(data: {
name: string;
slug: string;
baseUrl?: string;
parseMode: LlmParseMode;
apiKey?: string;
status?: LlmProviderStatus;
description?: string;
}) {
const [inserted] = await dbGlobal
.insert(llmProviders)
.values({
name: data.name,
slug: data.slug,
baseUrl: data.baseUrl || null,
parseMode: data.parseMode,
apiKey: data.apiKey || null,
status: data.status || "active",
description: data.description || null,
})
.returning({ id: llmProviders.id });
return inserted!;
}
export async function updateProvider(
id: number,
data: {
name?: string;
slug?: string;
baseUrl?: string;
parseMode?: LlmParseMode;
apiKey?: string;
status?: LlmProviderStatus;
description?: string;
},
) {
const updates: Record<string, any> = {};
if (data.name !== undefined) updates.name = data.name;
if (data.slug !== undefined) updates.slug = data.slug;
if (data.baseUrl !== undefined) updates.baseUrl = data.baseUrl;
if (data.parseMode !== undefined) updates.parseMode = data.parseMode;
if (data.status !== undefined) updates.status = data.status;
if (data.description !== undefined) updates.description = data.description;
if (data.apiKey !== undefined) {
if (data.apiKey === "") {
updates.apiKey = null;
} else if (data.apiKey !== "__unchanged__") {
updates.apiKey = data.apiKey;
}
}
if (Object.keys(updates).length === 0) return;
await dbGlobal
.update(llmProviders)
.set(updates)
.where(eq(llmProviders.id, id));
}
export async function deleteProvider(id: number) {
await dbGlobal.delete(llmProviders).where(eq(llmProviders.id, id));
}
export async function listModelsByProvider(providerId: number) {
return dbGlobal
.select()
.from(llmModels)
.where(eq(llmModels.providerId, providerId))
.orderBy(desc(llmModels.createdAt));
}
export async function createModel(data: {
providerId: number;
name: string;
modelId: string;
type: LlmModelType;
enabled?: number;
description?: string;
maxTokens?: number;
}) {
const [inserted] = await dbGlobal
.insert(llmModels)
.values({
providerId: data.providerId,
name: data.name,
modelId: data.modelId,
type: data.type,
enabled: data.enabled ?? 1,
description: data.description || null,
maxTokens: data.maxTokens || null,
})
.returning({ id: llmModels.id });
return inserted!;
}
export async function updateModel(
id: number,
data: {
name?: string;
modelId?: string;
type?: LlmModelType;
enabled?: number;
description?: string;
maxTokens?: number;
},
) {
const updates: Record<string, any> = {};
if (data.name !== undefined) updates.name = data.name;
if (data.modelId !== undefined) updates.modelId = data.modelId;
if (data.type !== undefined) updates.type = data.type;
if (data.enabled !== undefined) updates.enabled = data.enabled;
if (data.description !== undefined) updates.description = data.description;
if (data.maxTokens !== undefined) updates.maxTokens = data.maxTokens;
if (Object.keys(updates).length === 0) return;
await dbGlobal
.update(llmModels)
.set(updates)
.where(eq(llmModels.id, id));
}
export async function deleteModel(id: number) {
await dbGlobal.delete(llmModels).where(eq(llmModels.id, id));
}
Loading…
Cancel
Save