Repository navigation
fix(api): serve Muse Code's model catalog at /muse-code/models (HOUSE-114) #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" | ||
| "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" | ||
| log "github.com/sirupsen/logrus" | ||
| ) | ||
|
|
||
| const museCodeModelsPath = "/muse-code/models" | ||
|
|
||
| func (s *Server) museCodeModels(c *gin.Context) { | ||
| credential, baseURL, token := s.museCodeCredential() | ||
| if credential == nil { | ||
| c.JSON(http.StatusServiceUnavailable, gin.H{"error": "no usable Meta credential for Muse Code models"}) | ||
| return | ||
| } | ||
|
|
||
| parsed, errParse := url.Parse(baseURL) | ||
| if errParse != nil || parsed.Scheme == "" || parsed.Host == "" { | ||
| log.WithField("auth_id", credential.ID).Warn("muse code models: invalid Meta base URL") | ||
| c.JSON(http.StatusBadGateway, gin.H{"error": "invalid Meta base URL"}) | ||
| return | ||
| } | ||
| target := (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host, Path: museCodeModelsPath}).String() | ||
|
|
||
| ctx := c.Request.Context() | ||
| req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) | ||
| if errReq != nil { | ||
| c.JSON(http.StatusBadGateway, gin.H{"error": "failed to build Muse Code models request"}) | ||
| return | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+token) | ||
| req.Header.Set("Accept", "application/json") | ||
|
|
||
| resp, errDo := helps.NewProxyAwareHTTPClient(ctx, s.getConfig(), credential, 0).Do(req) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Catalog requests ignore configured Meta headers Meta credentials support custom headers, which the normal Meta executor applies through Also affects: |
||
| if errDo != nil { | ||
| log.WithField("auth_id", credential.ID).Warnf("muse code models: upstream request failed: %v", errDo) | ||
| c.JSON(http.StatusBadGateway, gin.H{"error": "Muse Code models upstream request failed"}) | ||
| return | ||
| } | ||
| defer func() { | ||
| if errClose := resp.Body.Close(); errClose != nil { | ||
| log.Errorf("muse code models: close upstream body: %v", errClose) | ||
| } | ||
| }() | ||
|
|
||
| body, errRead := io.ReadAll(resp.Body) | ||
| if errRead != nil { | ||
| log.WithField("auth_id", credential.ID).Warnf("muse code models: read upstream body: %v", errRead) | ||
| c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read Muse Code models response"}) | ||
| return | ||
| } | ||
| c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body) | ||
| } | ||
|
|
||
| func (s *Server) museCodeCredential() (*auth.Auth, string, string) { | ||
| if s.handlers == nil || s.handlers.AuthManager == nil { | ||
| return nil, "", "" | ||
| } | ||
| var chosen *auth.Auth | ||
| var chosenBaseURL, chosenToken string | ||
| for _, candidate := range s.handlers.AuthManager.List() { | ||
| if !strings.EqualFold(candidate.Provider, "meta") || candidate.Disabled || candidate.Status == auth.StatusDisabled { | ||
| continue | ||
| } | ||
| baseURL, token := helps.MetaCreds(candidate) | ||
| if token == "" { | ||
| continue | ||
| } | ||
| if chosen == nil || candidate.ID < chosen.ID { | ||
| chosen, chosenBaseURL, chosenToken = candidate, baseURL, token | ||
|
Comment on lines
+69
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Catalog route pins lowest-ID Meta credential, ignoring its health museCodeCredential skips only Disabled/StatusDisabled credentials and then always picks the lexically lowest ID. It ignores the error, unavailable and cooldown state that the auth manager uses to steer model traffic away from a broken credential, and it never falls back to another candidate. Scenario: two Meta credentials. |
||
| } | ||
| } | ||
| return chosen, chosenBaseURL, chosenToken | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "sync/atomic" | ||
| "testing" | ||
|
|
||
| "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" | ||
| ) | ||
|
|
||
| func TestMuseCodeModelsRoute(t *testing.T) { | ||
| const upstreamBody = `{"object":"list","data":[{"id":"muse-spark","object":"model"}]}` | ||
|
|
||
| for _, testCase := range []struct { | ||
| name string | ||
| clientKey string | ||
| registerMeta bool | ||
| wantStatus int | ||
| wantBody string | ||
| wantUpstreamN int32 | ||
| }{ | ||
| {name: "proxies_catalog", clientKey: "test-key", registerMeta: true, wantStatus: http.StatusTeapot, wantBody: upstreamBody, wantUpstreamN: 1}, | ||
| {name: "no_meta_credential", clientKey: "test-key", wantStatus: http.StatusServiceUnavailable}, | ||
| {name: "missing_client_key", registerMeta: true, wantStatus: http.StatusUnauthorized}, | ||
| } { | ||
| t.Run(testCase.name, func(t *testing.T) { | ||
| var upstreamCalls atomic.Int32 | ||
| upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| upstreamCalls.Add(1) | ||
| if r.URL.Path != museCodeModelsPath || r.URL.RawQuery != "" { | ||
| t.Errorf("upstream URL = %s, want %s", r.URL.String(), museCodeModelsPath) | ||
| } | ||
| if got := r.Header.Get("Authorization"); got != "Bearer meta-api-key" { | ||
| t.Errorf("upstream Authorization = %q, want Meta bearer", got) | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusTeapot) | ||
| _, _ = w.Write([]byte(upstreamBody)) | ||
| })) | ||
| t.Cleanup(upstream.Close) | ||
|
|
||
| server := newTestServer(t) | ||
| if testCase.registerMeta { | ||
| credential := &auth.Auth{ | ||
| ID: "meta-muse-code-models", | ||
| Provider: "meta", | ||
| Status: auth.StatusActive, | ||
| Attributes: map[string]string{ | ||
| "api_key": "meta-api-key", | ||
| "base_url": upstream.URL + "/v1?x=1", | ||
| }, | ||
| } | ||
| if _, errRegister := server.handlers.AuthManager.Register(t.Context(), credential); errRegister != nil { | ||
| t.Fatalf("register Meta credential: %v", errRegister) | ||
| } | ||
| } | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, museCodeModelsPath, nil) | ||
| if testCase.clientKey != "" { | ||
| req.Header.Set("Authorization", "Bearer "+testCase.clientKey) | ||
| } | ||
| recorder := httptest.NewRecorder() | ||
| server.engine.ServeHTTP(recorder, req) | ||
|
|
||
| if recorder.Code != testCase.wantStatus { | ||
| t.Fatalf("status = %d, want %d; body=%s", recorder.Code, testCase.wantStatus, recorder.Body.String()) | ||
| } | ||
| if testCase.wantBody != "" && recorder.Body.String() != testCase.wantBody { | ||
| t.Fatalf("body = %q, want %q", recorder.Body.String(), testCase.wantBody) | ||
| } | ||
| if got := upstreamCalls.Load(); got != testCase.wantUpstreamN { | ||
| t.Fatalf("upstream calls = %d, want %d", got, testCase.wantUpstreamN) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package helps | ||
|
|
||
| import ( | ||
| "strings" | ||
|
|
||
| metaauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/meta" | ||
| cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" | ||
| ) | ||
|
|
||
| // MetaCreds resolves the Meta API base URL and the non-DCA bearer token for a Meta auth. | ||
| func MetaCreds(a *cliproxyauth.Auth) (baseURL, token string) { | ||
| baseURL = metaauth.DefaultAPIBaseURL | ||
| if a == nil { | ||
| return baseURL, "" | ||
| } | ||
|
|
||
| if a.Attributes != nil { | ||
| if b := strings.TrimSpace(a.Attributes["base_url"]); b != "" { | ||
| baseURL = b | ||
| } | ||
| if k := strings.TrimSpace(a.Attributes["api_key"]); k != "" && !strings.HasPrefix(k, "dca:") { | ||
| token = k | ||
| } else if t := strings.TrimSpace(a.Attributes["access_token"]); t != "" && !strings.HasPrefix(t, "dca:") { | ||
| token = t | ||
| } | ||
| } | ||
| if a.Metadata != nil { | ||
| if baseURL == metaauth.DefaultAPIBaseURL { | ||
| if b, ok := a.Metadata["base_url"].(string); ok && strings.TrimSpace(b) != "" { | ||
| baseURL = strings.TrimSpace(b) | ||
| } else if b, ok := a.Metadata["api_base_url"].(string); ok && strings.TrimSpace(b) != "" { | ||
| baseURL = strings.TrimSpace(b) | ||
| } | ||
| } | ||
| if token == "" { | ||
| if k, ok := a.Metadata["api_key"].(string); ok && strings.TrimSpace(k) != "" && !strings.HasPrefix(strings.TrimSpace(k), "dca:") { | ||
| token = strings.TrimSpace(k) | ||
| } else if t, ok := a.Metadata["access_token"].(string); ok && strings.TrimSpace(t) != "" && !strings.HasPrefix(strings.TrimSpace(t), "dca:") { | ||
| token = strings.TrimSpace(t) | ||
| } | ||
| } | ||
| } | ||
| if token == "" && a.Storage != nil { | ||
| if ms, ok := a.Storage.(*metaauth.MetaTokenStorage); ok && ms != nil { | ||
| if ms.APIKey != "" { | ||
| token = ms.APIKey | ||
| } else if ms.AccessToken != "" && !strings.HasPrefix(ms.AccessToken, "dca:") { | ||
| token = ms.AccessToken | ||
| } | ||
| if ms.BaseURL != "" && baseURL == metaauth.DefaultAPIBaseURL { | ||
| baseURL = ms.BaseURL | ||
| } | ||
| } | ||
| } | ||
| return baseURL, token | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium · Redirects replace the upstream status and body
NewProxyAwareHTTPClientleavesCheckRedirectunset, so this call follows redirects rather than preserving the catalog endpoint's response. For example, an upstream302 Location: /loginwith a JSON error becomes the login page's200 text/htmlresponse. This was reproduced through the registered route and violates the required status/body passthrough, presenting non-catalog bytes as a successful response.