From b47b9c903c2ac38de4f529204bf66ac3888a586c Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Tue, 13 Oct 2026 03:35:27 -0500 Subject: [PATCH] feat(meta): hide catalog models an OAuth model alias takes over An oauth-model-alias entry on the meta channel whose alias is a model Meta lists sends every request for that ID to the alias's model. The relayed /muse-code/models catalog now marks such an entry is_hidden when the model the alias runs is in the same catalog, so Muse's picker, T3 and the Muse bridge offer only the model a request reaches. Muse still resolves a hidden ID named explicitly, and the alias runs it. With aliases from each Muse model to its -contributor twin, every Muse request runs in Contributor mode. While the channel has an alias, If-None-Match is not passed to Meta, and Meta's Etag and Last-Modified are dropped when the catalog changed. Co-Authored-By: Claude Opus 5.5 --- FORK.md | 26 ++++- internal/api/muse_code_relay.go | 98 +++++++++++++++++- internal/api/muse_code_relay_test.go | 148 ++++++++++++++++++++++++++- 3 files changed, 262 insertions(+), 10 deletions(-) diff --git a/FORK.md b/FORK.md index 7adbaa15c17..7388b6f673c 100644 --- a/FORK.md +++ b/FORK.md @@ -181,7 +181,7 @@ refer) answer `501` without touching a credential. The Muse CLI asks its endpoint for more than `/v1/responses`: it reads its model catalog from `/muse-code/models` at the root of the same origin and does not start without it. The fork serves these Muse Code endpoints by forwarding them to Meta with a Meta credential and returning the -answer unchanged: +answer unchanged, apart from the [models an alias takes over](#aliased-muse-models): | Endpoint | Used for | | --- | --- | @@ -211,6 +211,30 @@ to an endpoint named there, not to one passed with `--base-url`) and give it a c export META_API_KEY="sk-muse-77b0..." ``` +#### Aliased Muse models + +An `oauth-model-alias` entry on the `meta` channel whose alias is a model Meta lists takes that +model's ID over: a request for the ID runs the alias's model instead. The catalog the proxy +returns marks such an entry hidden (`is_hidden`), when the model the alias runs is in the same +catalog, so Muse's model picker, and every client that lists Muse's models, offers only what a +request would reach. Muse still accepts a hidden ID named explicitly, and the alias then runs it. + +Aliases from each Muse model to its `-contributor` twin send every Muse request in Contributor +mode, which Meta's subscriptions give more usage for (Meta may train on the content): + +```yaml +oauth-model-alias: + meta: + - name: "muse-wonder-1.0-contributor" + alias: "muse-wonder-1.0" + fork: true +``` + +`fork: true` keeps the `-contributor` ID itself. While the Meta channel has an alias, the proxy +does not pass a conditional catalog request (`If-None-Match`) on to Meta, and leaves out Meta's +`Etag` and `Last-Modified` when it changed the catalog. A Meta API key under `meta-api-key` gets +no OAuth model alias, so its catalog is left alone. + ### Meta subscription usage Meta has no usage endpoint. It reports the account's subscription windows with every Responses diff --git a/internal/api/muse_code_relay.go b/internal/api/muse_code_relay.go index 7dec881cfc2..002908f2e82 100644 --- a/internal/api/muse_code_relay.go +++ b/internal/api/muse_code_relay.go @@ -6,14 +6,18 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "github.com/gin-gonic/gin" metaauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/meta" "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) const ( @@ -41,8 +45,9 @@ func (s *Server) registerMuseCodeRoutes() { } // museCodeRelay forwards one Muse Code client request to Meta with a Meta -// credential and returns the answer unchanged. These payloads are already in -// Meta's format and must not pass through a protocol translator. +// credential and returns the answer unchanged, except that the model catalog +// hides the models an OAuth model alias has taken over. These payloads are +// already in Meta's format and must not pass through a protocol translator. func (s *Server) museCodeRelay(c *gin.Context) { if s == nil || s.handlers == nil || s.handlers.AuthManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Meta auth manager unavailable"}) @@ -86,7 +91,17 @@ func (s *Server) museCodeRelay(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": errURL.Error()}) return } - req, errRequest := s.handlers.AuthManager.NewHttpRequest(ctx, selected, c.Request.Method, upstreamURL, body, museCodeRelayHeaders(c.Request.Header)) + headers := museCodeRelayHeaders(c.Request.Header) + var shadowed map[string]string + if c.Request.Method == http.MethodGet && strings.TrimSuffix(c.Request.URL.Path, "/") == "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/muse-code/models" { + shadowed = museCodeShadowedModels(s.getConfig(), selected) + } + if len(shadowed) > 0 { + // The catalog sent back can differ from Meta's, so Meta must not answer + // 304 for a list this client was never given. + headers.Del("If-None-Match") + } + req, errRequest := s.handlers.AuthManager.NewHttpRequest(ctx, selected, c.Request.Method, upstreamURL, body, headers) if errRequest != nil { c.JSON(clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), gin.H{"error": errRequest.Error()}) return @@ -106,7 +121,15 @@ func (s *Server) museCodeRelay(c *gin.Context) { c.JSON(clienterror.HTTPStatusFromErrorOr(errRead, http.StatusBadGateway), gin.H{"error": "Failed to read Muse Code response"}) return } - for _, name := range []string{"Content-Type", "Cache-Control", "Etag", "Last-Modified"} { + relayedHeaders := []string{"Content-Type", "Cache-Control", "Etag", "Last-Modified"} + if len(shadowed) > 0 && resp.StatusCode == http.StatusOK { + if hidden, changed := hideShadowedMuseCodeModels(upstreamBody, shadowed); changed { + upstreamBody = hidden + // Meta's validators describe Meta's list, not this one. + relayedHeaders = relayedHeaders[:2] + } + } + for _, name := range relayedHeaders { if value := resp.Header.Get(name); value != "" { c.Header(name, value) } @@ -115,6 +138,73 @@ func (s *Server) museCodeRelay(c *gin.Context) { _, _ = c.Writer.Write(upstreamBody) } +// museCodeShadowedModels maps each model ID an OAuth model alias on the +// credential's channel has taken over to the upstream model the alias runs +// instead: a request for that ID never reaches the model of that name. The +// credential's own aliases come first, as they do when a request runs. +func museCodeShadowedModels(cfg *config.Config, selected *auth.Auth) map[string]string { + if selected == nil { + return nil + } + channel := auth.OAuthModelAliasChannel(selected.Provider, selected.AuthKind()) + if channel == "" { + return nil + } + aliases := auth.OAuthModelAliasesFromAttributes(selected.Attributes) + if cfg != nil { + for rawChannel, entries := range cfg.OAuthModelAlias { + if strings.EqualFold(strings.TrimSpace(rawChannel), channel) { + aliases = append(aliases, entries...) + } + } + } + shadowed := make(map[string]string, len(aliases)) + for _, entry := range aliases { + name, alias := strings.TrimSpace(entry.Name), strings.TrimSpace(entry.Alias) + if name == "" || alias == "" || strings.EqualFold(name, alias) { + continue + } + if _, exists := shadowed[strings.ToLower(alias)]; !exists { + shadowed[strings.ToLower(alias)] = name + } + } + return shadowed +} + +// hideShadowedMuseCodeModels marks a catalog entry hidden when an alias has +// taken over its ID and the model the alias runs is in the same catalog, so a +// model picker offers only what a request would reach. Muse still resolves a +// hidden model named explicitly, and the alias then runs it. The body comes +// back unchanged when nothing is hidden or it is not a catalog this reads. +func hideShadowedMuseCodeModels(body []byte, shadowed map[string]string) ([]byte, bool) { + data := gjson.GetBytes(body, "data") + if len(shadowed) == 0 || !data.IsArray() { + return body, false + } + entries := data.Array() + listed := make(map[string]bool, len(entries)) + for _, entry := range entries { + listed[strings.ToLower(strings.TrimSpace(entry.Get("id").String()))] = true + } + out, changed := body, false + for i, entry := range entries { + target, ok := shadowed[strings.ToLower(strings.TrimSpace(entry.Get("id").String()))] + if !ok || !listed[strings.ToLower(target)] { + continue + } + museCode := entry.Get("metadata.muse-code") + if !museCode.IsObject() || museCode.Get("is_hidden").Bool() { + continue + } + updated, errSet := sjson.SetBytes(out, "data."+strconv.Itoa(i)+".metadata.muse-code.is_hidden", true) + if errSet != nil { + return body, false + } + out, changed = updated, true + } + return out, changed +} + // MuseCodeURL returns the address of a Muse Code endpoint, such as // /muse-code/models, on the origin the Meta credential talks to. func MuseCodeURL(selected *auth.Auth, path string) (string, error) { diff --git a/internal/api/muse_code_relay_test.go b/internal/api/muse_code_relay_test.go index 1ae190e4dbf..a8b49837b3e 100644 --- a/internal/api/muse_code_relay_test.go +++ b/internal/api/muse_code_relay_test.go @@ -16,15 +16,17 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config" + "github.com/tidwall/gjson" ) // museRelayCaptureExecutor stands in for the Meta executor and records the // request the relay sends upstream. type museRelayCaptureExecutor struct { - request *http.Request - body []byte - calls int - status int + request *http.Request + body []byte + calls int + status int + response string } func (e *museRelayCaptureExecutor) Identifier() string { return "meta" } @@ -64,10 +66,14 @@ func (e *museRelayCaptureExecutor) HttpRequest(_ context.Context, _ *auth.Auth, if status == 0 { status = http.StatusOK } + response := e.response + if response == "" { + response = `{"rows":[{"model_id":"muse-test"}]}` + } return &http.Response{ StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}, "Etag": []string{`"catalog-1"`}, "Set-Cookie": []string{"session=upstream"}}, - Body: io.NopCloser(strings.NewReader(`{"rows":[{"model_id":"muse-test"}]}`)), + Body: io.NopCloser(strings.NewReader(response)), }, nil } @@ -228,3 +234,135 @@ func TestForkMuseCodeUpstreamURL(t *testing.T) { }) } } + +// museRelayCatalog is a Muse Code model catalog in the shape Meta serves it. +const museRelayCatalog = `{"object":"list","data":[` + + `{"id":"muse-wonder-1.0","metadata":{"muse-code":{"name":"Muse Wonder"}}},` + + `{"id":"muse-wonder-1.0-contributor","metadata":{"muse-code":{"name":"Muse Wonder"}}},` + + `{"id":"muse-spark-1.2","metadata":{"muse-code":{"name":"Muse Spark 1.2"}}},` + + `{"id":"muse-image-1.0","metadata":{"muse-code":{"name":"Muse Image","is_hidden":true}}}]}` + +func museRelayCatalogRequest(server *Server) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "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/muse-code/models?profile=tbh", nil) + req.Header.Set("Authorization", "Bearer sk-muse-test") + req.Header.Set("If-None-Match", `"catalog-0"`) + recorder := httptest.NewRecorder() + server.engine.ServeHTTP(recorder, req) + return recorder +} + +func museRelayHidden(t *testing.T, body []byte, id string) bool { + t.Helper() + entry := gjson.GetBytes(body, `data.#(id=="`+id+`")`) + if !entry.Exists() { + t.Fatalf("the catalog lost %s: %s", id, body) + } + return entry.Get("metadata.muse-code.is_hidden").Bool() +} + +func TestForkMuseCodeRelayHidesModelsAnAliasTakesOver(t *testing.T) { + server, executor := museRelayTestServer(t, map[string]string{"api_key": "meta-key", "auth_kind": "oauth"}) + server.cfg.OAuthModelAlias = map[string][]proxyconfig.OAuthModelAlias{"meta": { + {Name: "muse-wonder-1.0-contributor", Alias: "muse-wonder-1.0", Fork: true}, + // The model this alias runs is not in the catalog, so its ID stays offered. + {Name: "muse-spark-1.2-contributor", Alias: "muse-spark-1.2", Fork: true}, + }} + executor.response = museRelayCatalog + + recorder := museRelayCatalogRequest(server) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + if got := executor.request.Header.Get("If-None-Match"); got != "" { + t.Fatalf("upstream If-None-Match = %q, want it dropped while an alias can change the catalog", got) + } + body := recorder.Body.Bytes() + for id, want := range map[string]bool{"muse-wonder-1.0": true, "muse-wonder-1.0-contributor": false, "muse-spark-1.2": false, "muse-image-1.0": true} { + if got := museRelayHidden(t, body, id); got != want { + t.Errorf("%s is_hidden = %t, want %t", id, got, want) + } + } + if got := gjson.GetBytes(body, `data.#(id=="muse-wonder-1.0").metadata.muse-code.name`).String(); got != "Muse Wonder" { + t.Fatalf("hidden entry name = %q, want the rest of the entry unchanged", got) + } + if got := recorder.Header().Get("Etag"); got != "" { + t.Fatalf("Etag = %q, want Meta's validator dropped for a changed catalog", got) + } + if got := recorder.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q", got) + } +} + +func TestForkMuseCodeRelayLeavesTheCatalogAloneWithoutAnAlias(t *testing.T) { + for _, testCase := range []struct { + name string + attributes map[string]string + aliases map[string][]proxyconfig.OAuthModelAlias + }{ + {name: "no alias", attributes: map[string]string{"api_key": "meta-key", "auth_kind": "oauth"}}, + { + name: "alias for another channel", + attributes: map[string]string{"api_key": "meta-key", "auth_kind": "oauth"}, + aliases: map[string][]proxyconfig.OAuthModelAlias{"codex": {{Name: "muse-wonder-1.0-contributor", Alias: "muse-wonder-1.0"}}}, + }, + // A request on a Meta API key gets no OAuth model alias, so its catalog keeps every model. + { + name: "Meta API key", + attributes: map[string]string{"api_key": "meta-key"}, + aliases: map[string][]proxyconfig.OAuthModelAlias{"meta": {{Name: "muse-wonder-1.0-contributor", Alias: "muse-wonder-1.0"}}}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + server, executor := museRelayTestServer(t, testCase.attributes) + server.cfg.OAuthModelAlias = testCase.aliases + executor.response = museRelayCatalog + + recorder := museRelayCatalogRequest(server) + if got := recorder.Body.String(); got != museRelayCatalog { + t.Fatalf("body = %s, want Meta's catalog unchanged", got) + } + if got := executor.request.Header.Get("If-None-Match"); got != `"catalog-0"` { + t.Fatalf("upstream If-None-Match = %q, want it forwarded", got) + } + if got := recorder.Header().Get("Etag"); got != `"catalog-1"` { + t.Fatalf("Etag = %q, want it relayed", got) + } + }) + } +} + +func TestForkMuseCodeShadowedModelsPrefersTheCredentialsOwnAlias(t *testing.T) { + selected := &auth.Auth{Provider: "meta", Attributes: map[string]string{"auth_kind": "oauth"}} + auth.SetOAuthModelAliasesAttribute(selected, []proxyconfig.OAuthModelAlias{{Name: "muse-own-contributor", Alias: "muse-wonder-1.0"}}) + cfg := &proxyconfig.Config{OAuthModelAlias: map[string][]proxyconfig.OAuthModelAlias{"Meta": { + {Name: "muse-wonder-1.0-contributor", Alias: "muse-wonder-1.0"}, + {Name: "muse-spark-1.3-contributor", Alias: "MUSE-SPARK-1.3"}, + {Name: "muse-spark-1.2", Alias: "muse-spark-1.2"}, + }}} + + got := museCodeShadowedModels(cfg, selected) + want := map[string]string{"muse-wonder-1.0": "muse-own-contributor", "muse-spark-1.3": "muse-spark-1.3-contributor"} + if len(got) != len(want) { + t.Fatalf("shadowed = %v, want %v", got, want) + } + for alias, name := range want { + if got[alias] != name { + t.Fatalf("shadowed[%s] = %q, want %q (all: %v)", alias, got[alias], name, got) + } + } +} + +func TestForkHideShadowedMuseCodeModelsKeepsBodiesItDoesNotRead(t *testing.T) { + shadowed := map[string]string{"muse-test": "muse-test-contributor"} + for _, body := range []string{ + `{"rows":[{"model_id":"muse-test"}]}`, + `not json`, + // Entries without Muse Code metadata are not Muse Code catalog rows. + `{"data":[{"id":"muse-test"},{"id":"muse-test-contributor"}]}`, + } { + got, changed := hideShadowedMuseCodeModels([]byte(body), shadowed) + if changed || string(got) != body { + t.Fatalf("hideShadowedMuseCodeModels(%s) = %s, %t; want it unchanged", body, got, changed) + } + } +}