launch: add plan-aware model gating (#16027)

This commit is contained in:
Parth Sareen
2026-05-06 14:34:26 -07:00
committed by GitHub
parent 7c2c36bda2
commit bab59072fb
17 changed files with 1747 additions and 138 deletions

View File

@@ -20,9 +20,7 @@ import (
"github.com/ollama/ollama/format"
)
const (
modelRecommendationsURL = "https://ollama.com/api/experimental/model-recommendations"
)
const modelRecommendationsURL = "https://ollama.com/api/experimental/model-recommendations"
var (
modelRecommendationsRefreshInterval = 4 * time.Hour
@@ -323,6 +321,7 @@ func validateModelRecommendations(recs []api.ModelRecommendation) ([]api.ModelRe
for _, rec := range recs {
rec.Model = strings.TrimSpace(rec.Model)
rec.Description = strings.TrimSpace(rec.Description)
rec.RequiredPlan = strings.TrimSpace(rec.RequiredPlan)
if rec.Model == "" {
return nil, errors.New("recommendation missing model")

View File

@@ -255,7 +255,7 @@ func TestModelRecommendationsLoadSnapshotInvalidDoesNotOverwrite(t *testing.T) {
func TestValidateModelRecommendationsTrimsAndDropsInvalidCloudEntries(t *testing.T) {
input := []api.ModelRecommendation{
{Model: " good-cloud:cloud ", Description: " good cloud ", ContextLength: 1024, MaxOutputTokens: 256},
{Model: " good-cloud:cloud ", Description: " good cloud ", ContextLength: 1024, MaxOutputTokens: 256, RequiredPlan: " pro "},
{Model: "bad-cloud:cloud", Description: "missing limits"},
{Model: " good-local ", Description: " good local ", VRAMBytes: 2 * format.GigaByte},
}
@@ -266,7 +266,7 @@ func TestValidateModelRecommendationsTrimsAndDropsInvalidCloudEntries(t *testing
}
want := []api.ModelRecommendation{
{Model: "good-cloud:cloud", Description: "good cloud", ContextLength: 1024, MaxOutputTokens: 256},
{Model: "good-cloud:cloud", Description: "good cloud", ContextLength: 1024, MaxOutputTokens: 256, RequiredPlan: "pro"},
{Model: "good-local", Description: "good local", VRAMBytes: 2 * format.GigaByte},
}
if !slices.Equal(got, want) {
@@ -274,6 +274,38 @@ func TestValidateModelRecommendationsTrimsAndDropsInvalidCloudEntries(t *testing
}
}
func TestValidateModelRecommendationsDoesNotSynthesizeRequiredPlans(t *testing.T) {
input := []api.ModelRecommendation{
{Model: "kimi-k2.6:cloud", Description: "coding", ContextLength: 262_144, MaxOutputTokens: 262_144},
{Model: "qwen3.5:cloud", Description: "reasoning", ContextLength: 262_144, MaxOutputTokens: 32_768},
{Model: "custom:cloud", Description: "custom", ContextLength: 4096, MaxOutputTokens: 1024},
{Model: "minimax-m2.7:cloud", Description: "custom", ContextLength: 204_800, MaxOutputTokens: 128_000, RequiredPlan: "team"},
}
got, err := validateModelRecommendations(input)
if err != nil {
t.Fatalf("validateModelRecommendations failed: %v", err)
}
byName := make(map[string]api.ModelRecommendation, len(got))
for _, rec := range got {
byName[rec.Model] = rec
}
if rec := byName["kimi-k2.6:cloud"]; rec.RequiredPlan != "" {
t.Fatalf("kimi required plan should not be synthesized: %#v", rec)
}
if rec := byName["qwen3.5:cloud"]; rec.RequiredPlan != "" {
t.Fatalf("qwen required plan should not be synthesized: %#v", rec)
}
if rec := byName["custom:cloud"]; rec.RequiredPlan != "" {
t.Fatalf("custom required plan should not be synthesized: %#v", rec)
}
if rec := byName["minimax-m2.7:cloud"]; rec.RequiredPlan != "team" {
t.Fatalf("explicit required plan should not be overwritten: %#v", rec)
}
}
func TestModelRecommendationsHandlerReturnsDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)

View File

@@ -2044,11 +2044,30 @@ func (s *Server) WhoamiHandler(c *gin.Context) {
client := api.NewClient(u, http.DefaultClient)
user, err := client.Whoami(c)
if err != nil {
var authErr api.AuthorizationError
if errors.As(err, &authErr) && authErr.StatusCode == http.StatusUnauthorized {
// Preserve an actionable sign-in response for launch; other failures
// below mean account or plan verification is temporarily unavailable.
sURL := authErr.SigninURL
if sURL == "" {
var sErr error
sURL, sErr = signinURL()
if sErr != nil {
slog.Error(sErr.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "error getting authorization details"})
return
}
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized", "signin_url": sURL})
return
}
slog.Error(err.Error())
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "account unavailable"})
return
}
// user isn't signed in
if user != nil && user.Name == "" {
if user == nil || user.Name == "" {
sURL, sErr := signinURL()
if sErr != nil {
slog.Error(sErr.Error())
@@ -2060,6 +2079,10 @@ func (s *Server) WhoamiHandler(c *gin.Context) {
return
}
if strings.TrimSpace(user.Plan) == "" {
slog.Warn("account plan was not set; defaulting to free")
user.Plan = "free"
}
c.JSON(http.StatusOK, user)
}