Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down Expand Up @@ -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
Expand Down
98 changes: 94 additions & 4 deletions internal/api/muse_code_relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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, "/") == "/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
Expand All @@ -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)
}
Expand All @@ -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) {
Expand Down
148 changes: 143 additions & 5 deletions internal/api/muse_code_relay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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, "/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)
}
}
}