Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions components/backend/cmd/sync_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,17 @@ type FlagsConfig struct {
}

// FlagsFromManifest converts a model manifest into FlagSpecs.
// Skips the default model and unavailable models.
// Skips default models (global and per-provider) and unavailable models.
func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec {
// Build set of all default model IDs (global + per-provider)
defaults := map[string]bool{manifest.DefaultModel: true}
for _, id := range manifest.ProviderDefaults {
defaults[id] = true
}

var specs []FlagSpec
for _, model := range manifest.Models {
if model.ID == manifest.DefaultModel {
if defaults[model.ID] {
continue
}
if !model.Available {
Expand Down
40 changes: 31 additions & 9 deletions components/backend/cmd/sync_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,44 @@ func TestParseManifestPath(t *testing.T) {
func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) {
manifest := &types.ModelManifest{
DefaultModel: "claude-sonnet-4-5",
ProviderDefaults: map[string]string{
"anthropic": "claude-sonnet-4-5",
"google": "gemini-2.5-flash",
},
Models: []types.ModelEntry{
{ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true},
{ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true},
{ID: "claude-opus-4-1", Label: "Opus 4.1", Available: false},
{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},
},
}

flags := FlagsFromManifest(manifest)
if len(flags) != 1 {
t.Fatalf("expected 1 flag, got %d: %v", len(flags), flags)

// Should skip: claude-sonnet-4-5 (global default + anthropic default),
// gemini-2.5-flash (google default),
// claude-opus-4-1 (unavailable)
// 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)
}

names := map[string]bool{}
for _, f := range flags {
names[f.Name] = true
}
if !names["model.claude-opus-4-6.enabled"] {
t.Error("expected model.claude-opus-4-6.enabled")
}
if !names["model.gemini-2.5-pro.enabled"] {
t.Error("expected model.gemini-2.5-pro.enabled")
}
if flags[0].Name != "model.claude-opus-4-6.enabled" {
t.Errorf("expected model.claude-opus-4-6.enabled, got %s", flags[0].Name)
if names["model.claude-sonnet-4-5.enabled"] {
t.Error("global default should be skipped")
}
if len(flags[0].Tags) != 1 || flags[0].Tags[0].Type != "scope" || flags[0].Tags[0].Value != "workspace" {
t.Errorf("expected scope:workspace tag, got %v", flags[0].Tags)
if names["model.gemini-2.5-flash.enabled"] {
t.Error("provider default should be skipped")
}
}

Expand Down
153 changes: 71 additions & 82 deletions components/backend/handlers/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ func ListModelsForProject(c *gin.Context) {

ctx := c.Request.Context()
namespace := sanitizeParam(c.Param("projectName"))
providerFilter := sanitizeParam(c.Query("provider"))

manifest, err := LoadManifest(ManifestPath())
if err != nil {
log.Printf("WARNING: failed to load model manifest: %v", err)
log.Printf("WARNING: failed to load model manifest from disk: %v", err)
manifest = cachedManifest.Load()
if manifest == nil {
log.Printf("WARNING: no cached manifest available, using hardcoded defaults")
c.JSON(http.StatusOK, defaultModelsResponse())
log.Printf("ERROR: no model manifest available (file unreadable, no cache)")
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Model manifest unavailable"})
return
}
} else {
Expand All @@ -76,13 +77,27 @@ func ListModelsForProject(c *gin.Context) {
// Continue without overrides
}

var models []types.Model
// Resolve which model ID is the "default" for this request.
// When filtering by provider, use the provider-specific default.
effectiveDefault := manifest.DefaultModel
if providerFilter != "" {
if pd, ok := manifest.ProviderDefaults[providerFilter]; ok {
effectiveDefault = pd
}
}

models := make([]types.Model, 0)
for _, entry := range manifest.Models {
if !entry.Available {
continue
}

isDefault := entry.ID == manifest.DefaultModel
// Filter by provider if specified
if providerFilter != "" && entry.Provider != providerFilter {
continue
}

isDefault := entry.ID == effectiveDefault
flagName := fmt.Sprintf("model.%s.enabled", entry.ID)

// Default model is always included
Expand All @@ -103,15 +118,15 @@ func ListModelsForProject(c *gin.Context) {
}
}

responseDefault := effectiveDefault
if len(models) == 0 {
log.Printf("WARNING: no models passed filtering, using defaults")
c.JSON(http.StatusOK, defaultModelsResponse())
return
log.Printf("WARNING: no models passed filtering for provider=%q in namespace %s", providerFilter, namespace)
responseDefault = ""
}

c.JSON(http.StatusOK, types.ListModelsResponse{
Models: models,
DefaultModel: manifest.DefaultModel,
DefaultModel: responseDefault,
})
}

Expand Down Expand Up @@ -143,94 +158,68 @@ func LoadManifest(path string) (*types.ModelManifest, error) {
return &manifest, nil
}

// isModelAvailable checks if a model is available for session creation.
//
// Validation strategy:
// 1. Check the agent registry — if the model is declared in the selected
// runner's model list, it's valid. This is the primary check for all runners.
// 2. For models also in the models.json manifest (Claude models), additionally
// check feature-flag gating and workspace overrides.
// 3. If the model is not found in either source, reject it.
func isModelAvailable(ctx context.Context, k8sClient kubernetes.Interface, modelID, runnerTypeID, namespace string) bool {
// isModelAvailable checks if a model is available for session creation in the
// given workspace namespace. All models (Claude and Gemini) are validated
// against models.json. Returns true if the model exists, is available, and
// is enabled (checking workspace overrides first, then Unleash).
// When requiredProvider is non-empty, the model's provider must match
// (prevents using a Gemini model with a Claude runner, for example).
// The default model always returns true. Fails open when no manifest has
// ever been loaded (cold start).
func isModelAvailable(ctx context.Context, k8sClient kubernetes.Interface, modelID, requiredProvider, namespace string) bool {
if modelID == "" {
return true // Empty model will use default
}

// 1. Check agent registry — runner-specific model validation
rt, err := GetRuntime(runnerTypeID)
if err == nil && len(rt.Models) > 0 {
found := false
for _, m := range rt.Models {
if m.Value == modelID {
found = true
break
}
}
if !found {
log.Printf("Model %q not in runner %q model list, rejecting", modelID, runnerTypeID)
return false
}
// Model is in the runner's list — now check if it also needs
// feature-flag gating via the manifest (applies to Claude models).
}

// 2. Check models.json manifest for feature-flag gating (if applicable)
manifest, err := LoadManifest(ManifestPath())
if err != nil {
log.Printf("WARNING: failed to load model manifest: %v", err)
log.Printf("WARNING: failed to load model manifest for validation: %v", err)
manifest = cachedManifest.Load()
if manifest == nil {
// When we know the runner's provider, reject unknown models rather
// than allowing a cross-provider mismatch through to the runner.
// Fail-open only when both manifest and registry are unavailable
// (requiredProvider == "") to avoid blocking cold starts.
if requiredProvider != "" {
log.Printf("WARNING: no manifest available, rejecting model %q (provider=%q)", modelID, requiredProvider)
return false
}
log.Printf("WARNING: no manifest or registry available, allowing model %q", modelID)
return true
}
} else {
cachedManifest.Store(manifest)
}

if manifest != nil {
// Default model is always available
if modelID == manifest.DefaultModel {
return true
}
for _, entry := range manifest.Models {
if entry.ID == modelID {
if !entry.Available {
return false
}
flagName := fmt.Sprintf("model.%s.enabled", entry.ID)
overrides, oErr := getWorkspaceOverrides(ctx, k8sClient, namespace)
if oErr != nil {
log.Printf("WARNING: failed to read workspace overrides for %s: %v", namespace, oErr)
for _, entry := range manifest.Models {
if entry.ID == modelID {
if !entry.Available {
return false
}
// Provider mismatch check applies to ALL models, including defaults
if requiredProvider != "" && entry.Provider != requiredProvider {
log.Printf("Model %q has provider %q but runner requires %q", modelID, entry.Provider, requiredProvider)
return false
}
// Default models (global and per-provider) are always enabled
// (skip feature flag check) but must still pass provider matching above
if modelID == manifest.DefaultModel {
return true
}
for provider, pd := range manifest.ProviderDefaults {
if modelID == pd && (requiredProvider == "" || provider == requiredProvider) {
return true
}
return isModelEnabledWithOverrides(flagName, overrides)
}
flagName := fmt.Sprintf("model.%s.enabled", entry.ID)
overrides, oErr := getWorkspaceOverrides(ctx, k8sClient, namespace)
if oErr != nil {
log.Printf("WARNING: failed to read workspace overrides for %s: %v", namespace, oErr)
}
return isModelEnabledWithOverrides(flagName, overrides)
}
}

// 3. If we validated via registry in step 1 (found=true), allow it.
// Models not in the manifest skip feature-flag gating (e.g., Gemini models).
if rt != nil && len(rt.Models) > 0 {
return true // Already validated in step 1
}

// No manifest loaded and no registry available — fail-open on cold start
if manifest == nil {
log.Printf("WARNING: no manifest or registry available, allowing model %q", modelID)
return true
}

log.Printf("WARNING: model %q not found in manifest or agent registry, rejecting", modelID)
log.Printf("WARNING: model %q not found in manifest, rejecting", modelID)
return false
}

// defaultModelsResponse returns a hardcoded ListModelsResponse as a fallback
// when the model manifest file is unavailable or malformed.
// Keep in sync with components/manifests/base/models.json (available: true entries).
func defaultModelsResponse() types.ListModelsResponse {
return types.ListModelsResponse{
DefaultModel: "claude-sonnet-4-5",
Models: []types.Model{
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", Provider: "anthropic", IsDefault: true},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", IsDefault: false},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", Provider: "anthropic", IsDefault: false},
{ID: "claude-opus-4-5", Label: "Claude Opus 4.5", Provider: "anthropic", IsDefault: false},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", Provider: "anthropic", IsDefault: false},
},
}
}
Loading
Loading