Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
448 changes: 448 additions & 0 deletions apps/api/src/routes/custom-models.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { auditLogs } from "./audit-logs.js";
import { chatPlans } from "./chat-plans.js";
import { chat } from "./chat.js";
import { chats } from "./chats.js";
import { customModels } from "./custom-models.js";
import { devPlanCancellationFeedback } from "./dev-plan-cancellation-feedback.js";
import { devPlans } from "./dev-plans.js";
import { guardrails } from "./guardrails.js";
Expand Down Expand Up @@ -73,4 +74,5 @@ routes.route("/audit-logs", auditLogs);
routes.route("/model-ratings", modelRatings);
routes.route("/guardrails", guardrails);
routes.route("/routing-config", routingConfig);
routes.route("/custom-models", customModels);
routes.route("/video", video);
89 changes: 74 additions & 15 deletions apps/api/src/routes/keys-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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({
Expand Down Expand Up @@ -61,6 +64,7 @@ const providerKeySchema = z.object({
})
.nullable(),
status: z.enum(["active", "inactive", "deleted"]).nullable(),
customModelsOnly: z.boolean(),
organizationId: z.string(),
});

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -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);
Expand All @@ -621,6 +639,9 @@ keysProvider.openapi(updateStatus, async (c) => {
in: organizationIds,
},
},
with: {
organization: true,
},
});

if (!providerKey) {
Expand All @@ -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") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow disabling catalog restriction after downgrade

When an organization downgrades while a custom provider already has customModelsOnly enabled, 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 👍 / 👎.

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,
Expand All @@ -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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate the Drizzle provider-key cache

When a gateway has already cached this custom provider key, PATCHing customModelsOnly writes through the uncached db client and this only clears the SWR fallback mirror; it does not clear the Drizzle Redis cache used by findCustomProviderKey in apps/gateway/src/lib/cached-queries.ts. Until that cache entry expires, turning the catalog restriction on can keep allowing uncataloged models, and turning it off can keep rejecting them, so use the cached client/onMutate path or explicitly invalidate the Drizzle table cache as well.

Useful? React with 👍 / 👎.

}

return c.json({
message: `Provider key status updated to ${status}`,
message: "Provider key updated",
providerKey: {
...updatedProviderKey,
maskedToken: maskToken(updatedProviderKey.token),
Expand Down
Loading
Loading