Add GitLab Duo auth and executor support

This commit is contained in:
LuxVTZ
2026-03-10 17:57:55 +04:00
parent bb28cd26ad
commit 54c3eb1b1e
8 changed files with 356 additions and 179 deletions

View File

@@ -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. 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 ## 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. 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.

View File

@@ -426,7 +426,7 @@ func ExtractDiscoveredModels(metadata map[string]any) []DiscoveredModel {
if name == "" { if name == "" {
return return
} }
key := strings.ToLower(provider + "\x00" + name) key := strings.ToLower(name)
if _, ok := seen[key]; ok { if _, ok := seen[key]; ok {
return return
} }

View File

@@ -2,71 +2,137 @@ package gitlab
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings"
"testing" "testing"
) )
func TestNormalizeBaseURL(t *testing.T) { func TestAuthClientGenerateAuthURLIncludesPKCE(t *testing.T) {
tests := []struct { client := NewAuthClient(nil)
name string pkce, err := GeneratePKCECodes()
in string if err != nil {
want string t.Fatalf("GeneratePKCECodes() error = %v", err)
}{
{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"},
} }
for _, tc := range tests { rawURL, err := client.GenerateAuthURL("https://gitlab.example.com", "client-id", RedirectURL(17171), "state-123", pkce)
t.Run(tc.name, func(t *testing.T) { if err != nil {
if got := NormalizeBaseURL(tc.in); got != tc.want { t.Fatalf("GenerateAuthURL() error = %v", err)
t.Fatalf("NormalizeBaseURL(%q) = %q, want %q", tc.in, got, tc.want) }
}
}) 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) { func TestAuthClientExchangeCodeForTokens(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.URL.Path != "/oauth/token" {
t.Fatalf("expected POST, got %s", r.Method) t.Fatalf("unexpected path %q", r.URL.Path)
} }
if got := r.Header.Get("Authorization"); got != "Bearer pat-123" { if err := r.ParseForm(); err != nil {
t.Fatalf("expected Authorization header, got %q", got) t.Fatalf("ParseForm() error = %v", err)
} }
w.Header().Set("Content-Type", "application/json") if got := r.Form.Get("grant_type"); got != "authorization_code" {
_, _ = w.Write([]byte(`{ t.Fatalf("expected authorization_code grant, got %q", got)
"base_url":"https://gateway.gitlab.example.com/v1", }
"token":"duo-gateway-token", if got := r.Form.Get("code_verifier"); got != "verifier-123" {
"expires_at":2000000000, t.Fatalf("expected code_verifier, got %q", got)
"headers":{ }
"X-Gitlab-Realm":"saas", _ = json.NewEncoder(w).Encode(map[string]any{
"X-Gitlab-Host-Name":"gitlab.example.com" "access_token": "oauth-access",
}, "refresh_token": "oauth-refresh",
"model_details":{ "token_type": "Bearer",
"model_provider":"anthropic", "scope": "api read_user",
"model_name":"claude-sonnet-4-5" "created_at": 1710000000,
} "expires_in": 3600,
}`)) })
})) }))
defer server.Close() defer srv.Close()
client := &AuthClient{httpClient: server.Client()} client := NewAuthClient(nil)
direct, err := client.FetchDirectAccess(context.Background(), server.URL, "pat-123") token, err := client.ExchangeCodeForTokens(context.Background(), srv.URL, "client-id", "client-secret", RedirectURL(17171), "auth-code", "verifier-123")
if err != nil { 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" { if token.AccessToken != "oauth-access" {
t.Fatalf("unexpected base_url %q", direct.BaseURL) t.Fatalf("expected access token, got %q", token.AccessToken)
} }
if direct.Token != "duo-gateway-token" { if token.RefreshToken != "oauth-refresh" {
t.Fatalf("unexpected token %q", direct.Token) 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" { if direct.ModelDetails == nil || direct.ModelDetails.ModelName != "claude-sonnet-4-5" {
t.Fatalf("unexpected model details: %+v", direct.ModelDetails) t.Fatalf("expected model details, got %+v", direct.ModelDetails)
}
if direct.Headers["X-Gitlab-Realm"] != "saas" {
t.Fatalf("expected X-Gitlab-Realm header, got %+v", direct.Headers)
} }
} }

View File

