Repository navigation
feat(custom-models): per-key enterprise model catalog #2698
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
2dc6b32
2730af1
b965848
d1985bd
cb50636
664d001
c9be1d5
fd18709
560f8e4
05841de
e9721a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,8 @@ import { getActiveUserOrganizationIds } from "@/utils/authorization.js"; | |
|
|
||
| import { validateProviderKey } from "@llmgateway/actions"; | ||
| import { logAuditEvent } from "@llmgateway/audit"; | ||
| import { db, eq, tables } from "@llmgateway/db"; | ||
| import { invalidateSwrByTables } from "@llmgateway/cache"; | ||
| import { db, eq, getTableName, tables } from "@llmgateway/db"; | ||
| import { logger } from "@llmgateway/logger"; | ||
| import { providers } from "@llmgateway/models"; | ||
|
|
||
|
|
@@ -16,6 +17,8 @@ import type { ProviderId } from "@llmgateway/models"; | |
|
|
||
| export const keysProvider = new OpenAPIHono<ServerTypes>(); | ||
|
|
||
| const providerKeyTableName = getTableName(tables.providerKey); | ||
|
|
||
| // Create a schema for provider key responses | ||
| // Using z.object directly instead of createSelectSchema due to compatibility issues | ||
| const providerKeySchema = z.object({ | ||
|
|
@@ -61,6 +64,7 @@ const providerKeySchema = z.object({ | |
| }) | ||
| .nullable(), | ||
| status: z.enum(["active", "inactive", "deleted"]).nullable(), | ||
| customModelsOnly: z.boolean(), | ||
| organizationId: z.string(), | ||
| }); | ||
|
|
||
|
|
@@ -122,10 +126,16 @@ const createProviderKeySchema = z.object({ | |
| organizationId: z.string().min(1, "Organization ID is required"), | ||
| }); | ||
|
|
||
| // Schema for updating a provider key status | ||
| const updateProviderKeyStatusSchema = z.object({ | ||
| status: z.enum(["active", "inactive"]), | ||
| }); | ||
| // Schema for updating a provider key status / settings | ||
| const updateProviderKeyStatusSchema = z | ||
| .object({ | ||
| status: z.enum(["active", "inactive"]).optional(), | ||
| // Custom providers only: restrict requests to catalog-defined models. | ||
| customModelsOnly: z.boolean().optional(), | ||
| }) | ||
| .refine((v) => v.status !== undefined || v.customModelsOnly !== undefined, { | ||
| message: "No updatable fields provided", | ||
| }); | ||
|
|
||
| // Create a new provider key | ||
| const create = createRoute({ | ||
|
|
@@ -318,6 +328,10 @@ keysProvider.openapi(create, async (c) => { | |
| }, | ||
| }); | ||
|
|
||
| // The gateway caches provider keys via SWR; invalidate so a newly added key | ||
| // is usable immediately. | ||
| await invalidateSwrByTables([providerKeyTableName]); | ||
|
|
||
| return c.json({ | ||
| providerKey: { | ||
| ...providerKey, | ||
|
|
@@ -536,6 +550,10 @@ keysProvider.openapi(deleteKey, async (c) => { | |
| }, | ||
| }); | ||
|
|
||
| // The gateway caches provider keys via SWR; invalidate so a deleted key | ||
| // stops being used immediately. | ||
| await invalidateSwrByTables([providerKeyTableName]); | ||
|
|
||
| return c.json({ | ||
| message: "Provider key deleted successfully", | ||
| }); | ||
|
|
@@ -606,7 +624,7 @@ keysProvider.openapi(updateStatus, async (c) => { | |
| } | ||
|
|
||
| const { id } = c.req.param(); | ||
| const { status } = c.req.valid("json"); | ||
| const { status, customModelsOnly } = c.req.valid("json"); | ||
|
|
||
| // Get all active organization IDs the user has access to | ||
| const organizationIds = await getActiveUserOrganizationIds(user.id); | ||
|
|
@@ -621,6 +639,9 @@ keysProvider.openapi(updateStatus, async (c) => { | |
| in: organizationIds, | ||
| }, | ||
| }, | ||
| with: { | ||
| organization: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (!providerKey) { | ||
|
|
@@ -629,16 +650,53 @@ keysProvider.openapi(updateStatus, async (c) => { | |
| }); | ||
| } | ||
|
|
||
| // Update the provider key status | ||
| if (customModelsOnly !== undefined) { | ||
| if (providerKey.provider !== "custom") { | ||
| throw new HTTPException(400, { | ||
| message: "customModelsOnly can only be set on custom provider keys", | ||
| }); | ||
| } | ||
| // Restricting to a custom catalog is an enterprise feature. | ||
| if (providerKey.organization?.plan !== "enterprise") { | ||
| throw new HTTPException(403, { | ||
| message: "Custom models require an enterprise plan", | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| const updates: { | ||
| status?: "active" | "inactive"; | ||
| customModelsOnly?: boolean; | ||
| } = {}; | ||
| if (status !== undefined) { | ||
| updates.status = status; | ||
| } | ||
| if (customModelsOnly !== undefined) { | ||
| updates.customModelsOnly = customModelsOnly; | ||
| } | ||
|
|
||
| // Update the provider key | ||
| const [updatedProviderKey] = await db | ||
| .update(tables.providerKey) | ||
| .set({ | ||
| status, | ||
| }) | ||
| .set(updates) | ||
| .where(eq(tables.providerKey.id, id)) | ||
| .returning(); | ||
|
|
||
| if (providerKey.status !== status) { | ||
| const changes: Record<string, { old: unknown; new: unknown }> = {}; | ||
| if (status !== undefined && providerKey.status !== status) { | ||
| changes.status = { old: providerKey.status, new: status }; | ||
| } | ||
| if ( | ||
| customModelsOnly !== undefined && | ||
| providerKey.customModelsOnly !== customModelsOnly | ||
| ) { | ||
| changes.customModelsOnly = { | ||
| old: providerKey.customModelsOnly, | ||
| new: customModelsOnly, | ||
| }; | ||
| } | ||
|
|
||
| if (Object.keys(changes).length > 0) { | ||
| await logAuditEvent({ | ||
| organizationId: providerKey.organizationId, | ||
| userId: user.id, | ||
|
|
@@ -647,15 +705,16 @@ keysProvider.openapi(updateStatus, async (c) => { | |
| resourceId: id, | ||
| metadata: { | ||
| provider: providerKey.provider, | ||
| changes: { | ||
| status: { old: providerKey.status, new: status }, | ||
| }, | ||
| changes, | ||
| }, | ||
| }); | ||
| // The gateway caches provider keys (incl. customModelsOnly) via SWR; | ||
| // invalidate so status/restriction changes take effect promptly. | ||
| await invalidateSwrByTables([providerKeyTableName]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a gateway has already cached this custom provider key, PATCHing Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| return c.json({ | ||
| message: `Provider key status updated to ${status}`, | ||
| message: "Provider key updated", | ||
| providerKey: { | ||
| ...updatedProviderKey, | ||
| maskedToken: maskToken(updatedProviderKey.token), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an organization downgrades while a custom provider already has
customModelsOnlyenabled, this check also rejects{ customModelsOnly: false }, so the customer cannot turn off the restriction even though the gateway continues honoring the stored flag and will reject any model missing from the old catalog. Only require the enterprise plan when enabling the restriction, or otherwise allow disabling it for non-enterprise orgs.Useful? React with 👍 / 👎.