Accounts, API keys and Organizations views read the store and change it by running the users, keys and org commands in-process, so their checks and messages are the ones shown. A Mirror view lists what the mirror holds and which models use it, downloads into it and removes from it. Models gain adding from a definition file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
546 lines
18 KiB
Go
546 lines
18 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// The people side of the console: accounts, keys and the organizations they
|
|
// belong to. These live in the store rather than behind the admin API — the
|
|
// API mints no credentials — so the console reads the store as `llmgw users`
|
|
// does, and changes it by running those same commands, their checks and
|
|
// messages included.
|
|
|
|
// storeHolder is the store, opened on first use and shared by every copy of
|
|
// the console's model.
|
|
type storeHolder struct {
|
|
mu sync.Mutex
|
|
file string
|
|
db *store.DB
|
|
}
|
|
|
|
func (h *storeHolder) get(ctx context.Context) (*store.DB, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.db == nil {
|
|
sf := storeFlags{config: h.file}
|
|
db, err := sf.openCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.db = db
|
|
}
|
|
return h.db, nil
|
|
}
|
|
|
|
func (h *storeHolder) close() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.db != nil {
|
|
_ = h.db.Close()
|
|
h.db = nil
|
|
}
|
|
}
|
|
|
|
// orgRow is one line of the flattened tenancy tree.
|
|
type orgRow struct {
|
|
level string // org, team, project
|
|
id, name, path string
|
|
members, keys int
|
|
}
|
|
|
|
type peopleData struct {
|
|
users []store.User
|
|
where map[string][]string // user id → paths it is a member of
|
|
keys []store.KeyRow
|
|
orgs []orgRow
|
|
limits map[string]store.Limits
|
|
threads map[string]int // project id → hours kept, conversations
|
|
notes map[string]int // project id → hours kept, agent notes
|
|
err error
|
|
}
|
|
|
|
type consolePeopleMsg peopleData
|
|
|
|
func (m consoleModel) fetchPeople() tea.Cmd {
|
|
h := m.st
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var d peopleData
|
|
s, err := h.get(ctx)
|
|
if err != nil {
|
|
d.err = err
|
|
return consolePeopleMsg(d)
|
|
}
|
|
if d.users, d.err = s.Users(ctx, ""); d.err != nil {
|
|
return consolePeopleMsg(d)
|
|
}
|
|
all, _ := s.AllMemberships(ctx)
|
|
d.where = map[string][]string{}
|
|
for _, ms := range all {
|
|
d.where[ms.UserID] = append(d.where[ms.UserID], ms.Path)
|
|
}
|
|
d.keys, _ = s.AllKeys(ctx)
|
|
d.limits, _ = s.AllLimits(ctx)
|
|
tree, _ := s.Tree(ctx)
|
|
for _, o := range tree {
|
|
d.orgs = append(d.orgs, orgRow{level: "org", id: o.ID, name: o.Name, path: o.Name, members: o.Members})
|
|
for _, t := range o.Teams {
|
|
tp := o.Name + "/" + t.Name
|
|
d.orgs = append(d.orgs, orgRow{level: "team", id: t.ID, name: t.Name, path: tp, members: t.Members})
|
|
for _, p := range t.Projects {
|
|
d.orgs = append(d.orgs, orgRow{level: "project", id: p.ID, name: p.Name, path: tp + "/" + p.Name,
|
|
members: p.Users, keys: p.ActiveKeys})
|
|
}
|
|
}
|
|
}
|
|
d.threads, d.notes = map[string]int{}, map[string]int{}
|
|
for _, r := range d.orgs {
|
|
if r.level != "project" {
|
|
continue
|
|
}
|
|
if t, err := s.ThreadRetention(ctx, r.id); err == nil {
|
|
d.threads[r.id] = int(t.Hours())
|
|
}
|
|
if t, err := s.MemoryRetention(ctx, r.id); err == nil {
|
|
d.notes[r.id] = int(t.Hours())
|
|
}
|
|
}
|
|
return consolePeopleMsg(d)
|
|
}
|
|
}
|
|
|
|
// runCLI runs llmgw with these arguments in the background, against this
|
|
// console's store, and shows what it printed.
|
|
func (m *consoleModel) runCLI(title string, args ...string) tea.Cmd {
|
|
m.busy = title
|
|
file := m.file
|
|
return func() tea.Msg {
|
|
var out bytes.Buffer
|
|
c := Root()
|
|
c.SetArgs(append(append([]string{"--log-level=error"}, args...), "--config="+file))
|
|
c.SetOut(&out)
|
|
c.SetErr(&out)
|
|
c.SetIn(strings.NewReader(""))
|
|
err := c.ExecuteContext(context.Background())
|
|
text := strings.TrimSpace(out.String())
|
|
if text == "" {
|
|
text = "Done."
|
|
}
|
|
if err != nil {
|
|
if text != "Done." {
|
|
err = fmt.Errorf("%v\n%s", err, text)
|
|
}
|
|
return actionDoneMsg{title: title, err: err}
|
|
}
|
|
return actionDoneMsg{title: title, text: text}
|
|
}
|
|
}
|
|
|
|
func pwF(label, hint string) formField {
|
|
f := textF(label, "", hint)
|
|
f.text.Mask = true
|
|
return f
|
|
}
|
|
|
|
// --- accounts ---
|
|
|
|
func (m consoleModel) userMenu(u store.User) *menuDialog {
|
|
who := u.Email
|
|
cli := func(title string, args ...string) menuItem {
|
|
return menuItem{title, func(m *consoleModel) tea.Cmd { return m.runCLI(title+" "+who, args...) }}
|
|
}
|
|
var items []menuItem
|
|
switch u.State {
|
|
case store.UserPending:
|
|
items = append(items, menuItem{"Approve…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Approve " + who,
|
|
note: "Approved, they sign in and issue their own keys.",
|
|
fields: []formField{textF("project", "", "optional: a project to put them in, like Acme/ml/training")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
args := []string{"users", "approve", u.ID, "--by=console"}
|
|
if v[0] != "" {
|
|
args = append(args, "--project="+v[0])
|
|
}
|
|
return m.runCLI("Approve "+who, args...), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
case store.UserSuspended:
|
|
items = append(items, cli("Reinstate", "users", "reinstate", u.ID))
|
|
default:
|
|
items = append(items, menuItem{"Suspend…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Suspend " + who,
|
|
text: "The account stops working and its sessions close; it and its history are kept.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Suspend "+who, "users", "suspend", u.ID) }}
|
|
return nil
|
|
}})
|
|
}
|
|
if u.IsAdmin {
|
|
items = append(items, cli("Take away the cluster dashboard", "users", "demote", u.ID))
|
|
} else {
|
|
items = append(items, cli("Give the cluster dashboard (/admin)", "users", "promote", u.ID))
|
|
}
|
|
items = append(items,
|
|
menuItem{"Add to an organization, team or project…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Membership for " + who,
|
|
note: "A member of a team or organization may use every project in it.",
|
|
fields: []formField{
|
|
choiceF("level", []string{"org", "team", "project"}, "team"),
|
|
textF("which", "", "Acme, Acme/ml or Acme/ml/training"),
|
|
choiceF("role", []string{store.RoleMember, "manager"}, store.RoleMember),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[1] == "" {
|
|
return nil, fmt.Errorf("which %s?", v[0])
|
|
}
|
|
return m.runCLI("Membership for "+who, "users", "join", u.ID, "--"+v[0]+"="+v[1], "--role="+v[2], "--by=console"), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Take out of one…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Take " + who + " out",
|
|
note: "Keys they made in a project they can no longer reach are revoked.",
|
|
fields: []formField{
|
|
choiceF("level", []string{"org", "team", "project"}, "team"),
|
|
textF("which", "", strings.Join(m.people.where[u.ID], "; ")),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[1] == "" {
|
|
return nil, fmt.Errorf("which %s?", v[0])
|
|
}
|
|
return m.runCLI("Take "+who+" out", "users", "leave", u.ID, "--"+v[0]+"="+v[1]), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Their limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.ScopeUser, u.ID, who); return nil }},
|
|
menuItem{"Set a new password…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "New password for " + who,
|
|
note: "Their sessions close; tell them out of band and have them change it.",
|
|
fields: []formField{pwF("password", "at least 12 characters"), pwF("again", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" || v[0] != v[1] {
|
|
return nil, fmt.Errorf("the two are not the same")
|
|
}
|
|
return m.runCLI("Password for "+who, "users", "reset-password", u.ID, "--password="+v[0]), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Delete the account…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Delete " + who,
|
|
text: "They are signed out, every key they made is revoked, and the account is removed.\nTo keep it, suspend it instead.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Delete "+who, "users", "delete", u.ID, "--yes") }}
|
|
return nil
|
|
}},
|
|
)
|
|
return &menuDialog{title: who, items: items}
|
|
}
|
|
|
|
func (m *consoleModel) createUserForm() {
|
|
m.openForm(&formDialog{title: "New account",
|
|
note: "Created already approved. The password should be changed by its owner.",
|
|
fields: []formField{
|
|
textF("email", "", ""),
|
|
pwF("password", "at least 12 characters"),
|
|
textF("project", "", "optional: a project to put them in"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if !strings.Contains(v[0], "@") || v[1] == "" {
|
|
return nil, fmt.Errorf("an email and a password")
|
|
}
|
|
args := []string{"users", "create", v[0], "--password=" + v[1]}
|
|
if v[2] != "" {
|
|
args = append(args, "--project="+v[2])
|
|
}
|
|
return m.runCLI("New account", args...), nil
|
|
}})
|
|
}
|
|
|
|
// --- keys ---
|
|
|
|
func (m consoleModel) keyMenu(k store.KeyRow) *menuDialog {
|
|
name := k.Name + " (" + k.Prefix + ")"
|
|
return &menuDialog{title: name, items: []menuItem{
|
|
{"Its limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.ScopeKey, k.ID, name); return nil }},
|
|
{"Revoke…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Revoke " + name,
|
|
text: "It stops working at once. It is kept, revoked, for the usage that refers to it.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Revoke "+name, "keys", "revoke", k.ID) }}
|
|
return nil
|
|
}},
|
|
}}
|
|
}
|
|
|
|
func (m *consoleModel) createKeyForm() {
|
|
m.openForm(&formDialog{title: "New API key",
|
|
note: "Shown once, here, and never again: only its hash is kept.",
|
|
fields: []formField{
|
|
textF("name", "", "what it is for"),
|
|
textF("project", "", "Acme/ml/training; empty with a person below is their personal key"),
|
|
textF("for", "", "optional: the person it belongs to, by email"),
|
|
textF("expires in", "", "optional: like 720h; empty never expires"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a name")
|
|
}
|
|
args := []string{"keys", "create", v[0]}
|
|
if v[1] != "" {
|
|
args = append(args, "--project="+v[1])
|
|
}
|
|
if v[2] != "" {
|
|
args = append(args, "--for="+v[2])
|
|
}
|
|
if v[3] != "" {
|
|
if _, err := time.ParseDuration(v[3]); err != nil {
|
|
return nil, fmt.Errorf("expires in is a duration, like 720h")
|
|
}
|
|
args = append(args, "--expires-in="+v[3])
|
|
}
|
|
return m.runCLI("New key "+v[0], args...), nil
|
|
}})
|
|
}
|
|
|
|
// --- limits, at any level ---
|
|
|
|
func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
|
|
l := m.people.limits[store.LimitOwner{Scope: scope, ID: id}.CounterKey()]
|
|
budget, period := "", "month"
|
|
if l.Budget != nil {
|
|
budget = strconv.FormatFloat(float64(*l.Budget)/1e6, 'f', -1, 64)
|
|
}
|
|
if l.BudgetPeriod != "" {
|
|
period = string(l.BudgetPeriod)
|
|
}
|
|
models := "every model"
|
|
if l.AllowedModels != nil {
|
|
models = strings.Join(l.AllowedModels, ",")
|
|
}
|
|
m.openForm(&formDialog{title: "Limits on " + who,
|
|
note: "Each level only narrows what is above it. Empty is no limit here.",
|
|
fields: []formField{
|
|
textF("requests/min", ptrText(l.RPM), ""),
|
|
textF("tokens/min", ptrText(l.TPM), ""),
|
|
textF("budget", budget, "spend per period, in whole units"),
|
|
choiceF("per", []string{"day", "month"}, period),
|
|
textF("models", models, "allowed, separated by commas; \"every model\" for no list"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
args := []string{"keys", "limits", "--scope=" + string(scope), "--id=" + id}
|
|
for i, flag := range []string{"rpm", "tpm"} {
|
|
if v[i] != "" {
|
|
if _, err := strconv.Atoi(v[i]); err != nil {
|
|
return nil, fmt.Errorf("%s is a whole number", flag)
|
|
}
|
|
args = append(args, "--"+flag+"="+v[i])
|
|
}
|
|
}
|
|
if v[2] != "" {
|
|
if _, err := strconv.ParseFloat(v[2], 64); err != nil {
|
|
return nil, fmt.Errorf("the budget is a number")
|
|
}
|
|
args = append(args, "--budget="+v[2], "--period="+v[3])
|
|
}
|
|
if v[4] != "every model" {
|
|
args = append(args, "--models="+strings.ReplaceAll(v[4], " ", ""))
|
|
}
|
|
return m.runCLI("Limits on "+who, args...), nil
|
|
}})
|
|
}
|
|
|
|
// --- organizations ---
|
|
|
|
func (m consoleModel) orgMenu(r orgRow) *menuDialog {
|
|
child := map[string]string{"org": "team", "team": "project"}[r.level]
|
|
parent := map[string]string{"team": "organization", "project": "team"}[r.level]
|
|
var items []menuItem
|
|
if child != "" {
|
|
items = append(items, menuItem{"Add a " + child + " in it…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "New " + child + " in " + r.path,
|
|
fields: []formField{textF("name", "", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a name")
|
|
}
|
|
return m.runCLI("New "+child, "org", "add", child, v[0], "--in="+r.id), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
}
|
|
items = append(items, menuItem{"Rename…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Rename " + r.path,
|
|
fields: []formField{textF("name", r.name, "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
return m.runCLI("Rename "+r.path, "org", "rename", r.level, r.id, v[0]), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
if parent != "" {
|
|
items = append(items, menuItem{"Move to another " + parent + "…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Move " + r.path,
|
|
fields: []formField{textF(parent, "", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
return m.runCLI("Move "+r.path, "org", "move", r.level, r.id, "--to="+v[0]), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
}
|
|
items = append(items,
|
|
menuItem{"Limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.Scope(r.level), r.id, r.path); return nil }},
|
|
menuItem{"Limits for each member…", func(m *consoleModel) tea.Cmd {
|
|
m.limitsForm(store.MemberScope(store.Scope(r.level)), r.id, "each member of "+r.path)
|
|
return nil
|
|
}},
|
|
menuItem{"Capacity quota…", func(m *consoleModel) tea.Cmd {
|
|
q := adminapi.QuotaView{Scope: r.level + ":" + r.id}
|
|
for _, x := range m.quotas {
|
|
if x.Scope == q.Scope {
|
|
q = x
|
|
}
|
|
}
|
|
m.quotaForm(q)
|
|
return nil
|
|
}},
|
|
)
|
|
if r.level == "project" {
|
|
retention := func(label, sub, what string, now int) menuItem {
|
|
return menuItem{label + "…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: label + " · " + r.path,
|
|
note: "Off by default: llmgw keeps no " + what + " unless a project asks for it.",
|
|
fields: []formField{textF("hours", strconv.Itoa(now), "how long they are kept; 0 keeps none")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
h, err := strconv.Atoi(v[0])
|
|
if err != nil || h < 0 {
|
|
return nil, fmt.Errorf("hours is a whole number")
|
|
}
|
|
args := []string{"keys", sub, "--project=" + r.id}
|
|
if h == 0 {
|
|
args = append(args, "--off")
|
|
} else {
|
|
args = append(args, "--retain-hours="+v[0])
|
|
}
|
|
return m.runCLI(label, args...), nil
|
|
}})
|
|
return nil
|
|
}}
|
|
}
|
|
items = append(items,
|
|
retention("Keep conversations", "threads", "conversations", m.people.threads[r.id]),
|
|
retention("Keep agent notes", "memory", "notes", m.people.notes[r.id]))
|
|
}
|
|
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Remove " + r.path,
|
|
text: "Only something empty is removed: an organization with no teams, a team with\nno projects, a project with no accounts and no keys.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Remove "+r.path, "org", "remove", r.level, r.id) }}
|
|
return nil
|
|
}})
|
|
return &menuDialog{title: r.level + " " + r.path, items: items}
|
|
}
|
|
|
|
func (m *consoleModel) createOrgForm() {
|
|
m.openForm(&formDialog{title: "New organization",
|
|
fields: []formField{textF("name", "", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a name")
|
|
}
|
|
return m.runCLI("New organization", "org", "add", "org", v[0]), nil
|
|
}})
|
|
}
|
|
|
|
// --- the views ---
|
|
|
|
func (m consoleModel) peoplePanel(pw int) (string, string, string) {
|
|
title := consoleLabels[m.menu]
|
|
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
|
if m.inPanel {
|
|
keys = "↑↓ row · Enter actions · A add · ← back · Q leave"
|
|
}
|
|
if m.people.err != nil {
|
|
return title, "\n " + sBad.Render("Cannot read the store: ") + m.people.err.Error(), keys
|
|
}
|
|
if m.people.users == nil && m.people.orgs == nil {
|
|
return title, "\n " + m.spin.View() + " reading the store…", keys
|
|
}
|
|
now := time.Now()
|
|
var head []string
|
|
var rows [][]string
|
|
switch m.menu {
|
|
case cAccounts:
|
|
head = []string{"EMAIL", "STATE", "ADMIN", "MEMBER OF", "SIGNED UP", "LAST LOGIN"}
|
|
for _, u := range m.people.users {
|
|
state := string(u.State)
|
|
if u.State == store.UserPending {
|
|
state = sWarn.Render(state)
|
|
}
|
|
admin := ""
|
|
if u.IsAdmin {
|
|
admin = "yes"
|
|
}
|
|
where := strings.Join(m.people.where[u.ID], "; ")
|
|
if where == "" {
|
|
where = sDim.Render("on their own")
|
|
}
|
|
rows = append(rows, []string{u.Email, state, admin, where, ago(u.CreatedAt, now), ago(u.LastLoginAt, now)})
|
|
}
|
|
case cKeys:
|
|
head = []string{"NAME", "KEY", "PROJECT", "OWNER", "STATE", "LAST USED"}
|
|
for _, k := range m.people.keys {
|
|
where := k.ProjectPath
|
|
if where == "" {
|
|
where = "personal"
|
|
}
|
|
rows = append(rows, []string{k.Name, k.Prefix, where, dash(k.OwnerEmail), keyState(k.Key, now), ago(k.LastUsedAt, now)})
|
|
}
|
|
case cOrgs:
|
|
head = []string{"NAME", "LEVEL", "MEMBERS", "KEYS", "LIMITS"}
|
|
for _, r := range m.people.orgs {
|
|
name := r.name
|
|
switch r.level {
|
|
case "team":
|
|
name = " " + name
|
|
case "project":
|
|
name = " " + name
|
|
}
|
|
keysCell := ""
|
|
if r.level == "project" {
|
|
keysCell = strconv.Itoa(r.keys)
|
|
}
|
|
rows = append(rows, []string{name, r.level, strconv.Itoa(r.members), keysCell,
|
|
limitsCell(m.people.limits[store.LimitOwner{Scope: store.Scope(r.level), ID: r.id}.CounterKey()])})
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
return title, "\n " + sDim.Render("None yet. A adds one."), keys
|
|
}
|
|
return title, "\n" + table(head, rows, m.row, pw, m.inPanel), keys
|
|
}
|
|
|
|
func limitsCell(l store.Limits) string {
|
|
var p []string
|
|
if l.RPM != nil {
|
|
p = append(p, fmt.Sprintf("%d rpm", *l.RPM))
|
|
}
|
|
if l.TPM != nil {
|
|
p = append(p, fmt.Sprintf("%s tpm", compactNum(int64(*l.TPM))))
|
|
}
|
|
if l.Budget != nil {
|
|
p = append(p, fmt.Sprintf("%.2f/%s", float64(*l.Budget)/1e6, l.BudgetPeriod))
|
|
}
|
|
if l.AllowedModels != nil {
|
|
p = append(p, fmt.Sprintf("%d models", len(l.AllowedModels)))
|
|
}
|
|
return strings.Join(p, ", ")
|
|
}
|