diff --git a/packages/workshop-backend/__tests__/admin-config.test.ts b/packages/workshop-backend/__tests__/admin-config.test.ts index bd74bfc2..dec9766f 100644 --- a/packages/workshop-backend/__tests__/admin-config.test.ts +++ b/packages/workshop-backend/__tests__/admin-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_ADMIN_CONFIG, defaultOutputFormatId, parseAdminConfig, reorderFormats, resolveFormatOutput, sanitizeOutputOverrides, serializeAdminConfig } from "../src/admin-config.js"; +import { DEFAULT_ADMIN_CONFIG, defaultOutputFormatId, isAiModelDisabled, parseAdminConfig, reorderFormats, resolveFormatOutput, sanitizeOutputOverrides, serializeAdminConfig } from "../src/admin-config.js"; describe("parseAdminConfig", () => { it("backfills fields missing from a config persisted before they existed", () => { @@ -32,6 +32,23 @@ describe("parseAdminConfig", () => { ]); }); + it("keeps disabled AI model ids case-sensitive and drops non-strings", () => { + // Model ids like "@cf/moonshotai/kimi-k2.7-code" are case-sensitive, unlike vendor ids, so + // parsing must not lowercase them or the disable would silently stop matching. + let config = parseAdminConfig(JSON.stringify({ + disabledAiModels: ["@cf/moonshotai/Kimi-K2.7-code", 42, null, "gpt-5.6-sol"], + })); + + expect(config.disabledAiModels).toEqual(["@cf/moonshotai/Kimi-K2.7-code", "gpt-5.6-sol"]); + expect(isAiModelDisabled(config, "gpt-5.6-sol")).toBe(true); + expect(isAiModelDisabled(config, "@cf/moonshotai/kimi-k2.7-code")).toBe(false); + }); + + it("survives a serialize/parse round trip with disabled AI models", () => { + let config = parseAdminConfig(JSON.stringify({ disabledAiModels: ["gpt-5.6-sol"] })); + expect(parseAdminConfig(serializeAdminConfig(config))).toEqual(config); + }); + // Everything downstream keys formats by blueprint id; setFormatOrder() in particular treats the // list as a set and refuses every reordering if it isn't one. A duplicate would make the menu // permanently unorderable, so it can't be allowed to survive a read. diff --git a/packages/workshop-backend/__tests__/ai-gateway.test.ts b/packages/workshop-backend/__tests__/ai-gateway.test.ts index 54a5996e..29852f94 100644 --- a/packages/workshop-backend/__tests__/ai-gateway.test.ts +++ b/packages/workshop-backend/__tests__/ai-gateway.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AiGatewayLogRetryableError, + getAiGatewayConfig, getAiGatewayLogCost, } from "../src/ai-gateway.js"; @@ -13,6 +14,24 @@ function env(overrides: Partial = {}): Cloudflare.Env { } as Cloudflare.Env; } +describe("getModelCatalog", () => { + it("lists every suggested model of the enabled providers, with its provider", () => { + let gwConfig = getAiGatewayConfig(env({ + CF_AI_GATEWAY_ACCOUNT_ID: "account-id", + CF_AI_GATEWAY_API_TOKEN: "token", + CF_AI_GATEWAY_PROVIDERS: "cloudflare,openai", + }))!; + + let catalog = gwConfig.getModelCatalog(); + expect(catalog.length).toBeGreaterThan(0); + // Every entry belongs to an enabled provider; disabled providers contribute nothing. + expect(new Set(catalog.map(m => m.provider))).toEqual(new Set(["cloudflare", "openai"])); + // Matches what users are offered, entry for entry (the catalog only adds the provider). + expect(catalog.map(m => ({ type: "agent", id: m.id, name: m.name }))) + .toEqual(gwConfig.getModelList()); + }); +}); + describe("getAiGatewayLogCost", () => { afterEach(() => vi.unstubAllGlobals()); diff --git a/packages/workshop-backend/src/admin-config.ts b/packages/workshop-backend/src/admin-config.ts index 43e95633..2a390bc8 100644 --- a/packages/workshop-backend/src/admin-config.ts +++ b/packages/workshop-backend/src/admin-config.ts @@ -34,6 +34,10 @@ export type AdminConfig = { disabledResources: Record; // Fully-disabled gatekeeper vendor ids. disabledGatekeepers: string[]; + // Disabled AI Gateway built-in model ids (see SUGGESTED_MODELS). A disabled model is hidden from + // users and refused at resolution; user-added custom models are unaffected. Only meaningful in + // AI Gateway mode. Ids are case-sensitive (e.g. "@cf/moonshotai/kimi-k2.7-code"). + disabledAiModels: string[]; // Per-vendor provisioning mode for auto-provisioning ("ambient") gatekeepers (e.g. the Context // Library). Absent ⇒ the default ("optional", see provisioning-policy.ts). Only meaningful for // vendors that declare autoProvisionsAccount. @@ -75,6 +79,7 @@ export const DEFAULT_ADMIN_CONFIG: AdminConfig = { accentColor: "", disabledResources: {}, disabledGatekeepers: [], + disabledAiModels: [], ambientGatekeeperModes: {}, formats: [], }; @@ -277,6 +282,8 @@ export function parseAdminConfig(raw: string | null): AdminConfig { accentColor: typeof p.accentColor === "string" ? p.accentColor : "", disabledResources, disabledGatekeepers: strings(p.disabledGatekeepers).map(v => v.toLowerCase()), + // Model ids are case-sensitive, unlike vendor ids. + disabledAiModels: strings(p.disabledAiModels), ambientGatekeeperModes, formats: parseFormats(p.formats), }; @@ -308,6 +315,12 @@ export function filterEnabledResources( return resources.filter(r => !disabled.includes(r.urlPattern)); } +// --- AI-model-disable helpers --- + +export function isAiModelDisabled(config: AdminConfig, modelId: string): boolean { + return config.disabledAiModels.includes(modelId); +} + // --- Agent system-prompt instructions --- // Wrap the admin instructions in a clearly-delimited block for the system prompt, or "" when unset. diff --git a/packages/workshop-backend/src/admin-settings.ts b/packages/workshop-backend/src/admin-settings.ts index abc9d651..c75304ca 100644 --- a/packages/workshop-backend/src/admin-settings.ts +++ b/packages/workshop-backend/src/admin-settings.ts @@ -1,4 +1,4 @@ -import { AdminApi, AdminFormat, AdminFormatPatch, AdminResourceVendor, AdminSettingsView, AmbientGatekeeperMode, BannerColor, BlueprintPublicInfo, MAX_ANNOUNCEMENT_LENGTH, MAX_INSTANCE_INSTRUCTIONS_LENGTH, MAX_SITE_NAME_LENGTH, isAmbientGatekeeperMode, isBannerColor, isHexColor } from '@gadgets/workshop-shared/api'; +import { AdminAiModel, AdminApi, AdminFormat, AdminFormatPatch, AdminResourceVendor, AdminSettingsView, AmbientGatekeeperMode, BannerColor, BlueprintPublicInfo, MAX_ANNOUNCEMENT_LENGTH, MAX_INSTANCE_INSTRUCTIONS_LENGTH, MAX_SITE_NAME_LENGTH, SUGGESTED_MODELS, isAmbientGatekeeperMode, isBannerColor, isHexColor } from '@gadgets/workshop-shared/api'; import { GatekeeperVendor } from '@gadgets/workshop-shared/gatekeeper'; import { DurableObject } from 'cloudflare:workers'; import { RpcTarget } from 'capnweb'; @@ -9,6 +9,7 @@ import { ADMIN_CONFIG_KEY, FEATURED_BLUEPRINTS_KEY, isReservedBlueprintKey, pars import { AdminConfig, DEFAULT_ADMIN_CONFIG, FormatCuration, MAX_AGENT_HINT, defaultOutputFormatId, listPromotedFormats, reorderFormats, sanitizeOutputOverrides, serializeAdminConfig } from './admin-config.js'; import { SITE_LOGO_R2_KEY, siteLogoImage, validateSiteLogo } from './site-logo.js'; import { ambientGatekeeperMode, DEFAULT_AMBIENT_GATEKEEPER_MODE } from './provisioning-policy.js'; +import { getAiGatewayConfig } from './ai-gateway.js'; import { buildGatekeeperVendorMap } from './auth/auth-vendors.js'; import { UserDurableObject } from './user.js'; import { formatBlueprintsManifestVersion, installFormatBlueprints } from './format-blueprints.js'; @@ -311,9 +312,23 @@ export class AdminSettings extends DurableObject { accentColor: config.accentColor, resourceVendors: await this.#listResourceConfig(config, adminUserId), formats: await this.#listFormatConfig(config), + aiModels: this.#listAiModelConfig(config), }; } + // Admin view of the AI Gateway built-in models with their enabled state. Unlike the user-facing + // model list, this does NOT hide disabled models (so admins can re-enable them). Empty outside + // AI Gateway mode, where there are no deployment-managed models to curate. + #listAiModelConfig(config: AdminConfig): AdminAiModel[] { + let gwConfig = getAiGatewayConfig(this.env); + if (!gwConfig) return []; + let disabled = new Set(config.disabledAiModels); + return gwConfig.getModelCatalog().map(model => ({ + ...model, + enabled: !disabled.has(model.id), + })); + } + // --- Standard output formats --- // Admin view of the promoted formats: the deployment's curation joined with each blueprint, so @@ -425,6 +440,15 @@ export class AdminSettings extends DurableObject { }); } + // Enable/disable a single AI Gateway built-in model atomically (read-modify-write within the DO). + async setAiModelEnabled(modelId: string, enabled: boolean): Promise { + await this.#mutateAdminConfig(config => { + let disabled = new Set(config.disabledAiModels); + if (enabled) disabled.delete(modelId); else disabled.add(modelId); + return { ...config, disabledAiModels: [...disabled] }; + }); + } + async setSiteLogo(data: Uint8Array | null): Promise { let previous = this.siteLogoMutationTail; let release!: () => void; @@ -596,6 +620,15 @@ export class AdminApiImpl extends RpcTarget implements AdminApi { return this.admin.setGatekeeperMode(vendorId, mode); } + setAiModelEnabled(modelId: string, enabled: boolean): Promise { + // Validated against the full suggested catalog rather than the currently-enabled providers, so + // an admin's disable survives provider-list changes and can be set before enabling a provider. + if (!Object.values(SUGGESTED_MODELS).some(models => modelId in models)) { + throw new Error(`Unknown built-in model: ${modelId}`); + } + return this.admin.setAiModelEnabled(modelId, enabled); + } + async setAnnouncement(text: string): Promise { if (text.length > MAX_ANNOUNCEMENT_LENGTH) { throw new Error(`Announcement too long (max ${MAX_ANNOUNCEMENT_LENGTH} characters).`); diff --git a/packages/workshop-backend/src/ai-gateway.ts b/packages/workshop-backend/src/ai-gateway.ts index b0656408..d29d0ed4 100644 --- a/packages/workshop-backend/src/ai-gateway.ts +++ b/packages/workshop-backend/src/ai-gateway.ts @@ -38,6 +38,23 @@ export class AiGatewayConfig { ); } + /** + * Every built-in model this gateway configuration offers, with its provider — the admin + * panel's view. Unlike the user-facing model list, this is never filtered by admin curation: + * the panel exists to show disabled models so they can be re-enabled. + */ + getModelCatalog(): { id: string, name: string, provider: string }[] { + let result: { id: string, name: string, provider: string }[] = []; + for (let [provider, models] of Object.entries(SUGGESTED_MODELS)) { + if (this.providers.has(provider)) { + for (let [id, model] of Object.entries(models)) { + result.push({ id, name: model.name, provider }); + } + } + } + return result; + } + /** * Get the list of models available through AI Gateway, as AiChatAuthorInfo entries. */ diff --git a/packages/workshop-backend/src/user.ts b/packages/workshop-backend/src/user.ts index 727d0898..b85066b5 100644 --- a/packages/workshop-backend/src/user.ts +++ b/packages/workshop-backend/src/user.ts @@ -10,7 +10,7 @@ import { getAiGatewayConfig } from "./ai-gateway.js"; import { utcDayKey, nextUtcMidnightIso, DailyQuotaResult } from "./ai-gateway-billing/limits/config.js"; import type { AdminSettings } from "./admin-settings.js"; import { isReservedBlueprintKey, readBlueprintKvRecord } from "./blueprint-archive.js"; -import { filterEnabledResources, isResourceDisabled, readAdminConfig } from "./admin-config.js"; +import { filterEnabledResources, isAiModelDisabled, isResourceDisabled, readAdminConfig } from "./admin-config.js"; import { buildGatekeeperVendorMap } from "./auth/auth-vendors.js"; const logger = createWorkshopLogger("workshop.user"); @@ -505,11 +505,14 @@ export class UserDurableObject extends DurableObject { async listModels(): Promise { let result: AiChatAuthorInfo[] = []; - // When AI Gateway mode is active, include all suggested models for enabled providers. + // When AI Gateway mode is active, include all suggested models for enabled providers, except + // those the deployment admin has disabled. let gwConfig = getAiGatewayConfig(this.env); let gwModelIds = new Set(); if (gwConfig) { + let adminConfig = await readAdminConfig(this.env); for (let entry of gwConfig.getModelList()) { + if (isAiModelDisabled(adminConfig, entry.id)) continue; result.push(entry); gwModelIds.add(entry.id); } @@ -529,6 +532,11 @@ export class UserDurableObject extends DurableObject { if (gwConfig && !gwConfig.providers.has(config.provider)) { throw new Error(`Provider "${config.provider}" is not available in AI Gateway mode.`); } + // An admin-disabled built-in can't be re-added as a custom model: in AI Gateway mode a custom + // model routes through the platform gateway anyway, so allowing it would undo the disable. + if (isAiModelDisabled(await readAdminConfig(this.env), config.model)) { + throw new Error(`Model "${config.model}" has been disabled by the administrator.`); + } profile.type = "agent"; this.storage.aiModels.put({profile, config}); @@ -567,10 +575,11 @@ export class UserDurableObject extends DurableObject { async setPreferredModel(id: string | null): Promise { if (id !== null) { - // Validate that the model exists in the user's configured models or as a gateway model. + // Validate that the model exists in the user's configured models or as a gateway model, and + // that the deployment admin has not disabled it. let gwConfig = getAiGatewayConfig(this.env); let exists = !!this.storage.aiModels.get(id) || !!gwConfig?.resolveModel(id); - if (!exists) { + if (!exists || isAiModelDisabled(await readAdminConfig(this.env), id)) { throw new Error(`No such model: ${id}`); } } @@ -670,6 +679,11 @@ export class UserDurableObject extends DurableObject { profile: this.storage.profile.get() }; if (modelId) { + // Refuse a model the deployment admin has disabled, whether it resolves as a gateway + // built-in or as a user-added custom model: a chat pinned to it must pick another model. + if (isAiModelDisabled(await readAdminConfig(this.env), modelId)) { + throw new Error(`Model "${modelId}" has been disabled by the administrator.`); + } // In AI Gateway mode, resolve gateway models first. if (gwConfig) { result.aiModel = gwConfig.resolveModel(modelId); diff --git a/packages/workshop-frontend/src/AdminPage.tsx b/packages/workshop-frontend/src/AdminPage.tsx index 9587f24b..a0203061 100644 --- a/packages/workshop-frontend/src/AdminPage.tsx +++ b/packages/workshop-frontend/src/AdminPage.tsx @@ -3,7 +3,7 @@ import { RpcStub } from 'capnweb' import { Switch, Textarea, Input, Button, Tabs, useKumoToastManager } from '@cloudflare/kumo' import { Hexagon, ShieldWarning, UserPlus } from '@phosphor-icons/react' import { useAuthenticatedApi } from './AuthContext' -import { AdminApi, AdminFormat, AdminResourceVendor, AmbientGatekeeperMode, MAX_INSTANCE_INSTRUCTIONS_LENGTH, MAX_ANNOUNCEMENT_LENGTH, MAX_SITE_NAME_LENGTH, DEFAULT_SITE_NAME, BannerColor, BANNER_COLORS, DEFAULT_BANNER_COLOR } from '@gadgets/workshop-shared/api' +import { AdminAiModel, AdminApi, AdminFormat, AdminResourceVendor, AmbientGatekeeperMode, MAX_INSTANCE_INSTRUCTIONS_LENGTH, MAX_ANNOUNCEMENT_LENGTH, MAX_SITE_NAME_LENGTH, DEFAULT_SITE_NAME, BannerColor, BANNER_COLORS, DEFAULT_BANNER_COLOR } from '@gadgets/workshop-shared/api' import { applyAccentColor, DEFAULT_ACCENT_COLOR } from './theme' import { cacheBustSiteLogoUrl, prepareSiteLogo } from './siteLogoUtils' import SiteLogo from './components/SiteLogo' @@ -80,6 +80,9 @@ export default function AdminPage() { const [resourceVendors, setResourceVendors] = useState([]) const [resourceBusy, setResourceBusy] = useState>(new Set()) + // AI Gateway built-in models with their enabled state (empty outside AI Gateway mode). + const [aiModels, setAiModels] = useState([]) + const [activeTab, setActiveTab] = useState('general') // Promoted output formats, in menu order (see AdminFormatsPanel). @@ -104,6 +107,7 @@ export default function AdminPage() { setSavedAccent(view.accentColor) setAccentDraft(view.accentColor) setFormats(view.formats) + setAiModels(view.aiModels) } // Mint the admin capability once (the access check happens server-side) and load settings. @@ -150,12 +154,13 @@ export default function AdminPage() { return () => { applyAccentColor(savedAccent) } }, [accentDraft, savedAccent]) - // Re-fetch just the gatekeeper/resource state (used to revert an optimistic toggle on error). - // Leaves the General-tab drafts untouched. + // Re-fetch just the gatekeeper/resource and AI-model state (used to revert an optimistic toggle + // on error). Leaves the General-tab drafts untouched. const reloadResources = async () => { if (!admin) return const view = await admin.api.getSettings() setResourceVendors(view.resourceVendors) + setAiModels(view.aiModels) } const handleResourceToggle = async (vendorId: string, urlPattern: string, enabled: boolean) => { @@ -229,6 +234,27 @@ export default function AdminPage() { } } + const handleAiModelToggle = async (modelId: string, enabled: boolean) => { + if (!admin) return + const key = `model${modelId}` + setResourceBusy((prev) => new Set(prev).add(key)) + // Optimistic update. + setAiModels((prev) => prev.map((m) => (m.id === modelId ? { ...m, enabled } : m))) + try { + await admin.api.setAiModelEnabled(modelId, enabled) + } catch (err) { + const message = err instanceof Error ? err.message : 'Update failed' + toasts.add({ title: message, variant: 'error' }) + await reloadResources().catch(() => {}) + } finally { + setResourceBusy((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }) + } + } + const handleSaveAnnouncement = async () => { if (!admin) return setSavingAnnouncement(true) @@ -402,6 +428,7 @@ export default function AdminPage() { tabs={[ { value: 'general', label: 'General' }, { value: 'gatekeepers', label: 'Gatekeepers' }, + { value: 'models', label: 'AI models' }, { value: 'formats', label: 'Formats' }, { value: 'access', label: 'Access' }, ]} @@ -951,6 +978,63 @@ export default function AdminPage() { )} + + {/* Built-in AI model curation */} + {activeTab === 'models' && ( +
+

AI models

+

+ Turn built-in models on or off for your users. A disabled model disappears from model + pickers and can no longer be used, so you can offer a provider without offering every + one of its models. Custom models users add with their own API tokens are not affected. +

+ + {aiModels.length === 0 && ( +

+ This deployment has no built-in models to curate. Built-in models exist only in AI + Gateway mode; users add their own models from the AI providers page. +

+ )} + +
+ {aiModels.map((model) => { + const key = `model ${model.id}` + return ( +
!resourceBusy.has(key) && handleAiModelToggle(model.id, !model.enabled)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (!resourceBusy.has(key)) handleAiModelToggle(model.id, !model.enabled) + } + }} + className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-kumo-tint" + > +
+ + {model.name} + + {model.id} +
+ + {model.provider} + + e.stopPropagation()}> + handleAiModelToggle(model.id, enabled)} + /> + +
+ ) + })} +
+
+ )} ) } diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index 7cee2e78..b1163ed6 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -651,6 +651,19 @@ export type AdminResourceVendor = { | { autoProvisions: true; ambientMode: AmbientGatekeeperMode } ); +// One AI Gateway built-in model, as the admin AI-models panel sees it. Only present in AI Gateway +// mode; custom models users add with their own tokens are not curated here. +export type AdminAiModel = { + // The model id (e.g. "@cf/moonshotai/kimi-k2.7-code"). + id: string; + name: string; + // The gateway provider offering it (e.g. "cloudflare", "openai"). + provider: string; + // Offered to users. Disabling hides the model from model lists and refuses new use of it, so an + // org can offer a provider without offering every one of its models. + enabled: boolean; +}; + // A connectable third-party service: its vendor id, display metadata, and the resource types it // offers (empty for an auto-provisioning gatekeeper like the Context Library). Returned by both // listGatekeeperVendors and listAddableGatekeepers so the connect UI treats both uniformly. @@ -705,6 +718,9 @@ export type AdminSettingsView = { resourceVendors: AdminResourceVendor[]; // The blueprints promoted as standard output formats, in menu order (including disabled ones). formats: AdminFormat[]; + // AI Gateway built-in models with their enabled state (not hidden when disabled). Empty outside + // AI Gateway mode, where there are no deployment-managed models to curate. + aiModels: AdminAiModel[]; }; // One promoted blueprint, as the admin Formats panel sees it: the deployment's curation plus @@ -778,6 +794,11 @@ export interface AdminApi { // it. setGatekeeperMode(vendorId: string, mode: AmbientGatekeeperMode): Promise; + // Enable or disable one AI Gateway built-in model for users. Disabling hides it from model lists + // and refuses new use of it (chats pinned to it must pick another model); user-added custom + // models are unaffected. Throws for an id that is not a known built-in model. + setAiModelEnabled(modelId: string, enabled: boolean): Promise; + // Set the top-bar notice (centered text in the top navigation bar). Pass "" to clear. Rejects over // MAX_ANNOUNCEMENT_LENGTH. setAnnouncement(text: string): Promise;