diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 6dacb743f3..81705adbd1 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1838,9 +1838,6 @@ func (h *Handler) RequestGitLabToken(c *gin.Context) { metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModeOAuth, tokenResp, direct) metadata["auth_kind"] = "oauth" metadata["oauth_client_id"] = clientID - if clientSecret != "" { - metadata["oauth_client_secret"] = clientSecret - } metadata["username"] = strings.TrimSpace(user.Username) if email := primaryGitLabEmail(user); email != "" { metadata["email"] = email diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f80..190c043cd2 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -839,6 +839,9 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, hasClaude1MHeader = true } } + if !hasClaude1MHeader && auth != nil && auth.Attributes != nil { + hasClaude1MHeader = strings.EqualFold(strings.TrimSpace(auth.Attributes["gitlab_duo_force_context_1m"]), "true") + } // Merge extra betas from request body and request flags. if len(extraBetas) > 0 || hasClaude1MHeader { diff --git a/internal/runtime/executor/gitlab_executor.go b/internal/runtime/executor/gitlab_executor.go index f9fa9fc157..88e7964a32 100644 --- a/internal/runtime/executor/gitlab_executor.go +++ b/internal/runtime/executor/gitlab_executor.go @@ -30,12 +30,20 @@ const ( gitLabChatEndpoint = "/api/v4/chat/completions" gitLabCodeSuggestionsEndpoint = "/api/v4/code_suggestions/completions" gitLabSSEStreamingHeader = "X-Supports-Sse-Streaming" + gitLabContext1MBeta = "context-1m-2025-08-07" + gitLabNativeUserAgent = "CLIProxyAPIPlus/GitLab-Duo" ) type GitLabExecutor struct { cfg *config.Config } +type gitLabCatalogModel struct { + ID string + DisplayName string + Provider string +} + type gitLabPrompt struct { Instruction string FileName string @@ -53,6 +61,23 @@ type gitLabOpenAIStreamState struct { Finished bool } +var gitLabAgenticCatalog = []gitLabCatalogModel{ + {ID: "duo-chat-gpt-5-1", DisplayName: "GitLab Duo (GPT-5.1)", Provider: "openai"}, + {ID: "duo-chat-opus-4-6", DisplayName: "GitLab Duo (Claude Opus 4.6)", Provider: "anthropic"}, + {ID: "duo-chat-opus-4-5", DisplayName: "GitLab Duo (Claude Opus 4.5)", Provider: "anthropic"}, + {ID: "duo-chat-sonnet-4-6", DisplayName: "GitLab Duo (Claude Sonnet 4.6)", Provider: "anthropic"}, + {ID: "duo-chat-sonnet-4-5", DisplayName: "GitLab Duo (Claude Sonnet 4.5)", Provider: "anthropic"}, + {ID: "duo-chat-gpt-5-mini", DisplayName: "GitLab Duo (GPT-5 Mini)", Provider: "openai"}, + {ID: "duo-chat-gpt-5-2", DisplayName: "GitLab Duo (GPT-5.2)", Provider: "openai"}, + {ID: "duo-chat-gpt-5-2-codex", DisplayName: "GitLab Duo (GPT-5.2 Codex)", Provider: "openai"}, + {ID: "duo-chat-gpt-5-codex", DisplayName: "GitLab Duo (GPT-5 Codex)", Provider: "openai"}, + {ID: "duo-chat-haiku-4-5", DisplayName: "GitLab Duo (Claude Haiku 4.5)", Provider: "anthropic"}, +} + +var gitLabModelAliases = map[string]string{ + "duo-chat-haiku-4-6": "duo-chat-haiku-4-5", +} + func NewGitLabExecutor(cfg *config.Config) *GitLabExecutor { return &GitLabExecutor{cfg: cfg} } @@ -249,12 +274,12 @@ func (e *GitLabExecutor) nativeGateway( auth *cliproxyauth.Auth, req cliproxyexecutor.Request, ) (cliproxyauth.ProviderExecutor, *cliproxyauth.Auth, cliproxyexecutor.Request, bool) { - if nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth); ok { + if nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth, req.Model); ok { nativeReq := req nativeReq.Model = gitLabResolvedModel(auth, req.Model) return NewClaudeExecutor(e.cfg), nativeAuth, nativeReq, true } - if nativeAuth, ok := buildGitLabOpenAIGatewayAuth(auth); ok { + if nativeAuth, ok := buildGitLabOpenAIGatewayAuth(auth, req.Model); ok { nativeReq := req nativeReq.Model = gitLabResolvedModel(auth, req.Model) return NewCodexExecutor(e.cfg), nativeAuth, nativeReq, true @@ -263,10 +288,10 @@ func (e *GitLabExecutor) nativeGateway( } func (e *GitLabExecutor) nativeGatewayHTTP(auth *cliproxyauth.Auth) (cliproxyauth.ProviderExecutor, *cliproxyauth.Auth) { - if nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth); ok { + if nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth, ""); ok { return NewClaudeExecutor(e.cfg), nativeAuth } - if nativeAuth, ok := buildGitLabOpenAIGatewayAuth(auth); ok { + if nativeAuth, ok := buildGitLabOpenAIGatewayAuth(auth, ""); ok { return NewCodexExecutor(e.cfg), nativeAuth } return nil, nil @@ -664,7 +689,7 @@ func applyGitLabRequestHeaders(req *http.Request, auth *cliproxyauth.Auth) { if auth != nil { util.ApplyCustomHeadersFromAttrs(req, auth.Attributes) } - for key, value := range gitLabGatewayHeaders(auth) { + for key, value := range gitLabGatewayHeaders(auth, "") { if key == "" || value == "" { continue } @@ -672,34 +697,40 @@ func applyGitLabRequestHeaders(req *http.Request, auth *cliproxyauth.Auth) { } } -func gitLabGatewayHeaders(auth *cliproxyauth.Auth) map[string]string { - if auth == nil || auth.Metadata == nil { - return nil - } - raw, ok := auth.Metadata["duo_gateway_headers"] - if !ok { - return nil - } +func gitLabGatewayHeaders(auth *cliproxyauth.Auth, targetProvider string) map[string]string { out := make(map[string]string) - switch typed := raw.(type) { - case map[string]string: - for key, value := range typed { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key != "" && value != "" { - out[key] = value + if auth != nil && auth.Metadata != nil { + raw, ok := auth.Metadata["duo_gateway_headers"] + if ok { + switch typed := raw.(type) { + case map[string]string: + for key, value := range typed { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key != "" && value != "" { + out[key] = value + } + } + case map[string]any: + for key, value := range typed { + key = strings.TrimSpace(key) + if key == "" { + continue + } + strValue := strings.TrimSpace(fmt.Sprint(value)) + if strValue != "" { + out[key] = strValue + } + } } } - case map[string]any: - for key, value := range typed { - key = strings.TrimSpace(key) - if key == "" { - continue - } - strValue := strings.TrimSpace(fmt.Sprint(value)) - if strValue != "" { - out[key] = strValue - } + } + if _, ok := out["User-Agent"]; !ok { + out["User-Agent"] = gitLabNativeUserAgent + } + if strings.EqualFold(strings.TrimSpace(targetProvider), "openai") { + if _, ok := out["anthropic-beta"]; !ok { + out["anthropic-beta"] = gitLabContext1MBeta } } if len(out) == 0 { @@ -989,8 +1020,8 @@ func gitLabUsage(model string, translatedReq []byte, text string) (int64, int64) return promptTokens, int64(completionCount) } -func buildGitLabAnthropicGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, bool) { - if !gitLabUsesAnthropicGateway(auth) { +func buildGitLabAnthropicGatewayAuth(auth *cliproxyauth.Auth, requestedModel string) (*cliproxyauth.Auth, bool) { + if !gitLabUsesAnthropicGateway(auth, requestedModel) { return nil, false } baseURL := gitLabAnthropicGatewayBaseURL(auth) @@ -1006,7 +1037,8 @@ func buildGitLabAnthropicGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Aut } nativeAuth.Attributes["api_key"] = token nativeAuth.Attributes["base_url"] = baseURL - for key, value := range gitLabGatewayHeaders(auth) { + nativeAuth.Attributes["gitlab_duo_force_context_1m"] = "true" + for key, value := range gitLabGatewayHeaders(auth, "anthropic") { if key == "" || value == "" { continue } @@ -1015,8 +1047,8 @@ func buildGitLabAnthropicGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Aut return nativeAuth, true } -func buildGitLabOpenAIGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, bool) { - if !gitLabUsesOpenAIGateway(auth) { +func buildGitLabOpenAIGatewayAuth(auth *cliproxyauth.Auth, requestedModel string) (*cliproxyauth.Auth, bool) { + if !gitLabUsesOpenAIGateway(auth, requestedModel) { return nil, false } baseURL := gitLabOpenAIGatewayBaseURL(auth) @@ -1032,7 +1064,7 @@ func buildGitLabOpenAIGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, } nativeAuth.Attributes["api_key"] = token nativeAuth.Attributes["base_url"] = baseURL - for key, value := range gitLabGatewayHeaders(auth) { + for key, value := range gitLabGatewayHeaders(auth, "openai") { if key == "" || value == "" { continue } @@ -1041,34 +1073,41 @@ func buildGitLabOpenAIGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, return nativeAuth, true } -func gitLabUsesAnthropicGateway(auth *cliproxyauth.Auth) bool { +func gitLabUsesAnthropicGateway(auth *cliproxyauth.Auth, requestedModel string) bool { if auth == nil || auth.Metadata == nil { return false } - provider := strings.ToLower(gitLabMetadataString(auth.Metadata, "model_provider")) - if provider == "" { - modelName := strings.ToLower(gitLabMetadataString(auth.Metadata, "model_name")) - provider = inferGitLabProviderFromModel(modelName) - } + provider := gitLabGatewayProvider(auth, requestedModel) return provider == "anthropic" && gitLabMetadataString(auth.Metadata, "duo_gateway_base_url") != "" && gitLabMetadataString(auth.Metadata, "duo_gateway_token") != "" } -func gitLabUsesOpenAIGateway(auth *cliproxyauth.Auth) bool { +func gitLabUsesOpenAIGateway(auth *cliproxyauth.Auth, requestedModel string) bool { if auth == nil || auth.Metadata == nil { return false } - provider := strings.ToLower(gitLabMetadataString(auth.Metadata, "model_provider")) - if provider == "" { - modelName := strings.ToLower(gitLabMetadataString(auth.Metadata, "model_name")) - provider = inferGitLabProviderFromModel(modelName) - } + provider := gitLabGatewayProvider(auth, requestedModel) return provider == "openai" && gitLabMetadataString(auth.Metadata, "duo_gateway_base_url") != "" && gitLabMetadataString(auth.Metadata, "duo_gateway_token") != "" } +func gitLabGatewayProvider(auth *cliproxyauth.Auth, requestedModel string) string { + modelName := strings.TrimSpace(gitLabResolvedModel(auth, requestedModel)) + if provider := inferGitLabProviderFromModel(modelName); provider != "" { + return provider + } + if auth == nil || auth.Metadata == nil { + return "" + } + provider := strings.ToLower(gitLabMetadataString(auth.Metadata, "model_provider")) + if provider == "" { + provider = inferGitLabProviderFromModel(gitLabMetadataString(auth.Metadata, "model_name")) + } + return provider +} + func inferGitLabProviderFromModel(model string) string { model = strings.ToLower(strings.TrimSpace(model)) switch { @@ -1151,6 +1190,9 @@ func gitLabBaseURL(auth *cliproxyauth.Auth) string { func gitLabResolvedModel(auth *cliproxyauth.Auth, requested string) string { requested = strings.TrimSpace(thinking.ParseSuffix(requested).ModelName) if requested != "" && !strings.EqualFold(requested, "gitlab-duo") { + if mapped, ok := gitLabModelAliases[strings.ToLower(requested)]; ok && strings.TrimSpace(mapped) != "" { + return mapped + } return requested } if auth != nil && auth.Metadata != nil { @@ -1277,8 +1319,8 @@ func gitLabAuthKind(method string) string { } func GitLabModelsFromAuth(auth *cliproxyauth.Auth) []*registry.ModelInfo { - models := make([]*registry.ModelInfo, 0, 4) - seen := make(map[string]struct{}, 4) + models := make([]*registry.ModelInfo, 0, len(gitLabAgenticCatalog)+4) + seen := make(map[string]struct{}, len(gitLabAgenticCatalog)+4) addModel := func(id, displayName, provider string) { id = strings.TrimSpace(id) if id == "" { @@ -1302,6 +1344,18 @@ func GitLabModelsFromAuth(auth *cliproxyauth.Auth) []*registry.ModelInfo { } addModel("gitlab-duo", "GitLab Duo", "gitlab") + for _, model := range gitLabAgenticCatalog { + addModel(model.ID, model.DisplayName, model.Provider) + } + for alias, upstream := range gitLabModelAliases { + target := strings.TrimSpace(upstream) + displayName := "GitLab Duo Alias" + provider := strings.TrimSpace(inferGitLabProviderFromModel(target)) + if provider != "" { + displayName = fmt.Sprintf("GitLab Duo Alias (%s)", provider) + } + addModel(alias, displayName, provider) + } if auth == nil { return models } diff --git a/internal/runtime/executor/gitlab_executor_test.go b/internal/runtime/executor/gitlab_executor_test.go index 5d49c1d7be..6e1d100340 100644 --- a/internal/runtime/executor/gitlab_executor_test.go +++ b/internal/runtime/executor/gitlab_executor_test.go @@ -217,6 +217,69 @@ func TestGitLabExecutorExecuteUsesOpenAIGateway(t *testing.T) { } } +func TestGitLabExecutorExecuteUsesRequestedModelToSelectOpenAIGateway(t *testing.T) { + var gotAuthHeader, gotRealmHeader, gotBetaHeader, gotUserAgent string + var gotPath string + var gotModel string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthHeader = r.Header.Get("Authorization") + gotRealmHeader = r.Header.Get("X-Gitlab-Realm") + gotBetaHeader = r.Header.Get("anthropic-beta") + gotUserAgent = r.Header.Get("User-Agent") + gotModel = gjson.GetBytes(readBody(t, r), "model").String() + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"duo-chat-gpt-5-codex\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello from explicit openai model\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"duo-chat-gpt-5-codex\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello from explicit openai model\"}]}],\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"total_tokens\":15}}}\n\n")) + })) + defer srv.Close() + + exec := NewGitLabExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gitlab", + Metadata: map[string]any{ + "duo_gateway_base_url": srv.URL, + "duo_gateway_token": "gateway-token", + "duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"}, + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + } + req := cliproxyexecutor.Request{ + Model: "duo-chat-gpt-5-codex", + Payload: []byte(`{"model":"duo-chat-gpt-5-codex","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 gotPath != "/v1/proxy/openai/v1/responses" { + t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/openai/v1/responses") + } + if gotAuthHeader != "Bearer gateway-token" { + t.Fatalf("Authorization = %q, want Bearer gateway-token", gotAuthHeader) + } + if gotRealmHeader != "saas" { + t.Fatalf("X-Gitlab-Realm = %q, want saas", gotRealmHeader) + } + if gotBetaHeader != gitLabContext1MBeta { + t.Fatalf("anthropic-beta = %q, want %q", gotBetaHeader, gitLabContext1MBeta) + } + if gotUserAgent != gitLabNativeUserAgent { + t.Fatalf("User-Agent = %q, want %q", gotUserAgent, gitLabNativeUserAgent) + } + if gotModel != "duo-chat-gpt-5-codex" { + t.Fatalf("model = %q, want duo-chat-gpt-5-codex", gotModel) + } + if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "hello from explicit openai model" { + t.Fatalf("expected explicit openai model response, got %q payload=%s", got, string(resp.Payload)) + } +} + func TestGitLabExecutorRefreshUpdatesMetadata(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -251,13 +314,12 @@ func TestGitLabExecutorRefreshUpdatesMetadata(t *testing.T) { 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", + "base_url": srv.URL, + "access_token": "oauth-access", + "refresh_token": "oauth-refresh", + "oauth_client_id": "client-id", + "auth_method": "oauth", + "oauth_expires_at": "2000-01-01T00:00:00Z", }, } @@ -397,9 +459,11 @@ func TestGitLabExecutorExecuteStreamFallsBackToSyntheticChat(t *testing.T) { } func TestGitLabExecutorExecuteStreamUsesAnthropicGateway(t *testing.T) { - var gotPath string + var gotPath, gotBetaHeader, gotUserAgent string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path + gotBetaHeader = r.Header.Get("Anthropic-Beta") + gotUserAgent = r.Header.Get("User-Agent") w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("event: message_start\n")) _, _ = w.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n")) @@ -441,6 +505,12 @@ func TestGitLabExecutorExecuteStreamUsesAnthropicGateway(t *testing.T) { if gotPath != "/v1/proxy/anthropic/v1/messages" { t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages") } + if !strings.Contains(gotBetaHeader, gitLabContext1MBeta) { + t.Fatalf("Anthropic-Beta = %q, want to contain %q", gotBetaHeader, gitLabContext1MBeta) + } + if gotUserAgent != gitLabNativeUserAgent { + t.Fatalf("User-Agent = %q, want %q", gotUserAgent, gitLabNativeUserAgent) + } if !strings.Contains(strings.Join(lines, "\n"), "hello from gateway") { t.Fatalf("expected anthropic gateway stream, got %q", strings.Join(lines, "\n")) } diff --git a/sdk/auth/gitlab.go b/sdk/auth/gitlab.go index 61dd2acf1c..c81aa8ce43 100644 --- a/sdk/auth/gitlab.go +++ b/sdk/auth/gitlab.go @@ -209,9 +209,6 @@ waitForCallback: metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModeOAuth, tokenResp, direct) metadata["auth_kind"] = "oauth" metadata[gitLabOAuthClientIDMetadataKey] = clientID - if strings.TrimSpace(clientSecret) != "" { - metadata[gitLabOAuthClientSecretMetadataKey] = clientSecret - } metadata["username"] = strings.TrimSpace(user.Username) if email := strings.TrimSpace(primaryGitLabEmail(user)); email != "" { metadata["email"] = email diff --git a/sdk/cliproxy/service_gitlab_models_test.go b/sdk/cliproxy/service_gitlab_models_test.go index 4ecc5440c4..a708f335b7 100644 --- a/sdk/cliproxy/service_gitlab_models_test.go +++ b/sdk/cliproxy/service_gitlab_models_test.go @@ -46,3 +46,41 @@ func TestRegisterModelsForAuth_GitLabUsesDiscoveredModels(t *testing.T) { t.Fatalf("expected gitlab-duo and discovered model, got %+v", models) } } + +func TestRegisterModelsForAuth_GitLabIncludesAgenticCatalog(t *testing.T) { + service := &Service{cfg: &config.Config{}} + auth := &coreauth.Auth{ + ID: "gitlab-agentic-auth.json", + Provider: "gitlab", + Status: coreauth.StatusActive, + } + + reg := registry.GetGlobalRegistry() + reg.UnregisterClient(auth.ID) + t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) + + service.registerModelsForAuth(auth) + models := reg.GetModelsForClient(auth.ID) + if len(models) < 5 { + t.Fatalf("expected stable alias plus built-in agentic catalog, got %d entries", len(models)) + } + + required := map[string]bool{ + "gitlab-duo": false, + "duo-chat-opus-4-6": false, + "duo-chat-haiku-4-5": false, + "duo-chat-sonnet-4-5": false, + "duo-chat-opus-4-5": false, + "duo-chat-gpt-5-codex": false, + } + for _, model := range models { + if _, ok := required[model.ID]; ok { + required[model.ID] = true + } + } + for id, seen := range required { + if !seen { + t.Fatalf("expected built-in GitLab Duo model %q, got %+v", id, models) + } + } +}