Browse Source
- 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
19 changed files with 3659 additions and 0 deletions
File diff suppressed because it is too large
Binary file not shown.
@ -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), |
||||
|
], |
||||
|
); |
||||
@ -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`); |
||||
File diff suppressed because it is too large
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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([]); |
||||
|
} |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
@ -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…
Reference in new issue