diff --git a/CHANGELOG.md b/CHANGELOG.md index caf7148..9ab73c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ APIs change between minors without a deprecation period. ### Fixed +- **`llmgw keys limits` no longer clears the limits it was not given.** Setting one limit at a scope wrote the whole + row, so `--tools 'jira.*'` alone took away that scope's rate, budget and model list. Only what is given changes now. + `--replace` writes every limit, as the console's limits form does. + - **The plan refused tensor parallelism past a model's KV heads.** Qwen3-235B, with four KV heads, had no way to run on eight cards, though vLLM gives each card a copy of a head. A width that is a multiple of the KV heads is now offered, as is one that divides them. diff --git a/internal/cli/keys.go b/internal/cli/keys.go index d7db46d..1bd63d3 100644 --- a/internal/cli/keys.go +++ b/internal/cli/keys.go @@ -303,6 +303,7 @@ func newLimitsCmd(open opener) *cobra.Command { var rpm, tpm int var budget float64 var models, tools []string + var replace bool cmd := &cobra.Command{ Use: "limits", @@ -358,7 +359,19 @@ func newLimitsCmd(open opener) *cobra.Command { return errors.New("--per-member is for --scope org, team or project") } } + // Only what is given changes: setting a tool grant must not + // clear the scope's rate or budget. --replace writes the row + // whole, a flag not given then no limit, as the console's + // form does with every field it shows. l := store.Limits{Scope: level, ScopeID: scopeID} + if !replace { + switch cur, err := s.LimitsFor(cmd.Context(), level, scopeID); { + case err == nil: + l = cur + case !errors.Is(err, store.ErrNotFound): + return err + } + } if cmd.Flags().Changed("rpm") { l.RPM = &rpm } @@ -404,6 +417,8 @@ func newLimitsCmd(open opener) *cobra.Command { cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them") cmd.Flags().StringVar(&period, "period", "month", "day | month") cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none") + cmd.Flags().BoolVar(&replace, "replace", false, + "write every limit at the scope: one not given is then no limit there (default: only what is given changes)") cmd.Flags().StringSliceVar(&tools, "tools", nil, "the company MCP servers' tools granted here, as patterns: jira.*,db.query; none granted anywhere is none") return cmd diff --git a/internal/cli/org_test.go b/internal/cli/org_test.go index 7c526b7..c58dcce 100644 --- a/internal/cli/org_test.go +++ b/internal/cli/org_test.go @@ -131,3 +131,33 @@ func TestKeyBudgetsAreWholeUnits(t *testing.T) { } } } + +// A limit given alone changes that limit alone: granting a tool keeps the +// scope's rate. --replace writes the row whole, as the console's form does. +func TestALimitGivenAloneLeavesTheOthersAtTheScope(t *testing.T) { + db := filepath.Join(t.TempDir(), "s.db") + mustRun(t, db, "org", "add", "org", "Acme") + mustRun(t, db, "org", "add", "team", "platform") + mustRun(t, db, "org", "add", "project", "gateway") + mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--rpm", "30") + mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--tools", "jira.*") + read := func() store.Limits { + s, err := store.Open(context.Background(), db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + all, _ := s.AllLimits(context.Background()) + for _, l := range all { + return l + } + return store.Limits{} + } + if l := read(); l.RPM == nil || *l.RPM != 30 || len(l.AllowedTools) != 1 { + t.Errorf("after a tool grant: rpm %v, tools %v", l.RPM, l.AllowedTools) + } + mustRun(t, db, "keys", "limits", "--replace", "--scope", "project", "--id", "gateway", "--tpm", "1000") + if l := read(); l.RPM != nil || l.AllowedTools != nil || l.TPM == nil { + t.Errorf("after --replace: %+v", l) + } +} diff --git a/internal/cli/tui_people.go b/internal/cli/tui_people.go index 4d02097..15a8e5d 100644 --- a/internal/cli/tui_people.go +++ b/internal/cli/tui_people.go @@ -407,7 +407,8 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) { textF("company tools", tools, "MCP tools granted here, as jira.*,db.query; empty grants none here"), }, submit: func(m *consoleModel, v []string) (tea.Cmd, error) { - args := []string{"keys", "limits", "--scope=" + string(scope), "--id=" + id} + // Every field the form shows, so an emptied one is cleared. + args := []string{"keys", "limits", "--replace", "--scope=" + string(scope), "--id=" + id} for i, flag := range []string{"rpm", "tpm"} { if v[i] != "" { if _, err := strconv.Atoi(v[i]); err != nil {