diff --git a/components/backend/cmd/sync_flags.go b/components/backend/cmd/sync_flags.go index 6d453d196..75f11d4f6 100644 --- a/components/backend/cmd/sync_flags.go +++ b/components/backend/cmd/sync_flags.go @@ -48,7 +48,8 @@ type FlagsConfig struct { } // FlagsFromManifest converts a model manifest into FlagSpecs. -// Skips default models (global and per-provider) and unavailable models. +// Only creates flags for models that are available and feature-gated. +// Skips default models (global and per-provider) as defense-in-depth. func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec { // Build set of all default model IDs (global + per-provider) defaults := map[string]bool{manifest.DefaultModel: true} @@ -58,6 +59,9 @@ func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec { var specs []FlagSpec for _, model := range manifest.Models { + if !model.FeatureGated { + continue + } if defaults[model.ID] { continue } @@ -65,7 +69,7 @@ func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec { continue } specs = append(specs, FlagSpec{ - Name: sanitizeLogString(fmt.Sprintf("model.%s.enabled", model.ID)), + Name: fmt.Sprintf("model.%s.enabled", model.ID), Description: sanitizeLogString(fmt.Sprintf("Enable %s (%s) for users", model.Label, model.ID)), Tags: []FlagTag{{Type: "scope", Value: "workspace"}}, }) @@ -73,6 +77,75 @@ func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec { return specs } +// StaleFlagsFromManifest returns Unleash flag names that should be archived +// because the corresponding model is no longer feature-gated. +func StaleFlagsFromManifest(manifest *types.ModelManifest) []string { + var stale []string + for _, model := range manifest.Models { + if model.FeatureGated { + continue + } + stale = append(stale, fmt.Sprintf("model.%s.enabled", model.ID)) + } + return stale +} + +// CleanupStaleFlags archives Unleash flags that are no longer needed. +// Flags that don't exist are silently skipped (already archived or never created). +// +// Required env vars: UNLEASH_ADMIN_URL, UNLEASH_ADMIN_TOKEN +// Optional env var: UNLEASH_PROJECT (default: "default") +func CleanupStaleFlags(ctx context.Context, flagNames []string) error { + if len(flagNames) == 0 { + return nil + } + + adminURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_URL")), "/") + adminToken := strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_TOKEN")) + project := strings.TrimSpace(os.Getenv("UNLEASH_PROJECT")) + if project == "" { + project = "default" + } + + if adminURL == "" || adminToken == "" { + log.Printf("cleanup-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping") + return nil + } + + client := &http.Client{Timeout: 10 * time.Second} + + var archived, skipped, errCount int + log.Printf("Cleaning up %d stale Unleash flag(s)...", len(flagNames)) + + for _, name := range flagNames { + exists, err := flagExists(ctx, client, adminURL, project, name, adminToken) + if err != nil { + log.Printf(" ERROR checking %s: %v", name, err) + errCount++ + continue + } + if !exists { + skipped++ + continue + } + + if err := archiveFlag(ctx, client, adminURL, project, name, adminToken); err != nil { + log.Printf(" ERROR archiving %s: %v", name, err) + errCount++ + continue + } + log.Printf(" %s: archived", name) + archived++ + } + + log.Printf("Cleanup summary: %d archived, %d not found, %d errors", archived, skipped, errCount) + + if errCount > 0 { + return fmt.Errorf("%d errors occurred during cleanup", errCount) + } + return nil +} + // FlagsConfigPath returns the filesystem path to the generic flags config. // Defaults to defaultFlagsConfig; override via FLAGS_CONFIG_PATH env var. func FlagsConfigPath() string { @@ -126,17 +199,31 @@ func SyncModelFlagsFromFile(manifestPath string) error { return fmt.Errorf("parsing manifest: %w", err) } - return SyncFlags(context.Background(), FlagsFromManifest(&manifest)) + ctx := context.Background() + if err := SyncFlags(ctx, FlagsFromManifest(&manifest)); err != nil { + return err + } + return CleanupStaleFlags(ctx, StaleFlagsFromManifest(&manifest)) } // SyncFlagsAsync runs SyncFlags in a background goroutine with retries. // Intended for use at server startup — does not block the caller. // Cancel the context to abort retries (e.g. on SIGTERM). func SyncFlagsAsync(ctx context.Context, flags []FlagSpec) { + SyncAndCleanupAsync(ctx, flags, nil) +} + +// SyncAndCleanupAsync runs SyncFlags and CleanupStaleFlags in a background +// goroutine with retries. After a successful sync, stale flags are archived. +// Cancel the context to abort retries (e.g. on SIGTERM). +func SyncAndCleanupAsync(ctx context.Context, flags []FlagSpec, staleFlags []string) { go func() { for attempt := 1; attempt <= maxRetries; attempt++ { err := SyncFlags(ctx, flags) if err == nil { + if cErr := CleanupStaleFlags(ctx, staleFlags); cErr != nil { + log.Printf("sync-flags: cleanup failed (non-fatal): %v", cErr) + } return } log.Printf("sync-flags: attempt %d/%d failed: %v", attempt, maxRetries, err) @@ -360,6 +447,25 @@ func createFlag(ctx context.Context, client *http.Client, adminURL, project, fla } } +func archiveFlag(ctx context.Context, client *http.Client, adminURL, project, flagName, token string) error { + reqURL := fmt.Sprintf("%s/api/admin/projects/%s/features/%s", adminURL, url.PathEscape(project), url.PathEscape(flagName)) + resp, err := doRequest(ctx, client, "DELETE", reqURL, token, nil) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case http.StatusOK, http.StatusAccepted, http.StatusNoContent: + return nil + case http.StatusNotFound: + return nil // already gone + default: + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } +} + func addFlagTag(ctx context.Context, client *http.Client, adminURL, flagName string, tag FlagTag, token string) error { reqURL := fmt.Sprintf("%s/api/admin/features/%s/tags", adminURL, url.PathEscape(flagName)) body, err := json.Marshal(map[string]string{ diff --git a/components/backend/cmd/sync_flags_test.go b/components/backend/cmd/sync_flags_test.go index 2d2ddb61c..4d66d5685 100644 --- a/components/backend/cmd/sync_flags_test.go +++ b/components/backend/cmd/sync_flags_test.go @@ -63,7 +63,7 @@ func TestParseManifestPath(t *testing.T) { // --- FlagsFromManifest --- -func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) { +func TestFlagsFromManifest_SkipsDefaultUnavailableAndNonGated(t *testing.T) { manifest := &types.ModelManifest{ DefaultModel: "claude-sonnet-4-5", ProviderDefaults: map[string]string{ @@ -71,19 +71,21 @@ func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) { "google": "gemini-2.5-flash", }, Models: []types.ModelEntry{ - {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Provider: "anthropic", Available: true}, - {ID: "claude-opus-4-6", Label: "Opus 4.6", Provider: "anthropic", Available: true}, - {ID: "claude-opus-4-1", Label: "Opus 4.1", Provider: "anthropic", Available: false}, - {ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google", Available: true}, - {ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Available: true}, + {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "claude-opus-4-6", Label: "Opus 4.6", Provider: "anthropic", Available: true, FeatureGated: true}, + {ID: "claude-opus-4-1", Label: "Opus 4.1", Provider: "anthropic", Available: false, FeatureGated: true}, + {ID: "claude-haiku-4-5", Label: "Haiku 4.5", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google", Available: true, FeatureGated: false}, + {ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Available: true, FeatureGated: true}, }, } flags := FlagsFromManifest(manifest) - // Should skip: claude-sonnet-4-5 (global default + anthropic default), - // gemini-2.5-flash (google default), - // claude-opus-4-1 (unavailable) + // Should skip: claude-sonnet-4-5 (default + not gated), + // claude-opus-4-1 (unavailable), + // claude-haiku-4-5 (not gated), + // gemini-2.5-flash (default + not gated) // Should include: claude-opus-4-6, gemini-2.5-pro if len(flags) != 2 { t.Fatalf("expected 2 flags, got %d: %v", len(flags), flags) @@ -102,6 +104,9 @@ func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) { if names["model.claude-sonnet-4-5.enabled"] { t.Error("global default should be skipped") } + if names["model.claude-haiku-4-5.enabled"] { + t.Error("non-gated model should be skipped") + } if names["model.gemini-2.5-flash.enabled"] { t.Error("provider default should be skipped") } @@ -115,6 +120,137 @@ func TestFlagsFromManifest_EmptyManifest(t *testing.T) { } } +// --- StaleFlagsFromManifest --- + +func TestStaleFlagsFromManifest_ReturnsNonGatedModels(t *testing.T) { + manifest := &types.ModelManifest{ + DefaultModel: "claude-sonnet-4-5", + Models: []types.ModelEntry{ + {ID: "claude-sonnet-4-5", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "claude-opus-4-6", Provider: "anthropic", Available: true, FeatureGated: true}, + {ID: "claude-haiku-4-5", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "gemini-2.5-pro", Provider: "google", Available: true, FeatureGated: true}, + }, + } + + stale := StaleFlagsFromManifest(manifest) + + // Non-gated models: claude-sonnet-4-5, claude-haiku-4-5 + if len(stale) != 2 { + t.Fatalf("expected 2 stale flags, got %d: %v", len(stale), stale) + } + names := map[string]bool{} + for _, s := range stale { + names[s] = true + } + if !names["model.claude-sonnet-4-5.enabled"] { + t.Error("expected model.claude-sonnet-4-5.enabled in stale list") + } + if !names["model.claude-haiku-4-5.enabled"] { + t.Error("expected model.claude-haiku-4-5.enabled in stale list") + } + if names["model.claude-opus-4-6.enabled"] { + t.Error("gated model should not be in stale list") + } +} + +func TestStaleFlagsFromManifest_EmptyWhenAllGated(t *testing.T) { + manifest := &types.ModelManifest{ + DefaultModel: "claude-sonnet-4-5", + Models: []types.ModelEntry{ + {ID: "claude-sonnet-4-5", Provider: "anthropic", Available: true, FeatureGated: true}, + }, + } + + stale := StaleFlagsFromManifest(manifest) + if len(stale) != 0 { + t.Errorf("expected 0 stale flags, got %d", len(stale)) + } +} + +// --- CleanupStaleFlags --- + +func TestCleanupStaleFlags_ArchivesExistingFlags(t *testing.T) { + var deleteCalled bool + var deletePath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"model.claude-haiku-4-5.enabled"}`)) + return + } + if r.Method == "DELETE" && strings.Contains(r.URL.Path, "/features/") { + deleteCalled = true + deletePath = r.URL.Path + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("UNLEASH_ADMIN_URL", server.URL) + t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") + t.Setenv("UNLEASH_PROJECT", "default") + + err := CleanupStaleFlags(context.Background(), []string{"model.claude-haiku-4-5.enabled"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !deleteCalled { + t.Error("expected DELETE to be called") + } + if !strings.Contains(deletePath, "model.claude-haiku-4-5.enabled") { + t.Errorf("expected delete path to contain flag name, got %s", deletePath) + } +} + +func TestCleanupStaleFlags_SkipsNonExistentFlags(t *testing.T) { + var deleteCalled bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") { + w.WriteHeader(http.StatusNotFound) + return + } + if r.Method == "DELETE" { + deleteCalled = true + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("UNLEASH_ADMIN_URL", server.URL) + t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") + + err := CleanupStaleFlags(context.Background(), []string{"model.nonexistent.enabled"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deleteCalled { + t.Error("DELETE should not be called for non-existent flags") + } +} + +func TestCleanupStaleFlags_SkipsWhenEnvNotSet(t *testing.T) { + t.Setenv("UNLEASH_ADMIN_URL", "") + t.Setenv("UNLEASH_ADMIN_TOKEN", "") + + err := CleanupStaleFlags(context.Background(), []string{"model.test.enabled"}) + if err != nil { + t.Errorf("expected nil error when env not set, got: %v", err) + } +} + +func TestCleanupStaleFlags_EmptyList(t *testing.T) { + err := CleanupStaleFlags(context.Background(), nil) + if err != nil { + t.Errorf("expected nil error for empty list, got: %v", err) + } +} + // --- FlagsFromConfig --- func TestFlagsFromConfig_LoadsValidFile(t *testing.T) { diff --git a/components/backend/handlers/models.go b/components/backend/handlers/models.go index a8d40e24c..c05adcef0 100644 --- a/components/backend/handlers/models.go +++ b/components/backend/handlers/models.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "sort" "sync/atomic" "ambient-code-backend/featureflags" @@ -98,7 +99,6 @@ func ListModelsForProject(c *gin.Context) { } isDefault := entry.ID == effectiveDefault - flagName := fmt.Sprintf("model.%s.enabled", entry.ID) // Default model is always included if isDefault { @@ -109,7 +109,17 @@ func ListModelsForProject(c *gin.Context) { continue } - // Check workspace override first, then fall back to Unleash + // Non-gated models are always included (no feature flag required) + if !entry.FeatureGated { + models = append(models, types.Model{ + ID: entry.ID, Label: entry.Label, Provider: entry.Provider, + IsDefault: false, + }) + continue + } + + // Gated models: check workspace override first, then fall back to Unleash + flagName := fmt.Sprintf("model.%s.enabled", entry.ID) if isModelEnabledWithOverrides(flagName, overrides) { models = append(models, types.Model{ ID: entry.ID, Label: entry.Label, Provider: entry.Provider, @@ -118,6 +128,10 @@ func ListModelsForProject(c *gin.Context) { } } + sort.Slice(models, func(i, j int) bool { + return models[i].Label < models[j].Label + }) + responseDefault := effectiveDefault if len(models) == 0 { log.Printf("WARNING: no models passed filtering for provider=%q in namespace %s", providerFilter, namespace) @@ -211,6 +225,10 @@ func isModelAvailable(ctx context.Context, k8sClient kubernetes.Interface, model return true } } + // Non-gated models are always available (no feature flag required) + if !entry.FeatureGated { + return true + } flagName := fmt.Sprintf("model.%s.enabled", entry.ID) overrides, oErr := getWorkspaceOverrides(ctx, k8sClient, namespace) if oErr != nil { diff --git a/components/backend/handlers/models_test.go b/components/backend/handlers/models_test.go index 7a6eb7576..e278c9e78 100644 --- a/components/backend/handlers/models_test.go +++ b/components/backend/handlers/models_test.go @@ -43,12 +43,12 @@ var _ = Describe("Models Handler", Label(test_constants.LabelUnit, test_constant "google": "gemini-2.5-flash", }, Models: []types.ModelEntry{ - {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", VertexID: "claude-sonnet-4-5@20250929", Provider: "anthropic", Available: true}, - {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", VertexID: "claude-opus-4-6@default", Provider: "anthropic", Available: true}, - {ID: "claude-opus-4-5", Label: "Claude Opus 4.5", VertexID: "claude-opus-4-5@20251101", Provider: "anthropic", Available: true}, - {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", VertexID: "claude-haiku-4-5@20251001", Provider: "anthropic", Available: true}, - {ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", VertexID: "gemini-2.5-flash", Provider: "google", Available: true}, - {ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", VertexID: "gemini-2.5-pro", Provider: "google", Available: true}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", VertexID: "claude-sonnet-4-5@20250929", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", VertexID: "claude-opus-4-6@default", Provider: "anthropic", Available: true, FeatureGated: true}, + {ID: "claude-opus-4-5", Label: "Claude Opus 4.5", VertexID: "claude-opus-4-5@20251101", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", VertexID: "claude-haiku-4-5@20251001", Provider: "anthropic", Available: true, FeatureGated: false}, + {ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", VertexID: "gemini-2.5-flash", Provider: "google", Available: true, FeatureGated: false}, + {ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", VertexID: "gemini-2.5-pro", Provider: "google", Available: true, FeatureGated: true}, }, } @@ -610,5 +610,25 @@ var _ = Describe("Models Handler", Label(test_constants.LabelUnit, test_constant result := isModelAvailable(context.Background(), K8sClient, "claude-opus-4-6", "anthropic", "test-ns") Expect(result).To(BeTrue()) }) + + It("should return true for non-gated model without checking feature flags", func() { + logger.Log("Testing isModelAvailable allows non-gated model regardless of flags") + writeManifestFile(validManifest) + // Override would disable this model if it were gated + overrideCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "model.claude-haiku-4-5.enabled": "false", + }, + } + setupK8sWithOverrides(overrideCM) + + // claude-haiku-4-5 is featureGated=false, so the override should be ignored + result := isModelAvailable(context.Background(), K8sClient, "claude-haiku-4-5", "", "test-project") + Expect(result).To(BeTrue()) + }) }) }) diff --git a/components/backend/main.go b/components/backend/main.go index 092a458df..b2b7f6984 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -90,18 +90,20 @@ func main() { defer syncCancel() var allFlags []cmd.FlagSpec + var staleFlags []string if manifest, err := handlers.LoadManifest(handlers.ManifestPath()); err != nil { log.Printf("WARNING: cannot load model manifest for flag sync: %v", err) } else { allFlags = append(allFlags, cmd.FlagsFromManifest(manifest)...) + staleFlags = append(staleFlags, cmd.StaleFlagsFromManifest(manifest)...) } if extraFlags, err := cmd.FlagsFromConfig(cmd.FlagsConfigPath()); err != nil { log.Printf("WARNING: cannot load flags config: %v", err) } else { allFlags = append(allFlags, extraFlags...) } - if len(allFlags) > 0 { - cmd.SyncFlagsAsync(syncCtx, allFlags) + if len(allFlags) > 0 || len(staleFlags) > 0 { + cmd.SyncAndCleanupAsync(syncCtx, allFlags, staleFlags) } // Initialize git package diff --git a/components/backend/types/models.go b/components/backend/types/models.go index 3f38c527d..b9da80193 100644 --- a/components/backend/types/models.go +++ b/components/backend/types/models.go @@ -11,11 +11,12 @@ type Model struct { // ModelEntry represents a model entry in the manifest file (internal). // Includes fields not exposed in the API response. type ModelEntry struct { - ID string `json:"id"` - Label string `json:"label"` - VertexID string `json:"vertexId"` - Provider string `json:"provider"` - Available bool `json:"available"` + ID string `json:"id"` + Label string `json:"label"` + VertexID string `json:"vertexId"` + Provider string `json:"provider"` + Available bool `json:"available"` + FeatureGated bool `json:"featureGated"` } // ModelManifest represents the top-level model manifest structure. diff --git a/components/frontend/src/components/workspace-sections/feature-flags-section.tsx b/components/frontend/src/components/workspace-sections/feature-flags-section.tsx index 07be537ec..cf371e527 100644 --- a/components/frontend/src/components/workspace-sections/feature-flags-section.tsx +++ b/components/frontend/src/components/workspace-sections/feature-flags-section.tsx @@ -20,6 +20,7 @@ import { EmptyState } from "@/components/empty-state"; import { cn } from "@/lib/utils"; import { useFeatureFlags } from "@/services/queries/use-feature-flags-admin"; +import type { FeatureToggle } from "@/services/api/feature-flags-admin"; import * as featureFlagsApi from "@/services/api/feature-flags-admin"; import { successToast, errorToast } from "@/hooks/use-toast"; import { useQueryClient } from "@tanstack/react-query"; @@ -28,6 +29,12 @@ type FeatureFlagsSectionProps = { projectName: string; }; +type FlagGroup = { + category: string; + label: string; + flags: FeatureToggle[]; +}; + // "default" = no override (use platform value), "on" = force enable, "off" = force disable type OverrideValue = "default" | "on" | "off"; @@ -36,6 +43,41 @@ type LocalFlagState = { serverOverride: OverrideValue; // what the server currently has }; +/** Known category labels; unknown prefixes get title-cased automatically. */ +const CATEGORY_LABELS: Record = { + model: "Models", + runner: "Runners", + framework: "Frameworks", +}; + +/** Extract category from flag name (e.g. "model" from "model.claude-sonnet-4-5.enabled"). */ +function flagCategory(name: string): string { + const dot = name.indexOf("."); + return dot > 0 ? name.slice(0, dot) : "other"; +} + +/** Group flags by prefix category, sort groups and flags within each group alphabetically. */ +function groupAndSortFlags(flags: FeatureToggle[]): FlagGroup[] { + const groups = new Map(); + for (const flag of flags) { + const cat = flagCategory(flag.name); + const list = groups.get(cat); + if (list) { + list.push(flag); + } else { + groups.set(cat, [flag]); + } + } + + return Array.from(groups.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, groupFlags]) => ({ + category, + label: CATEGORY_LABELS[category] ?? category.charAt(0).toUpperCase() + category.slice(1), + flags: groupFlags.sort((a, b) => a.name.localeCompare(b.name)), + })); +} + export function FeatureFlagsSection({ projectName }: FeatureFlagsSectionProps) { const queryClient = useQueryClient(); const { @@ -69,6 +111,9 @@ export function FeatureFlagsSection({ projectName }: FeatureFlagsSectionProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [flagsKey]); + // Group and sort flags by category + const groupedFlags = useMemo(() => groupAndSortFlags(flags), [flags]); + // Check if there are unsaved changes const hasChanges = useMemo(() => { return Object.values(localState).some((s) => s.override !== s.serverOverride); @@ -242,51 +287,15 @@ export function FeatureFlagsSection({ projectName }: FeatureFlagsSectionProps) { - {flags.map((flag) => { - const state = localState[flag.name]; - const currentOverride = state?.override ?? "default"; - const isChanged = state ? state.override !== state.serverOverride : false; - return ( - - -
- - {flag.name} - - {isChanged && ( - - Unsaved - - )} -
- {flag.stale && ( - - Stale - - )} -
- -
- {flag.description || "\u2014"} -
-
- - - {flag.enabled ? "On" : "Off"} - - - - handleOverrideChange(flag.name, v)} - /> - - - {getTypeBadge(flag.type)} - -
- ); - })} + {groupedFlags.map((group) => ( + + ))}
@@ -344,6 +353,79 @@ function deriveServerOverride(overrideEnabled?: boolean | null): OverrideValue { return overrideEnabled ? "on" : "off"; } +/** Renders a category header row followed by the flag rows for that group. */ +function GroupRows({ + group, + localState, + onOverrideChange, + getTypeBadge, +}: { + group: FlagGroup; + localState: Record; + onOverrideChange: (flagName: string, value: OverrideValue) => void; + getTypeBadge: (type?: string) => React.ReactNode; +}) { + return ( + <> + + + + {group.label} + + + ({group.flags.length}) + + + + {group.flags.map((flag) => { + const state = localState[flag.name]; + const currentOverride = state?.override ?? "default"; + const isChanged = state ? state.override !== state.serverOverride : false; + return ( + + +
+ + {flag.name} + + {isChanged && ( + + Unsaved + + )} +
+ {flag.stale && ( + + Stale + + )} +
+ +
+ {flag.description || "\u2014"} +
+
+ + + {flag.enabled ? "On" : "Off"} + + + + onOverrideChange(flag.name, v)} + /> + + + {getTypeBadge(flag.type)} + +
+ ); + })} + + ); +} + /** Segmented control with three states: Default | On | Off */ function OverrideControl({ value, diff --git a/components/manifests/base/models.json b/components/manifests/base/models.json index a9d08a383..74f59300d 100644 --- a/components/manifests/base/models.json +++ b/components/manifests/base/models.json @@ -11,49 +11,56 @@ "label": "Claude Sonnet 4.5", "vertexId": "claude-sonnet-4-5@20250929", "provider": "anthropic", - "available": true + "available": true, + "featureGated": false }, { "id": "claude-opus-4-6", "label": "Claude Opus 4.6", "vertexId": "claude-opus-4-6@default", "provider": "anthropic", - "available": true + "available": true, + "featureGated": true }, { "id": "claude-opus-4-5", "label": "Claude Opus 4.5", "vertexId": "claude-opus-4-5@20251101", "provider": "anthropic", - "available": true + "available": true, + "featureGated": false }, { "id": "claude-haiku-4-5", "label": "Claude Haiku 4.5", "vertexId": "claude-haiku-4-5@20251001", "provider": "anthropic", - "available": true + "available": true, + "featureGated": false }, { "id": "claude-sonnet-4-6", "label": "Claude Sonnet 4.6", "vertexId": "claude-sonnet-4-6@default", "provider": "anthropic", - "available": true + "available": true, + "featureGated": true }, { "id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash", "vertexId": "gemini-2.5-flash", "provider": "google", - "available": true + "available": true, + "featureGated": false }, { "id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "vertexId": "gemini-2.5-pro", "provider": "google", - "available": true + "available": true, + "featureGated": true } ] } diff --git a/scripts/model-discovery.py b/scripts/model-discovery.py index 21503ae2e..c24f564cb 100755 --- a/scripts/model-discovery.py +++ b/scripts/model-discovery.py @@ -301,6 +301,7 @@ def main() -> int: "vertexId": vertex_id, "provider": "anthropic", "available": is_available, + "featureGated": True, # New models require explicit opt-in via feature flag } manifest["models"].append(new_entry) changes.append(f" {model_id}: added (available={is_available})")