mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-24 08:50:27 +00:00
Merge pull request #428 from LuxVTZ/feat/gitlab-duo-auth-plus
Add GitLab Duo provider support
This commit is contained in:
@@ -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.
|
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
|
## Contributing
|
||||||
|
|
||||||
This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
|
This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
|
|
||||||
该 Plus 版本的主线功能与主线功能强制同步。
|
该 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 都将被拒绝。
|
该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
|
||||||
@@ -16,4 +23,4 @@
|
|||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ func main() {
|
|||||||
var kiloLogin bool
|
var kiloLogin bool
|
||||||
var iflowLogin bool
|
var iflowLogin bool
|
||||||
var iflowCookie bool
|
var iflowCookie bool
|
||||||
|
var gitlabLogin bool
|
||||||
|
var gitlabTokenLogin bool
|
||||||
var noBrowser bool
|
var noBrowser bool
|
||||||
var oauthCallbackPort int
|
var oauthCallbackPort int
|
||||||
var antigravityLogin bool
|
var antigravityLogin bool
|
||||||
@@ -111,6 +113,8 @@ func main() {
|
|||||||
flag.BoolVar(&kiloLogin, "kilo-login", false, "Login to Kilo AI using device flow")
|
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(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
||||||
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
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.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.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)")
|
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)
|
cmd.DoIFlowLogin(cfg, options)
|
||||||
} else if iflowCookie {
|
} else if iflowCookie {
|
||||||
cmd.DoIFlowCookieAuth(cfg, options)
|
cmd.DoIFlowCookieAuth(cfg, options)
|
||||||
|
} else if gitlabLogin {
|
||||||
|
cmd.DoGitLabLogin(cfg, options)
|
||||||
|
} else if gitlabTokenLogin {
|
||||||
|
cmd.DoGitLabTokenLogin(cfg, options)
|
||||||
} else if kimiLogin {
|
} else if kimiLogin {
|
||||||
cmd.DoKimiLogin(cfg, options)
|
cmd.DoKimiLogin(cfg, options)
|
||||||
} else if kiroLogin {
|
} else if kiroLogin {
|
||||||
|
|||||||
115
docs/gitlab-duo.md
Normal file
115
docs/gitlab-duo.md
Normal file
@@ -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/
|
||||||
115
docs/gitlab-duo_CN.md
Normal file
115
docs/gitlab-duo_CN.md
Normal file
@@ -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/
|
||||||
278
gitlab-duo-codex-parity-plan.md
Normal file
278
gitlab-duo-codex-parity-plan.md
Normal file
@@ -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.
|
||||||
492
internal/auth/gitlab/gitlab.go
Normal file
492
internal/auth/gitlab/gitlab.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
138
internal/auth/gitlab/gitlab_test.go
Normal file
138
internal/auth/gitlab/gitlab_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ func newAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewKiroAuthenticator(),
|
sdkAuth.NewKiroAuthenticator(),
|
||||||
sdkAuth.NewGitHubCopilotAuthenticator(),
|
sdkAuth.NewGitHubCopilotAuthenticator(),
|
||||||
sdkAuth.NewKiloAuthenticator(),
|
sdkAuth.NewKiloAuthenticator(),
|
||||||
|
sdkAuth.NewGitLabAuthenticator(),
|
||||||
)
|
)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|||||||
69
internal/cmd/gitlab_login.go
Normal file
69
internal/cmd/gitlab_login.go
Normal file
@@ -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!")
|
||||||
|
}
|
||||||
1320
internal/runtime/executor/gitlab_executor.go
Normal file
1320
internal/runtime/executor/gitlab_executor.go
Normal file
File diff suppressed because it is too large
Load Diff
469
internal/runtime/executor/gitlab_executor_test.go
Normal file
469
internal/runtime/executor/gitlab_executor_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
151
sdk/api/handlers/claude/gitlab_duo_handler_test.go
Normal file
151
sdk/api/handlers/claude/gitlab_duo_handler_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
143
sdk/api/handlers/openai/gitlab_duo_handler_test.go
Normal file
143
sdk/api/handlers/openai/gitlab_duo_handler_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
485
sdk/auth/gitlab.go
Normal file
485
sdk/auth/gitlab.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
66
sdk/auth/gitlab_test.go
Normal file
66
sdk/auth/gitlab_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGitLabAuthenticatorLoginPAT(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v4/user":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 42,
|
||||||
|
"username": "duo-user",
|
||||||
|
"email": "duo@example.com",
|
||||||
|
"name": "Duo User",
|
||||||
|
})
|
||||||
|
case "/api/v4/personal_access_tokens/self":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 5,
|
||||||
|
"name": "CLIProxyAPI",
|
||||||
|
"scopes": []string{"api"},
|
||||||
|
})
|
||||||
|
case "/api/v4/code_suggestions/direct_access":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"base_url": "https://cloud.gitlab.example.com",
|
||||||
|
"token": "gateway-token",
|
||||||
|
"expires_at": 1710003600,
|
||||||
|
"headers": map[string]string{"X-Gitlab-Realm": "saas"},
|
||||||
|
"model_details": map[string]any{
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
authenticator := NewGitLabAuthenticator()
|
||||||
|
record, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"login_mode": "pat",
|
||||||
|
"base_url": srv.URL,
|
||||||
|
"personal_access_token": "glpat-test-token",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login() error = %v", err)
|
||||||
|
}
|
||||||
|
if record.Provider != "gitlab" {
|
||||||
|
t.Fatalf("expected gitlab provider, got %q", record.Provider)
|
||||||
|
}
|
||||||
|
if got := record.Metadata["model_name"]; got != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("expected discovered model, got %#v", got)
|
||||||
|
}
|
||||||
|
if got := record.Metadata["auth_kind"]; got != "personal_access_token" {
|
||||||
|
t.Fatalf("expected personal_access_token auth kind, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ func init() {
|
|||||||
registerRefreshLead("kimi", func() Authenticator { return NewKimiAuthenticator() })
|
registerRefreshLead("kimi", func() Authenticator { return NewKimiAuthenticator() })
|
||||||
registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
|
registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
|
||||||
registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
|
registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
|
||||||
|
registerRefreshLead("gitlab", func() Authenticator { return NewGitLabAuthenticator() })
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerRefreshLead(provider string, factory func() Authenticator) {
|
func registerRefreshLead(provider string, factory func() Authenticator) {
|
||||||
|
|||||||
@@ -390,6 +390,27 @@ func (a *Auth) AccountInfo() (string, string) {
|
|||||||
|
|
||||||
// Check metadata for email first (OAuth-style auth)
|
// Check metadata for email first (OAuth-style auth)
|
||||||
if a.Metadata != nil {
|
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 {
|
if v, ok := a.Metadata["email"].(string); ok {
|
||||||
email := strings.TrimSpace(v)
|
email := strings.TrimSpace(v)
|
||||||
if email != "" {
|
if email != "" {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func newDefaultAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewCodexAuthenticator(),
|
sdkAuth.NewCodexAuthenticator(),
|
||||||
sdkAuth.NewClaudeAuthenticator(),
|
sdkAuth.NewClaudeAuthenticator(),
|
||||||
sdkAuth.NewQwenAuthenticator(),
|
sdkAuth.NewQwenAuthenticator(),
|
||||||
|
sdkAuth.NewGitLabAuthenticator(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +445,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace
|
|||||||
s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg))
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
|
||||||
|
case "gitlab":
|
||||||
|
s.coreManager.RegisterExecutor(executor.NewGitLabExecutor(s.cfg))
|
||||||
default:
|
default:
|
||||||
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
|
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
|
||||||
if providerKey == "" {
|
if providerKey == "" {
|
||||||
@@ -891,7 +894,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
case "kimi":
|
case "kimi":
|
||||||
models = registry.GetKimiModels()
|
models = registry.GetKimiModels()
|
||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -903,6 +906,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
case "kilo":
|
case "kilo":
|
||||||
models = executor.FetchKiloModels(context.Background(), a, s.cfg)
|
models = executor.FetchKiloModels(context.Background(), a, s.cfg)
|
||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
|
case "gitlab":
|
||||||
|
models = executor.GitLabModelsFromAuth(a)
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
default:
|
default:
|
||||||
// Handle OpenAI-compatibility providers by name using config
|
// Handle OpenAI-compatibility providers by name using config
|
||||||
if s.cfg != nil {
|
if s.cfg != nil {
|
||||||
|
|||||||
48
sdk/cliproxy/service_gitlab_models_test.go
Normal file
48
sdk/cliproxy/service_gitlab_models_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user