A person leaves licences out, and no key of theirs reaches a model under one
CI / test (push) Successful in 11m28s
CI / test (push) Successful in 11m28s
A game sold in the EU cannot ship what a licence excluding the EU made. On the profile page a person leaves out licences one by one, or by rule: every licence forbidding commercial use, every licence granting nothing in a region. The rules cover models added later. The choice reaches every principal through the resolved limits, so permits() leaves such models out of /v1/models, requests, jobs, fallbacks and the portal's pickers, and a request for one is refused 403 licence_excluded saying which choice did. Kept in user_excluded_licences (migration 0046), copied with the store and gone with the person. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a313b3e0da
commit
4bbac0833d
20 files changed
+658
-7
No files matched your search
@@ -60,6 +60,11 @@ APIs change between minors without a deprecation period.
|
||||
up now; a picture is cut out and put on grey before TripoSR sees it
|
||||
(`LLMGWPrepareForMesh`, rembg when it has no alpha).
|
||||
|
||||
- **Licences a person leaves out**: on the profile page, by licence, or by
|
||||
rule — every licence forbidding commercial use, every licence granting
|
||||
nothing in a region — so a model under one is neither listed to nor
|
||||
served for that person's keys and sessions (403 `licence_excluded`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A model waiting for another on-demand model to give up the card failed at
|
||||
|
||||
+14
-1
@@ -534,6 +534,19 @@ curl https://llm.example.com/mcp \
|
||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||
```
|
||||
|
||||
### Licences you leave out
|
||||
|
||||
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||
it allows commercial use and the regions it grants nothing in). On your
|
||||
profile page, under **Licences**, you can leave licences out: one by one,
|
||||
every licence that forbids commercial use, or every licence that grants
|
||||
nothing in a region you name (`EU` for the union). The rules cover models
|
||||
added later too. From then on those models are not listed to your keys and
|
||||
a request for one is refused with 403 and the code `licence_excluded`,
|
||||
naming the choice that left it out — on every key you own and on the
|
||||
portal's pages, whatever a key's own allowlist says. A change reaches keys
|
||||
already in use within a minute.
|
||||
|
||||
### Headers llmgw sends back
|
||||
|
||||
| Header | Says |
|
||||
@@ -553,7 +566,7 @@ its usual exception with the gateway's message in it. The ones worth knowing:
|
||||
|---|---|
|
||||
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
|
||||
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
|
||||
| 403 | The key may not use that model. |
|
||||
| 403 | The key may not use that model; with the code `licence_excluded`, its licence is one you left out on your profile page. |
|
||||
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
|
||||
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the queue is full. The SDKs retry by themselves; a name that is not served will not start to be. |
|
||||
| 504 | Every replica was busy for as long as the key's band may wait. `Retry-After` says when to send it again. |
|
||||
|
||||
@@ -58,6 +58,9 @@ func (r *Router) registerChat(mux *http.ServeMux, pages *web.AccountPages) {
|
||||
}))
|
||||
r.registerImagesPage(mux, pages)
|
||||
r.registerMeshesPage(mux, pages)
|
||||
if r.store != nil {
|
||||
pages.SetLicences(r.licenceChoices, r.store)
|
||||
}
|
||||
}
|
||||
|
||||
// chatSend dispatches one turn on behalf of a signed-in person.
|
||||
|
||||
@@ -546,7 +546,7 @@ func (r *Router) handleInference(path string) http.HandlerFunc {
|
||||
}
|
||||
if p, ok := auth.FromContext(req.Context()); ok {
|
||||
if !r.permits(p, parsed.Model) {
|
||||
auth.WriteForbiddenModel(w, parsed.Model)
|
||||
r.writeForbidden(w, p, parsed.Model)
|
||||
r.m.observeRequest(r.metricModel(parsed.Model), "", http.StatusForbidden, time.Since(start), nil, 0)
|
||||
return
|
||||
}
|
||||
@@ -702,6 +702,11 @@ func (r *Router) permits(p auth.Principal, requested string) bool {
|
||||
return true
|
||||
}
|
||||
canonical, m := r.resolve(requested)
|
||||
// A licence the person has said not to use leaves the model out
|
||||
// whatever the allowlist says: it is theirs to narrow, not to widen.
|
||||
if licenceExcluded(p.Limits.ExcludedLicences, m) != "" {
|
||||
return false
|
||||
}
|
||||
if p.Allows(canonical) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
@@ -107,7 +106,7 @@ func (r *Router) createJob(w http.ResponseWriter, req *http.Request, path string
|
||||
return
|
||||
}
|
||||
if !r.permits(p, in.Model) {
|
||||
auth.WriteForbiddenModel(w, in.Model)
|
||||
r.writeForbidden(w, p, in.Model)
|
||||
return
|
||||
}
|
||||
if why := r.imageRouteRefusal(path, in.Model, body); why != "" {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/web"
|
||||
)
|
||||
|
||||
// What a person may say not to use, besides a licence by its id: every
|
||||
// licence that forbids commercial use, and every licence that grants nothing
|
||||
// in a region. Rules rather than a list, so a model added later under such
|
||||
// a licence is left out without anybody remembering to tick it.
|
||||
const (
|
||||
licenceUnspecified = "unspecified"
|
||||
ruleNonCommercial = "rule:non-commercial"
|
||||
ruleRegionPrefix = "rule:region:"
|
||||
)
|
||||
|
||||
// licenceKey is how a model's licence is matched: its id, else its name,
|
||||
// lowercased; licenceUnspecified for a model whose entry names none.
|
||||
func licenceKey(m catalog.Model) string {
|
||||
if m.License == nil {
|
||||
return licenceUnspecified
|
||||
}
|
||||
if m.License.ID != "" {
|
||||
return strings.ToLower(m.License.ID)
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(m.License.Name))
|
||||
}
|
||||
|
||||
// licenceExcluded says which of the person's choices leaves a model out,
|
||||
// empty when none does.
|
||||
func licenceExcluded(excluded []string, m catalog.Model) string {
|
||||
if len(excluded) == 0 {
|
||||
return ""
|
||||
}
|
||||
key := licenceKey(m)
|
||||
for _, e := range excluded {
|
||||
switch {
|
||||
case strings.EqualFold(e, key):
|
||||
return e
|
||||
case e == ruleNonCommercial:
|
||||
if m.License != nil && m.License.Commercial != nil && !*m.License.Commercial {
|
||||
return e
|
||||
}
|
||||
case strings.HasPrefix(e, ruleRegionPrefix):
|
||||
if m.License != nil && slices.Contains(m.License.ExcludedRegions, strings.TrimPrefix(e, ruleRegionPrefix)) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// excludedByLicence says why the principal's person has left this model
|
||||
// out, empty when they have not.
|
||||
func (r *Router) excludedByLicence(p auth.Principal, requested string) string {
|
||||
if len(p.Limits.ExcludedLicences) == 0 {
|
||||
return ""
|
||||
}
|
||||
_, m := r.resolve(requested)
|
||||
return licenceExcluded(p.Limits.ExcludedLicences, m)
|
||||
}
|
||||
|
||||
// licenceChoices are what the profile page offers: each licence the
|
||||
// catalog's models are under, with the models, and the regions any of them
|
||||
// excludes.
|
||||
func (r *Router) licenceChoices() web.LicenceChoices {
|
||||
by := map[string]*web.LicenceChoice{}
|
||||
regions := map[string]bool{}
|
||||
nonCommercial := false
|
||||
for name, m := range r.modelMap() {
|
||||
if name != m.Name || r.hiddenModel(name) {
|
||||
continue // an alias, or a guardrail's judge nobody asks for
|
||||
}
|
||||
key := licenceKey(m)
|
||||
c, ok := by[key]
|
||||
if !ok {
|
||||
c = &web.LicenceChoice{Key: key}
|
||||
if m.License != nil {
|
||||
c.Name, c.URL = m.License.Name, m.License.URL
|
||||
if cm := m.License.Commercial; cm != nil {
|
||||
c.Commercial = map[bool]string{true: "yes", false: "no"}[*cm]
|
||||
}
|
||||
c.ExcludedRegions = m.License.ExcludedRegions
|
||||
}
|
||||
by[key] = c
|
||||
}
|
||||
c.Models = append(c.Models, m.Name)
|
||||
if m.License != nil {
|
||||
for _, reg := range m.License.ExcludedRegions {
|
||||
regions[reg] = true
|
||||
}
|
||||
if m.License.Commercial != nil && !*m.License.Commercial {
|
||||
nonCommercial = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var out web.LicenceChoices
|
||||
for _, c := range by {
|
||||
sort.Strings(c.Models)
|
||||
out.Licences = append(out.Licences, *c)
|
||||
}
|
||||
sort.Slice(out.Licences, func(i, j int) bool { return out.Licences[i].Key < out.Licences[j].Key })
|
||||
for reg := range regions {
|
||||
out.Regions = append(out.Regions, reg)
|
||||
}
|
||||
sort.Strings(out.Regions)
|
||||
out.AnyNonCommercial = nonCommercial
|
||||
return out
|
||||
}
|
||||
|
||||
// writeForbidden refuses a model the principal may not use, saying why when
|
||||
// it is the person's own choice of licences rather than the key's allowlist.
|
||||
func (r *Router) writeForbidden(w http.ResponseWriter, p auth.Principal, model string) {
|
||||
if why := r.excludedByLicence(p, model); why != "" {
|
||||
writeErrorCode(w, http.StatusForbidden, "invalid_request_error", "licence_excluded",
|
||||
fmt.Sprintf("the model %q is under a licence its account has chosen not to use (%s); change it on the profile page", model, why))
|
||||
return
|
||||
}
|
||||
auth.WriteForbiddenModel(w, model)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestAPersonsLicenceChoiceLeavesModelsOutOfTheirKeys(t *testing.T) {
|
||||
// A game sold in the EU cannot ship what a licence excluding the EU
|
||||
// made: its maker says so once, and no key of theirs reaches such a model.
|
||||
yes, no := true, false
|
||||
names := []string{"mit", "nc", "eu-out", "plain"}
|
||||
e := newFakeEngine(t, "m")
|
||||
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: names})
|
||||
mk := func(name string, l *catalog.License) catalog.Model {
|
||||
m := catModel(name, nil)
|
||||
m.License = l
|
||||
return m
|
||||
}
|
||||
cfg.Models = []catalog.Model{
|
||||
mk("mit", &catalog.License{ID: "MIT", Name: "MIT", Commercial: &yes}),
|
||||
mk("nc", &catalog.License{ID: "CC-BY-NC-4.0", Name: "CC BY-NC 4.0", Commercial: &no}),
|
||||
mk("eu-out", &catalog.License{ID: "tencent-hunyuan-community", Name: "Tencent Hunyuan Community",
|
||||
ExcludedRegions: []string{"EU", "UK", "KR"}}),
|
||||
mk("plain", nil),
|
||||
}
|
||||
cfg.Auth.Enabled = true
|
||||
cfg.Auth.Store = filepath.Join(t.TempDir(), "store.db")
|
||||
rt := newTestRouter(t, cfg)
|
||||
ctx := t.Context()
|
||||
if err := rt.store.CreateUser(ctx, store.User{ID: "usr-g", Email: "g@game.test", State: store.UserActive,
|
||||
CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := store.KeyPrefix + strings.Repeat("g", 43)
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
if err := rt.store.CreateUserKey(ctx, "usr-g", store.Key{ID: "key-g", Hash: sum[:], Prefix: "gggg",
|
||||
Name: "game", CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.store.SetUserExcludedLicences(ctx, "usr-g",
|
||||
[]string{"rule:non-commercial", "rule:region:EU", "unspecified"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := rt.Handler()
|
||||
|
||||
_, list, raw := call(t, h, secret, http.MethodGet, "/v1/models", nil)
|
||||
var listed []string
|
||||
for _, m := range list["data"].([]any) {
|
||||
listed = append(listed, m.(map[string]any)["id"].(string))
|
||||
}
|
||||
if !slices.Equal(listed, []string{"mit"}) {
|
||||
t.Errorf("listed %v: %s", listed, raw)
|
||||
}
|
||||
for _, name := range []string{"nc", "eu-out", "plain"} {
|
||||
code, _, raw := call(t, h, secret, http.MethodPost, "/v1/chat/completions",
|
||||
map[string]any{"model": name, "messages": []any{map[string]any{"role": "user", "content": "hi"}}})
|
||||
if code != http.StatusForbidden || !strings.Contains(raw, "licence_excluded") {
|
||||
t.Errorf("%s: %d %s", name, code, raw)
|
||||
}
|
||||
}
|
||||
if code, _, raw := call(t, h, secret, http.MethodPost, "/v1/chat/completions",
|
||||
map[string]any{"model": "mit", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}); code != 200 {
|
||||
t.Errorf("mit: %d %s", code, raw)
|
||||
}
|
||||
|
||||
// The profile page is offered every licence, and the regions in play.
|
||||
c := rt.licenceChoices()
|
||||
var keys []string
|
||||
for _, l := range c.Licences {
|
||||
keys = append(keys, l.Key)
|
||||
}
|
||||
if !slices.Equal(keys, []string{"cc-by-nc-4.0", "mit", "tencent-hunyuan-community", "unspecified"}) ||
|
||||
!slices.Equal(c.Regions, []string{"EU", "KR", "UK"}) || !c.AnyNonCommercial {
|
||||
t.Errorf("choices %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ var copyOrder = []string{
|
||||
"users",
|
||||
// After users and the three levels of the tree, which it points at.
|
||||
"memberships",
|
||||
// After users, which it points at.
|
||||
"user_excluded_licences",
|
||||
"api_keys",
|
||||
"key_claims",
|
||||
"sessions",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserExcludedLicences is what a person has said not to use, sorted: a
|
||||
// licence by its id, or a rule over licences ("rule:non-commercial",
|
||||
// "rule:region:EU"). What each means is the router's to match; the store
|
||||
// keeps the words.
|
||||
func (s *DB) UserExcludedLicences(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT licence FROM user_excluded_licences WHERE user_id = ? ORDER BY licence`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var l string
|
||||
if err := rows.Scan(&l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetUserExcludedLicences replaces what a person has said not to use; an
|
||||
// empty list excludes nothing.
|
||||
func (s *DB) SetUserExcludedLicences(ctx context.Context, userID string, licences []string) error {
|
||||
keys := make([]string, 0, len(licences))
|
||||
for _, l := range licences {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
keys = append(keys, l)
|
||||
}
|
||||
}
|
||||
slices.Sort(keys)
|
||||
keys = slices.Compact(keys)
|
||||
return s.inTx(ctx, func(c conn) error {
|
||||
if _, err := c.ExecContext(ctx, `DELETE FROM user_excluded_licences WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, err := c.ExecContext(ctx,
|
||||
`INSERT INTO user_excluded_licences (user_id, licence) VALUES (?, ?)`, userID, k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPersonsExcludedLicencesReachEveryWayTheyAsk(t *testing.T) {
|
||||
db := openDB(t)
|
||||
ctx := t.Context()
|
||||
if err := db.CreateUser(ctx, User{ID: "usr-1", Email: "a@b.c", State: UserActive, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetUserExcludedLicences(ctx, "usr-1", []string{"tencent-hunyuan-community", " CC-BY-NC-4.0", "", "CC-BY-NC-4.0", "rule:region:EU"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.UserExcludedLicences(ctx, "usr-1")
|
||||
want := []string{"CC-BY-NC-4.0", "rule:region:EU", "tencent-hunyuan-community"}
|
||||
if err != nil || !slices.Equal(got, want) {
|
||||
t.Errorf("excluded %v %v, want %v", got, err, want)
|
||||
}
|
||||
|
||||
// A session, which has no key row, gets them as a key does.
|
||||
eff, err := db.ResolveFor(ctx, "session:usr-1", "usr-1", "")
|
||||
if err != nil || !slices.Equal(eff.ExcludedLicences, want) {
|
||||
t.Errorf("session: %v %v", eff.ExcludedLicences, err)
|
||||
}
|
||||
|
||||
if err := db.SetUserExcludedLicences(ctx, "usr-1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := db.UserExcludedLicences(ctx, "usr-1"); len(got) != 0 {
|
||||
t.Errorf("cleared, still %v", got)
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,13 @@ func (s *DB) ResolveFor(ctx context.Context, callerID, userID, projectID string)
|
||||
// person's limit by every key they own; a per-member limit by one person
|
||||
// within one scope.
|
||||
func (s *DB) resolveChain(ctx context.Context, e *Effective, keyLevel bool) error {
|
||||
if e.UserID != "" {
|
||||
ex, err := s.UserExcludedLicences(ctx, e.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.ExcludedLicences = ex
|
||||
}
|
||||
if e.ProjectID != "" {
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT t.id, t.org_id FROM projects p JOIN teams t ON t.id = p.team_id WHERE p.id = ?`,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Ported from the SQLite migration of the same number.
|
||||
-- The licences a person has said not to use. Models under them are not
|
||||
-- listed to the person, nor served to their keys or their sessions: a
|
||||
-- game sold in the EU cannot ship what a licence excluding the EU made.
|
||||
CREATE TABLE user_excluded_licences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
licence TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, licence)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- The licences a person has said not to use. Models under them are not
|
||||
-- listed to the person, nor served to their keys or their sessions: a
|
||||
-- game sold in the EU cannot ship what a licence excluding the EU made.
|
||||
CREATE TABLE user_excluded_licences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
licence TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, licence)
|
||||
);
|
||||
@@ -165,6 +165,9 @@ type Effective struct {
|
||||
BudgetPeriod Period
|
||||
// AllowedModels is the intersection along the chain. nil means every model.
|
||||
AllowedModels []string
|
||||
// ExcludedLicences are the licences the caller's person has said not to
|
||||
// use, as LicenceKey gives them; none for a key without a person.
|
||||
ExcludedLicences []string
|
||||
|
||||
// RPMOwner, TPMOwner and BudgetOwner say which scope set the value that
|
||||
// won, so consumption can be counted against that scope rather than
|
||||
|
||||
+14
-3
@@ -95,9 +95,13 @@ type AccountPages struct {
|
||||
hasSettings bool
|
||||
// images says whether some model makes pictures; nil when the
|
||||
// pictures page is not mounted.
|
||||
images func() bool
|
||||
meshes func() bool
|
||||
guardLibrary func() guard.Config
|
||||
// licenceChoices and licencePrefs offer the choice of licences on the
|
||||
// profile page; nil offers none.
|
||||
licenceChoices func() LicenceChoices
|
||||
licencePrefs LicencePrefs
|
||||
images func() bool
|
||||
meshes func() bool
|
||||
guardLibrary func() guard.Config
|
||||
// guardFor names the guardrail policies in force for someone, and
|
||||
// whether they keep nothing; nil when there are none.
|
||||
guardFor func(guard.Subject) (string, bool)
|
||||
@@ -193,6 +197,7 @@ func (p *AccountPages) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
|
||||
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
|
||||
mux.HandleFunc("POST /profile/password", p.selfAudited(p.changePassword))
|
||||
mux.HandleFunc("POST /profile/licences", p.selfAudited(p.setLicences))
|
||||
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
|
||||
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
|
||||
}
|
||||
@@ -379,6 +384,11 @@ type profileData struct {
|
||||
Models []string
|
||||
Error string
|
||||
|
||||
// Licences is the choice of licences not to use; nil when not offered.
|
||||
Licences *LicenceChoices
|
||||
LicencesSaved bool
|
||||
LicenceError string
|
||||
|
||||
PasswordChanged bool
|
||||
PasswordError string
|
||||
MinPassword int
|
||||
@@ -505,6 +515,7 @@ func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
d.Keys = keys
|
||||
p.fillPlaces(r.Context(), langOf(r), user, &d)
|
||||
p.fillLicences(r.Context(), user.ID, &d)
|
||||
render(w, r, "profile", d)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LicenceChoice is one licence the catalog's models are under, as the
|
||||
// profile page offers it.
|
||||
type LicenceChoice struct {
|
||||
// Key is what is stored when it is ticked: the licence's id, else its
|
||||
// name, lowercased; "unspecified" for models whose entry names none.
|
||||
Key string
|
||||
Name, URL string
|
||||
// Commercial is "yes" or "no" as the licence says, empty when it does
|
||||
// not say.
|
||||
Commercial string
|
||||
ExcludedRegions []string
|
||||
Models []string
|
||||
// Excluded is whether the person has left it out.
|
||||
Excluded bool
|
||||
}
|
||||
|
||||
// LicenceChoices is the licences on offer, and what the rules can be about.
|
||||
type LicenceChoices struct {
|
||||
Licences []LicenceChoice
|
||||
// Regions are those some licence in the catalog grants nothing in.
|
||||
Regions []string
|
||||
// AnyNonCommercial is whether some licence forbids commercial use.
|
||||
AnyNonCommercial bool
|
||||
// What the person has chosen, for the rules.
|
||||
NonCommercial bool
|
||||
ExcludedRegions []string
|
||||
}
|
||||
|
||||
// LicencePrefs keeps what a person has said not to use.
|
||||
type LicencePrefs interface {
|
||||
UserExcludedLicences(ctx context.Context, userID string) ([]string, error)
|
||||
SetUserExcludedLicences(ctx context.Context, userID string, licences []string) error
|
||||
}
|
||||
|
||||
// Rule words, matched by the router.
|
||||
const (
|
||||
licenceRuleNonCommercial = "rule:non-commercial"
|
||||
licenceRuleRegion = "rule:region:"
|
||||
)
|
||||
|
||||
var regionCode = regexp.MustCompile(`^[A-Z]{2}$`)
|
||||
|
||||
// SetLicences offers the choice of licences on the profile page: what the
|
||||
// catalog's models are under, and where the choices are kept.
|
||||
func (p *AccountPages) SetLicences(choices func() LicenceChoices, prefs LicencePrefs) {
|
||||
p.licenceChoices, p.licencePrefs = choices, prefs
|
||||
}
|
||||
|
||||
// fillLicences puts the choice, and what the person has made of it, on the
|
||||
// profile page; nothing when it is not offered.
|
||||
func (p *AccountPages) fillLicences(ctx context.Context, userID string, d *profileData) {
|
||||
if p.licenceChoices == nil || p.licencePrefs == nil {
|
||||
return
|
||||
}
|
||||
c := p.licenceChoices()
|
||||
ex, err := p.licencePrefs.UserExcludedLicences(ctx, userID)
|
||||
if err != nil {
|
||||
p.log.Error("could not read a person's licence choices", "err", err)
|
||||
}
|
||||
for i := range c.Licences {
|
||||
c.Licences[i].Excluded = slices.ContainsFunc(ex, func(e string) bool { return strings.EqualFold(e, c.Licences[i].Key) })
|
||||
}
|
||||
for _, e := range ex {
|
||||
switch {
|
||||
case e == licenceRuleNonCommercial:
|
||||
c.NonCommercial = true
|
||||
case strings.HasPrefix(e, licenceRuleRegion):
|
||||
c.ExcludedRegions = append(c.ExcludedRegions, strings.TrimPrefix(e, licenceRuleRegion))
|
||||
}
|
||||
}
|
||||
// A region the person excluded that no licence in the catalog names yet
|
||||
// is still offered, ticked: it is there for the model added next.
|
||||
for _, reg := range c.ExcludedRegions {
|
||||
if !slices.Contains(c.Regions, reg) {
|
||||
c.Regions = append(c.Regions, reg)
|
||||
}
|
||||
}
|
||||
slices.Sort(c.Regions)
|
||||
d.Licences = &c
|
||||
}
|
||||
|
||||
func (p *AccountPages) setLicences(w http.ResponseWriter, r *http.Request) {
|
||||
noStore(w)
|
||||
sess, user, ok := p.session(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if p.licencePrefs == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var ex []string
|
||||
for _, k := range r.Form["licence"] {
|
||||
if k = strings.TrimSpace(k); k != "" && !strings.HasPrefix(k, "rule:") {
|
||||
ex = append(ex, strings.ToLower(k))
|
||||
}
|
||||
}
|
||||
if r.FormValue("non_commercial") != "" {
|
||||
ex = append(ex, licenceRuleNonCommercial)
|
||||
}
|
||||
regions := r.Form["region"]
|
||||
regions = append(regions, strings.FieldsFunc(r.FormValue("regions_other"), func(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
})...)
|
||||
l := langOf(r)
|
||||
for _, reg := range regions {
|
||||
reg = strings.ToUpper(strings.TrimSpace(reg))
|
||||
if reg == "" {
|
||||
continue
|
||||
}
|
||||
if !regionCode.MatchString(reg) {
|
||||
p.render(w, r, sess, user, profileData{LicenceError: l.T("profile.licences.err_region", reg)})
|
||||
return
|
||||
}
|
||||
ex = append(ex, licenceRuleRegion+reg)
|
||||
}
|
||||
if err := p.licencePrefs.SetUserExcludedLicences(r.Context(), user.ID, ex); err != nil {
|
||||
p.log.Error("could not keep a person's licence choices", "err", err)
|
||||
p.render(w, r, sess, user, profileData{LicenceError: l.T("profile.err.try_again")})
|
||||
return
|
||||
}
|
||||
p.render(w, r, sess, user, profileData{LicencesSaved: true})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestTheProfileKeepsTheLicencesAPersonLeavesOut(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "l.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
ada := store.User{ID: "usr-a", Email: "ada@example.com", State: store.UserActive, CreatedAt: time.Now()}
|
||||
if err := st.CreateUser(ctx, ada); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := NewAccountPages(fakeAccounts{user: ada}, st, nil, "v0", slog.New(slog.DiscardHandler))
|
||||
p.SetLicences(func() LicenceChoices {
|
||||
return LicenceChoices{
|
||||
Licences: []LicenceChoice{
|
||||
{Key: "mit", Name: "MIT", Commercial: "yes", Models: []string{"triposr"}},
|
||||
{Key: "tencent-hunyuan-community", Name: "Tencent Hunyuan Community",
|
||||
ExcludedRegions: []string{"EU", "UK", "KR"}, Models: []string{"hunyuan3d"}},
|
||||
},
|
||||
Regions: []string{"EU", "KR", "UK"},
|
||||
}
|
||||
}, st)
|
||||
mux := http.NewServeMux()
|
||||
p.Register(mux)
|
||||
do := func(method string, form url.Values) string {
|
||||
var req *http.Request
|
||||
if form == nil {
|
||||
req = httptest.NewRequest(method, "/profile", nil)
|
||||
} else {
|
||||
form.Set("csrf", "tok")
|
||||
req = httptest.NewRequest(method, "/profile/licences", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: "good"})
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
page := do(http.MethodGet, nil)
|
||||
for _, want := range []string{`action="/profile/licences"`, "Tencent Hunyuan Community", "not in EU, UK, KR", "hunyuan3d"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("the page lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
saved := do(http.MethodPost, url.Values{"region": {"EU"}, "regions_other": {"ch, NO"}, "licence": {"MIT"},
|
||||
"non_commercial": {"1"}})
|
||||
got, _ := st.UserExcludedLicences(ctx, ada.ID)
|
||||
want := []string{"mit", "rule:non-commercial", "rule:region:CH", "rule:region:EU", "rule:region:NO"}
|
||||
if !slices.Equal(got, want) || !strings.Contains(saved, "Saved.") {
|
||||
t.Errorf("kept %v, want %v", got, want)
|
||||
}
|
||||
// What was kept is what the page shows ticked, a region no licence
|
||||
// names yet included.
|
||||
if !strings.Contains(saved, `value="CH" checked`) || !strings.Contains(saved, `value="mit" checked`) {
|
||||
t.Error("the page does not show what was kept")
|
||||
}
|
||||
|
||||
bad := do(http.MethodPost, url.Values{"regions_other": {"Europe"}})
|
||||
if !strings.Contains(bad, "is not a region code") {
|
||||
t.Error("a bad region was not refused")
|
||||
}
|
||||
if got2, _ := st.UserExcludedLicences(ctx, ada.ID); !slices.Equal(got2, want) {
|
||||
t.Errorf("a refused form changed what was kept: %v", got2)
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,22 @@
|
||||
"profile.new_password": "New password",
|
||||
"profile.change_password": "Change password",
|
||||
"profile.password_note": "Changing it signs out every other browser, which is the point if\nyou are changing it because something went wrong.",
|
||||
"profile.licences": "Licences",
|
||||
"profile.licences.intro": "Models under a licence you leave out here are not listed to you and do not answer your keys or this portal — for example, nothing made under a licence that excludes where your product is sold.",
|
||||
"profile.licences.rules": "Leave out every licence that",
|
||||
"profile.licences.non_commercial": "forbids commercial use",
|
||||
"profile.licences.region": "grants nothing in %s",
|
||||
"profile.licences.regions_other": "Also regions not listed (ISO codes, EU for the union)",
|
||||
"profile.licences.regions_example": "EU, UK",
|
||||
"profile.licences.each": "Or these licences",
|
||||
"profile.licences.unspecified": "No licence stated",
|
||||
"profile.licences.commercial_yes": "commercial use allowed",
|
||||
"profile.licences.commercial_no": "non-commercial",
|
||||
"profile.licences.not_in": "not in %s",
|
||||
"profile.licences.save": "Save",
|
||||
"profile.licences.saved": "Saved. Your keys follow it within a minute.",
|
||||
"profile.licences.err_region": "%q is not a region code: two capital letters, or EU",
|
||||
"profile.licences.note": "The rules also cover models added later. A licence that says nothing about commercial use or regions is not caught by them; tick it here if you have not checked it.",
|
||||
"profile.err.personal_off": "personal keys are off on this gateway; choose a project",
|
||||
"profile.err.try_again": "something went wrong; try again",
|
||||
"profile.err.not_member": "you are not a member of that project",
|
||||
|
||||
@@ -143,6 +143,22 @@
|
||||
"profile.new_password": "Yeni parola",
|
||||
"profile.change_password": "Parolayı değiştir",
|
||||
"profile.password_note": "Parolayı değiştirmek diğer tüm tarayıcılardaki oturumları kapatır; bir sorun\nolduğu için değiştiriyorsanız amaç da zaten budur.",
|
||||
"profile.licences": "Lisanslar",
|
||||
"profile.licences.intro": "Burada dışarıda bıraktığınız lisanslardaki modeller size listelenmez, anahtarlarınıza ve bu portala yanıt vermez — örneğin ürününüzün satıldığı bölgeyi dışlayan bir lisansla üretilmiş hiçbir şey.",
|
||||
"profile.licences.rules": "Şu lisansların hepsini dışarıda bırak",
|
||||
"profile.licences.non_commercial": "ticari kullanıma izin vermeyenler",
|
||||
"profile.licences.region": "%s bölgesinde hak tanımayanlar",
|
||||
"profile.licences.regions_other": "Listede olmayan bölgeler de (ISO kodları, birlik için EU)",
|
||||
"profile.licences.regions_example": "EU, UK",
|
||||
"profile.licences.each": "Ya da şu lisanslar",
|
||||
"profile.licences.unspecified": "Lisansı belirtilmemiş",
|
||||
"profile.licences.commercial_yes": "ticari kullanım serbest",
|
||||
"profile.licences.commercial_no": "ticari değil",
|
||||
"profile.licences.not_in": "%s dışında",
|
||||
"profile.licences.save": "Kaydet",
|
||||
"profile.licences.saved": "Kaydedildi. Anahtarlarınız bir dakika içinde buna uyar.",
|
||||
"profile.licences.err_region": "%q bir bölge kodu değil: iki büyük harf ya da EU",
|
||||
"profile.licences.note": "Kurallar sonradan eklenen modelleri de kapsar. Ticari kullanım ya da bölge hakkında bir şey söylemeyen lisanslar kurallara takılmaz; kontrol etmediyseniz onları burada işaretleyin.",
|
||||
"profile.err.personal_off": "bu ağ geçidinde kişisel anahtarlar kapalı; bir proje seçin",
|
||||
"profile.err.try_again": "bir sorun oluştu; yeniden deneyin",
|
||||
"profile.err.not_member": "o projenin üyesi değilsiniz",
|
||||
|
||||
@@ -200,6 +200,30 @@ document.addEventListener("click", (e) => {
|
||||
{{else}}<p class="empty">{{t "selfuse.nowhere"}}</p>{{end}}
|
||||
<p class="note">{{t "selfuse.strictest"}}</p>
|
||||
|
||||
{{with .Licences}}
|
||||
<h2 id="licences">{{t "profile.licences"}}</h2>
|
||||
{{if $.LicencesSaved}}<p>{{t "profile.licences.saved"}}</p>{{end}}
|
||||
{{if $.LicenceError}}<p class="error">{{$.LicenceError}}</p>{{end}}
|
||||
<p class="note">{{t "profile.licences.intro"}}</p>
|
||||
<form method="post" action="/profile/licences" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset><legend>{{t "profile.licences.rules"}}</legend>
|
||||
<label class="check"><input type="checkbox" name="non_commercial" value="1"{{if .NonCommercial}} checked{{end}}>
|
||||
{{t "profile.licences.non_commercial"}}</label>
|
||||
{{range .Regions}}<label class="check"><input type="checkbox" name="region" value="{{.}}"{{if has $.Licences.ExcludedRegions .}} checked{{end}}>
|
||||
{{t "profile.licences.region" .}}</label>{{end}}
|
||||
<label>{{t "profile.licences.regions_other"}}<input type="text" name="regions_other" placeholder="{{t "profile.licences.regions_example"}}" autocomplete="off"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>{{t "profile.licences.each"}}</legend>
|
||||
{{range .Licences}}<label class="check"><input type="checkbox" name="licence" value="{{.Key}}"{{if .Excluded}} checked{{end}}>
|
||||
{{if .Name}}{{if .URL}}<a href="{{.URL}}" rel="noopener noreferrer">{{.Name}}</a>{{else}}{{.Name}}{{end}}{{else}}{{t "profile.licences.unspecified"}}{{end}}
|
||||
<span class="opt">{{if eq .Commercial "yes"}}{{t "profile.licences.commercial_yes"}} · {{else if eq .Commercial "no"}}{{t "profile.licences.commercial_no"}} · {{end}}{{with .ExcludedRegions}}{{t "profile.licences.not_in" (join .)}} · {{end}}{{join .Models}}</span></label>{{end}}
|
||||
</fieldset>
|
||||
<button type="submit">{{t "profile.licences.save"}}</button>
|
||||
</form>
|
||||
<p class="note">{{t "profile.licences.note"}}</p>
|
||||
{{end}}
|
||||
|
||||
<h2>{{t "profile.password"}}</h2>
|
||||
{{if .PasswordChanged}}<p>{{t "profile.password_changed"}}</p>{{end}}
|
||||
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
|
||||
|
||||
Reference in new issue
Block a user