mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
launch: add plan-aware model gating (#16027)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user