From 54c3eb1b1e8989d856b999e4ade5026fe29b20ea Mon Sep 17 00:00:00 2001 From: LuxVTZ Date: Tue, 10 Mar 2026 17:57:55 +0400 Subject: [PATCH] Add GitLab Duo auth and executor support --- README.md | 2 + internal/auth/gitlab/gitlab.go | 2 +- internal/auth/gitlab/gitlab_test.go | 160 +++++++++---- internal/runtime/executor/gitlab_executor.go | 22 +- .../runtime/executor/gitlab_executor_test.go | 223 ++++++++++-------- sdk/auth/gitlab.go | 23 ++ sdk/auth/gitlab_test.go | 66 ++++++ sdk/cliproxy/service_gitlab_models_test.go | 37 +-- 8 files changed, 356 insertions(+), 179 deletions(-) create mode 100644 sdk/auth/gitlab_test.go diff --git a/README.md b/README.md index 02aef963..4a482c03 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ All third-party provider support is maintained by community contributors; CLIPro The Plus release stays in lockstep with the mainline features. +GitLab Duo is supported here via OAuth or personal access token login, with model discovery and provider-native routing through the GitLab AI gateway when managed credentials are available. + ## Contributing This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected. diff --git a/internal/auth/gitlab/gitlab.go b/internal/auth/gitlab/gitlab.go index 7be2a141..5cf8876c 100644 --- a/internal/auth/gitlab/gitlab.go +++ b/internal/auth/gitlab/gitlab.go @@ -426,7 +426,7 @@ func ExtractDiscoveredModels(metadata map[string]any) []DiscoveredModel { if name == "" { return } - key := strings.ToLower(provider + "\x00" + name) + key := strings.ToLower(name) if _, ok := seen[key]; ok { return } diff --git a/internal/auth/gitlab/gitlab_test.go b/internal/auth/gitlab/gitlab_test.go index aa4c0b2b..dde09dd7 100644 --- a/internal/auth/gitlab/gitlab_test.go +++ b/internal/auth/gitlab/gitlab_test.go @@ -2,71 +2,137 @@ package gitlab import ( "context" + "encoding/json" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" ) -func TestNormalizeBaseURL(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {name: "default", in: "", want: DefaultBaseURL}, - {name: "plain host", in: "gitlab.example.com", want: "https://gitlab.example.com"}, - {name: "trim trailing slash", in: "https://gitlab.example.com/", want: "https://gitlab.example.com"}, +func TestAuthClientGenerateAuthURLIncludesPKCE(t *testing.T) { + client := NewAuthClient(nil) + pkce, err := GeneratePKCECodes() + if err != nil { + t.Fatalf("GeneratePKCECodes() error = %v", err) } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := NormalizeBaseURL(tc.in); got != tc.want { - t.Fatalf("NormalizeBaseURL(%q) = %q, want %q", tc.in, got, tc.want) - } - }) + rawURL, err := client.GenerateAuthURL("https://gitlab.example.com", "client-id", RedirectURL(17171), "state-123", pkce) + if err != nil { + t.Fatalf("GenerateAuthURL() error = %v", err) + } + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("Parse(authURL) error = %v", err) + } + if got := parsed.Path; got != "/oauth/authorize" { + t.Fatalf("expected /oauth/authorize path, got %q", got) + } + query := parsed.Query() + if got := query.Get("client_id"); got != "client-id" { + t.Fatalf("expected client_id, got %q", got) + } + if got := query.Get("scope"); got != defaultOAuthScope { + t.Fatalf("expected scope %q, got %q", defaultOAuthScope, got) + } + if got := query.Get("code_challenge_method"); got != "S256" { + t.Fatalf("expected PKCE method S256, got %q", got) + } + if got := query.Get("code_challenge"); got == "" { + t.Fatal("expected non-empty code_challenge") } } -func TestFetchDirectAccess_ParsesModelDetails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("expected POST, got %s", r.Method) +func TestAuthClientExchangeCodeForTokens(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" { + t.Fatalf("unexpected path %q", r.URL.Path) } - if got := r.Header.Get("Authorization"); got != "Bearer pat-123" { - t.Fatalf("expected Authorization header, got %q", got) + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm() error = %v", err) } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "base_url":"https://gateway.gitlab.example.com/v1", - "token":"duo-gateway-token", - "expires_at":2000000000, - "headers":{ - "X-Gitlab-Realm":"saas", - "X-Gitlab-Host-Name":"gitlab.example.com" - }, - "model_details":{ - "model_provider":"anthropic", - "model_name":"claude-sonnet-4-5" - } - }`)) + if got := r.Form.Get("grant_type"); got != "authorization_code" { + t.Fatalf("expected authorization_code grant, got %q", got) + } + if got := r.Form.Get("code_verifier"); got != "verifier-123" { + t.Fatalf("expected code_verifier, got %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "oauth-access", + "refresh_token": "oauth-refresh", + "token_type": "Bearer", + "scope": "api read_user", + "created_at": 1710000000, + "expires_in": 3600, + }) })) - defer server.Close() + defer srv.Close() - client := &AuthClient{httpClient: server.Client()} - direct, err := client.FetchDirectAccess(context.Background(), server.URL, "pat-123") + client := NewAuthClient(nil) + token, err := client.ExchangeCodeForTokens(context.Background(), srv.URL, "client-id", "client-secret", RedirectURL(17171), "auth-code", "verifier-123") if err != nil { - t.Fatalf("FetchDirectAccess returned error: %v", err) + t.Fatalf("ExchangeCodeForTokens() error = %v", err) } - if direct.BaseURL != "https://gateway.gitlab.example.com/v1" { - t.Fatalf("unexpected base_url %q", direct.BaseURL) + if token.AccessToken != "oauth-access" { + t.Fatalf("expected access token, got %q", token.AccessToken) } - if direct.Token != "duo-gateway-token" { - t.Fatalf("unexpected token %q", direct.Token) + if token.RefreshToken != "oauth-refresh" { + t.Fatalf("expected refresh token, got %q", token.RefreshToken) + } +} + +func TestExtractDiscoveredModels(t *testing.T) { + models := ExtractDiscoveredModels(map[string]any{ + "model_details": map[string]any{ + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + "supported_models": []any{ + map[string]any{"model_provider": "openai", "model_name": "gpt-4.1"}, + "claude-sonnet-4-5", + }, + }) + if len(models) != 2 { + t.Fatalf("expected 2 unique models, got %d", len(models)) + } + if models[0].ModelName != "claude-sonnet-4-5" { + t.Fatalf("unexpected first model %q", models[0].ModelName) + } + if models[1].ModelName != "gpt-4.1" { + t.Fatalf("unexpected second model %q", models[1].ModelName) + } +} + +func TestFetchDirectAccessDecodesModelDetails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v4/code_suggestions/direct_access" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); !strings.Contains(got, "token-123") { + t.Fatalf("expected bearer token, got %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "base_url": "https://cloud.gitlab.example.com", + "token": "gateway-token", + "expires_at": 1710003600, + "headers": map[string]string{ + "X-Gitlab-Realm": "saas", + }, + "model_details": map[string]any{ + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + }) + })) + defer srv.Close() + + client := NewAuthClient(nil) + direct, err := client.FetchDirectAccess(context.Background(), srv.URL, "token-123") + if err != nil { + t.Fatalf("FetchDirectAccess() error = %v", err) } if direct.ModelDetails == nil || direct.ModelDetails.ModelName != "claude-sonnet-4-5" { - t.Fatalf("unexpected model details: %+v", direct.ModelDetails) - } - if direct.Headers["X-Gitlab-Realm"] != "saas" { - t.Fatalf("expected X-Gitlab-Realm header, got %+v", direct.Headers) + t.Fatalf("expected model details, got %+v", direct.ModelDetails) } } diff --git a/internal/runtime/executor/gitlab_executor.go b/internal/runtime/executor/gitlab_executor.go index d05fa086..16441cce 100644 --- a/internal/runtime/executor/gitlab_executor.go +++ b/internal/runtime/executor/gitlab_executor.go @@ -21,10 +21,10 @@ import ( ) const ( - gitLabProviderKey = "gitlab" - gitLabAuthMethodOAuth = "oauth" - gitLabAuthMethodPAT = "pat" - gitLabChatEndpoint = "/api/v4/chat/completions" + gitLabProviderKey = "gitlab" + gitLabAuthMethodOAuth = "oauth" + gitLabAuthMethodPAT = "pat" + gitLabChatEndpoint = "/api/v4/chat/completions" gitLabCodeSuggestionsEndpoint = "/api/v4/code_suggestions/completions" ) @@ -33,10 +33,10 @@ type GitLabExecutor struct { } type gitLabPrompt struct { - Instruction string - FileName string - ContentAboveCursor string - ChatContext []map[string]any + Instruction string + FileName string + ContentAboveCursor string + ChatContext []map[string]any CodeSuggestionContext []map[string]any } @@ -246,10 +246,10 @@ func (e *GitLabExecutor) requestCodeSuggestions(ctx context.Context, auth *clipr "content_above_cursor": contentAbove, "content_below_cursor": "", }, - "intent": "generation", - "generation_type": "small_file", + "intent": "generation", + "generation_type": "small_file", "user_instruction": prompt.Instruction, - "stream": false, + "stream": false, } if len(prompt.CodeSuggestionContext) > 0 { body["context"] = prompt.CodeSuggestionContext diff --git a/internal/runtime/executor/gitlab_executor_test.go b/internal/runtime/executor/gitlab_executor_test.go index 8257ddb6..89eecaea 100644 --- a/internal/runtime/executor/gitlab_executor_test.go +++ b/internal/runtime/executor/gitlab_executor_test.go @@ -2,123 +2,154 @@ package executor import ( "context" - "io" + "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/tidwall/gjson" ) -func TestGitLabExecutorRefresh_WithPATStoresGatewayMetadata(t *testing.T) { - var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v4/code_suggestions/direct_access" { - t.Fatalf("unexpected path %s", r.URL.Path) +func TestGitLabExecutorExecuteUsesChatEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != gitLabChatEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) } - if got := r.Header.Get("Authorization"); got != "Bearer pat-123" { - t.Fatalf("unexpected Authorization header %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "base_url":"` + server.URL + `", - "token":"gateway-token", - "expires_at":2000000000, - "headers":{"X-Gitlab-Realm":"saas"}, - "model_details":{"model_provider":"mistral","model_name":"codestral-2501"} - }`)) + _, _ = w.Write([]byte(`"chat response"`)) })) - defer server.Close() + defer srv.Close() - exec := NewGitLabExecutor(nil) + exec := NewGitLabExecutor(&config.Config{}) auth := &cliproxyauth.Auth{ - ID: "gitlab-pat.json", Provider: "gitlab", Metadata: map[string]any{ - "type": "gitlab", + "base_url": srv.URL, + "access_token": "oauth-access", + "model_name": "claude-sonnet-4-5", + }, + } + req := cliproxyexecutor.Request{ + Model: "gitlab-duo", + Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":"hello"}]}`), + } + + resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "chat response" { + t.Fatalf("expected chat response, got %q", got) + } + if got := gjson.GetBytes(resp.Payload, "model").String(); got != "claude-sonnet-4-5" { + t.Fatalf("expected resolved model, got %q", got) + } +} + +func TestGitLabExecutorExecuteFallsBackToCodeSuggestions(t *testing.T) { + chatCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case gitLabChatEndpoint: + chatCalls++ + http.Error(w, "feature unavailable", http.StatusForbidden) + case gitLabCodeSuggestionsEndpoint: + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{ + "text": "fallback response", + }}, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer srv.Close() + + exec := NewGitLabExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gitlab", + Metadata: map[string]any{ + "base_url": srv.URL, + "personal_access_token": "glpat-token", "auth_method": "pat", - "base_url": server.URL, - "personal_access_token": "pat-123", + }, + } + req := cliproxyexecutor.Request{ + Model: "gitlab-duo", + Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":"write code"}]}`), + } + + resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if chatCalls != 1 { + t.Fatalf("expected chat endpoint to be tried once, got %d", chatCalls) + } + if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "fallback response" { + t.Fatalf("expected fallback response, got %q", got) + } +} + +func TestGitLabExecutorRefreshUpdatesMetadata(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/token": + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "oauth-refreshed", + "refresh_token": "oauth-refresh", + "token_type": "Bearer", + "scope": "api read_user", + "created_at": 1710000000, + "expires_in": 3600, + }) + case "/api/v4/code_suggestions/direct_access": + _ = json.NewEncoder(w).Encode(map[string]any{ + "base_url": "https://cloud.gitlab.example.com", + "token": "gateway-token", + "expires_at": 1710003600, + "headers": map[string]string{"X-Gitlab-Realm": "saas"}, + "model_details": map[string]any{ + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer srv.Close() + + exec := NewGitLabExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + ID: "gitlab-auth.json", + Provider: "gitlab", + Metadata: map[string]any{ + "base_url": srv.URL, + "access_token": "oauth-access", + "refresh_token": "oauth-refresh", + "oauth_client_id": "client-id", + "oauth_client_secret": "client-secret", + "auth_method": "oauth", + "oauth_expires_at": "2000-01-01T00:00:00Z", }, } updated, err := exec.Refresh(context.Background(), auth) if err != nil { - t.Fatalf("Refresh returned error: %v", err) + t.Fatalf("Refresh() error = %v", err) } - if got := metadataString(updated.Metadata, "duo_gateway_token"); got != "gateway-token" { - t.Fatalf("unexpected gateway token %q", got) + if got := updated.Metadata["access_token"]; got != "oauth-refreshed" { + t.Fatalf("expected refreshed access token, got %#v", got) } - if got := gitLabModelName(updated); got != "codestral-2501" { - t.Fatalf("unexpected model name %q", got) - } - headers := gitLabHeaders(updated) - if headers["X-Gitlab-Realm"] != "saas" { - t.Fatalf("unexpected gateway headers %+v", headers) + if got := updated.Metadata["model_name"]; got != "claude-sonnet-4-5" { + t.Fatalf("expected refreshed model metadata, got %#v", got) } } - -func TestGitLabExecutorExecute_UsesGatewayHeadersAndResolvedModel(t *testing.T) { - var receivedAuth string - var receivedRealm string - var receivedModel string - - gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - receivedAuth = r.Header.Get("Authorization") - receivedRealm = r.Header.Get("X-Gitlab-Realm") - receivedModel = findJSONField(string(body), `"model":"`, `"`) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":"ok","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) - })) - defer gateway.Close() - - exec := NewGitLabExecutor(nil) - auth := &cliproxyauth.Auth{ - ID: "gitlab-oauth.json", - Provider: "gitlab", - Metadata: map[string]any{ - "type": "gitlab", - "auth_method": "oauth", - "duo_gateway_base_url": gateway.URL, - "duo_gateway_token": "gateway-token", - "duo_gateway_headers": map[string]any{"X-Gitlab-Realm": "saas"}, - "model_details": map[string]any{"model_name": "codestral-2501", "model_provider": "mistral"}, - }, - } - - resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gitlab-duo", - Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":"hello"}]}`), - }, cliproxyexecutor.Options{}) - if err != nil { - t.Fatalf("Execute returned error: %v", err) - } - if len(resp.Payload) == 0 { - t.Fatal("expected non-empty payload") - } - if receivedAuth != "Bearer gateway-token" { - t.Fatalf("unexpected Authorization header %q", receivedAuth) - } - if receivedRealm != "saas" { - t.Fatalf("unexpected X-Gitlab-Realm header %q", receivedRealm) - } - if receivedModel != "codestral-2501" { - t.Fatalf("unexpected resolved model %q", receivedModel) - } -} - -func findJSONField(body, prefix, suffix string) string { - start := strings.Index(body, prefix) - if start < 0 { - return "" - } - start += len(prefix) - end := strings.Index(body[start:], suffix) - if end < 0 { - return "" - } - return body[start : start+end] -} diff --git a/sdk/auth/gitlab.go b/sdk/auth/gitlab.go index ae2b9177..61dd2acf 100644 --- a/sdk/auth/gitlab.go +++ b/sdk/auth/gitlab.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "os" "strings" "time" @@ -360,6 +361,13 @@ func (a *GitLabAuthenticator) resolveString(opts *LoginOptions, key, fallback st return value } } + for _, envKey := range gitLabEnvKeys(key) { + if raw, ok := os.LookupEnv(envKey); ok { + if trimmed := strings.TrimSpace(raw); trimmed != "" { + return trimmed + } + } + } if strings.TrimSpace(fallback) != "" { return fallback } @@ -460,3 +468,18 @@ func maskGitLabToken(token string) string { } return trimmed[:4] + "..." + trimmed[len(trimmed)-4:] } + +func gitLabEnvKeys(key string) []string { + switch strings.TrimSpace(key) { + case gitLabBaseURLMetadataKey: + return []string{"GITLAB_BASE_URL"} + case gitLabOAuthClientIDMetadataKey: + return []string{"GITLAB_OAUTH_CLIENT_ID"} + case gitLabOAuthClientSecretMetadataKey: + return []string{"GITLAB_OAUTH_CLIENT_SECRET"} + case gitLabPersonalAccessTokenMetadataKey: + return []string{"GITLAB_PERSONAL_ACCESS_TOKEN"} + default: + return nil + } +} diff --git a/sdk/auth/gitlab_test.go b/sdk/auth/gitlab_test.go new file mode 100644 index 00000000..055a16a5 --- /dev/null +++ b/sdk/auth/gitlab_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestGitLabAuthenticatorLoginPAT(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v4/user": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "username": "duo-user", + "email": "duo@example.com", + "name": "Duo User", + }) + case "/api/v4/personal_access_tokens/self": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 5, + "name": "CLIProxyAPI", + "scopes": []string{"api"}, + }) + case "/api/v4/code_suggestions/direct_access": + _ = json.NewEncoder(w).Encode(map[string]any{ + "base_url": "https://cloud.gitlab.example.com", + "token": "gateway-token", + "expires_at": 1710003600, + "headers": map[string]string{"X-Gitlab-Realm": "saas"}, + "model_details": map[string]any{ + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer srv.Close() + + authenticator := NewGitLabAuthenticator() + record, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{ + Metadata: map[string]string{ + "login_mode": "pat", + "base_url": srv.URL, + "personal_access_token": "glpat-test-token", + }, + }) + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if record.Provider != "gitlab" { + t.Fatalf("expected gitlab provider, got %q", record.Provider) + } + if got := record.Metadata["model_name"]; got != "claude-sonnet-4-5" { + t.Fatalf("expected discovered model, got %#v", got) + } + if got := record.Metadata["auth_kind"]; got != "personal_access_token" { + t.Fatalf("expected personal_access_token auth kind, got %#v", got) + } +} diff --git a/sdk/cliproxy/service_gitlab_models_test.go b/sdk/cliproxy/service_gitlab_models_test.go index 7794649c..4ecc5440 100644 --- a/sdk/cliproxy/service_gitlab_models_test.go +++ b/sdk/cliproxy/service_gitlab_models_test.go @@ -1,7 +1,6 @@ package cliproxy import ( - "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" @@ -9,51 +8,41 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" ) -func TestRegisterModelsForAuth_GitLabUsesDiscoveredModelAndAlias(t *testing.T) { +func TestRegisterModelsForAuth_GitLabUsesDiscoveredModels(t *testing.T) { service := &Service{cfg: &config.Config{}} auth := &coreauth.Auth{ - ID: "gitlab-auth", + ID: "gitlab-auth.json", Provider: "gitlab", Status: coreauth.StatusActive, Metadata: map[string]any{ "model_details": map[string]any{ - "model_provider": "mistral", - "model_name": "codestral-2501", + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", }, }, } reg := registry.GetGlobalRegistry() reg.UnregisterClient(auth.ID) - t.Cleanup(func() { - reg.UnregisterClient(auth.ID) - }) + t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) service.registerModelsForAuth(auth) - models := reg.GetModelsForClient(auth.ID) - if len(models) == 0 { - t.Fatal("expected GitLab models to be registered") + if len(models) < 2 { + t.Fatalf("expected stable alias and discovered model, got %d entries", len(models)) } - seenActual := false seenAlias := false + seenDiscovered := false for _, model := range models { - if model == nil { - continue - } - switch strings.TrimSpace(model.ID) { - case "codestral-2501": - seenActual = true + switch model.ID { case "gitlab-duo": seenAlias = true + case "claude-sonnet-4-5": + seenDiscovered = true } } - - if !seenActual { - t.Fatal("expected discovered GitLab model to be registered") - } - if !seenAlias { - t.Fatal("expected stable GitLab Duo alias to be registered") + if !seenAlias || !seenDiscovered { + t.Fatalf("expected gitlab-duo and discovered model, got %+v", models) } }