@@ -21,10 +21,10 @@ import (
) )
const ( const (
gitLabProviderKey = "gitlab" gitLabProviderKey = "gitlab"
gitLabAuthMethodOAuth = "oauth" gitLabAuthMethodOAuth = "oauth"
gitLabAuthMethodPAT = "pat" gitLabAuthMethodPAT = "pat"
gitLabChatEndpoint = "/api/v4/chat/completions" gitLabChatEndpoint = "/api/v4/chat/completions"
gitLabCodeSuggestionsEndpoint = "/api/v4/code_suggestions/completions" gitLabCodeSuggestionsEndpoint = "/api/v4/code_suggestions/completions"
) )
@@ -33,10 +33,10 @@ type GitLabExecutor struct {
} }
type gitLabPrompt struct { type gitLabPrompt struct {
Instruction string Instruction string
FileName string FileName string
ContentAboveCursor string ContentAboveCursor string
ChatContext []map[string]any ChatContext []map[string]any
CodeSuggestionContext []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_above_cursor": contentAbove,
"content_below_cursor": "", "content_below_cursor": "",
}, },
"intent": "generation", "intent": "generation",
"generation_type": "small_file", "generation_type": "small_file",
"user_instruction": prompt.Instruction, "user_instruction": prompt.Instruction,
"stream": false, "stream": false,
} }
if len(prompt.CodeSuggestionContext) > 0 { if len(prompt.CodeSuggestionContext) > 0 {
body["context"] = prompt.CodeSuggestionContext body["context"] = prompt.CodeSuggestionContext

View File

@@ -2,123 +2,154 @@ package executor
import ( import (
"context" "context"
"io" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" 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) { func TestGitLabExecutorExecuteUsesChatEndpoint(t *testing.T) {
var server *httptest.Server srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != gitLabChatEndpoint {
if r.URL.Path != "/api/v4/code_suggestions/direct_access" { t.Fatalf("unexpected path %q", r.URL.Path)
t.Fatalf("unexpected path %s", r.URL.Path)
} }
if got := r.Header.Get("Authorization"); got != "Bearer pat-123" { _, _ = w.Write([]byte(`"chat response"`))
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"}
}`))
})) }))
defer server.Close() defer srv.Close()
exec := NewGitLabExecutor(nil) exec := NewGitLabExecutor(&config.Config{})
auth := &cliproxyauth.Auth{ auth := &cliproxyauth.Auth{
ID: "gitlab-pat.json",
Provider: "gitlab", Provider: "gitlab",
Metadata: map[string]any{ 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", "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) updated, err := exec.Refresh(context.Background(), auth)
if err != nil { 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" { if got := updated.Metadata["access_token"]; got != "oauth-refreshed" {
t.Fatalf("unexpected gateway token %q", got) t.Fatalf("expected refreshed access token, got %#v", got)
} }
if got := gitLabModelName(updated); got != "codestral-2501" { if got := updated.Metadata["model_name"]; got != "claude-sonnet-4-5" {
t.Fatalf("unexpected model name %q", got) t.Fatalf("expected refreshed model metadata, got %#v", got)
}
headers := gitLabHeaders(updated)
if headers["X-Gitlab-Realm"] != "saas" {
t.Fatalf("unexpected gateway headers %+v", headers)
} }
} }
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]
}

View File

@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
"time" "time"
@@ -360,6 +361,13 @@ func (a *GitLabAuthenticator) resolveString(opts *LoginOptions, key, fallback st
return value 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) != "" { if strings.TrimSpace(fallback) != "" {
return fallback return fallback
} }
@@ -460,3 +468,18 @@ func maskGitLabToken(token string) string {
} }
return trimmed[:4] + "..." + trimmed[len(trimmed)-4:] 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
}
}

66
sdk/auth/gitlab_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -1,7 +1,6 @@
package cliproxy package cliproxy
import ( import (
"strings"
"testing" "testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
@@ -9,51 +8,41 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config" "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{}} service := &Service{cfg: &config.Config{}}
auth := &coreauth.Auth{ auth := &coreauth.Auth{
ID: "gitlab-auth", ID: "gitlab-auth.json",
Provider: "gitlab", Provider: "gitlab",
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
Metadata: map[string]any{ Metadata: map[string]any{
"model_details": map[string]any{ "model_details": map[string]any{
"model_provider": "mistral", "model_provider": "anthropic",
"model_name": "codestral-2501", "model_name": "claude-sonnet-4-5",
}, },
}, },
} }
reg := registry.GetGlobalRegistry() reg := registry.GetGlobalRegistry()
reg.UnregisterClient(auth.ID) reg.UnregisterClient(auth.ID)
t.Cleanup(func() { t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
reg.UnregisterClient(auth.ID)
})
service.registerModelsForAuth(auth) service.registerModelsForAuth(auth)
models := reg.GetModelsForClient(auth.ID) models := reg.GetModelsForClient(auth.ID)
if len(models) == 0 { if len(models) < 2 {
t.Fatal("expected GitLab models to be registered") t.Fatalf("expected stable alias and discovered model, got %d entries", len(models))
} }
seenActual := false
seenAlias := false seenAlias := false
seenDiscovered := false
for _, model := range models { for _, model := range models {
if model == nil { switch model.ID {
continue
}
switch strings.TrimSpace(model.ID) {
case "codestral-2501":
seenActual = true
case "gitlab-duo": case "gitlab-duo":
seenAlias = true seenAlias = true
case "claude-sonnet-4-5":
seenDiscovered = true
} }
} }
if !seenAlias || !seenDiscovered {
if !seenActual { t.Fatalf("expected gitlab-duo and discovered model, got %+v", models)
t.Fatal("expected discovered GitLab model to be registered")
}
if !seenAlias {
t.Fatal("expected stable GitLab Duo alias to be registered")
} }
} }