diff --git a/README.md b/README.md index 02aef963fa..ea3082017e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ 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. + +## Docs + +- GitLab Duo guide: [docs/gitlab-duo.md](docs/gitlab-duo.md) +- 中文说明: [docs/gitlab-duo_CN.md](docs/gitlab-duo_CN.md) + ## 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/README_CN.md b/README_CN.md index bf83624e81..ebc3e42f05 100644 --- a/README_CN.md +++ b/README_CN.md @@ -8,6 +8,13 @@ 该 Plus 版本的主线功能与主线功能强制同步。 +GitLab Duo 已在这里接入,支持 OAuth 或 personal access token 登录,并在 GitLab 提供 managed credentials 时通过 GitLab AI gateway 做 provider-native 路由。 + +## 文档 + +- GitLab Duo 说明:[docs/gitlab-duo_CN.md](docs/gitlab-duo_CN.md) +- English guide: [docs/gitlab-duo.md](docs/gitlab-duo.md) + ## 贡献 该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。 @@ -16,4 +23,4 @@ ## 许可证 -此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 \ No newline at end of file +此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 4148cd061a..f66c12ee39 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -79,6 +79,8 @@ func main() { var kiloLogin bool var iflowLogin bool var iflowCookie bool + var gitlabLogin bool + var gitlabTokenLogin bool var noBrowser bool var oauthCallbackPort int var antigravityLogin bool @@ -111,6 +113,8 @@ func main() { flag.BoolVar(&kiloLogin, "kilo-login", false, "Login to Kilo AI using device flow") flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") + flag.BoolVar(&gitlabLogin, "gitlab-login", false, "Login to GitLab Duo using OAuth") + flag.BoolVar(&gitlabTokenLogin, "gitlab-token-login", false, "Login to GitLab Duo using a personal access token") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)") flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)") @@ -527,6 +531,10 @@ func main() { cmd.DoIFlowLogin(cfg, options) } else if iflowCookie { cmd.DoIFlowCookieAuth(cfg, options) + } else if gitlabLogin { + cmd.DoGitLabLogin(cfg, options) + } else if gitlabTokenLogin { + cmd.DoGitLabTokenLogin(cfg, options) } else if kimiLogin { cmd.DoKimiLogin(cfg, options) } else if kiroLogin { diff --git a/docs/gitlab-duo.md b/docs/gitlab-duo.md new file mode 100644 index 0000000000..809737cbd1 --- /dev/null +++ b/docs/gitlab-duo.md @@ -0,0 +1,115 @@ +# GitLab Duo guide + +CLIProxyAPI can now use GitLab Duo as a first-class provider instead of treating it as a plain text wrapper. + +It supports: + +- OAuth login +- personal access token login +- automatic refresh of GitLab `direct_access` metadata +- dynamic model discovery from GitLab metadata +- native GitLab AI gateway routing for Anthropic and OpenAI/Codex managed models +- Claude-compatible and OpenAI-compatible downstream APIs + +## What this means + +If GitLab Duo returns an Anthropic-managed model, CLIProxyAPI routes requests through the GitLab AI gateway Anthropic proxy and uses the existing Claude executor path. + +If GitLab Duo returns an OpenAI-managed model, CLIProxyAPI routes requests through the GitLab AI gateway OpenAI proxy and uses the existing Codex/OpenAI executor path. + +That gives GitLab Duo much closer runtime behavior to the built-in `codex` provider: + +- Claude-compatible clients can use GitLab Duo models through `/v1/messages` +- OpenAI-compatible clients can use GitLab Duo models through `/v1/chat/completions` +- OpenAI Responses clients can use GitLab Duo models through `/v1/responses` + +The model list is not hardcoded. CLIProxyAPI reads the current model metadata from GitLab `direct_access` and registers: + +- a stable alias: `gitlab-duo` +- any discovered managed model names, such as `claude-sonnet-4-5` or `gpt-5-codex` + +## Login + +OAuth login: + +```bash +./CLIProxyAPI -gitlab-login +``` + +PAT login: + +```bash +./CLIProxyAPI -gitlab-token-login +``` + +You can also provide inputs through environment variables: + +```bash +export GITLAB_BASE_URL=https://gitlab.com +export GITLAB_OAUTH_CLIENT_ID=your-client-id +export GITLAB_OAUTH_CLIENT_SECRET=your-client-secret +export GITLAB_PERSONAL_ACCESS_TOKEN=glpat-... +``` + +Notes: + +- OAuth requires a GitLab OAuth application. +- PAT login requires a personal access token that can call the GitLab APIs used by Duo. In practice, `api` scope is the safe baseline. +- Self-managed GitLab instances are supported through `GITLAB_BASE_URL`. + +## Using the models + +After login, start CLIProxyAPI normally and point your client at the local proxy. + +You can select: + +- `gitlab-duo` to use the current Duo-managed model for that account +- the discovered provider model name if you want to pin it explicitly + +Examples: + +```bash +curl http://127.0.0.1:8080/v1/models +``` + +```bash +curl http://127.0.0.1:8080/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gitlab-duo", + "messages": [ + {"role": "user", "content": "Write a Go HTTP middleware for request IDs."} + ] + }' +``` + +If the GitLab account is currently mapped to an Anthropic model, Claude-compatible clients can use the same account through the Claude handler path. If the account is currently mapped to an OpenAI/Codex model, OpenAI-compatible clients can use `/v1/chat/completions` or `/v1/responses`. + +## How model freshness works + +CLIProxyAPI does not ship a fixed GitLab Duo model catalog. + +Instead, it refreshes GitLab `direct_access` metadata and uses the returned `model_details` and any discovered model list entries to keep the local registry aligned with the current GitLab-managed model assignment. + +This matches GitLab's current public contract better than hardcoding model names. + +## Current scope + +The GitLab Duo provider now has: + +- OAuth and PAT auth flows +- runtime refresh of Duo gateway credentials +- native Anthropic gateway routing +- native OpenAI/Codex gateway routing +- handler-level smoke tests for Claude-compatible and OpenAI-compatible paths + +Still out of scope today: + +- websocket or session-specific parity beyond the current HTTP APIs +- GitLab-specific IDE features that are not exposed through the public gateway contract + +## References + +- GitLab Code Suggestions API: https://docs.gitlab.com/api/code_suggestions/ +- GitLab Agent Assistant and managed credentials: https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/ +- GitLab Duo model selection: https://docs.gitlab.com/user/gitlab_duo/model_selection/ diff --git a/docs/gitlab-duo_CN.md b/docs/gitlab-duo_CN.md new file mode 100644 index 0000000000..1c21d1cfc8 --- /dev/null +++ b/docs/gitlab-duo_CN.md @@ -0,0 +1,115 @@ +# GitLab Duo 使用说明 + +CLIProxyAPI 现在可以把 GitLab Duo 当作一等 Provider 来使用,而不是仅仅把它当成简单的文本补全封装。 + +当前支持: + +- OAuth 登录 +- personal access token 登录 +- 自动刷新 GitLab `direct_access` 元数据 +- 根据 GitLab 返回的元数据动态发现模型 +- 针对 Anthropic 和 OpenAI/Codex 托管模型的 GitLab AI gateway 原生路由 +- Claude 兼容与 OpenAI 兼容下游 API + +## 这意味着什么 + +如果 GitLab Duo 返回的是 Anthropic 托管模型,CLIProxyAPI 会通过 GitLab AI gateway 的 Anthropic 代理转发,并复用现有的 Claude executor 路径。 + +如果 GitLab Duo 返回的是 OpenAI 托管模型,CLIProxyAPI 会通过 GitLab AI gateway 的 OpenAI 代理转发,并复用现有的 Codex/OpenAI executor 路径。 + +这让 GitLab Duo 的运行时行为更接近内置的 `codex` Provider: + +- Claude 兼容客户端可以通过 `/v1/messages` 使用 GitLab Duo 模型 +- OpenAI 兼容客户端可以通过 `/v1/chat/completions` 使用 GitLab Duo 模型 +- OpenAI Responses 客户端可以通过 `/v1/responses` 使用 GitLab Duo 模型 + +模型列表不是硬编码的。CLIProxyAPI 会从 GitLab `direct_access` 中读取当前模型元数据,并注册: + +- 一个稳定别名:`gitlab-duo` +- GitLab 当前发现到的托管模型名,例如 `claude-sonnet-4-5` 或 `gpt-5-codex` + +## 登录 + +OAuth 登录: + +```bash +./CLIProxyAPI -gitlab-login +``` + +PAT 登录: + +```bash +./CLIProxyAPI -gitlab-token-login +``` + +也可以通过环境变量提供输入: + +```bash +export GITLAB_BASE_URL=https://gitlab.com +export GITLAB_OAUTH_CLIENT_ID=your-client-id +export GITLAB_OAUTH_CLIENT_SECRET=your-client-secret +export GITLAB_PERSONAL_ACCESS_TOKEN=glpat-... +``` + +说明: + +- OAuth 方式需要一个 GitLab OAuth application。 +- PAT 登录需要一个能够调用 GitLab Duo 相关 API 的 personal access token。实践上,`api` scope 是最稳妥的基线。 +- 自建 GitLab 实例可以通过 `GITLAB_BASE_URL` 接入。 + +## 如何使用模型 + +登录完成后,正常启动 CLIProxyAPI,并让客户端连接到本地代理。 + +你可以选择: + +- `gitlab-duo`,始终使用该账号当前的 Duo 托管模型 +- GitLab 当前发现到的 provider 模型名,如果你想显式固定模型 + +示例: + +```bash +curl http://127.0.0.1:8080/v1/models +``` + +```bash +curl http://127.0.0.1:8080/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gitlab-duo", + "messages": [ + {"role": "user", "content": "Write a Go HTTP middleware for request IDs."} + ] + }' +``` + +如果该 GitLab 账号当前绑定的是 Anthropic 模型,Claude 兼容客户端可以通过 Claude handler 路径直接使用它。如果当前绑定的是 OpenAI/Codex 模型,OpenAI 兼容客户端可以通过 `/v1/chat/completions` 或 `/v1/responses` 使用它。 + +## 模型如何保持最新 + +CLIProxyAPI 不内置固定的 GitLab Duo 模型清单。 + +它会刷新 GitLab `direct_access` 元数据,并使用返回的 `model_details` 以及可能存在的模型列表字段,让本地 registry 尽量与 GitLab 当前分配的托管模型保持一致。 + +这比硬编码模型名更符合 GitLab 当前公开 API 的实际契约。 + +## 当前覆盖范围 + +GitLab Duo Provider 目前已经具备: + +- OAuth 和 PAT 登录流程 +- Duo gateway 凭据的运行时刷新 +- Anthropic gateway 原生路由 +- OpenAI/Codex gateway 原生路由 +- Claude 兼容和 OpenAI 兼容路径的 handler 级 smoke 测试 + +当前仍未覆盖: + +- websocket 或 session 级别的完全对齐 +- GitLab 公开 gateway 契约之外的 IDE 专有能力 + +## 参考资料 + +- GitLab Code Suggestions API: https://docs.gitlab.com/api/code_suggestions/ +- GitLab Agent Assistant 与 managed credentials: https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/ +- GitLab Duo 模型选择: https://docs.gitlab.com/user/gitlab_duo/model_selection/ diff --git a/gitlab-duo-codex-parity-plan.md b/gitlab-duo-codex-parity-plan.md new file mode 100644 index 0000000000..f4fc90d02a --- /dev/null +++ b/gitlab-duo-codex-parity-plan.md @@ -0,0 +1,278 @@ +# Plan: GitLab Duo Codex Parity + +**Generated**: 2026-03-10 +**Estimated Complexity**: High + +## Overview +Bring GitLab Duo support from the current "auth + basic executor" stage to the same practical level as `codex` inside `CLIProxyAPI`: a user logs in once, points external clients such as Claude Code at `CLIProxyAPI`, selects GitLab Duo-backed models, and gets stable streaming, multi-turn behavior, tool calling compatibility, and predictable model routing without manual provider-specific workarounds. + +The core architectural shift is to stop treating GitLab Duo as only two REST wrappers (`/api/v4/chat/completions` and `/api/v4/code_suggestions/completions`) and instead use GitLab's `direct_access` contract as the primary runtime entrypoint wherever possible. Official GitLab docs confirm that `direct_access` returns AI gateway connection details, headers, token, and expiry; that contract is the closest path to codex-like provider behavior. + +## Prerequisites +- Official GitLab Duo API references confirmed during implementation: + - `POST /api/v4/code_suggestions/direct_access` + - `POST /api/v4/code_suggestions/completions` + - `POST /api/v4/chat/completions` +- Access to at least one real GitLab Duo account for manual verification. +- One downstream client target for acceptance testing: + - Claude Code against Claude-compatible endpoint + - OpenAI-compatible client against `/v1/chat/completions` and `/v1/responses` +- Existing PR branch as starting point: + - `feat/gitlab-duo-auth` + - PR [#2028](https://github.com/router-for-me/CLIProxyAPI/pull/2028) + +## Definition Of Done +- GitLab Duo models can be used via `CLIProxyAPI` from the same client surfaces that already work for `codex`. +- Upstream streaming is real passthrough or faithful chunked forwarding, not synthetic whole-response replay. +- Tool/function calling survives translation layers without dropping fields or corrupting names. +- Multi-turn and session semantics are stable across `chat/completions`, `responses`, and Claude-compatible routes. +- Model exposure stays current from GitLab metadata or gateway discovery without hardcoded stale model tables. +- `go test ./...` stays green and at least one real manual end-to-end client flow is documented. + +## Sprint 1: Contract And Gap Closure +**Goal**: Replace assumptions with a hard compatibility contract between current `codex` behavior and what GitLab Duo can actually support. + +**Demo/Validation**: +- Written matrix showing `codex` features vs current GitLab Duo behavior. +- One checked-in developer note or test fixture for real GitLab Duo payload examples. + +### Task 1.1: Freeze Codex Parity Checklist +- **Location**: [internal/runtime/executor/codex_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/codex_executor.go), [internal/runtime/executor/codex_websockets_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/codex_websockets_executor.go), [sdk/api/handlers/openai/openai_responses_handlers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_handlers.go), [sdk/api/handlers/openai/openai_responses_websocket.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_websocket.go) +- **Description**: Produce a concrete feature matrix for `codex`: HTTP execute, SSE execute, `/v1/responses`, websocket downstream path, tool calling, request IDs, session close semantics, and model registration behavior. +- **Dependencies**: None +- **Acceptance Criteria**: + - A checklist exists in repo docs or issue notes. + - Each capability is marked `required`, `optional`, or `not possible` for GitLab Duo. +- **Validation**: + - Review against current `codex` code paths. + +### Task 1.2: Lock GitLab Duo Runtime Contract +- **Location**: [internal/auth/gitlab/gitlab.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/auth/gitlab/gitlab.go), [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go) +- **Description**: Validate the exact upstream contract we can rely on: + - `direct_access` fields and refresh cadence + - whether AI gateway path is usable directly + - when `chat/completions` is available vs when fallback is required + - what streaming shape is returned by `code_suggestions/completions?stream=true` +- **Dependencies**: Task 1.1 +- **Acceptance Criteria**: + - GitLab transport decision is explicit: `gateway-first`, `REST-first`, or `hybrid`. + - Unknown areas are isolated behind feature flags, not spread across executor logic. +- **Validation**: + - Official docs + captured real responses from a Duo account. + +### Task 1.3: Define Client-Facing Compatibility Targets +- **Location**: [README.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/README.md), [gitlab-duo-codex-parity-plan.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/gitlab-duo-codex-parity-plan.md) +- **Description**: Define exactly which external flows must work to call GitLab Duo support "like codex". +- **Dependencies**: Task 1.2 +- **Acceptance Criteria**: + - Required surfaces are listed: + - Claude-compatible route + - OpenAI `chat/completions` + - OpenAI `responses` + - optional downstream websocket path + - Non-goals are explicit if GitLab upstream cannot support them. +- **Validation**: + - Maintainer review of stated scope. + +## Sprint 2: Primary Transport Parity +**Goal**: Move GitLab Duo execution onto a transport that supports codex-like runtime behavior. + +**Demo/Validation**: +- A GitLab Duo model works over real streaming through `/v1/chat/completions`. +- No synthetic "collect full body then fake stream" path remains on the primary flow. + +### Task 2.1: Refactor GitLab Executor Into Strategy Layers +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go) +- **Description**: Split current executor into explicit strategies: + - auth refresh/direct access refresh + - gateway transport + - GitLab REST fallback transport + - downstream translation helpers +- **Dependencies**: Sprint 1 +- **Acceptance Criteria**: + - Executor no longer mixes discovery, refresh, fallback selection, and response synthesis in one path. + - Transport choice is testable in isolation. +- **Validation**: + - Unit tests for strategy selection and fallback boundaries. + +### Task 2.2: Implement Real Streaming Path +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [internal/runtime/executor/gitlab_executor_test.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor_test.go) +- **Description**: Replace synthetic streaming with true upstream incremental forwarding: + - use gateway stream if available + - otherwise consume GitLab Code Suggestions streaming response and map chunks incrementally +- **Dependencies**: Task 2.1 +- **Acceptance Criteria**: + - `ExecuteStream` emits chunks before upstream completion. + - error handling preserves status and early failure semantics. +- **Validation**: + - tests with chunked upstream server + - manual curl check against `/v1/chat/completions` with `stream=true` + +### Task 2.3: Preserve Upstream Auth And Headers Correctly +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [internal/auth/gitlab/gitlab.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/auth/gitlab/gitlab.go) +- **Description**: Use `direct_access` connection details as first-class transport state: + - gateway token + - expiry + - mandatory forwarded headers + - model metadata +- **Dependencies**: Task 2.1 +- **Acceptance Criteria**: + - executor stops ignoring gateway headers/token when transport requires them + - refresh logic never over-fetches `direct_access` +- **Validation**: + - tests verifying propagated headers and refresh interval behavior + +## Sprint 3: Request/Response Semantics Parity +**Goal**: Make GitLab Duo behave correctly under the same request shapes that current `codex` consumers send. + +**Demo/Validation**: +- OpenAI and Claude-compatible clients can do non-streaming and streaming conversations without losing structure. + +### Task 3.1: Normalize Multi-Turn Message Mapping +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [sdk/translator](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/translator) +- **Description**: Replace the current "flatten prompt into one instruction" behavior with stable multi-turn mapping: + - preserve system context + - preserve user/assistant ordering + - maintain bounded context truncation +- **Dependencies**: Sprint 2 +- **Acceptance Criteria**: + - multi-turn requests are not collapsed into a lossy single string unless fallback mode explicitly requires it + - truncation policy is deterministic and tested +- **Validation**: + - golden tests for request mapping + +### Task 3.2: Tool Calling Compatibility Layer +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [sdk/api/handlers/openai/openai_responses_handlers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_handlers.go) +- **Description**: Decide and implement one of two paths: + - native pass-through if GitLab gateway supports tool/function structures + - strict downgrade path with explicit unsupported errors instead of silent field loss +- **Dependencies**: Task 3.1 +- **Acceptance Criteria**: + - tool-related fields are either preserved correctly or rejected explicitly + - no silent corruption of tool names, tool calls, or tool results +- **Validation**: + - table-driven tests for tool payloads + - one manual client scenario using tools + +### Task 3.3: Token Counting And Usage Reporting Fidelity +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [internal/runtime/executor/usage_helpers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/usage_helpers.go) +- **Description**: Improve token/usage reporting so GitLab models behave like first-class providers in logs and scheduling. +- **Dependencies**: Sprint 2 +- **Acceptance Criteria**: + - `CountTokens` uses the closest supported estimation path + - usage logging distinguishes prompt vs completion when possible +- **Validation**: + - unit tests for token estimation outputs + +## Sprint 4: Responses And Session Parity +**Goal**: Reach codex-level support for OpenAI Responses clients and long-lived sessions where GitLab upstream permits it. + +**Demo/Validation**: +- `/v1/responses` works with GitLab Duo in a realistic client flow. +- If websocket parity is not possible, the code explicitly declines it and keeps HTTP paths stable. + +### Task 4.1: Make GitLab Compatible With `/v1/responses` +- **Location**: [sdk/api/handlers/openai/openai_responses_handlers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_handlers.go), [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go) +- **Description**: Ensure GitLab transport can safely back the Responses API path, including compact responses if applicable. +- **Dependencies**: Sprint 3 +- **Acceptance Criteria**: + - GitLab Duo can be selected behind `/v1/responses` + - response IDs and follow-up semantics are defined +- **Validation**: + - handler tests analogous to codex/openai responses tests + +### Task 4.2: Evaluate Downstream Websocket Parity +- **Location**: [sdk/api/handlers/openai/openai_responses_websocket.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_websocket.go), [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go) +- **Description**: Decide whether GitLab Duo can support downstream websocket sessions like codex: + - if yes, add session-aware execution path + - if no, mark GitLab auth as websocket-ineligible and keep HTTP routes first-class +- **Dependencies**: Task 4.1 +- **Acceptance Criteria**: + - websocket behavior is explicit, not accidental + - no route claims websocket support when the upstream cannot honor it +- **Validation**: + - websocket handler tests or explicit capability tests + +### Task 4.3: Add Session Cleanup And Failure Recovery Semantics +- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [sdk/cliproxy/auth/conductor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/cliproxy/auth/conductor.go) +- **Description**: Add codex-like session cleanup, retry boundaries, and model suspension/resume behavior for GitLab failures and quota events. +- **Dependencies**: Sprint 2 +- **Acceptance Criteria**: + - auth/model cooldown behavior is predictable on GitLab 4xx/5xx/quota responses + - executor cleans up per-session resources if any are introduced +- **Validation**: + - tests for quota and retry behavior + +## Sprint 5: Client UX, Model UX, And Manual E2E +**Goal**: Make GitLab Duo feel like a normal built-in provider to operators and downstream clients. + +**Demo/Validation**: +- A documented setup exists for "login once, point Claude Code at CLIProxyAPI, use GitLab Duo-backed model". + +### Task 5.1: Model Alias And Provider UX Cleanup +- **Location**: [sdk/cliproxy/service.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/cliproxy/service.go), [README.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/README.md) +- **Description**: Normalize what users see: + - stable alias such as `gitlab-duo` + - discovered upstream model names + - optional prefix behavior + - account labels that clearly distinguish OAuth vs PAT +- **Dependencies**: Sprint 3 +- **Acceptance Criteria**: + - users can select a stable GitLab alias even when upstream model changes + - dynamic model discovery does not cause confusing model churn +- **Validation**: + - registry tests and manual `/v1/models` inspection + +### Task 5.2: Add Real End-To-End Acceptance Tests +- **Location**: [internal/runtime/executor/gitlab_executor_test.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor_test.go), [sdk/api/handlers/openai](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai) +- **Description**: Add higher-level tests covering the actual proxy surfaces: + - OpenAI `chat/completions` + - OpenAI `responses` + - Claude-compatible request path if GitLab is routed there +- **Dependencies**: Sprint 4 +- **Acceptance Criteria**: + - tests fail if streaming regresses into synthetic buffering again + - tests cover at least one tool-related request and one multi-turn request +- **Validation**: + - `go test ./...` + +### Task 5.3: Publish Operator Documentation +- **Location**: [README.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/README.md) +- **Description**: Document: + - OAuth setup requirements + - PAT requirements + - current capability matrix + - known limitations if websocket/tool parity is partial +- **Dependencies**: Sprint 5.1 +- **Acceptance Criteria**: + - setup instructions are enough for a new user to reproduce the GitLab Duo flow + - limitations are explicit +- **Validation**: + - dry-run docs review from a clean environment + +## Testing Strategy +- Keep `go test ./...` green after every committable task. +- Add table-driven tests first for request mapping, refresh behavior, and dynamic model registration. +- Add transport tests with `httptest.Server` for: + - real chunked streaming + - header propagation from `direct_access` + - upstream fallback rules +- Add at least one manual acceptance checklist: + - login via OAuth + - login via PAT + - list models + - run one streaming prompt via OpenAI route + - run one prompt from the target downstream client + +## Potential Risks & Gotchas +- GitLab public docs expose `direct_access`, but do not fully document every possible AI gateway path. We should isolate any empirically discovered gateway assumptions behind one transport layer and feature flags. +- `chat/completions` availability differs by GitLab offering and version. The executor must not assume it always exists. +- Code Suggestions is completion-oriented; lossy mapping from rich chat/tool payloads will make GitLab Duo feel worse than codex unless explicitly handled. +- Synthetic streaming is not good enough for codex parity and will cause regressions in interactive clients. +- Dynamic model discovery can create unstable UX if the stable alias and discovered model IDs are not separated cleanly. +- PAT auth may validate successfully while still lacking effective Duo permissions. Error reporting must surface this explicitly. + +## Rollback Plan +- Keep the current basic GitLab executor behind a fallback mode until the new transport path is stable. +- If parity work destabilizes existing providers, revert only GitLab-specific executor changes and leave auth support intact. +- Preserve the stable `gitlab-duo` alias so rollback does not break client configuration. diff --git a/internal/auth/gitlab/gitlab.go b/internal/auth/gitlab/gitlab.go new file mode 100644 index 0000000000..5cf8876c4f --- /dev/null +++ b/internal/auth/gitlab/gitlab.go @@ -0,0 +1,492 @@ +package gitlab + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + log "github.com/sirupsen/logrus" +) + +const ( + DefaultBaseURL = "https://gitlab.com" + DefaultCallbackPort = 17171 + defaultOAuthScope = "api read_user" +) + +type PKCECodes struct { + CodeVerifier string + CodeChallenge string +} + +type OAuthResult struct { + Code string + State string + Error string +} + +type OAuthServer struct { + server *http.Server + port int + resultChan chan *OAuthResult + errorChan chan error + mu sync.Mutex + running bool +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + CreatedAt int64 `json:"created_at"` + ExpiresIn int `json:"expires_in"` +} + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + PublicEmail string `json:"public_email"` +} + +type PersonalAccessTokenSelf struct { + ID int64 `json:"id"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + UserID int64 `json:"user_id"` +} + +type ModelDetails struct { + ModelProvider string `json:"model_provider"` + ModelName string `json:"model_name"` +} + +type DirectAccessResponse struct { + BaseURL string `json:"base_url"` + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` + Headers map[string]string `json:"headers"` + ModelDetails *ModelDetails `json:"model_details,omitempty"` +} + +type DiscoveredModel struct { + ModelProvider string + ModelName string +} + +type AuthClient struct { + httpClient *http.Client +} + +func NewAuthClient(cfg *config.Config) *AuthClient { + client := &http.Client{} + if cfg != nil { + client = util.SetProxy(&cfg.SDKConfig, client) + } + return &AuthClient{httpClient: client} +} + +func NormalizeBaseURL(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return DefaultBaseURL + } + if !strings.Contains(value, "://") { + value = "https://" + value + } + value = strings.TrimRight(value, "/") + return value +} + +func TokenExpiry(now time.Time, token *TokenResponse) time.Time { + if token == nil { + return time.Time{} + } + if token.CreatedAt > 0 && token.ExpiresIn > 0 { + return time.Unix(token.CreatedAt+int64(token.ExpiresIn), 0).UTC() + } + if token.ExpiresIn > 0 { + return now.UTC().Add(time.Duration(token.ExpiresIn) * time.Second) + } + return time.Time{} +} + +func GeneratePKCECodes() (*PKCECodes, error) { + verifierBytes := make([]byte, 32) + if _, err := rand.Read(verifierBytes); err != nil { + return nil, fmt.Errorf("gitlab pkce generation failed: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return &PKCECodes{ + CodeVerifier: verifier, + CodeChallenge: challenge, + }, nil +} + +func NewOAuthServer(port int) *OAuthServer { + return &OAuthServer{ + port: port, + resultChan: make(chan *OAuthResult, 1), + errorChan: make(chan error, 1), + } +} + +func (s *OAuthServer) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("gitlab oauth server already running") + } + if !s.isPortAvailable() { + return fmt.Errorf("port %d is already in use", s.port) + } + + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", s.handleCallback) + + s.server = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + s.running = true + + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.errorChan <- err + } + }() + + time.Sleep(100 * time.Millisecond) + return nil +} + +func (s *OAuthServer) Stop(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running || s.server == nil { + return nil + } + defer func() { + s.running = false + s.server = nil + }() + return s.server.Shutdown(ctx) +} + +func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) { + select { + case result := <-s.resultChan: + return result, nil + case err := <-s.errorChan: + return nil, err + case <-time.After(timeout): + return nil, fmt.Errorf("timeout waiting for OAuth callback") + } +} + +func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + query := r.URL.Query() + if errParam := strings.TrimSpace(query.Get("error")); errParam != "" { + s.sendResult(&OAuthResult{Error: errParam}) + http.Error(w, errParam, http.StatusBadRequest) + return + } + code := strings.TrimSpace(query.Get("code")) + state := strings.TrimSpace(query.Get("state")) + if code == "" || state == "" { + s.sendResult(&OAuthResult{Error: "missing_code_or_state"}) + http.Error(w, "missing code or state", http.StatusBadRequest) + return + } + s.sendResult(&OAuthResult{Code: code, State: state}) + _, _ = w.Write([]byte("GitLab authentication received. You can close this tab.")) +} + +func (s *OAuthServer) sendResult(result *OAuthResult) { + select { + case s.resultChan <- result: + default: + log.Debug("gitlab oauth result channel full, dropping callback result") + } +} + +func (s *OAuthServer) isPortAvailable() bool { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port)) + if err != nil { + return false + } + _ = listener.Close() + return true +} + +func RedirectURL(port int) string { + return fmt.Sprintf("http://localhost:%d/auth/callback", port) +} + +func (c *AuthClient) GenerateAuthURL(baseURL, clientID, redirectURI, state string, pkce *PKCECodes) (string, error) { + if pkce == nil { + return "", fmt.Errorf("gitlab auth URL generation failed: PKCE codes are required") + } + if strings.TrimSpace(clientID) == "" { + return "", fmt.Errorf("gitlab auth URL generation failed: client ID is required") + } + baseURL = NormalizeBaseURL(baseURL) + params := url.Values{ + "client_id": {strings.TrimSpace(clientID)}, + "response_type": {"code"}, + "redirect_uri": {strings.TrimSpace(redirectURI)}, + "scope": {defaultOAuthScope}, + "state": {strings.TrimSpace(state)}, + "code_challenge": {pkce.CodeChallenge}, + "code_challenge_method": {"S256"}, + } + return fmt.Sprintf("%s/oauth/authorize?%s", baseURL, params.Encode()), nil +} + +func (c *AuthClient) ExchangeCodeForTokens(ctx context.Context, baseURL, clientID, clientSecret, redirectURI, code, codeVerifier string) (*TokenResponse, error) { + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {strings.TrimSpace(clientID)}, + "code": {strings.TrimSpace(code)}, + "redirect_uri": {strings.TrimSpace(redirectURI)}, + "code_verifier": {strings.TrimSpace(codeVerifier)}, + } + if secret := strings.TrimSpace(clientSecret); secret != "" { + form.Set("client_secret", secret) + } + return c.postToken(ctx, NormalizeBaseURL(baseURL)+"/oauth/token", form) +} + +func (c *AuthClient) RefreshTokens(ctx context.Context, baseURL, clientID, clientSecret, refreshToken string) (*TokenResponse, error) { + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {strings.TrimSpace(refreshToken)}, + } + if clientID = strings.TrimSpace(clientID); clientID != "" { + form.Set("client_id", clientID) + } + if secret := strings.TrimSpace(clientSecret); secret != "" { + form.Set("client_secret", secret) + } + return c.postToken(ctx, NormalizeBaseURL(baseURL)+"/oauth/token", form) +} + +func (c *AuthClient) postToken(ctx context.Context, tokenURL string, form url.Values) (*TokenResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("gitlab token request failed: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab token request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("gitlab token response read failed: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("gitlab token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var token TokenResponse + if err := json.Unmarshal(body, &token); err != nil { + return nil, fmt.Errorf("gitlab token response decode failed: %w", err) + } + return &token, nil +} + +func (c *AuthClient) GetCurrentUser(ctx context.Context, baseURL, token string) (*User, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, NormalizeBaseURL(baseURL)+"/api/v4/user", nil) + if err != nil { + return nil, fmt.Errorf("gitlab user request failed: %w", err) + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab user request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("gitlab user response read failed: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("gitlab user request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var user User + if err := json.Unmarshal(body, &user); err != nil { + return nil, fmt.Errorf("gitlab user response decode failed: %w", err) + } + return &user, nil +} + +func (c *AuthClient) GetPersonalAccessTokenSelf(ctx context.Context, baseURL, token string) (*PersonalAccessTokenSelf, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, NormalizeBaseURL(baseURL)+"/api/v4/personal_access_tokens/self", nil) + if err != nil { + return nil, fmt.Errorf("gitlab PAT self request failed: %w", err) + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab PAT self request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("gitlab PAT self response read failed: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("gitlab PAT self request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var pat PersonalAccessTokenSelf + if err := json.Unmarshal(body, &pat); err != nil { + return nil, fmt.Errorf("gitlab PAT self response decode failed: %w", err) + } + return &pat, nil +} + +func (c *AuthClient) FetchDirectAccess(ctx context.Context, baseURL, token string) (*DirectAccessResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, NormalizeBaseURL(baseURL)+"/api/v4/code_suggestions/direct_access", nil) + if err != nil { + return nil, fmt.Errorf("gitlab direct access request failed: %w", err) + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab direct access request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("gitlab direct access response read failed: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("gitlab direct access request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var direct DirectAccessResponse + if err := json.Unmarshal(body, &direct); err != nil { + return nil, fmt.Errorf("gitlab direct access response decode failed: %w", err) + } + if direct.Headers == nil { + direct.Headers = make(map[string]string) + } + return &direct, nil +} + +func ExtractDiscoveredModels(metadata map[string]any) []DiscoveredModel { + if len(metadata) == 0 { + return nil + } + + models := make([]DiscoveredModel, 0, 4) + seen := make(map[string]struct{}) + appendModel := func(provider, name string) { + provider = strings.TrimSpace(provider) + name = strings.TrimSpace(name) + if name == "" { + return + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + models = append(models, DiscoveredModel{ + ModelProvider: provider, + ModelName: name, + }) + } + + if raw, ok := metadata["model_details"]; ok { + appendDiscoveredModels(raw, appendModel) + } + appendModel(stringValue(metadata["model_provider"]), stringValue(metadata["model_name"])) + + for _, key := range []string{"models", "supported_models", "discovered_models"} { + if raw, ok := metadata[key]; ok { + appendDiscoveredModels(raw, appendModel) + } + } + + return models +} + +func appendDiscoveredModels(raw any, appendModel func(provider, name string)) { + switch typed := raw.(type) { + case map[string]any: + appendModel(stringValue(typed["model_provider"]), stringValue(typed["model_name"])) + appendModel(stringValue(typed["provider"]), stringValue(typed["name"])) + if nested, ok := typed["models"]; ok { + appendDiscoveredModels(nested, appendModel) + } + case []any: + for _, item := range typed { + appendDiscoveredModels(item, appendModel) + } + case []string: + for _, item := range typed { + appendModel("", item) + } + case string: + appendModel("", typed) + } +} + +func stringValue(raw any) string { + switch typed := raw.(type) { + case string: + return strings.TrimSpace(typed) + case fmt.Stringer: + return strings.TrimSpace(typed.String()) + case json.Number: + return typed.String() + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + case float64: + return strconv.FormatInt(int64(typed), 10) + default: + return "" + } +} diff --git a/internal/auth/gitlab/gitlab_test.go b/internal/auth/gitlab/gitlab_test.go new file mode 100644 index 0000000000..dde09dd7d4 --- /dev/null +++ b/internal/auth/gitlab/gitlab_test.go @@ -0,0 +1,138 @@ +package gitlab + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestAuthClientGenerateAuthURLIncludesPKCE(t *testing.T) { + client := NewAuthClient(nil) + pkce, err := GeneratePKCECodes() + if err != nil { + t.Fatalf("GeneratePKCECodes() error = %v", err) + } + + 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 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 err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm() error = %v", err) + } + 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 srv.Close() + + 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("ExchangeCodeForTokens() error = %v", err) + } + if token.AccessToken != "oauth-access" { + t.Fatalf("expected access token, got %q", token.AccessToken) + } + 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("expected model details, got %+v", direct.ModelDetails) + } +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 2a3407be49..ea7a05321f 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -23,6 +23,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewKiroAuthenticator(), sdkAuth.NewGitHubCopilotAuthenticator(), sdkAuth.NewKiloAuthenticator(), + sdkAuth.NewGitLabAuthenticator(), ) return manager } diff --git a/internal/cmd/gitlab_login.go b/internal/cmd/gitlab_login.go new file mode 100644 index 0000000000..9384bec1f2 --- /dev/null +++ b/internal/cmd/gitlab_login.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" +) + +func DoGitLabLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + promptFn := options.Prompt + if promptFn == nil { + promptFn = defaultProjectPrompt() + } + + manager := newAuthManager() + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{ + "login_mode": "oauth", + }, + Prompt: promptFn, + } + + _, savedPath, err := manager.Login(context.Background(), "gitlab", cfg, authOpts) + if err != nil { + fmt.Printf("GitLab Duo authentication failed: %v\n", err) + return + } + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + fmt.Println("GitLab Duo authentication successful!") +} + +func DoGitLabTokenLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + promptFn := options.Prompt + if promptFn == nil { + promptFn = defaultProjectPrompt() + } + + manager := newAuthManager() + authOpts := &sdkAuth.LoginOptions{ + Metadata: map[string]string{ + "login_mode": "pat", + }, + Prompt: promptFn, + } + + _, savedPath, err := manager.Login(context.Background(), "gitlab", cfg, authOpts) + if err != nil { + fmt.Printf("GitLab Duo PAT authentication failed: %v\n", err) + return + } + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + fmt.Println("GitLab Duo PAT authentication successful!") +} diff --git a/internal/runtime/executor/gitlab_executor.go b/internal/runtime/executor/gitlab_executor.go new file mode 100644 index 0000000000..f9fa9fc157 --- /dev/null +++ b/internal/runtime/executor/gitlab_executor.go @@ -0,0 +1,1320 @@ +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.nativeGateway(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.nativeGateway(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.nativeGateway(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.nativeGatewayHTTP(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) nativeGateway( + auth *cliproxyauth.Auth, + req cliproxyexecutor.Request, +) (cliproxyauth.ProviderExecutor, *cliproxyauth.Auth, cliproxyexecutor.Request, bool) { + if nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth); ok { + nativeReq := req + nativeReq.Model = gitLabResolvedModel(auth, req.Model) + return NewClaudeExecutor(e.cfg), nativeAuth, nativeReq, true + } + if nativeAuth, ok := buildGitLabOpenAIGatewayAuth(auth); ok { + nativeReq := req + nativeReq.Model = gitLabResolvedModel(auth, req.Model) + return NewCodexExecutor(e.cfg), nativeAuth, nativeReq, true + } + return nil, nil, req, false +} + +func (e *GitLabExecutor) nativeGatewayHTTP(auth *cliproxyauth.Auth) (cliproxyauth.ProviderExecutor, *cliproxyauth.Auth) { + if nativeAuth, ok := buildGitLabAnthropicGatewayAuth(auth); ok { + return NewClaudeExecutor(e.cfg), nativeAuth + } + if nativeAuth, ok := buildGitLabOpenAIGatewayAuth(auth); ok { + return NewCodexExecutor(e.cfg), nativeAuth + } + return nil, nil +} + +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 buildGitLabOpenAIGatewayAuth(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, bool) { + if !gitLabUsesOpenAIGateway(auth) { + return nil, false + } + baseURL := gitLabOpenAIGatewayBaseURL(auth) + token := gitLabMetadataString(auth.Metadata, "duo_gateway_token") + if baseURL == "" || token == "" { + return nil, false + } + + nativeAuth := auth.Clone() + nativeAuth.Provider = "codex" + 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 gitLabUsesOpenAIGateway(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 == "openai" && + 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 gitLabOpenAIGatewayBaseURL(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/openai/v1"), strings.HasSuffix(path, "/v1/proxy/openai/v1"): + return strings.TrimRight(base.String(), "/") + case path == "/ai": + base.Path = "/ai/v1/proxy/openai/v1" + case path != "": + base.Path = strings.TrimRight(path, "/") + "/v1/proxy/openai/v1" + case strings.Contains(strings.ToLower(base.Host), "gitlab.com"): + base.Path = "/ai/v1/proxy/openai/v1" + default: + base.Path = "/v1/proxy/openai/v1" + } + 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 +} diff --git a/internal/runtime/executor/gitlab_executor_test.go b/internal/runtime/executor/gitlab_executor_test.go new file mode 100644 index 0000000000..5d49c1d7be --- /dev/null +++ b/internal/runtime/executor/gitlab_executor_test.go @@ -0,0 +1,469 @@ +package executor + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + 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 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) + } + _, _ = w.Write([]byte(`"chat response"`)) + })) + defer srv.Close() + + exec := NewGitLabExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gitlab", + Metadata: map[string]any{ + "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", + }, + } + 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 TestGitLabExecutorExecuteUsesAnthropicGateway(t *testing.T) { + var gotAuthHeader, gotRealmHeader 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") + gotModel = gjson.GetBytes(readBody(t, r), "model").String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"cmd":"ls"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":4}}`)) + })) + 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: "gitlab-duo", + Payload: []byte(`{ + "model":"gitlab-duo", + "messages":[{"role":"user","content":[{"type":"text","text":"list files"}]}], + "tools":[{"name":"Bash","description":"run bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}], + "max_tokens":128 + }`), + } + + resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("claude"), + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if gotPath != "/v1/proxy/anthropic/v1/messages" { + t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages") + } + 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 gotModel != "claude-sonnet-4-5" { + t.Fatalf("model = %q, want claude-sonnet-4-5", gotModel) + } + if got := gjson.GetBytes(resp.Payload, "content.0.type").String(); got != "tool_use" { + t.Fatalf("expected tool_use response, got %q", got) + } + if got := gjson.GetBytes(resp.Payload, "content.0.name").String(); got != "Bash" { + t.Fatalf("expected tool name Bash, got %q", got) + } +} + +func TestGitLabExecutorExecuteUsesOpenAIGateway(t *testing.T) { + var gotAuthHeader, gotRealmHeader 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") + 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\":\"gpt-5-codex\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello from openai gateway\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello from openai gateway\"}]}],\"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": "openai", + "model_name": "gpt-5-codex", + }, + } + 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 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 gotModel != "gpt-5-codex" { + t.Fatalf("model = %q, want gpt-5-codex", gotModel) + } + if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "hello from openai gateway" { + t.Fatalf("expected openai gateway 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 { + 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() error = %v", err) + } + if got := updated.Metadata["access_token"]; got != "oauth-refreshed" { + t.Fatalf("expected refreshed access token, got %#v", got) + } + if got := updated.Metadata["model_name"]; got != "claude-sonnet-4-5" { + t.Fatalf("expected refreshed model metadata, got %#v", got) + } +} + +func TestGitLabExecutorExecuteStreamUsesCodeSuggestionsSSE(t *testing.T) { + var gotAccept, gotStreamingHeader, gotEncoding string + var gotStreamFlag bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != gitLabCodeSuggestionsEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + gotAccept = r.Header.Get("Accept") + gotStreamingHeader = r.Header.Get(gitLabSSEStreamingHeader) + gotEncoding = r.Header.Get("Accept-Encoding") + gotStreamFlag = gjson.GetBytes(readBody(t, r), "stream").Bool() + + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: stream_start\n")) + _, _ = w.Write([]byte("data: {\"model\":{\"name\":\"claude-sonnet-4-5\"}}\n\n")) + _, _ = w.Write([]byte("event: content_chunk\n")) + _, _ = w.Write([]byte("data: {\"content\":\"hello\"}\n\n")) + _, _ = w.Write([]byte("event: content_chunk\n")) + _, _ = w.Write([]byte("data: {\"content\":\" world\"}\n\n")) + _, _ = w.Write([]byte("event: stream_end\n")) + _, _ = w.Write([]byte("data: {}\n\n")) + })) + defer srv.Close() + + exec := NewGitLabExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gitlab", + Metadata: map[string]any{ + "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","stream":true,"messages":[{"role":"user","content":"hello"}]}`), + } + + result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + }) + if err != nil { + t.Fatalf("ExecuteStream() error = %v", err) + } + + lines := collectStreamLines(t, result) + if gotAccept != "text/event-stream" { + t.Fatalf("Accept = %q, want text/event-stream", gotAccept) + } + if gotStreamingHeader != "true" { + t.Fatalf("%s = %q, want true", gitLabSSEStreamingHeader, gotStreamingHeader) + } + if gotEncoding != "identity" { + t.Fatalf("Accept-Encoding = %q, want identity", gotEncoding) + } + if !gotStreamFlag { + t.Fatalf("expected upstream request to set stream=true") + } + if len(lines) < 4 { + t.Fatalf("expected translated stream chunks, got %d", len(lines)) + } + if !strings.Contains(strings.Join(lines, "\n"), `"content":"hello"`) { + t.Fatalf("expected hello delta in stream, got %q", strings.Join(lines, "\n")) + } + if !strings.Contains(strings.Join(lines, "\n"), `"content":" world"`) { + t.Fatalf("expected world delta in stream, got %q", strings.Join(lines, "\n")) + } + last := lines[len(lines)-1] + if last != "data: [DONE]" && !strings.Contains(last, `"finish_reason":"stop"`) { + t.Fatalf("expected stream terminator, got %q", last) + } +} + +func TestGitLabExecutorExecuteStreamFallsBackToSyntheticChat(t *testing.T) { + chatCalls := 0 + streamCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case gitLabCodeSuggestionsEndpoint: + streamCalls++ + http.Error(w, "feature unavailable", http.StatusForbidden) + case gitLabChatEndpoint: + chatCalls++ + _, _ = w.Write([]byte(`"chat 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, + "access_token": "oauth-access", + "model_name": "claude-sonnet-4-5", + }, + } + req := cliproxyexecutor.Request{ + Model: "gitlab-duo", + Payload: []byte(`{"model":"gitlab-duo","stream":true,"messages":[{"role":"user","content":"hello"}]}`), + } + + result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + }) + if err != nil { + t.Fatalf("ExecuteStream() error = %v", err) + } + + lines := collectStreamLines(t, result) + if streamCalls != 1 { + t.Fatalf("expected streaming endpoint once, got %d", streamCalls) + } + if chatCalls != 1 { + t.Fatalf("expected chat fallback once, got %d", chatCalls) + } + if !strings.Contains(strings.Join(lines, "\n"), `"content":"chat fallback response"`) { + t.Fatalf("expected fallback content in stream, got %q", strings.Join(lines, "\n")) + } +} + +func TestGitLabExecutorExecuteStreamUsesAnthropicGateway(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + 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")) + _, _ = w.Write([]byte("event: content_block_start\n")) + _, _ = w.Write([]byte("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")) + _, _ = w.Write([]byte("event: content_block_delta\n")) + _, _ = w.Write([]byte("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello from gateway\"}}\n\n")) + _, _ = w.Write([]byte("event: message_delta\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":3}}\n\n")) + _, _ = w.Write([]byte("event: message_stop\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\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: "gitlab-duo", + Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}],"max_tokens":64}`), + } + + result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("claude"), + }) + if err != nil { + t.Fatalf("ExecuteStream() error = %v", err) + } + + lines := collectStreamLines(t, result) + if gotPath != "/v1/proxy/anthropic/v1/messages" { + t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages") + } + if !strings.Contains(strings.Join(lines, "\n"), "hello from gateway") { + t.Fatalf("expected anthropic gateway stream, got %q", strings.Join(lines, "\n")) + } +} + +func collectStreamLines(t *testing.T, result *cliproxyexecutor.StreamResult) []string { + t.Helper() + lines := make([]string, 0, 8) + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("unexpected stream error: %v", chunk.Err) + } + lines = append(lines, string(chunk.Payload)) + } + return lines +} + +func readBody(t *testing.T, r *http.Request) []byte { + t.Helper() + defer func() { _ = r.Body.Close() }() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + return body +} diff --git a/sdk/api/handlers/claude/gitlab_duo_handler_test.go b/sdk/api/handlers/claude/gitlab_duo_handler_test.go new file mode 100644 index 0000000000..97c3293e59 --- /dev/null +++ b/sdk/api/handlers/claude/gitlab_duo_handler_test.go @@ -0,0 +1,151 @@ +package claude + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestClaudeMessagesWithGitLabDuoAnthropicGateway(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath, gotAuthHeader, gotRealmHeader string + upstream := 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") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"cmd":"ls"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":4}}`)) + })) + defer upstream.Close() + + manager, _ := registerGitLabDuoAnthropicAuth(t, upstream.URL) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewClaudeCodeAPIHandler(base) + router := gin.New() + router.POST("/v1/messages", h.ClaudeMessages) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ + "model":"claude-sonnet-4-5", + "max_tokens":128, + "messages":[{"role":"user","content":"list files"}], + "tools":[{"name":"Bash","description":"run bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}] + }`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Anthropic-Version", "2023-06-01") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + if gotPath != "/v1/proxy/anthropic/v1/messages" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages") + } + 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 !strings.Contains(resp.Body.String(), `"tool_use"`) { + t.Fatalf("expected tool_use response, got %s", resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), `"Bash"`) { + t.Fatalf("expected Bash tool in response, got %s", resp.Body.String()) + } +} + +func TestClaudeMessagesStreamWithGitLabDuoAnthropicGateway(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + 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")) + _, _ = w.Write([]byte("event: content_block_start\n")) + _, _ = w.Write([]byte("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")) + _, _ = w.Write([]byte("event: content_block_delta\n")) + _, _ = w.Write([]byte("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello from duo\"}}\n\n")) + _, _ = w.Write([]byte("event: message_delta\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":3}}\n\n")) + _, _ = w.Write([]byte("event: message_stop\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n")) + })) + defer upstream.Close() + + manager, _ := registerGitLabDuoAnthropicAuth(t, upstream.URL) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewClaudeCodeAPIHandler(base) + router := gin.New() + router.POST("/v1/messages", h.ClaudeMessages) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ + "model":"claude-sonnet-4-5", + "stream":true, + "max_tokens":64, + "messages":[{"role":"user","content":"hello"}] + }`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Anthropic-Version", "2023-06-01") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + if gotPath != "/v1/proxy/anthropic/v1/messages" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages") + } + if got := resp.Header().Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("content-type = %q, want text/event-stream", got) + } + if !strings.Contains(resp.Body.String(), "event: content_block_delta") { + t.Fatalf("expected streamed claude event, got %s", resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "hello from duo") { + t.Fatalf("expected streamed text, got %s", resp.Body.String()) + } +} + +func registerGitLabDuoAnthropicAuth(t *testing.T, upstreamURL string) (*coreauth.Manager, string) { + t.Helper() + + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(runtimeexecutor.NewGitLabExecutor(&internalconfig.Config{})) + + auth := &coreauth.Auth{ + ID: "gitlab-duo-claude-handler-test", + Provider: "gitlab", + Status: coreauth.StatusActive, + Metadata: map[string]any{ + "duo_gateway_base_url": upstreamURL, + "duo_gateway_token": "gateway-token", + "duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"}, + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + } + registered, err := manager.Register(context.Background(), auth) + if err != nil { + t.Fatalf("register auth: %v", err) + } + + registry.GetGlobalRegistry().RegisterClient(registered.ID, registered.Provider, runtimeexecutor.GitLabModelsFromAuth(registered)) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(registered.ID) + }) + return manager, registered.ID +} diff --git a/sdk/api/handlers/openai/gitlab_duo_handler_test.go b/sdk/api/handlers/openai/gitlab_duo_handler_test.go new file mode 100644 index 0000000000..e70f7f0470 --- /dev/null +++ b/sdk/api/handlers/openai/gitlab_duo_handler_test.go @@ -0,0 +1,143 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestOpenAIChatCompletionsWithGitLabDuoOpenAIGateway(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath, gotAuthHeader, gotRealmHeader string + upstream := 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") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello from duo openai\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello from duo openai\"}]}],\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"total_tokens\":15}}}\n\n")) + })) + defer upstream.Close() + + manager := registerGitLabDuoOpenAIAuth(t, upstream.URL) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIAPIHandler(base) + router := gin.New() + router.POST("/v1/chat/completions", h.ChatCompletions) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"gpt-5-codex", + "messages":[{"role":"user","content":"hello"}] + }`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + 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 !strings.Contains(resp.Body.String(), `"content":"hello from duo openai"`) { + t.Fatalf("expected translated chat completion, got %s", resp.Body.String()) + } +} + +func TestOpenAIResponsesStreamWithGitLabDuoOpenAIGateway(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath, gotAuthHeader string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthHeader = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"streamed duo output\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"streamed duo output\"}]}],\"usage\":{\"input_tokens\":10,\"output_tokens\":3,\"total_tokens\":13}}}\n\n")) + })) + defer upstream.Close() + + manager := registerGitLabDuoOpenAIAuth(t, upstream.URL) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + router := gin.New() + router.POST("/v1/responses", h.Responses) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"gpt-5-codex", + "stream":true, + "input":"hello" + }`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + 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 got := resp.Header().Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("content-type = %q, want text/event-stream", got) + } + if !strings.Contains(resp.Body.String(), `"type":"response.output_text.delta"`) { + t.Fatalf("expected streamed responses delta, got %s", resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), `"type":"response.completed"`) { + t.Fatalf("expected streamed responses completion, got %s", resp.Body.String()) + } +} + +func registerGitLabDuoOpenAIAuth(t *testing.T, upstreamURL string) *coreauth.Manager { + t.Helper() + + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(runtimeexecutor.NewGitLabExecutor(&internalconfig.Config{})) + + auth := &coreauth.Auth{ + ID: "gitlab-duo-openai-handler-test", + Provider: "gitlab", + Status: coreauth.StatusActive, + Metadata: map[string]any{ + "duo_gateway_base_url": upstreamURL, + "duo_gateway_token": "gateway-token", + "duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"}, + "model_provider": "openai", + "model_name": "gpt-5-codex", + }, + } + registered, err := manager.Register(context.Background(), auth) + if err != nil { + t.Fatalf("register auth: %v", err) + } + + registry.GetGlobalRegistry().RegisterClient(registered.ID, registered.Provider, runtimeexecutor.GitLabModelsFromAuth(registered)) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(registered.ID) + }) + return manager +} diff --git a/sdk/auth/gitlab.go b/sdk/auth/gitlab.go new file mode 100644 index 0000000000..61dd2acf1c --- /dev/null +++ b/sdk/auth/gitlab.go @@ -0,0 +1,485 @@ +package auth + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + gitlabauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab" + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +const ( + gitLabLoginModeMetadataKey = "login_mode" + gitLabLoginModeOAuth = "oauth" + gitLabLoginModePAT = "pat" + gitLabBaseURLMetadataKey = "base_url" + gitLabOAuthClientIDMetadataKey = "oauth_client_id" + gitLabOAuthClientSecretMetadataKey = "oauth_client_secret" + gitLabPersonalAccessTokenMetadataKey = "personal_access_token" +) + +var gitLabRefreshLead = 5 * time.Minute + +type GitLabAuthenticator struct { + CallbackPort int +} + +func NewGitLabAuthenticator() *GitLabAuthenticator { + return &GitLabAuthenticator{CallbackPort: gitlabauth.DefaultCallbackPort} +} + +func (a *GitLabAuthenticator) Provider() string { + return "gitlab" +} + +func (a *GitLabAuthenticator) RefreshLead() *time.Duration { + return &gitLabRefreshLead +} + +func (a *GitLabAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("cliproxy auth: configuration is required") + } + if ctx == nil { + ctx = context.Background() + } + if opts == nil { + opts = &LoginOptions{} + } + + switch strings.ToLower(strings.TrimSpace(opts.Metadata[gitLabLoginModeMetadataKey])) { + case "", gitLabLoginModeOAuth: + return a.loginOAuth(ctx, cfg, opts) + case gitLabLoginModePAT: + return a.loginPAT(ctx, cfg, opts) + default: + return nil, fmt.Errorf("gitlab auth: unsupported login mode %q", opts.Metadata[gitLabLoginModeMetadataKey]) + } +} + +func (a *GitLabAuthenticator) loginOAuth(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + client := gitlabauth.NewAuthClient(cfg) + baseURL := a.resolveString(opts, gitLabBaseURLMetadataKey, gitlabauth.DefaultBaseURL) + clientID, err := a.requireInput(opts, gitLabOAuthClientIDMetadataKey, "Enter GitLab OAuth application client ID: ") + if err != nil { + return nil, err + } + clientSecret, err := a.optionalInput(opts, gitLabOAuthClientSecretMetadataKey, "Enter GitLab OAuth application client secret (press Enter for public PKCE app): ") + if err != nil { + return nil, err + } + + callbackPort := a.CallbackPort + if opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + redirectURI := gitlabauth.RedirectURL(callbackPort) + + pkceCodes, err := gitlabauth.GeneratePKCECodes() + if err != nil { + return nil, err + } + state, err := misc.GenerateRandomState() + if err != nil { + return nil, fmt.Errorf("gitlab state generation failed: %w", err) + } + + oauthServer := gitlabauth.NewOAuthServer(callbackPort) + if err := oauthServer.Start(); err != nil { + return nil, err + } + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if stopErr := oauthServer.Stop(stopCtx); stopErr != nil { + log.Warnf("gitlab oauth server stop error: %v", stopErr) + } + }() + + authURL, err := client.GenerateAuthURL(baseURL, clientID, redirectURI, state, pkceCodes) + if err != nil { + return nil, err + } + + if !opts.NoBrowser { + fmt.Println("Opening browser for GitLab Duo authentication") + if !browser.IsAvailable() { + log.Warn("No browser available; please open the URL manually") + util.PrintSSHTunnelInstructions(callbackPort) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } else if err = browser.OpenURL(authURL); err != nil { + log.Warnf("Failed to open browser automatically: %v", err) + util.PrintSSHTunnelInstructions(callbackPort) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + } else { + util.PrintSSHTunnelInstructions(callbackPort) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + + fmt.Println("Waiting for GitLab OAuth callback...") + + callbackCh := make(chan *gitlabauth.OAuthResult, 1) + callbackErrCh := make(chan error, 1) + go func() { + result, waitErr := oauthServer.WaitForCallback(5 * time.Minute) + if waitErr != nil { + callbackErrCh <- waitErr + return + } + callbackCh <- result + }() + + var result *gitlabauth.OAuthResult + var manualPromptTimer *time.Timer + var manualPromptC <-chan time.Time + if opts.Prompt != nil { + manualPromptTimer = time.NewTimer(15 * time.Second) + manualPromptC = manualPromptTimer.C + defer manualPromptTimer.Stop() + } + +waitForCallback: + for { + select { + case result = <-callbackCh: + break waitForCallback + case err = <-callbackErrCh: + return nil, err + case <-manualPromptC: + manualPromptC = nil + if manualPromptTimer != nil { + manualPromptTimer.Stop() + } + input, promptErr := opts.Prompt("Paste the GitLab callback URL (or press Enter to keep waiting): ") + if promptErr != nil { + return nil, promptErr + } + parsed, parseErr := misc.ParseOAuthCallback(input) + if parseErr != nil { + return nil, parseErr + } + if parsed == nil { + continue + } + result = &gitlabauth.OAuthResult{ + Code: parsed.Code, + State: parsed.State, + Error: parsed.Error, + } + break waitForCallback + } + } + + if result.Error != "" { + return nil, fmt.Errorf("gitlab oauth returned error: %s", result.Error) + } + if result.State != state { + return nil, fmt.Errorf("gitlab auth: state mismatch") + } + + tokenResp, err := client.ExchangeCodeForTokens(ctx, baseURL, clientID, clientSecret, redirectURI, result.Code, pkceCodes.CodeVerifier) + if err != nil { + return nil, err + } + accessToken := strings.TrimSpace(tokenResp.AccessToken) + if accessToken == "" { + return nil, fmt.Errorf("gitlab auth: missing access token") + } + + user, err := client.GetCurrentUser(ctx, baseURL, accessToken) + if err != nil { + return nil, err + } + direct, err := client.FetchDirectAccess(ctx, baseURL, accessToken) + if err != nil { + return nil, err + } + + identifier := gitLabAccountIdentifier(user) + fileName := fmt.Sprintf("gitlab-%s.json", sanitizeGitLabFileName(identifier)) + 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 + } + metadata["name"] = strings.TrimSpace(user.Name) + + fmt.Println("GitLab Duo authentication successful") + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Label: identifier, + Metadata: metadata, + }, nil +} + +func (a *GitLabAuthenticator) loginPAT(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + client := gitlabauth.NewAuthClient(cfg) + baseURL := a.resolveString(opts, gitLabBaseURLMetadataKey, gitlabauth.DefaultBaseURL) + token, err := a.requireInput(opts, gitLabPersonalAccessTokenMetadataKey, "Enter GitLab personal access token: ") + if err != nil { + return nil, err + } + + user, err := client.GetCurrentUser(ctx, baseURL, token) + if err != nil { + return nil, err + } + _, err = client.GetPersonalAccessTokenSelf(ctx, baseURL, token) + if err != nil { + return nil, err + } + direct, err := client.FetchDirectAccess(ctx, baseURL, token) + if err != nil { + return nil, err + } + + identifier := gitLabAccountIdentifier(user) + fileName := fmt.Sprintf("gitlab-%s-pat.json", sanitizeGitLabFileName(identifier)) + metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModePAT, nil, direct) + metadata["auth_kind"] = "personal_access_token" + metadata[gitLabPersonalAccessTokenMetadataKey] = strings.TrimSpace(token) + metadata["token_preview"] = maskGitLabToken(token) + metadata["username"] = strings.TrimSpace(user.Username) + if email := strings.TrimSpace(primaryGitLabEmail(user)); email != "" { + metadata["email"] = email + } + metadata["name"] = strings.TrimSpace(user.Name) + + fmt.Println("GitLab Duo PAT authentication successful") + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Label: identifier + " (PAT)", + Metadata: metadata, + }, nil +} + +func buildGitLabAuthMetadata(baseURL, mode string, tokenResp *gitlabauth.TokenResponse, direct *gitlabauth.DirectAccessResponse) map[string]any { + metadata := map[string]any{ + "type": "gitlab", + "auth_method": strings.TrimSpace(mode), + gitLabBaseURLMetadataKey: gitlabauth.NormalizeBaseURL(baseURL), + "last_refresh": time.Now().UTC().Format(time.RFC3339), + "refresh_interval_seconds": 240, + } + if tokenResp != nil { + metadata["access_token"] = strings.TrimSpace(tokenResp.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 := gitlabauth.TokenExpiry(time.Now(), tokenResp); !expiry.IsZero() { + metadata["oauth_expires_at"] = expiry.Format(time.RFC3339) + } + } + mergeGitLabDirectAccessMetadata(metadata, direct) + return metadata +} + +func mergeGitLabDirectAccessMetadata(metadata map[string]any, direct *gitlabauth.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) + now := time.Now().UTC() + if ttl := expiry.Sub(now); 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 (a *GitLabAuthenticator) resolveString(opts *LoginOptions, key, fallback string) string { + if opts != nil && opts.Metadata != nil { + if value := strings.TrimSpace(opts.Metadata[key]); 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) != "" { + return fallback + } + return "" +} + +func (a *GitLabAuthenticator) requireInput(opts *LoginOptions, key, prompt string) (string, error) { + if value := a.resolveString(opts, key, ""); value != "" { + return value, nil + } + if opts != nil && opts.Prompt != nil { + value, err := opts.Prompt(prompt) + if err != nil { + return "", err + } + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed, nil + } + } + return "", fmt.Errorf("gitlab auth: missing required %s", key) +} + +func (a *GitLabAuthenticator) optionalInput(opts *LoginOptions, key, prompt string) (string, error) { + if value := a.resolveString(opts, key, ""); value != "" { + return value, nil + } + if opts != nil && opts.Prompt != nil { + value, err := opts.Prompt(prompt) + if err != nil { + return "", err + } + return strings.TrimSpace(value), nil + } + return "", nil +} + +func primaryGitLabEmail(user *gitlabauth.User) string { + if user == nil { + return "" + } + if value := strings.TrimSpace(user.Email); value != "" { + return value + } + return strings.TrimSpace(user.PublicEmail) +} + +func gitLabAccountIdentifier(user *gitlabauth.User) string { + if user == nil { + return "user" + } + for _, value := range []string{user.Username, primaryGitLabEmail(user), user.Name} { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "user" +} + +func sanitizeGitLabFileName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "user" + } + var builder strings.Builder + lastDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + builder.WriteRune(r) + lastDash = false + case r >= '0' && r <= '9': + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == '.': + builder.WriteRune(r) + lastDash = false + default: + if !lastDash { + builder.WriteRune('-') + lastDash = true + } + } + } + result := strings.Trim(builder.String(), "-") + if result == "" { + return "user" + } + return result +} + +func maskGitLabToken(token string) string { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + return "" + } + if len(trimmed) <= 8 { + return trimmed + } + 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 0000000000..055a16a5a7 --- /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/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index ecf8e820af..411950aefd 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -17,6 +17,7 @@ func init() { registerRefreshLead("kimi", func() Authenticator { return NewKimiAuthenticator() }) registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() }) registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() }) + registerRefreshLead("gitlab", func() Authenticator { return NewGitLabAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) { diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index e24919f5ca..225038e1bd 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -390,6 +390,27 @@ func (a *Auth) AccountInfo() (string, string) { // Check metadata for email first (OAuth-style auth) if a.Metadata != nil { + if method, ok := a.Metadata["auth_method"].(string); ok { + switch strings.ToLower(strings.TrimSpace(method)) { + case "oauth": + for _, key := range []string{"email", "username", "name"} { + if value, okValue := a.Metadata[key].(string); okValue { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return "oauth", trimmed + } + } + } + case "pat", "personal_access_token": + for _, key := range []string{"username", "email", "name", "token_preview"} { + if value, okValue := a.Metadata[key].(string); okValue { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return "personal_access_token", trimmed + } + } + } + return "personal_access_token", "" + } + } if v, ok := a.Metadata["email"].(string); ok { email := strings.TrimSpace(v) if email != "" { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 82f6c85dfa..e09556038f 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -119,6 +119,7 @@ func newDefaultAuthManager() *sdkAuth.Manager { sdkAuth.NewCodexAuthenticator(), sdkAuth.NewClaudeAuthenticator(), sdkAuth.NewQwenAuthenticator(), + sdkAuth.NewGitLabAuthenticator(), ) } @@ -444,6 +445,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg)) case "github-copilot": s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) + case "gitlab": + s.coreManager.RegisterExecutor(executor.NewGitLabExecutor(s.cfg)) default: providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) if providerKey == "" { @@ -891,7 +894,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = applyExcludedModels(models, excluded) case "kimi": models = registry.GetKimiModels() - models = applyExcludedModels(models, excluded) + models = applyExcludedModels(models, excluded) case "github-copilot": ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -903,6 +906,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "kilo": models = executor.FetchKiloModels(context.Background(), a, s.cfg) models = applyExcludedModels(models, excluded) + case "gitlab": + models = executor.GitLabModelsFromAuth(a) + models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil { diff --git a/sdk/cliproxy/service_gitlab_models_test.go b/sdk/cliproxy/service_gitlab_models_test.go new file mode 100644 index 0000000000..4ecc5440c4 --- /dev/null +++ b/sdk/cliproxy/service_gitlab_models_test.go @@ -0,0 +1,48 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestRegisterModelsForAuth_GitLabUsesDiscoveredModels(t *testing.T) { + service := &Service{cfg: &config.Config{}} + auth := &coreauth.Auth{ + ID: "gitlab-auth.json", + Provider: "gitlab", + Status: coreauth.StatusActive, + Metadata: map[string]any{ + "model_details": map[string]any{ + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + }, + } + + 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) < 2 { + t.Fatalf("expected stable alias and discovered model, got %d entries", len(models)) + } + + seenAlias := false + seenDiscovered := false + for _, model := range models { + switch model.ID { + case "gitlab-duo": + seenAlias = true + case "claude-sonnet-4-5": + seenDiscovered = true + } + } + if !seenAlias || !seenDiscovered { + t.Fatalf("expected gitlab-duo and discovered model, got %+v", models) + } +}