Skip to content

Commit c270847

Browse files
maskarbclaude
andauthored
feat: add featureGated field to models, cleanup stale flags, sort and group flags in UI (#808)
## Summary - Archive stale Unleash feature flags when models are marked `featureGated: false` in `models.json` - Sort `/models` API response alphabetically by label for consistent frontend dropdown ordering ## Changes ### Stale flag cleanup (`sync_flags.go`, `main.go`) - `StaleFlagsFromManifest()` — identifies flag names for non-gated models that should be removed from Unleash - `CleanupStaleFlags()` — checks if each flag exists in Unleash and archives it via the Admin API DELETE endpoint; silently skips flags that don't exist (idempotent) - `archiveFlag()` — calls `DELETE /api/admin/projects/{project}/features/{name}`, treats 404 as success - `SyncAndCleanupAsync()` — runs sync then cleanup in a background goroutine with retries; cleanup failure is non-fatal (logged but doesn't trigger retries or block startup) - Wired into both entry points: `SyncModelFlagsFromFile` (CLI subcommand) and server startup in `main.go` ### Sorted models response (`models.go`) - `ListModelsForProject` now sorts the filtered model list by `Label` before returning, so the frontend dropdown is always alphabetically ordered regardless of manifest order or flag state ### Tests (`sync_flags_test.go`) - `TestStaleFlagsFromManifest_ReturnsNonGatedModels` — verifies non-gated models produce stale flag names - `TestStaleFlagsFromManifest_EmptyWhenAllGated` — empty result when all models are gated - `TestCleanupStaleFlags_ArchivesExistingFlags` — mocks Unleash, verifies DELETE called with correct path - `TestCleanupStaleFlags_SkipsNonExistentFlags` — 404 from GET → no DELETE - `TestCleanupStaleFlags_SkipsWhenEnvNotSet` — graceful no-op when Unleash not configured - `TestCleanupStaleFlags_EmptyList` — early return for nil input ## Test plan - [ ] `cd components/backend && go vet ./... && go test -tags test ./cmd/` - [ ] Verify stale flags are archived on server startup when `UNLEASH_ADMIN_URL` and `UNLEASH_ADMIN_TOKEN` are set - [ ] Verify models dropdown is sorted alphabetically in create-session dialog - [ ] Verify `sync-model-flags` CLI subcommand archives stale flags after sync 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4981f6a commit c270847

9 files changed

Lines changed: 452 additions & 79 deletions

File tree

components/backend/cmd/sync_flags.go

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ type FlagsConfig struct {
4848
}
4949

5050
// FlagsFromManifest converts a model manifest into FlagSpecs.
51-
// Skips default models (global and per-provider) and unavailable models.
51+
// Only creates flags for models that are available and feature-gated.
52+
// Skips default models (global and per-provider) as defense-in-depth.
5253
func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec {
5354
// Build set of all default model IDs (global + per-provider)
5455
defaults := map[string]bool{manifest.DefaultModel: true}
@@ -58,21 +59,93 @@ func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec {
5859

5960
var specs []FlagSpec
6061
for _, model := range manifest.Models {
62+
if !model.FeatureGated {
63+
continue
64+
}
6165
if defaults[model.ID] {
6266
continue
6367
}
6468
if !model.Available {
6569
continue
6670
}
6771
specs = append(specs, FlagSpec{
68-
Name: sanitizeLogString(fmt.Sprintf("model.%s.enabled", model.ID)),
72+
Name: fmt.Sprintf("model.%s.enabled", model.ID),
6973
Description: sanitizeLogString(fmt.Sprintf("Enable %s (%s) for users", model.Label, model.ID)),
7074
Tags: []FlagTag{{Type: "scope", Value: "workspace"}},
7175
})
7276
}
7377
return specs
7478
}
7579

80+
// StaleFlagsFromManifest returns Unleash flag names that should be archived
81+
// because the corresponding model is no longer feature-gated.
82+
func StaleFlagsFromManifest(manifest *types.ModelManifest) []string {
83+
var stale []string
84+
for _, model := range manifest.Models {
85+
if model.FeatureGated {
86+
continue
87+
}
88+
stale = append(stale, fmt.Sprintf("model.%s.enabled", model.ID))
89+
}
90+
return stale
91+
}
92+
93+
// CleanupStaleFlags archives Unleash flags that are no longer needed.
94+
// Flags that don't exist are silently skipped (already archived or never created).
95+
//
96+
// Required env vars: UNLEASH_ADMIN_URL, UNLEASH_ADMIN_TOKEN
97+
// Optional env var: UNLEASH_PROJECT (default: "default")
98+
func CleanupStaleFlags(ctx context.Context, flagNames []string) error {
99+
if len(flagNames) == 0 {
100+
return nil
101+
}
102+
103+
adminURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_URL")), "/")
104+
adminToken := strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_TOKEN"))
105+
project := strings.TrimSpace(os.Getenv("UNLEASH_PROJECT"))
106+
if project == "" {
107+
project = "default"
108+
}
109+
110+
if adminURL == "" || adminToken == "" {
111+
log.Printf("cleanup-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping")
112+
return nil
113+
}
114+
115+
client := &http.Client{Timeout: 10 * time.Second}
116+
117+
var archived, skipped, errCount int
118+
log.Printf("Cleaning up %d stale Unleash flag(s)...", len(flagNames))
119+
120+
for _, name := range flagNames {
121+
exists, err := flagExists(ctx, client, adminURL, project, name, adminToken)
122+
if err != nil {
123+
log.Printf(" ERROR checking %s: %v", name, err)
124+
errCount++
125+
continue
126+
}
127+
if !exists {
128+
skipped++
129+
continue
130+
}
131+
132+
if err := archiveFlag(ctx, client, adminURL, project, name, adminToken); err != nil {
133+
log.Printf(" ERROR archiving %s: %v", name, err)
134+
errCount++
135+
continue
136+
}
137+
log.Printf(" %s: archived", name)
138+
archived++
139+
}
140+
141+
log.Printf("Cleanup summary: %d archived, %d not found, %d errors", archived, skipped, errCount)
142+
143+
if errCount > 0 {
144+
return fmt.Errorf("%d errors occurred during cleanup", errCount)
145+
}
146+
return nil
147+
}
148+
76149
// FlagsConfigPath returns the filesystem path to the generic flags config.
77150
// Defaults to defaultFlagsConfig; override via FLAGS_CONFIG_PATH env var.
78151
func FlagsConfigPath() string {
@@ -126,17 +199,31 @@ func SyncModelFlagsFromFile(manifestPath string) error {
126199
return fmt.Errorf("parsing manifest: %w", err)
127200
}
128201

129-
return SyncFlags(context.Background(), FlagsFromManifest(&manifest))
202+
ctx := context.Background()
203+
if err := SyncFlags(ctx, FlagsFromManifest(&manifest)); err != nil {
204+
return err
205+
}
206+
return CleanupStaleFlags(ctx, StaleFlagsFromManifest(&manifest))
130207
}
131208

132209
// SyncFlagsAsync runs SyncFlags in a background goroutine with retries.
133210
// Intended for use at server startup — does not block the caller.
134211
// Cancel the context to abort retries (e.g. on SIGTERM).
135212
func SyncFlagsAsync(ctx context.Context, flags []FlagSpec) {
213+
SyncAndCleanupAsync(ctx, flags, nil)
214+
}
215+
216+
// SyncAndCleanupAsync runs SyncFlags and CleanupStaleFlags in a background
217+
// goroutine with retries. After a successful sync, stale flags are archived.
218+
// Cancel the context to abort retries (e.g. on SIGTERM).
219+
func SyncAndCleanupAsync(ctx context.Context, flags []FlagSpec, staleFlags []string) {
136220
go func() {
137221
for attempt := 1; attempt <= maxRetries; attempt++ {
138222
err := SyncFlags(ctx, flags)
139223
if err == nil {
224+
if cErr := CleanupStaleFlags(ctx, staleFlags); cErr != nil {
225+
log.Printf("sync-flags: cleanup failed (non-fatal): %v", cErr)
226+
}
140227
return
141228
}
142229
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
360447
}
361448
}
362449

450+
func archiveFlag(ctx context.Context, client *http.Client, adminURL, project, flagName, token string) error {
451+
reqURL := fmt.Sprintf("%s/api/admin/projects/%s/features/%s", adminURL, url.PathEscape(project), url.PathEscape(flagName))
452+
resp, err := doRequest(ctx, client, "DELETE", reqURL, token, nil)
453+
if err != nil {
454+
return err
455+
}
456+
defer resp.Body.Close()
457+
respBody, _ := io.ReadAll(resp.Body)
458+
459+
switch resp.StatusCode {
460+
case http.StatusOK, http.StatusAccepted, http.StatusNoContent:
461+
return nil
462+
case http.StatusNotFound:
463+
return nil // already gone
464+
default:
465+
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
466+
}
467+
}
468+
363469
func addFlagTag(ctx context.Context, client *http.Client, adminURL, flagName string, tag FlagTag, token string) error {
364470
reqURL := fmt.Sprintf("%s/api/admin/features/%s/tags", adminURL, url.PathEscape(flagName))
365471
body, err := json.Marshal(map[string]string{

components/backend/cmd/sync_flags_test.go

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,27 +63,29 @@ func TestParseManifestPath(t *testing.T) {
6363

6464
// --- FlagsFromManifest ---
6565

66-
func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) {
66+
func TestFlagsFromManifest_SkipsDefaultUnavailableAndNonGated(t *testing.T) {
6767
manifest := &types.ModelManifest{
6868
DefaultModel: "claude-sonnet-4-5",
6969
ProviderDefaults: map[string]string{
7070
"anthropic": "claude-sonnet-4-5",
7171
"google": "gemini-2.5-flash",
7272
},
7373
Models: []types.ModelEntry{
74-
{ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Provider: "anthropic", Available: true},
75-
{ID: "claude-opus-4-6", Label: "Opus 4.6", Provider: "anthropic", Available: true},
76-
{ID: "claude-opus-4-1", Label: "Opus 4.1", Provider: "anthropic", Available: false},
77-
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google", Available: true},
78-
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Available: true},
74+
{ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Provider: "anthropic", Available: true, FeatureGated: false},
75+
{ID: "claude-opus-4-6", Label: "Opus 4.6", Provider: "anthropic", Available: true, FeatureGated: true},
76+
{ID: "claude-opus-4-1", Label: "Opus 4.1", Provider: "anthropic", Available: false, FeatureGated: true},
77+
{ID: "claude-haiku-4-5", Label: "Haiku 4.5", Provider: "anthropic", Available: true, FeatureGated: false},
78+
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google", Available: true, FeatureGated: false},
79+
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Available: true, FeatureGated: true},
7980
},
8081
}
8182

8283
flags := FlagsFromManifest(manifest)
8384

84-
// Should skip: claude-sonnet-4-5 (global default + anthropic default),
85-
// gemini-2.5-flash (google default),
86-
// claude-opus-4-1 (unavailable)
85+
// Should skip: claude-sonnet-4-5 (default + not gated),
86+
// claude-opus-4-1 (unavailable),
87+
// claude-haiku-4-5 (not gated),
88+
// gemini-2.5-flash (default + not gated)
8789
// Should include: claude-opus-4-6, gemini-2.5-pro
8890
if len(flags) != 2 {
8991
t.Fatalf("expected 2 flags, got %d: %v", len(flags), flags)
@@ -102,6 +104,9 @@ func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) {
102104
if names["model.claude-sonnet-4-5.enabled"] {
103105
t.Error("global default should be skipped")
104106
}
107+
if names["model.claude-haiku-4-5.enabled"] {
108+
t.Error("non-gated model should be skipped")
109+
}
105110
if names["model.gemini-2.5-flash.enabled"] {
106111
t.Error("provider default should be skipped")
107112
}
@@ -115,6 +120,137 @@ func TestFlagsFromManifest_EmptyManifest(t *testing.T) {
115120
}
116121
}
117122

123+
// --- StaleFlagsFromManifest ---
124+
125+
func TestStaleFlagsFromManifest_ReturnsNonGatedModels(t *testing.T) {
126+
manifest := &types.ModelManifest{
127+
DefaultModel: "claude-sonnet-4-5",
128+
Models: []types.ModelEntry{
129+
{ID: "claude-sonnet-4-5", Provider: "anthropic", Available: true, FeatureGated: false},
130+
{ID: "claude-opus-4-6", Provider: "anthropic", Available: true, FeatureGated: true},
131+
{ID: "claude-haiku-4-5", Provider: "anthropic", Available: true, FeatureGated: false},
132+
{ID: "gemini-2.5-pro", Provider: "google", Available: true, FeatureGated: true},
133+
},
134+
}
135+
136+
stale := StaleFlagsFromManifest(manifest)
137+
138+
// Non-gated models: claude-sonnet-4-5, claude-haiku-4-5
139+
if len(stale) != 2 {
140+
t.Fatalf("expected 2 stale flags, got %d: %v", len(stale), stale)
141+
}
142+
names := map[string]bool{}
143+
for _, s := range stale {
144+
names[s] = true
145+
}
146+
if !names["model.claude-sonnet-4-5.enabled"] {
147+
t.Error("expected model.claude-sonnet-4-5.enabled in stale list")
148+
}
149+
if !names["model.claude-haiku-4-5.enabled"] {
150+
t.Error("expected model.claude-haiku-4-5.enabled in stale list")
151+
}
152+
if names["model.claude-opus-4-6.enabled"] {
153+
t.Error("gated model should not be in stale list")
154+
}
155+
}
156+
157+
func TestStaleFlagsFromManifest_EmptyWhenAllGated(t *testing.T) {
158+
manifest := &types.ModelManifest{
159+
DefaultModel: "claude-sonnet-4-5",
160+
Models: []types.ModelEntry{
161+
{ID: "claude-sonnet-4-5", Provider: "anthropic", Available: true, FeatureGated: true},
162+
},
163+
}
164+
165+
stale := StaleFlagsFromManifest(manifest)
166+
if len(stale) != 0 {
167+
t.Errorf("expected 0 stale flags, got %d", len(stale))
168+
}
169+
}
170+
171+
// --- CleanupStaleFlags ---
172+
173+
func TestCleanupStaleFlags_ArchivesExistingFlags(t *testing.T) {
174+
var deleteCalled bool
175+
var deletePath string
176+
177+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178+
if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") {
179+
w.WriteHeader(http.StatusOK)
180+
w.Write([]byte(`{"name":"model.claude-haiku-4-5.enabled"}`))
181+
return
182+
}
183+
if r.Method == "DELETE" && strings.Contains(r.URL.Path, "/features/") {
184+
deleteCalled = true
185+
deletePath = r.URL.Path
186+
w.WriteHeader(http.StatusOK)
187+
return
188+
}
189+
w.WriteHeader(http.StatusOK)
190+
}))
191+
defer server.Close()
192+
193+
t.Setenv("UNLEASH_ADMIN_URL", server.URL)
194+
t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token")
195+
t.Setenv("UNLEASH_PROJECT", "default")
196+
197+
err := CleanupStaleFlags(context.Background(), []string{"model.claude-haiku-4-5.enabled"})
198+
if err != nil {
199+
t.Fatalf("unexpected error: %v", err)
200+
}
201+
if !deleteCalled {
202+
t.Error("expected DELETE to be called")
203+
}
204+
if !strings.Contains(deletePath, "model.claude-haiku-4-5.enabled") {
205+
t.Errorf("expected delete path to contain flag name, got %s", deletePath)
206+
}
207+
}
208+
209+
func TestCleanupStaleFlags_SkipsNonExistentFlags(t *testing.T) {
210+
var deleteCalled bool
211+
212+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
213+
if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") {
214+
w.WriteHeader(http.StatusNotFound)
215+
return
216+
}
217+
if r.Method == "DELETE" {
218+
deleteCalled = true
219+
return
220+
}
221+
w.WriteHeader(http.StatusOK)
222+
}))
223+
defer server.Close()
224+
225+
t.Setenv("UNLEASH_ADMIN_URL", server.URL)
226+
t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token")
227+
228+
err := CleanupStaleFlags(context.Background(), []string{"model.nonexistent.enabled"})
229+
if err != nil {
230+
t.Fatalf("unexpected error: %v", err)
231+
}
232+
if deleteCalled {
233+
t.Error("DELETE should not be called for non-existent flags")
234+
}
235+
}
236+
237+
func TestCleanupStaleFlags_SkipsWhenEnvNotSet(t *testing.T) {
238+
t.Setenv("UNLEASH_ADMIN_URL", "")
239+
t.Setenv("UNLEASH_ADMIN_TOKEN", "")
240+
241+
err := CleanupStaleFlags(context.Background(), []string{"model.test.enabled"})
242+
if err != nil {
243+
t.Errorf("expected nil error when env not set, got: %v", err)
244+
}
245+
}
246+
247+
func TestCleanupStaleFlags_EmptyList(t *testing.T) {
248+
err := CleanupStaleFlags(context.Background(), nil)
249+
if err != nil {
250+
t.Errorf("expected nil error for empty list, got: %v", err)
251+
}
252+
}
253+
118254
// --- FlagsFromConfig ---
119255

120256
func TestFlagsFromConfig_LoadsValidFile(t *testing.T) {

0 commit comments

Comments
 (0)