package executor import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" 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" ) const ( gitLabProviderKey = "gitlab" gitLabAuthMethodOAuth = "oauth" gitLabAuthMethodPAT = "pat" gitLabChatEndpoint = "/api/v4/chat/completions" gitLabCodeSuggestionsEndpoint = "/api/v4/code_suggestions/completions" gitLabSSEStreamingHeader = "X-Supports-Sse-Streaming" ) type GitLabExecutor struct { cfg *config.Config } type gitLabPrompt struct { Instruction string FileName string ContentAboveCursor string ChatContext []map[string]any CodeSuggestionContext []map[string]any } type gitLabOpenAIStreamState struct { ID string Model string Created int64 LastFullText string Started bool Finished bool } func NewGitLabExecutor(cfg *config.Config) *GitLabExecutor { return &GitLabExecutor{cfg: cfg} } func (e *GitLabExecutor) Identifier() string { return gitLabProviderKey } func (e *GitLabExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { if nativeExec, nativeAuth, nativeReq, ok := e.nativeAnthropicGateway(auth, req); ok { return nativeExec.Execute(ctx, nativeAuth, nativeReq, opts) } baseModel := thinking.ParseSuffix(req.Model).ModelName reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) defer reporter.trackFailure(ctx, &err) translated, err := e.translateToOpenAI(req, opts) if err != nil { return resp, err } prompt := buildGitLabPrompt(translated) if strings.TrimSpace(prompt.Instruction) == "" && strings.TrimSpace(prompt.ContentAboveCursor) == "" { err = statusErr{code: http.StatusBadRequest, msg: "gitlab duo executor: request has no usable text content"} return resp, err } text, err := e.invokeText(ctx, auth, prompt) if err != nil { return resp, err } responseModel := gitLabResolvedModel(auth, req.Model) openAIResponse := buildGitLabOpenAIResponse(responseModel, text, translated) reporter.publish(ctx, parseOpenAIUsage(openAIResponse)) reporter.ensurePublished(ctx) var param any out := sdktranslator.TranslateNonStream( ctx, sdktranslator.FromString("openai"), opts.SourceFormat, req.Model, opts.OriginalRequest, translated, openAIResponse, ¶m, ) return cliproxyexecutor.Response{Payload: []byte(out), Headers: make(http.Header)}, nil } func (e *GitLabExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { if nativeExec, nativeAuth, nativeReq, ok := e.nativeAnthropicGateway(auth, req); ok { return nativeExec.ExecuteStream(ctx, nativeAuth, nativeReq, opts) } baseModel := thinking.ParseSuffix(req.Model).ModelName reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) defer reporter.trackFailure(ctx, &err) translated, err := e.translateToOpenAI(req, opts) if err != nil { return nil, err } prompt := buildGitLabPrompt(translated) if strings.TrimSpace(prompt.Instruction) == "" && strings.TrimSpace(prompt.ContentAboveCursor) == "" { return nil, statusErr{code: http.StatusBadRequest, msg: "gitlab duo executor: request has no usable text content"} } if result, streamErr := e.requestCodeSuggestionsStream(ctx, auth, prompt, translated, req, opts, reporter); streamErr == nil { return result, nil } else if !shouldFallbackToCodeSuggestions(streamErr) { return nil, streamErr } text, err := e.invokeText(ctx, auth, prompt) if err != nil { return nil, err } responseModel := gitLabResolvedModel(auth, req.Model) openAIResponse := buildGitLabOpenAIResponse(responseModel, text, translated) reporter.publish(ctx, parseOpenAIUsage(openAIResponse)) reporter.ensurePublished(ctx) out := make(chan cliproxyexecutor.StreamChunk, 8) go func() { defer close(out) var param any lines := buildGitLabOpenAIStream(responseModel, text) for _, line := range lines { chunks := sdktranslator.TranslateStream( ctx, sdktranslator.FromString("openai"), opts.SourceFormat, req.Model, opts.OriginalRequest, translated, []byte(line), ¶m, ) for i := range chunks { out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} } } }() return &cliproxyexecutor.StreamResult{Headers: make(http.Header), Chunks: out}, nil } func (e *GitLabExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { if auth == nil { return nil, fmt.Errorf("gitlab duo executor: auth is nil") } baseURL := gitLabBaseURL(auth) token := gitLabPrimaryToken(auth) if baseURL == "" || token == "" { return nil, fmt.Errorf("gitlab duo executor: missing base URL or token") } client := gitlab.NewAuthClient(e.cfg) method := strings.ToLower(strings.TrimSpace(gitLabMetadataString(auth.Metadata, "auth_method", "auth_kind"))) if method == "" { method = gitLabAuthMethodOAuth } if method == gitLabAuthMethodOAuth { if refreshed, refreshErr := e.refreshOAuthToken(ctx, client, auth, baseURL); refreshErr == nil && refreshed != nil { token = refreshed.AccessToken applyGitLabTokenMetadata(auth.Metadata, refreshed) } } direct, err := client.FetchDirectAccess(ctx, baseURL, token) if err != nil && method == gitLabAuthMethodOAuth { if refreshed, refreshErr := e.refreshOAuthToken(ctx, client, auth, baseURL); refreshErr == nil && refreshed != nil { token = refreshed.AccessToken applyGitLabTokenMetadata(auth.Metadata, refreshed) direct, err = client.FetchDirectAccess(ctx, baseURL, token) } } if err != nil { return nil, err } if auth.Metadata == nil { auth.Metadata = make(map[string]any) } auth.Metadata["type"] = gitLabProviderKey auth.Metadata["auth_method"] = method auth.Metadata["auth_kind"] = gitLabAuthKind(method) auth.Metadata["base_url"] = gitlab.NormalizeBaseURL(baseURL) auth.Metadata["last_refresh"] = time.Now().UTC().Format(time.RFC3339) mergeGitLabDirectAccessMetadata(auth.Metadata, direct) return auth, nil } func (e *GitLabExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { if nativeExec, nativeAuth, nativeReq, ok := e.nativeAnthropicGateway(auth, req); ok { return nativeExec.CountTokens(ctx, nativeAuth, nativeReq, opts) } baseModel := thinking.ParseSuffix(req.Model).ModelName translated := sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FromString("openai"), baseModel, req.Payload, false) enc, err := tokenizerForModel(baseModel) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("gitlab duo executor: tokenizer init failed: %w", err) } count, err := countOpenAIChatTokens(enc, translated) if err != nil { return cliproxyexecutor.Response{}, err } return cliproxyexecutor.Response{Payload: buildOpenAIUsageJSON(count), Headers: make(http.Header)}, nil } func (e *GitLabExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { if req == nil { return nil, fmt.Errorf("gitlab duo executor: request is nil") } if nativeExec, nativeAuth := e.nativeAnthropicGatewayHTTP(auth); nativeExec != nil { return nativeExec.HttpRequest(ctx, nativeAuth, req) } if ctx == nil { ctx = req.Context() } httpReq := req.WithContext(ctx) if token := gitLabPrimaryToken(auth); token != "" { httpReq.Header.Set("Authorization", "Bearer "+token) } return newProxyAwareHTTPClient(ctx, e.cfg, auth, 0).Do(httpReq) } func (e *GitLabExecutor) translateToOpenAI(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName return sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FromString("openai"), baseModel, req.Payload, opts.Stream), nil } func (e *GitLabExecutor) nativeAnthropicGateway( auth *cliproxyauth.Auth, req cliproxyexecutor.Request, ) (*ClaudeExecutor, *cliproxyauth.Auth, cliproxyexecutor.Request, bool) { nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth) if !ok { return nil, nil, req, false } nativeReq := req nativeReq.Model = gitLabResolvedModel(auth, req.Model) return NewClaudeExecutor(e.cfg), nativeAuth, nativeReq, true } func (e *GitLabExecutor) nativeAnthropicGatewayHTTP(auth *cliproxyauth.Auth) (*ClaudeExecutor, *cliproxyauth.Auth) { nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth) if !ok { return nil, nil } return NewClaudeExecutor(e.cfg), nativeAuth } func (e *GitLabExecutor) invokeText(ctx context.Context, auth *cliproxyauth.Auth, prompt gitLabPrompt) (string, error) { if text, err := e.requestChat(ctx, auth, prompt); err == nil { return text, nil } else if !shouldFallbackToCodeSuggestions(err) { return "", err } return e.requestCodeSuggestions(ctx, auth, prompt) } func (e *GitLabExecutor) requestChat(ctx context.Context, auth *cliproxyauth.Auth, prompt gitLabPrompt) (string, error) { body := map[string]any{ "content": prompt.Instruction, "with_clean_history": true, } if len(prompt.ChatContext) > 0 { body["additional_context"] = prompt.ChatContext } return e.doJSONTextRequest(ctx, auth, gitLabChatEndpoint, body) } func (e *GitLabExecutor) requestCodeSuggestions(ctx context.Context, auth *cliproxyauth.Auth, prompt gitLabPrompt) (string, error) { contentAbove := strings.TrimSpace(prompt.ContentAboveCursor) if contentAbove == "" { contentAbove = prompt.Instruction } body := map[string]any{ "current_file": map[string]any{ "file_name": prompt.FileName, "content_above_cursor": contentAbove, "content_below_cursor": "", }, "intent": "generation", "generation_type": "small_file", "user_instruction": prompt.Instruction, "stream": false, } if len(prompt.CodeSuggestionContext) > 0 { body["context"] = prompt.CodeSuggestionContext } return e.doJSONTextRequest(ctx, auth, gitLabCodeSuggestionsEndpoint, body) } func (e *GitLabExecutor) requestCodeSuggestionsStream( ctx context.Context, auth *cliproxyauth.Auth, prompt gitLabPrompt, translated []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, reporter *usageReporter, ) (*cliproxyexecutor.StreamResult, error) { contentAbove := strings.TrimSpace(prompt.ContentAboveCursor) if contentAbove == "" { contentAbove = prompt.Instruction } body := map[string]any{ "current_file": map[string]any{ "file_name": prompt.FileName, "content_above_cursor": contentAbove, "content_below_cursor": "", }, "intent": "generation", "generation_type": "small_file", "user_instruction": prompt.Instruction, "stream": true, } if len(prompt.CodeSuggestionContext) > 0 { body["context"] = prompt.CodeSuggestionContext } httpResp, bodyRaw, err := e.doJSONRequest(ctx, auth, gitLabCodeSuggestionsEndpoint, body, "text/event-stream") if err != nil { return nil, err } if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { defer func() { _ = httpResp.Body.Close() }() respBody, readErr := io.ReadAll(httpResp.Body) if readErr != nil { recordAPIResponseError(ctx, e.cfg, readErr) return nil, readErr } appendAPIResponseChunk(ctx, e.cfg, respBody) return nil, statusErr{code: httpResp.StatusCode, msg: strings.TrimSpace(string(respBody))} } responseModel := gitLabResolvedModel(auth, req.Model) out := make(chan cliproxyexecutor.StreamChunk, 16) go func() { defer close(out) defer func() { _ = httpResp.Body.Close() }() scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 52_428_800) var ( param any eventName string state gitLabOpenAIStreamState ) for scanner.Scan() { line := bytes.Clone(scanner.Bytes()) appendAPIResponseChunk(ctx, e.cfg, line) trimmed := bytes.TrimSpace(line) if len(trimmed) == 0 { continue } if bytes.HasPrefix(trimmed, []byte("event:")) { eventName = strings.TrimSpace(string(trimmed[len("event:"):])) continue } if !bytes.HasPrefix(trimmed, []byte("data:")) { continue } payload := bytes.TrimSpace(trimmed[len("data:"):]) normalized := normalizeGitLabStreamChunk(eventName, payload, responseModel, &state) eventName = "" for _, item := range normalized { if detail, ok := parseOpenAIStreamUsage(item); ok { reporter.publish(ctx, detail) } chunks := sdktranslator.TranslateStream( ctx, sdktranslator.FromString("openai"), opts.SourceFormat, req.Model, opts.OriginalRequest, translated, item, ¶m, ) for i := range chunks { out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} } } } if errScan := scanner.Err(); errScan != nil { recordAPIResponseError(ctx, e.cfg, errScan) reporter.publishFailure(ctx) out <- cliproxyexecutor.StreamChunk{Err: errScan} return } if !state.Finished { for _, item := range finalizeGitLabStream(responseModel, &state) { chunks := sdktranslator.TranslateStream( ctx, sdktranslator.FromString("openai"), opts.SourceFormat, req.Model, opts.OriginalRequest, translated, item, ¶m, ) for i := range chunks { out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} } } } reporter.ensurePublished(ctx) }() return &cliproxyexecutor.StreamResult{ Headers: cloneGitLabStreamHeaders(httpResp.Header, bodyRaw), Chunks: out, }, nil } func (e *GitLabExecutor) doJSONTextRequest(ctx context.Context, auth *cliproxyauth.Auth, endpoint string, payload map[string]any) (string, error) { resp, _, err := e.doJSONRequest(ctx, auth, endpoint, payload, "application/json") if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { recordAPIResponseError(ctx, e.cfg, err) return "", err } appendAPIResponseChunk(ctx, e.cfg, respBody) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", statusErr{code: resp.StatusCode, msg: strings.TrimSpace(string(respBody))} } text, err := parseGitLabTextResponse(endpoint, respBody) if err != nil { return "", err } return strings.TrimSpace(text), nil } func (e *GitLabExecutor) doJSONRequest( ctx context.Context, auth *cliproxyauth.Auth, endpoint string, payload map[string]any, accept string, ) (*http.Response, []byte, error) { token := gitLabPrimaryToken(auth) baseURL := gitLabBaseURL(auth) if token == "" || baseURL == "" { return nil, nil, statusErr{code: http.StatusUnauthorized, msg: "gitlab duo executor: missing credentials"} } body, err := json.Marshal(payload) if err != nil { return nil, nil, fmt.Errorf("gitlab duo executor: marshal request failed: %w", err) } url := strings.TrimRight(baseURL, "/") + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", accept) req.Header.Set("User-Agent", "CLIProxyAPI/GitLab-Duo") applyGitLabRequestHeaders(req, auth) if strings.EqualFold(accept, "text/event-stream") { req.Header.Set("Cache-Control", "no-cache") req.Header.Set(gitLabSSEStreamingHeader, "true") req.Header.Set("Accept-Encoding", "identity") } var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID authLabel = auth.Label authType, authValue = auth.AccountInfo() } recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ URL: url, Method: http.MethodPost, Headers: req.Header.Clone(), Body: body, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, AuthValue: authValue, }) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) resp, err := httpClient.Do(req) if err != nil { recordAPIResponseError(ctx, e.cfg, err) return nil, body, err } recordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone()) return resp, body, nil } func (e *GitLabExecutor) refreshOAuthToken(ctx context.Context, client *gitlab.AuthClient, auth *cliproxyauth.Auth, baseURL string) (*gitlab.TokenResponse, error) { if auth == nil { return nil, fmt.Errorf("gitlab duo executor: auth is nil") } refreshToken := gitLabMetadataString(auth.Metadata, "refresh_token") if refreshToken == "" { return nil, fmt.Errorf("gitlab duo executor: refresh token missing") } if !gitLabOAuthTokenNeedsRefresh(auth.Metadata) && gitLabPrimaryToken(auth) != "" { return nil, nil } return client.RefreshTokens( ctx, baseURL, gitLabMetadataString(auth.Metadata, "oauth_client_id"), gitLabMetadataString(auth.Metadata, "oauth_client_secret"), refreshToken, ) } func buildGitLabPrompt(payload []byte) gitLabPrompt { root := gjson.ParseBytes(payload) prompt := gitLabPrompt{ FileName: "prompt.txt", } msgs := root.Get("messages") if msgs.Exists() && msgs.IsArray() { systemIndex := 0 contextIndex := 0 transcript := make([]string, 0, len(msgs.Array())) var lastUser string msgs.ForEach(func(_, msg gjson.Result) bool { role := strings.TrimSpace(msg.Get("role").String()) if role == "" { role = "user" } content := openAIContentText(msg.Get("content")) if content == "" { return true } switch role { case "system": systemIndex++ prompt.ChatContext = append(prompt.ChatContext, map[string]any{ "category": "snippet", "id": fmt.Sprintf("system-%d", systemIndex), "content": content, }) case "user": lastUser = content contextIndex++ prompt.CodeSuggestionContext = append(prompt.CodeSuggestionContext, map[string]any{ "type": "snippet", "name": fmt.Sprintf("user-%d", contextIndex), "content": content, }) transcript = append(transcript, "User:\n"+content) default: contextIndex++ prompt.ChatContext = append(prompt.ChatContext, map[string]any{ "category": "snippet", "id": fmt.Sprintf("%s-%d", role, contextIndex), "content": content, }) prompt.CodeSuggestionContext = append(prompt.CodeSuggestionContext, map[string]any{ "type": "snippet", "name": fmt.Sprintf("%s-%d", role, contextIndex), "content": content, }) transcript = append(transcript, strings.Title(role)+":\n"+content) } return true }) prompt.Instruction = strings.TrimSpace(lastUser) prompt.ContentAboveCursor = truncateGitLabPrompt(strings.Join(transcript, "\n\n"), 12000) } if prompt.Instruction == "" { for _, key := range []string{"prompt", "input", "instructions"} { if value := strings.TrimSpace(root.Get(key).String()); value != "" { prompt.Instruction = value break } } } if prompt.ContentAboveCursor == "" { prompt.ContentAboveCursor = prompt.Instruction } prompt.Instruction = truncateGitLabPrompt(prompt.Instruction, 4000) prompt.ContentAboveCursor = truncateGitLabPrompt(prompt.ContentAboveCursor, 12000) return prompt } func openAIContentText(content gjson.Result) string { segments := make([]string, 0, 8) collectOpenAIContent(content, &segments) return strings.TrimSpace(strings.Join(segments, "\n")) } func truncateGitLabPrompt(value string, limit int) string { value = strings.TrimSpace(value) if limit <= 0 || len(value) <= limit { return value } return strings.TrimSpace(value[:limit]) } func parseGitLabTextResponse(endpoint string, body []byte) (string, error) { if endpoint == gitLabChatEndpoint { var text string if err := json.Unmarshal(body, &text); err == nil { return text, nil } if value := strings.TrimSpace(gjson.GetBytes(body, "response").String()); value != "" { return value, nil } } if value := strings.TrimSpace(gjson.GetBytes(body, "choices.0.text").String()); value != "" { return value, nil } if value := strings.TrimSpace(gjson.GetBytes(body, "response").String()); value != "" { return value, nil } var plain string if err := json.Unmarshal(body, &plain); err == nil && strings.TrimSpace(plain) != "" { return plain, nil } return "", fmt.Errorf("gitlab duo executor: upstream returned no text payload") } func applyGitLabRequestHeaders(req *http.Request, auth *cliproxyauth.Auth) { if req == nil { return } if auth != nil { util.ApplyCustomHeadersFromAttrs(req, auth.Attributes) } for key, value := range gitLabGatewayHeaders(auth) { if key == "" || value == "" { continue } req.Header.Set(key, value) } } 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 } 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 } } 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 len(out) == 0 { return nil } return out } func cloneGitLabStreamHeaders(headers http.Header, _ []byte) http.Header { cloned := headers.Clone() if cloned == nil { cloned = make(http.Header) } cloned.Set("Content-Type", "text/event-stream") return cloned } func normalizeGitLabStreamChunk(eventName string, payload []byte, fallbackModel string, state *gitLabOpenAIStreamState) [][]byte { payload = bytes.TrimSpace(payload) if len(payload) == 0 { return nil } if bytes.Equal(payload, []byte("[DONE]")) { return finalizeGitLabStream(fallbackModel, state) } root := gjson.ParseBytes(payload) if root.Exists() { if obj := root.Get("object").String(); obj == "chat.completion.chunk" { return [][]byte{append([]byte("data: "), bytes.Clone(payload)...)} } if root.Get("choices.0.delta").Exists() || root.Get("choices.0.finish_reason").Exists() { return [][]byte{append([]byte("data: "), bytes.Clone(payload)...)} } } state.ensureInitialized(fallbackModel, root) switch strings.TrimSpace(eventName) { case "stream_end": return finalizeGitLabStream(fallbackModel, state) case "stream_start": if text := extractGitLabStreamText(root); text != "" { return state.emitText(text) } return nil } if done := root.Get("done"); done.Exists() && done.Bool() { return finalizeGitLabStream(fallbackModel, state) } if finishReason := strings.TrimSpace(root.Get("finish_reason").String()); finishReason != "" { out := state.emitText(extractGitLabStreamText(root)) return append(out, state.finish(finishReason)...) } return state.emitText(extractGitLabStreamText(root)) } func extractGitLabStreamText(root gjson.Result) string { for _, key := range []string{ "choices.0.delta.content", "choices.0.text", "delta.content", "content_chunk", "content", "text", "response", "completion", } { if value := root.Get(key).String(); strings.TrimSpace(value) != "" { return value } } return "" } func finalizeGitLabStream(fallbackModel string, state *gitLabOpenAIStreamState) [][]byte { if state == nil { return nil } state.ensureInitialized(fallbackModel, gjson.Result{}) return state.finish("stop") } func (s *gitLabOpenAIStreamState) ensureInitialized(fallbackModel string, root gjson.Result) { if s == nil { return } if s.ID == "" { s.ID = fmt.Sprintf("gitlab-%d", time.Now().UnixNano()) } if s.Created == 0 { s.Created = time.Now().Unix() } if s.Model == "" { for _, key := range []string{"model.name", "model", "metadata.model_name"} { if value := strings.TrimSpace(root.Get(key).String()); value != "" { s.Model = value break } } } if s.Model == "" { s.Model = fallbackModel } } func (s *gitLabOpenAIStreamState) emitText(text string) [][]byte { if s == nil { return nil } if strings.TrimSpace(text) == "" { return nil } delta := s.nextDelta(text) if delta == "" { return nil } out := make([][]byte, 0, 2) if !s.Started { out = append(out, s.buildChunk(map[string]any{"role": "assistant"}, "")) s.Started = true } out = append(out, s.buildChunk(map[string]any{"content": delta}, "")) return out } func (s *gitLabOpenAIStreamState) finish(reason string) [][]byte { if s == nil || s.Finished { return nil } if !s.Started { s.Started = true } s.Finished = true return [][]byte{ s.buildChunk(map[string]any{}, reason), []byte("data: [DONE]"), } } func (s *gitLabOpenAIStreamState) nextDelta(text string) string { if s == nil { return text } if strings.TrimSpace(text) == "" { return "" } if s.LastFullText == "" { s.LastFullText = text return text } if text == s.LastFullText { return "" } if strings.HasPrefix(text, s.LastFullText) { delta := text[len(s.LastFullText):] s.LastFullText = text return delta } s.LastFullText += text return text } func (s *gitLabOpenAIStreamState) buildChunk(delta map[string]any, finishReason string) []byte { payload := map[string]any{ "id": s.ID, "object": "chat.completion.chunk", "created": s.Created, "model": s.Model, "choices": []map[string]any{{ "index": 0, "delta": delta, }}, } if finishReason != "" { payload["choices"] = []map[string]any{{ "index": 0, "delta": delta, "finish_reason": finishReason, }} } raw, _ := json.Marshal(payload) return append([]byte("data: "), raw...) } func shouldFallbackToCodeSuggestions(err error) bool { if err == nil { return false } status, ok := err.(interface{ StatusCode() int }) if !ok { return false } switch status.StatusCode() { case http.StatusForbidden, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented: return true default: return false } } func buildGitLabOpenAIResponse(model, text string, translatedReq []byte) []byte { promptTokens, completionTokens := gitLabUsage(model, translatedReq, text) payload := map[string]any{ "id": fmt.Sprintf("gitlab-%d", time.Now().UnixNano()), "object": "chat.completion", "created": time.Now().Unix(), "model": model, "choices": []map[string]any{{ "index": 0, "message": map[string]any{ "role": "assistant", "content": text, }, "finish_reason": "stop", }}, "usage": map[string]any{ "prompt_tokens": promptTokens, "completion_tokens": completionTokens, "total_tokens": promptTokens + completionTokens, }, } raw, _ := json.Marshal(payload) return raw } func buildGitLabOpenAIStream(model, text string) []string { now := time.Now().Unix() id := fmt.Sprintf("gitlab-%d", time.Now().UnixNano()) chunks := []map[string]any{ { "id": id, "object": "chat.completion.chunk", "created": now, "model": model, "choices": []map[string]any{{ "index": 0, "delta": map[string]any{"role": "assistant"}, }}, }, { "id": id, "object": "chat.completion.chunk", "created": now, "model": model, "choices": []map[string]any{{ "index": 0, "delta": map[string]any{"content": text}, }}, }, { "id": id, "object": "chat.completion.chunk", "created": now, "model": model, "choices": []map[string]any{{ "index": 0, "delta": map[string]any{}, "finish_reason": "stop", }}, }, } lines := make([]string, 0, len(chunks)+1) for _, chunk := range chunks { raw, _ := json.Marshal(chunk) lines = append(lines, "data: "+string(raw)) } lines = append(lines, "data: [DONE]") return lines } func gitLabUsage(model string, translatedReq []byte, text string) (int64, int64) { enc, err := tokenizerForModel(model) if err != nil { return 0, 0 } promptTokens, err := countOpenAIChatTokens(enc, translatedReq) if err != nil { promptTokens = 0 } completionCount, err := enc.Count(strings.TrimSpace(text)) if err != nil { return promptTokens, 0 } return promptTokens, int64(completionCount) } func buildGitLabAnthropicGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, bool) { if !gitLabUsesAnthropicGateway(auth) { return nil, false } baseURL := gitLabAnthropicGatewayBaseURL(auth) token := gitLabMetadataString(auth.Metadata, "duo_gateway_token") if baseURL == "" || token == "" { return nil, false } nativeAuth := auth.Clone() nativeAuth.Provider = "claude" if nativeAuth.Attributes == nil { nativeAuth.Attributes = make(map[string]string) } nativeAuth.Attributes["api_key"] = token nativeAuth.Attributes["base_url"] = baseURL for key, value := range gitLabGatewayHeaders(auth) { if key == "" || value == "" { continue } nativeAuth.Attributes["header:"+key] = value } return nativeAuth, true } func gitLabUsesAnthropicGateway(auth *cliproxyauth.Auth) 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) } return provider == "anthropic" && gitLabMetadataString(auth.Metadata, "duo_gateway_base_url") != "" && gitLabMetadataString(auth.Metadata, "duo_gateway_token") != "" } func inferGitLabProviderFromModel(model string) string { model = strings.ToLower(strings.TrimSpace(model)) switch { case strings.Contains(model, "claude"): return "anthropic" case strings.Contains(model, "gpt"), strings.Contains(model, "o1"), strings.Contains(model, "o3"), strings.Contains(model, "o4"): return "openai" default: return "" } } func gitLabAnthropicGatewayBaseURL(auth *cliproxyauth.Auth) string { raw := strings.TrimSpace(gitLabMetadataString(auth.Metadata, "duo_gateway_base_url")) if raw == "" { return "" } base, err := url.Parse(raw) if err != nil { return strings.TrimRight(raw, "/") } path := strings.TrimRight(base.EscapedPath(), "/") switch { case strings.HasSuffix(path, "/ai/v1/proxy/anthropic"), strings.HasSuffix(path, "/v1/proxy/anthropic"): return strings.TrimRight(base.String(), "/") case path == "/ai": base.Path = "/ai/v1/proxy/anthropic" case path != "": base.Path = strings.TrimRight(path, "/") + "/v1/proxy/anthropic" case strings.Contains(strings.ToLower(base.Host), "gitlab.com"): base.Path = "/ai/v1/proxy/anthropic" default: base.Path = "/v1/proxy/anthropic" } return strings.TrimRight(base.String(), "/") } func gitLabPrimaryToken(auth *cliproxyauth.Auth) string { if auth == nil || auth.Metadata == nil { return "" } if token := gitLabMetadataString(auth.Metadata, "access_token"); token != "" { return token } return gitLabMetadataString(auth.Metadata, "personal_access_token") } func gitLabBaseURL(auth *cliproxyauth.Auth) string { if auth == nil || auth.Metadata == nil { return "" } return gitlab.NormalizeBaseURL(gitLabMetadataString(auth.Metadata, "base_url")) } func gitLabResolvedModel(auth *cliproxyauth.Auth, requested string) string { requested = strings.TrimSpace(thinking.ParseSuffix(requested).ModelName) if requested != "" && !strings.EqualFold(requested, "gitlab-duo") { return requested } if auth != nil && auth.Metadata != nil { for _, model := range gitlab.ExtractDiscoveredModels(auth.Metadata) { if name := strings.TrimSpace(model.ModelName); name != "" { return name } } } if requested != "" { return requested } return "gitlab-duo" } func gitLabMetadataString(metadata map[string]any, keys ...string) string { for _, key := range keys { if metadata == nil { return "" } if value, ok := metadata[key].(string); ok { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } } return "" } func gitLabOAuthTokenNeedsRefresh(metadata map[string]any) bool { expiry := gitLabMetadataString(metadata, "oauth_expires_at") if expiry == "" { return true } ts, err := time.Parse(time.RFC3339, expiry) if err != nil { return true } return time.Until(ts) <= 5*time.Minute } func applyGitLabTokenMetadata(metadata map[string]any, tokenResp *gitlab.TokenResponse) { if metadata == nil || tokenResp == nil { return } if accessToken := strings.TrimSpace(tokenResp.AccessToken); accessToken != "" { metadata["access_token"] = accessToken } if refreshToken := strings.TrimSpace(tokenResp.RefreshToken); refreshToken != "" { metadata["refresh_token"] = refreshToken } if tokenType := strings.TrimSpace(tokenResp.TokenType); tokenType != "" { metadata["token_type"] = tokenType } if scope := strings.TrimSpace(tokenResp.Scope); scope != "" { metadata["scope"] = scope } if expiry := gitlab.TokenExpiry(time.Now(), tokenResp); !expiry.IsZero() { metadata["oauth_expires_at"] = expiry.Format(time.RFC3339) } } func mergeGitLabDirectAccessMetadata(metadata map[string]any, direct *gitlab.DirectAccessResponse) { if metadata == nil || direct == nil { return } if base := strings.TrimSpace(direct.BaseURL); base != "" { metadata["duo_gateway_base_url"] = base } if token := strings.TrimSpace(direct.Token); token != "" { metadata["duo_gateway_token"] = token } if direct.ExpiresAt > 0 { expiry := time.Unix(direct.ExpiresAt, 0).UTC() metadata["duo_gateway_expires_at"] = expiry.Format(time.RFC3339) if ttl := expiry.Sub(time.Now().UTC()); ttl > 0 { interval := int(ttl.Seconds()) / 2 switch { case interval < 60: interval = 60 case interval > 240: interval = 240 } metadata["refresh_interval_seconds"] = interval } } if len(direct.Headers) > 0 { headers := make(map[string]string, len(direct.Headers)) for key, value := range direct.Headers { key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" || value == "" { continue } headers[key] = value } if len(headers) > 0 { metadata["duo_gateway_headers"] = headers } } if direct.ModelDetails != nil { modelDetails := map[string]any{} if provider := strings.TrimSpace(direct.ModelDetails.ModelProvider); provider != "" { modelDetails["model_provider"] = provider metadata["model_provider"] = provider } if model := strings.TrimSpace(direct.ModelDetails.ModelName); model != "" { modelDetails["model_name"] = model metadata["model_name"] = model } if len(modelDetails) > 0 { metadata["model_details"] = modelDetails } } } func gitLabAuthKind(method string) string { switch strings.ToLower(strings.TrimSpace(method)) { case gitLabAuthMethodPAT: return "personal_access_token" default: return "oauth" } } func GitLabModelsFromAuth(auth *cliproxyauth.Auth) []*registry.ModelInfo { models := make([]*registry.ModelInfo, 0, 4) seen := make(map[string]struct{}, 4) addModel := func(id, displayName, provider string) { id = strings.TrimSpace(id) if id == "" { return } key := strings.ToLower(id) if _, ok := seen[key]; ok { return } seen[key] = struct{}{} models = append(models, ®istry.ModelInfo{ ID: id, Object: "model", Created: time.Now().Unix(), OwnedBy: "gitlab", Type: "gitlab", DisplayName: displayName, Description: provider, UserDefined: true, }) } addModel("gitlab-duo", "GitLab Duo", "gitlab") if auth == nil { return models } for _, model := range gitlab.ExtractDiscoveredModels(auth.Metadata) { name := strings.TrimSpace(model.ModelName) if name == "" { continue } displayName := "GitLab Duo" if provider := strings.TrimSpace(model.ModelProvider); provider != "" { displayName = fmt.Sprintf("GitLab Duo (%s)", provider) } addModel(name, displayName, strings.TrimSpace(model.ModelProvider)) } return models }