sshinmen commited on
Commit
f94dd26
·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +36 -0
  2. .env.example +34 -0
  3. .factory/settings.json +100 -0
  4. .github/FUNDING.yml +1 -0
  5. .github/ISSUE_TEMPLATE/bug_report.md +44 -0
  6. .github/workflows/docker-image.yml +46 -0
  7. .github/workflows/pr-path-guard.yml +28 -0
  8. .github/workflows/pr-test-build.yml +23 -0
  9. .github/workflows/release.yaml +38 -0
  10. .gitignore +50 -0
  11. .goreleaser.yml +39 -0
  12. Dockerfile +35 -0
  13. LICENSE +22 -0
  14. README.md +157 -0
  15. README_CN.md +164 -0
  16. assets/cubence.png +0 -0
  17. assets/packycode.png +0 -0
  18. auths/.gitkeep +0 -0
  19. cmd/server/main.go +482 -0
  20. config.example.yaml +308 -0
  21. docker-build.ps1 +53 -0
  22. docker-build.sh +180 -0
  23. docker-compose.yml +28 -0
  24. examples/custom-provider/main.go +225 -0
  25. examples/http-request/main.go +140 -0
  26. examples/translator/main.go +42 -0
  27. go.mod +76 -0
  28. go.sum +197 -0
  29. internal/access/config_access/provider.go +112 -0
  30. internal/access/reconcile.go +270 -0
  31. internal/api/handlers/management/api_tools.go +704 -0
  32. internal/api/handlers/management/api_tools_test.go +173 -0
  33. internal/api/handlers/management/auth_files.go +2191 -0
  34. internal/api/handlers/management/config_basic.go +309 -0
  35. internal/api/handlers/management/config_lists.go +1365 -0
  36. internal/api/handlers/management/handler.go +317 -0
  37. internal/api/handlers/management/logs.go +583 -0
  38. internal/api/handlers/management/model_definitions.go +33 -0
  39. internal/api/handlers/management/oauth_callback.go +100 -0
  40. internal/api/handlers/management/oauth_sessions.go +283 -0
  41. internal/api/handlers/management/quota.go +18 -0
  42. internal/api/handlers/management/usage.go +79 -0
  43. internal/api/handlers/management/vertex_import.go +156 -0
  44. internal/api/middleware/request_logging.go +122 -0
  45. internal/api/middleware/response_writer.go +382 -0
  46. internal/api/modules/amp/amp.go +428 -0
  47. internal/api/modules/amp/amp_test.go +352 -0
  48. internal/api/modules/amp/fallback_handlers.go +331 -0
  49. internal/api/modules/amp/fallback_handlers_test.go +73 -0
  50. internal/api/modules/amp/gemini_bridge.go +59 -0
.dockerignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git and GitHub folders
2
+ .git/*
3
+ .github/*
4
+
5
+ # Docker and CI/CD related files
6
+ docker-compose.yml
7
+ .dockerignore
8
+ .gitignore
9
+ .goreleaser.yml
10
+ Dockerfile
11
+
12
+ # Documentation and license
13
+ docs/*
14
+ README.md
15
+ README_CN.md
16
+ LICENSE
17
+
18
+ # Runtime data folders (should be mounted as volumes)
19
+ auths/*
20
+ logs/*
21
+ conv/*
22
+ config.yaml
23
+
24
+ # Development/editor
25
+ bin/*
26
+ .vscode/*
27
+ .claude/*
28
+ .codex/*
29
+ .gemini/*
30
+ .serena/*
31
+ .agent/*
32
+ .agents/*
33
+ .opencode/*
34
+ .bmad/*
35
+ _bmad/*
36
+ _bmad-output/*
.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Example environment configuration for CLIProxyAPI.
2
+ # Copy this file to `.env` and uncomment the variables you need.
3
+ #
4
+ # NOTE: Environment variables are only required when using remote storage options.
5
+ # For local file-based storage (default), no environment variables need to be set.
6
+
7
+ # ------------------------------------------------------------------------------
8
+ # Management Web UI
9
+ # ------------------------------------------------------------------------------
10
+ # MANAGEMENT_PASSWORD=change-me-to-a-strong-password
11
+
12
+ # ------------------------------------------------------------------------------
13
+ # Postgres Token Store (optional)
14
+ # ------------------------------------------------------------------------------
15
+ # PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
16
+ # PGSTORE_SCHEMA=public
17
+ # PGSTORE_LOCAL_PATH=/var/lib/cliproxy
18
+
19
+ # ------------------------------------------------------------------------------
20
+ # Git-Backed Config Store (optional)
21
+ # ------------------------------------------------------------------------------
22
+ # GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
23
+ # GITSTORE_GIT_USERNAME=git-user
24
+ # GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
25
+ # GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
26
+
27
+ # ------------------------------------------------------------------------------
28
+ # Object Store Token Store (optional)
29
+ # ------------------------------------------------------------------------------
30
+ # OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
31
+ # OBJECTSTORE_BUCKET=cli-proxy-config
32
+ # OBJECTSTORE_ACCESS_KEY=your_access_key
33
+ # OBJECTSTORE_SECRET_KEY=your_secret_key
34
+ # OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore
.factory/settings.json ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "z-ai/glm4.7",
3
+ "showThinkingInMainView": true,
4
+ "reasoningEffort": "medium",
5
+ "customModels": [
6
+ {
7
+ "model": "gpt-5.1-codex-max",
8
+ "displayName": "gpt-5.1-codex-max",
9
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
10
+ "apiKey": "shin",
11
+ "provider": "generic-chat-completion-api"
12
+ },
13
+ {
14
+ "model": "GPT-5.1",
15
+ "displayName": "GPT-5.1",
16
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
17
+ "apiKey": "shin",
18
+ "provider": "generic-chat-completion-api"
19
+ },
20
+ {
21
+ "model": "gpt-5.2-codex",
22
+ "displayName": "gpt-5.2-codex",
23
+ "baseUrl": "https://shinmen07.up.railway.app/v1",
24
+ "apiKey": "shin",
25
+ "provider": "generic-chat-completion-api"
26
+ },
27
+ {
28
+ "model": "gpt-5.2",
29
+ "displayName": "gpt-5.2",
30
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
31
+ "apiKey": "shin",
32
+ "provider": "generic-chat-completion-api"
33
+ },
34
+ {
35
+ "model": "gpt-5.1-codex",
36
+ "displayName": "gpt-5.1-codex",
37
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
38
+ "apiKey": "shin",
39
+ "provider": "generic-chat-completion-api"
40
+ },
41
+ {
42
+ "model": "gemini-claude-sonnet-4-5",
43
+ "displayName": "gemini-claude-sonnet-4-5",
44
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
45
+ "apiKey": "shin",
46
+ "provider": "generic-chat-completion-api"
47
+ },
48
+ {
49
+ "model": "gemini-claude-opus-4-5-thinking",
50
+ "displayName": "gemini-claude-opus-4-5-thinking",
51
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
52
+ "apiKey": "shin",
53
+ "provider": "generic-chat-completion-api",
54
+ "supportsExtendedThinking": true
55
+ },
56
+ {
57
+ "model": "gemini-3-pro-preview",
58
+ "displayName": "gemini-3-pro-preview",
59
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
60
+ "apiKey": "shin",
61
+ "provider": "generic-chat-completion-api",
62
+ "supportsExtendedThinking": true
63
+ },
64
+ {
65
+ "model": "z-ai/glm4.7",
66
+ "displayName": "z-ai/glm4.7",
67
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
68
+ "apiKey": "shin",
69
+ "provider": "generic-chat-completion-api"
70
+ },
71
+ {
72
+ "model": "glm-4.7",
73
+ "displayName": "glm-4.7",
74
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
75
+ "apiKey": "shin",
76
+ "provider": "generic-chat-completion-api"
77
+ },
78
+ {
79
+ "model": "minimaxai/minimax-m2.1",
80
+ "displayName": "minimaxai/minimax-m2.1",
81
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
82
+ "apiKey": "shin",
83
+ "provider": "generic-chat-completion-api"
84
+ },
85
+ {
86
+ "model": "claude-haiku-4.5",
87
+ "displayName": "claude-haiku-4.5",
88
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
89
+ "apiKey": "shin",
90
+ "provider": "generic-chat-completion-api"
91
+ },
92
+ {
93
+ "model": "claude-opus-4.5",
94
+ "displayName": "claude-opus-4.5",
95
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
96
+ "apiKey": "shin",
97
+ "provider": "generic-chat-completion-api"
98
+ }
99
+ ]
100
+ }
.github/FUNDING.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ github: [router-for-me]
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug report
3
+ about: Create a report to help us improve
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Is it a request payload issue?**
11
+ [ ] Yes, this is a request payload issue. I am using a client/cURL to send a request payload, but I received an unexpected error.
12
+ [ ] No, it's another issue.
13
+
14
+ **If it's a request payload issue, you MUST know**
15
+ Our team doesn't have any GODs or ORACLEs or MIND READERs. Please make sure to attach the request log or curl payload.
16
+
17
+ **Describe the bug**
18
+ A clear and concise description of what the bug is.
19
+
20
+ **CLI Type**
21
+ What type of CLI account do you use? (gemini-cli, gemini, codex, claude code or openai-compatibility)
22
+
23
+ **Model Name**
24
+ What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
25
+
26
+ **LLM Client**
27
+ What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
28
+
29
+ **Request Information**
30
+ The best way is to paste the cURL command of the HTTP request here.
31
+ Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
32
+
33
+ **Expected behavior**
34
+ A clear and concise description of what you expected to happen.
35
+
36
+ **Screenshots**
37
+ If applicable, add screenshots to help explain your problem.
38
+
39
+ **OS Type**
40
+ - OS: [e.g. macOS]
41
+ - Version [e.g. 15.6.0]
42
+
43
+ **Additional context**
44
+ Add any other context about the problem here.
.github/workflows/docker-image.yml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: docker-image
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+
8
+ env:
9
+ APP_NAME: CLIProxyAPI
10
+ DOCKERHUB_REPO: eceasy/cli-proxy-api
11
+
12
+ jobs:
13
+ docker:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+ - name: Set up QEMU
19
+ uses: docker/setup-qemu-action@v3
20
+ - name: Set up Docker Buildx
21
+ uses: docker/setup-buildx-action@v3
22
+ - name: Login to DockerHub
23
+ uses: docker/login-action@v3
24
+ with:
25
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
26
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
27
+ - name: Generate Build Metadata
28
+ run: |
29
+ echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
30
+ echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
31
+ echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
32
+ - name: Build and push
33
+ uses: docker/build-push-action@v6
34
+ with:
35
+ context: .
36
+ platforms: |
37
+ linux/amd64
38
+ linux/arm64
39
+ push: true
40
+ build-args: |
41
+ VERSION=${{ env.VERSION }}
42
+ COMMIT=${{ env.COMMIT }}
43
+ BUILD_DATE=${{ env.BUILD_DATE }}
44
+ tags: |
45
+ ${{ env.DOCKERHUB_REPO }}:latest
46
+ ${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
.github/workflows/pr-path-guard.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: translator-path-guard
2
+
3
+ on:
4
+ pull_request:
5
+ types:
6
+ - opened
7
+ - synchronize
8
+ - reopened
9
+
10
+ jobs:
11
+ ensure-no-translator-changes:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ - name: Detect internal/translator changes
18
+ id: changed-files
19
+ uses: tj-actions/changed-files@v45
20
+ with:
21
+ files: |
22
+ internal/translator/**
23
+ - name: Fail when restricted paths change
24
+ if: steps.changed-files.outputs.any_changed == 'true'
25
+ run: |
26
+ echo "Changes under internal/translator are not allowed in pull requests."
27
+ echo "You need to create an issue for our maintenance team to make the necessary changes."
28
+ exit 1
.github/workflows/pr-test-build.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: pr-test-build
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ permissions:
7
+ contents: read
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+ - name: Set up Go
16
+ uses: actions/setup-go@v5
17
+ with:
18
+ go-version-file: go.mod
19
+ cache: true
20
+ - name: Build
21
+ run: |
22
+ go build -o test-output ./cmd/server
23
+ rm -f test-output
.github/workflows/release.yaml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: goreleaser
2
+
3
+ on:
4
+ push:
5
+ # run only against tags
6
+ tags:
7
+ - '*'
8
+
9
+ permissions:
10
+ contents: write
11
+
12
+ jobs:
13
+ goreleaser:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+ - run: git fetch --force --tags
20
+ - uses: actions/setup-go@v4
21
+ with:
22
+ go-version: '>=1.24.0'
23
+ cache: true
24
+ - name: Generate Build Metadata
25
+ run: |
26
+ echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
27
+ echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
28
+ echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
29
+ - uses: goreleaser/goreleaser-action@v4
30
+ with:
31
+ distribution: goreleaser
32
+ version: latest
33
+ args: release --clean
34
+ env:
35
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36
+ VERSION: ${{ env.VERSION }}
37
+ COMMIT: ${{ env.COMMIT }}
38
+ BUILD_DATE: ${{ env.BUILD_DATE }}
.gitignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Binaries
2
+ cli-proxy-api
3
+ *.exe
4
+
5
+ # Configuration
6
+ config.yaml
7
+ .env
8
+
9
+ # Generated content
10
+ bin/*
11
+ logs/*
12
+ conv/*
13
+ temp/*
14
+ refs/*
15
+
16
+ # Storage backends
17
+ pgstore/*
18
+ gitstore/*
19
+ objectstore/*
20
+
21
+ # Static assets
22
+ static/*
23
+
24
+ # Authentication data
25
+ auths/*
26
+ !auths/.gitkeep
27
+
28
+ # Documentation
29
+ docs/*
30
+ AGENTS.md
31
+ CLAUDE.md
32
+ GEMINI.md
33
+
34
+ # Tooling metadata
35
+ .vscode/*
36
+ .codex/*
37
+ .claude/*
38
+ .gemini/*
39
+ .serena/*
40
+ .agent/*
41
+ .agents/*
42
+ .agents/*
43
+ .opencode/*
44
+ .bmad/*
45
+ _bmad/*
46
+ _bmad-output/*
47
+
48
+ # macOS
49
+ .DS_Store
50
+ ._*
.goreleaser.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ builds:
2
+ - id: "cli-proxy-api"
3
+ env:
4
+ - CGO_ENABLED=0
5
+ goos:
6
+ - linux
7
+ - windows
8
+ - darwin
9
+ goarch:
10
+ - amd64
11
+ - arm64
12
+ main: ./cmd/server/
13
+ binary: cli-proxy-api
14
+ ldflags:
15
+ - -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
16
+ archives:
17
+ - id: "cli-proxy-api"
18
+ format: tar.gz
19
+ format_overrides:
20
+ - goos: windows
21
+ format: zip
22
+ files:
23
+ - LICENSE
24
+ - README.md
25
+ - README_CN.md
26
+ - config.example.yaml
27
+
28
+ checksum:
29
+ name_template: 'checksums.txt'
30
+
31
+ snapshot:
32
+ name_template: "{{ incpatch .Version }}-next"
33
+
34
+ changelog:
35
+ sort: asc
36
+ filters:
37
+ exclude:
38
+ - '^docs:'
39
+ - '^test:'
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM golang:1.24-alpine AS builder
2
+
3
+ WORKDIR /app
4
+
5
+ COPY go.mod go.sum ./
6
+
7
+ RUN go mod download
8
+
9
+ COPY . .
10
+
11
+ ARG VERSION=dev
12
+ ARG COMMIT=none
13
+ ARG BUILD_DATE=unknown
14
+
15
+ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
16
+
17
+ FROM alpine:3.22.0
18
+
19
+ RUN apk add --no-cache tzdata
20
+
21
+ RUN mkdir /CLIProxyAPI
22
+
23
+ COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
24
+
25
+ COPY config.example.yaml /CLIProxyAPI/config.example.yaml
26
+
27
+ WORKDIR /CLIProxyAPI
28
+
29
+ EXPOSE 8317
30
+
31
+ ENV TZ=Asia/Shanghai
32
+
33
+ RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
34
+
35
+ CMD ["./CLIProxyAPI"]
LICENSE ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2005.9 Luis Pater
4
+ Copyright (c) 2025.9-present Router-For.ME
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLI Proxy API
2
+
3
+ English | [中文](README_CN.md)
4
+
5
+ A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
6
+
7
+ It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
8
+
9
+ So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
10
+
11
+ ## Sponsor
12
+
13
+ [![z.ai](https://assets.router-for.me/english-4.7.png)](https://z.ai/subscribe?ic=8JVLJQFSKB)
14
+
15
+ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
16
+
17
+ GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.7 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
18
+
19
+ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
20
+
21
+ ---
22
+
23
+ <table>
24
+ <tbody>
25
+ <tr>
26
+ <td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
27
+ <td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
28
+ </tr>
29
+ <tr>
30
+ <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
31
+ <td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
32
+ </tr>
33
+ </tbody>
34
+ </table>
35
+
36
+ ## Overview
37
+
38
+ - OpenAI/Gemini/Claude compatible API endpoints for CLI models
39
+ - OpenAI Codex support (GPT models) via OAuth login
40
+ - Claude Code support via OAuth login
41
+ - Qwen Code support via OAuth login
42
+ - iFlow support via OAuth login
43
+ - Amp CLI and IDE extensions support with provider routing
44
+ - Streaming and non-streaming responses
45
+ - Function calling/tools support
46
+ - Multimodal input support (text and images)
47
+ - Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
48
+ - Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
49
+ - Generative Language API Key support
50
+ - AI Studio Build multi-account load balancing
51
+ - Gemini CLI multi-account load balancing
52
+ - Claude Code multi-account load balancing
53
+ - Qwen Code multi-account load balancing
54
+ - iFlow multi-account load balancing
55
+ - OpenAI Codex multi-account load balancing
56
+ - OpenAI-compatible upstream providers via config (e.g., OpenRouter)
57
+ - Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
58
+
59
+ ## Getting Started
60
+
61
+ CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
62
+
63
+ ## Management API
64
+
65
+ see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
66
+
67
+ ## Amp CLI Support
68
+
69
+ CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
70
+
71
+ - Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
72
+ - Management proxy for OAuth authentication and account features
73
+ - Smart model fallback with automatic routing
74
+ - **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
75
+ - Security-first design with localhost-only management endpoints
76
+
77
+ **→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
78
+
79
+ ## SDK Docs
80
+
81
+ - Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
82
+ - Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
83
+ - Access: [docs/sdk-access.md](docs/sdk-access.md)
84
+ - Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
85
+ - Custom Provider Example: `examples/custom-provider`
86
+
87
+ ## Contributing
88
+
89
+ Contributions are welcome! Please feel free to submit a Pull Request.
90
+
91
+ 1. Fork the repository
92
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
93
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
94
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
95
+ 5. Open a Pull Request
96
+
97
+ ## Who is with us?
98
+
99
+ Those projects are based on CLIProxyAPI:
100
+
101
+ ### [vibeproxy](https://github.com/automazeio/vibeproxy)
102
+
103
+ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
104
+
105
+ ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
106
+
107
+ Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
108
+
109
+ ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
110
+
111
+ CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
112
+
113
+ ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
114
+
115
+ Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
116
+
117
+ ### [Quotio](https://github.com/nguyenphutrong/quotio)
118
+
119
+ Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
120
+
121
+ ### [CodMate](https://github.com/loocor/CodMate)
122
+
123
+ Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
124
+
125
+ ### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
126
+
127
+ Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
128
+
129
+ ### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
130
+
131
+ VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.
132
+
133
+ ### [ZeroLimit](https://github.com/0xtbug/zero-limit)
134
+
135
+ Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas via CLIProxyAPI. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed.
136
+
137
+ ### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
138
+
139
+ A lightweight web admin panel for CLIProxyAPI with health checks, resource monitoring, real-time logs, auto-update, request statistics and pricing display. Supports one-click installation and systemd service.
140
+
141
+ > [!NOTE]
142
+ > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
143
+
144
+ ## More choices
145
+
146
+ Those projects are ports of CLIProxyAPI or inspired by it:
147
+
148
+ ### [9Router](https://github.com/decolua/9router)
149
+
150
+ A Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.
151
+
152
+ > [!NOTE]
153
+ > If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
154
+
155
+ ## License
156
+
157
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
README_CN.md ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLI 代理 API
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
6
+
7
+ 现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
8
+
9
+ 您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
10
+
11
+ ## 赞助商
12
+
13
+ [![bigmodel.cn](https://assets.router-for.me/chinese-4.7.png)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
14
+
15
+ 本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
16
+
17
+ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7,为开发者提供顶尖的编码体验。
18
+
19
+ 智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
20
+
21
+ ---
22
+
23
+ <table>
24
+ <tbody>
25
+ <tr>
26
+ <td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
27
+ <td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
28
+ </tr>
29
+ <tr>
30
+ <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
31
+ <td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
32
+ </tr>
33
+ </tbody>
34
+ </table>
35
+
36
+
37
+ ## 功能特性
38
+
39
+ - 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
40
+ - 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
41
+ - 新增 Claude Code 支持(OAuth 登录)
42
+ - 新增 Qwen Code 支持(OAuth 登录)
43
+ - 新增 iFlow 支持(OAuth 登录)
44
+ - 支持流式与非流式响应
45
+ - 函数调用/工具支持
46
+ - 多模态输入(文本、图片)
47
+ - 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
48
+ - 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
49
+ - 支持 Gemini AIStudio API 密钥
50
+ - 支持 AI Studio Build 多账户轮询
51
+ - 支持 Gemini CLI 多账户轮询
52
+ - 支持 Claude Code 多账户轮询
53
+ - 支持 Qwen Code 多账户轮询
54
+ - 支持 iFlow 多账户轮询
55
+ - 支持 OpenAI Codex 多账户轮询
56
+ - 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
57
+ - 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
58
+
59
+ ## 新手入门
60
+
61
+ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
62
+
63
+ ## 管理 API 文档
64
+
65
+ 请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
66
+
67
+ ## Amp CLI 支持
68
+
69
+ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
70
+
71
+ - 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
72
+ - 管理代理,处理 OAuth 认证和账号功能
73
+ - 智能模型回退与自动路由
74
+ - 以安全为先的设计,管理端点仅限 localhost
75
+
76
+ **→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
77
+
78
+ ## SDK 文档
79
+
80
+ - 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
81
+ - 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
82
+ - 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
83
+ - 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
84
+ - 自定义 Provider 示例:`examples/custom-provider`
85
+
86
+ ## 贡献
87
+
88
+ 欢迎贡献!请随时提交 Pull Request。
89
+
90
+ 1. Fork 仓库
91
+ 2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
92
+ 3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
93
+ 4. 推送到分支(`git push origin feature/amazing-feature`)
94
+ 5. 打开 Pull Request
95
+
96
+ ## 谁与我们在一起?
97
+
98
+ 这些项目基于 CLIProxyAPI:
99
+
100
+ ### [vibeproxy](https://github.com/automazeio/vibeproxy)
101
+
102
+ 一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
103
+
104
+ ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
105
+
106
+ 一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
107
+
108
+ ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
109
+
110
+ CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
111
+
112
+ ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
113
+
114
+ 基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。
115
+
116
+ ### [Quotio](https://github.com/nguyenphutrong/quotio)
117
+
118
+ 原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
119
+
120
+ ### [CodMate](https://github.com/loocor/CodMate)
121
+
122
+ 原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
123
+
124
+ ### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
125
+
126
+ 原生 Windows CLIProxyAPI 分支,集成 TUI、系统托盘及多服务商 OAuth 认证,专为 AI 编程工具打造,无需 API 密钥。
127
+
128
+ ### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
129
+
130
+ 一款 VSCode 扩展,提供了在 VSCode 中快速切换 Claude Code 模型的功能,内置 CLIProxyAPI 作为其后端,支持后台自动启动和关闭。
131
+
132
+ ### [ZeroLimit](https://github.com/0xtbug/zero-limit)
133
+
134
+ Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI 监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。
135
+
136
+ ### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
137
+
138
+ 面向 CLIProxyAPI 的 Web 管理面板,提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示,支持一键安装与 systemd 服务。
139
+
140
+ > [!NOTE]
141
+ > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
142
+
143
+ ## 更多选择
144
+
145
+ 以下项目是 CLIProxyAPI 的移植版或受其启发:
146
+
147
+ ### [9Router](https://github.com/decolua/9router)
148
+
149
+ 基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
150
+
151
+ > [!NOTE]
152
+ > 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
153
+
154
+ ## 许可证
155
+
156
+ 此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
157
+
158
+ ## 写给所有中国网友的
159
+
160
+ QQ 群:188637136
161
+
162
+
163
+
164
+ Telegram 群:https://t.me/CLIProxyAPI
assets/cubence.png ADDED
assets/packycode.png ADDED
auths/.gitkeep ADDED
File without changes
cmd/server/main.go ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package main provides the entry point for the CLI Proxy API server.
2
+ // This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
3
+ // for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
4
+ package main
5
+
6
+ import (
7
+ "context"
8
+ "errors"
9
+ "flag"
10
+ "fmt"
11
+ "io/fs"
12
+ "net/url"
13
+ "os"
14
+ "path/filepath"
15
+ "strings"
16
+ "time"
17
+
18
+ "github.com/joho/godotenv"
19
+ configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
20
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
21
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
22
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
23
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
24
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
25
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
26
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/store"
27
+ _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
28
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
29
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
30
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
31
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
32
+ log "github.com/sirupsen/logrus"
33
+ )
34
+
35
+ var (
36
+ Version = "dev"
37
+ Commit = "none"
38
+ BuildDate = "unknown"
39
+ DefaultConfigPath = ""
40
+ )
41
+
42
+ // init initializes the shared logger setup.
43
+ func init() {
44
+ logging.SetupBaseLogger()
45
+ buildinfo.Version = Version
46
+ buildinfo.Commit = Commit
47
+ buildinfo.BuildDate = BuildDate
48
+ }
49
+
50
+ // main is the entry point of the application.
51
+ // It parses command-line flags, loads configuration, and starts the appropriate
52
+ // service based on the provided flags (login, codex-login, or server mode).
53
+ func main() {
54
+ fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
55
+
56
+ // Command-line flags to control the application's behavior.
57
+ var login bool
58
+ var codexLogin bool
59
+ var claudeLogin bool
60
+ var qwenLogin bool
61
+ var iflowLogin bool
62
+ var iflowCookie bool
63
+ var noBrowser bool
64
+ var oauthCallbackPort int
65
+ var antigravityLogin bool
66
+ var projectID string
67
+ var vertexImport string
68
+ var configPath string
69
+ var password string
70
+
71
+ // Define command-line flags for different operation modes.
72
+ flag.BoolVar(&login, "login", false, "Login Google Account")
73
+ flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
74
+ flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
75
+ flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
76
+ flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
77
+ flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
78
+ flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
79
+ flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
80
+ flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
81
+ flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
82
+ flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
83
+ flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
84
+ flag.StringVar(&password, "password", "", "")
85
+
86
+ flag.CommandLine.Usage = func() {
87
+ out := flag.CommandLine.Output()
88
+ _, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
89
+ flag.CommandLine.VisitAll(func(f *flag.Flag) {
90
+ if f.Name == "password" {
91
+ return
92
+ }
93
+ s := fmt.Sprintf(" -%s", f.Name)
94
+ name, unquoteUsage := flag.UnquoteUsage(f)
95
+ if name != "" {
96
+ s += " " + name
97
+ }
98
+ if len(s) <= 4 {
99
+ s += " "
100
+ } else {
101
+ s += "\n "
102
+ }
103
+ if unquoteUsage != "" {
104
+ s += unquoteUsage
105
+ }
106
+ if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
107
+ s += fmt.Sprintf(" (default %s)", f.DefValue)
108
+ }
109
+ _, _ = fmt.Fprint(out, s+"\n")
110
+ })
111
+ }
112
+
113
+ // Parse the command-line flags.
114
+ flag.Parse()
115
+
116
+ // Core application variables.
117
+ var err error
118
+ var cfg *config.Config
119
+ var isCloudDeploy bool
120
+ var (
121
+ usePostgresStore bool
122
+ pgStoreDSN string
123
+ pgStoreSchema string
124
+ pgStoreLocalPath string
125
+ pgStoreInst *store.PostgresStore
126
+ useGitStore bool
127
+ gitStoreRemoteURL string
128
+ gitStoreUser string
129
+ gitStorePassword string
130
+ gitStoreLocalPath string
131
+ gitStoreInst *store.GitTokenStore
132
+ gitStoreRoot string
133
+ useObjectStore bool
134
+ objectStoreEndpoint string
135
+ objectStoreAccess string
136
+ objectStoreSecret string
137
+ objectStoreBucket string
138
+ objectStoreLocalPath string
139
+ objectStoreInst *store.ObjectTokenStore
140
+ )
141
+
142
+ wd, err := os.Getwd()
143
+ if err != nil {
144
+ log.Errorf("failed to get working directory: %v", err)
145
+ return
146
+ }
147
+
148
+ // Load environment variables from .env if present.
149
+ if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
150
+ if !errors.Is(errLoad, os.ErrNotExist) {
151
+ log.WithError(errLoad).Warn("failed to load .env file")
152
+ }
153
+ }
154
+
155
+ lookupEnv := func(keys ...string) (string, bool) {
156
+ for _, key := range keys {
157
+ if value, ok := os.LookupEnv(key); ok {
158
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
159
+ return trimmed, true
160
+ }
161
+ }
162
+ }
163
+ return "", false
164
+ }
165
+ writableBase := util.WritablePath()
166
+ if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
167
+ usePostgresStore = true
168
+ pgStoreDSN = value
169
+ }
170
+ if usePostgresStore {
171
+ if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
172
+ pgStoreSchema = value
173
+ }
174
+ if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
175
+ pgStoreLocalPath = value
176
+ }
177
+ if pgStoreLocalPath == "" {
178
+ if writableBase != "" {
179
+ pgStoreLocalPath = writableBase
180
+ } else {
181
+ pgStoreLocalPath = wd
182
+ }
183
+ }
184
+ useGitStore = false
185
+ }
186
+ if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
187
+ useGitStore = true
188
+ gitStoreRemoteURL = value
189
+ }
190
+ if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
191
+ gitStoreUser = value
192
+ }
193
+ if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
194
+ gitStorePassword = value
195
+ }
196
+ if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
197
+ gitStoreLocalPath = value
198
+ }
199
+ if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
200
+ useObjectStore = true
201
+ objectStoreEndpoint = value
202
+ }
203
+ if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
204
+ objectStoreAccess = value
205
+ }
206
+ if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
207
+ objectStoreSecret = value
208
+ }
209
+ if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
210
+ objectStoreBucket = value
211
+ }
212
+ if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
213
+ objectStoreLocalPath = value
214
+ }
215
+
216
+ // Check for cloud deploy mode only on first execution
217
+ // Read env var name in uppercase: DEPLOY
218
+ deployEnv := os.Getenv("DEPLOY")
219
+ if deployEnv == "cloud" {
220
+ isCloudDeploy = true
221
+ }
222
+
223
+ // Determine and load the configuration file.
224
+ // Prefer the Postgres store when configured, otherwise fallback to git or local files.
225
+ var configFilePath string
226
+ if usePostgresStore {
227
+ if pgStoreLocalPath == "" {
228
+ pgStoreLocalPath = wd
229
+ }
230
+ pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
231
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
232
+ pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
233
+ DSN: pgStoreDSN,
234
+ Schema: pgStoreSchema,
235
+ SpoolDir: pgStoreLocalPath,
236
+ })
237
+ cancel()
238
+ if err != nil {
239
+ log.Errorf("failed to initialize postgres token store: %v", err)
240
+ return
241
+ }
242
+ examplePath := filepath.Join(wd, "config.example.yaml")
243
+ ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
244
+ if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
245
+ cancel()
246
+ log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
247
+ return
248
+ }
249
+ cancel()
250
+ configFilePath = pgStoreInst.ConfigPath()
251
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
252
+ if err == nil {
253
+ cfg.AuthDir = pgStoreInst.AuthDir()
254
+ log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
255
+ }
256
+ } else if useObjectStore {
257
+ if objectStoreLocalPath == "" {
258
+ if writableBase != "" {
259
+ objectStoreLocalPath = writableBase
260
+ } else {
261
+ objectStoreLocalPath = wd
262
+ }
263
+ }
264
+ objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
265
+ resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
266
+ useSSL := true
267
+ if strings.Contains(resolvedEndpoint, "://") {
268
+ parsed, errParse := url.Parse(resolvedEndpoint)
269
+ if errParse != nil {
270
+ log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
271
+ return
272
+ }
273
+ switch strings.ToLower(parsed.Scheme) {
274
+ case "http":
275
+ useSSL = false
276
+ case "https":
277
+ useSSL = true
278
+ default:
279
+ log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
280
+ return
281
+ }
282
+ if parsed.Host == "" {
283
+ log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
284
+ return
285
+ }
286
+ resolvedEndpoint = parsed.Host
287
+ if parsed.Path != "" && parsed.Path != "/" {
288
+ resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
289
+ }
290
+ }
291
+ resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
292
+ objCfg := store.ObjectStoreConfig{
293
+ Endpoint: resolvedEndpoint,
294
+ Bucket: objectStoreBucket,
295
+ AccessKey: objectStoreAccess,
296
+ SecretKey: objectStoreSecret,
297
+ LocalRoot: objectStoreRoot,
298
+ UseSSL: useSSL,
299
+ PathStyle: true,
300
+ }
301
+ objectStoreInst, err = store.NewObjectTokenStore(objCfg)
302
+ if err != nil {
303
+ log.Errorf("failed to initialize object token store: %v", err)
304
+ return
305
+ }
306
+ examplePath := filepath.Join(wd, "config.example.yaml")
307
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
308
+ if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
309
+ cancel()
310
+ log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
311
+ return
312
+ }
313
+ cancel()
314
+ configFilePath = objectStoreInst.ConfigPath()
315
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
316
+ if err == nil {
317
+ if cfg == nil {
318
+ cfg = &config.Config{}
319
+ }
320
+ cfg.AuthDir = objectStoreInst.AuthDir()
321
+ log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
322
+ }
323
+ } else if useGitStore {
324
+ if gitStoreLocalPath == "" {
325
+ if writableBase != "" {
326
+ gitStoreLocalPath = writableBase
327
+ } else {
328
+ gitStoreLocalPath = wd
329
+ }
330
+ }
331
+ gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
332
+ authDir := filepath.Join(gitStoreRoot, "auths")
333
+ gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
334
+ gitStoreInst.SetBaseDir(authDir)
335
+ if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
336
+ log.Errorf("failed to prepare git token store: %v", errRepo)
337
+ return
338
+ }
339
+ configFilePath = gitStoreInst.ConfigPath()
340
+ if configFilePath == "" {
341
+ configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
342
+ }
343
+ if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
344
+ examplePath := filepath.Join(wd, "config.example.yaml")
345
+ if _, errExample := os.Stat(examplePath); errExample != nil {
346
+ log.Errorf("failed to find template config file: %v", errExample)
347
+ return
348
+ }
349
+ if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
350
+ log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
351
+ return
352
+ }
353
+ if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
354
+ log.Errorf("failed to commit initial git-backed config: %v", errCommit)
355
+ return
356
+ }
357
+ log.Infof("git-backed config initialized from template: %s", configFilePath)
358
+ } else if statErr != nil {
359
+ log.Errorf("failed to inspect git-backed config: %v", statErr)
360
+ return
361
+ }
362
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
363
+ if err == nil {
364
+ cfg.AuthDir = gitStoreInst.AuthDir()
365
+ log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
366
+ }
367
+ } else if configPath != "" {
368
+ configFilePath = configPath
369
+ cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
370
+ } else {
371
+ wd, err = os.Getwd()
372
+ if err != nil {
373
+ log.Errorf("failed to get working directory: %v", err)
374
+ return
375
+ }
376
+ configFilePath = filepath.Join(wd, "config.yaml")
377
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
378
+ }
379
+ if err != nil {
380
+ log.Errorf("failed to load config: %v", err)
381
+ return
382
+ }
383
+ if cfg == nil {
384
+ cfg = &config.Config{}
385
+ }
386
+
387
+ // In cloud deploy mode, check if we have a valid configuration
388
+ var configFileExists bool
389
+ if isCloudDeploy {
390
+ if info, errStat := os.Stat(configFilePath); errStat != nil {
391
+ // Don't mislead: API server will not start until configuration is provided.
392
+ log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
393
+ configFileExists = false
394
+ } else if info.IsDir() {
395
+ log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
396
+ configFileExists = false
397
+ } else if cfg.Port == 0 {
398
+ // LoadConfigOptional returns empty config when file is empty or invalid.
399
+ // Config file exists but is empty or invalid; treat as missing config
400
+ log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
401
+ configFileExists = false
402
+ } else {
403
+ log.Info("Cloud deploy mode: Configuration file detected; starting service")
404
+ configFileExists = true
405
+ }
406
+ }
407
+ usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
408
+ coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
409
+
410
+ if err = logging.ConfigureLogOutput(cfg); err != nil {
411
+ log.Errorf("failed to configure log output: %v", err)
412
+ return
413
+ }
414
+
415
+ log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
416
+
417
+ // Set the log level based on the configuration.
418
+ util.SetLogLevel(cfg)
419
+
420
+ if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
421
+ log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
422
+ return
423
+ } else {
424
+ cfg.AuthDir = resolvedAuthDir
425
+ }
426
+ managementasset.SetCurrentConfig(cfg)
427
+
428
+ // Create login options to be used in authentication flows.
429
+ options := &cmd.LoginOptions{
430
+ NoBrowser: noBrowser,
431
+ CallbackPort: oauthCallbackPort,
432
+ }
433
+
434
+ // Register the shared token store once so all components use the same persistence backend.
435
+ if usePostgresStore {
436
+ sdkAuth.RegisterTokenStore(pgStoreInst)
437
+ } else if useObjectStore {
438
+ sdkAuth.RegisterTokenStore(objectStoreInst)
439
+ } else if useGitStore {
440
+ sdkAuth.RegisterTokenStore(gitStoreInst)
441
+ } else {
442
+ sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
443
+ }
444
+
445
+ // Register built-in access providers before constructing services.
446
+ configaccess.Register()
447
+
448
+ // Handle different command modes based on the provided flags.
449
+
450
+ if vertexImport != "" {
451
+ // Handle Vertex service account import
452
+ cmd.DoVertexImport(cfg, vertexImport)
453
+ } else if login {
454
+ // Handle Google/Gemini login
455
+ cmd.DoLogin(cfg, projectID, options)
456
+ } else if antigravityLogin {
457
+ // Handle Antigravity login
458
+ cmd.DoAntigravityLogin(cfg, options)
459
+ } else if codexLogin {
460
+ // Handle Codex login
461
+ cmd.DoCodexLogin(cfg, options)
462
+ } else if claudeLogin {
463
+ // Handle Claude login
464
+ cmd.DoClaudeLogin(cfg, options)
465
+ } else if qwenLogin {
466
+ cmd.DoQwenLogin(cfg, options)
467
+ } else if iflowLogin {
468
+ cmd.DoIFlowLogin(cfg, options)
469
+ } else if iflowCookie {
470
+ cmd.DoIFlowCookieAuth(cfg, options)
471
+ } else {
472
+ // In cloud deploy mode without config file, just wait for shutdown signals
473
+ if isCloudDeploy && !configFileExists {
474
+ // No config file available, just wait for shutdown
475
+ cmd.WaitForCloudDeploy()
476
+ return
477
+ }
478
+ // Start the main proxy service
479
+ managementasset.StartAutoUpdater(context.Background(), configFilePath)
480
+ cmd.StartService(cfg, configFilePath, password)
481
+ }
482
+ }
config.example.yaml ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Server host/interface to bind to. Default is empty ("") to bind all interfaces (IPv4 + IPv6).
2
+ # Use "127.0.0.1" or "localhost" to restrict access to local machine only.
3
+ host: ""
4
+
5
+ # Server port
6
+ port: 8317
7
+
8
+ # TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key.
9
+ tls:
10
+ enable: false
11
+ cert: ""
12
+ key: ""
13
+
14
+ # Management API settings
15
+ remote-management:
16
+ # Whether to allow remote (non-localhost) management access.
17
+ # When false, only localhost can access management endpoints (a key is still required).
18
+ allow-remote: false
19
+
20
+ # Management key. If a plaintext value is provided here, it will be hashed on startup.
21
+ # All management requests (even from localhost) require this key.
22
+ # Leave empty to disable the Management API entirely (404 for all /v0/management routes).
23
+ secret-key: ""
24
+
25
+ # Disable the bundled management control panel asset download and HTTP route when true.
26
+ disable-control-panel: false
27
+
28
+ # GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
29
+ panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
30
+
31
+ # Authentication directory (supports ~ for home directory)
32
+ auth-dir: "~/.cli-proxy-api"
33
+
34
+ # API keys for authentication
35
+ api-keys:
36
+ - "your-api-key-1"
37
+ - "your-api-key-2"
38
+ - "your-api-key-3"
39
+
40
+ # Enable debug logging
41
+ debug: false
42
+
43
+ # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
44
+ commercial-mode: false
45
+
46
+ # When true, write application logs to rotating files instead of stdout
47
+ logging-to-file: false
48
+
49
+ # Maximum total size (MB) of log files under the logs directory. When exceeded, the oldest log
50
+ # files are deleted until within the limit. Set to 0 to disable.
51
+ logs-max-total-size-mb: 0
52
+
53
+ # When false, disable in-memory usage statistics aggregation
54
+ usage-statistics-enabled: false
55
+
56
+ # Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
57
+ proxy-url: ""
58
+
59
+ # When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
60
+ force-model-prefix: false
61
+
62
+ # Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
63
+ request-retry: 3
64
+
65
+ # Maximum wait time in seconds for a cooled-down credential before triggering a retry.
66
+ max-retry-interval: 30
67
+
68
+ # Quota exceeded behavior
69
+ quota-exceeded:
70
+ switch-project: true # Whether to automatically switch to another project when a quota is exceeded
71
+ switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
72
+
73
+ # Routing strategy for selecting credentials when multiple match.
74
+ routing:
75
+ strategy: "round-robin" # round-robin (default), fill-first
76
+
77
+ # When true, enable authentication for the WebSocket API (/v1/ws).
78
+ ws-auth: false
79
+
80
+ # When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.
81
+ nonstream-keepalive-interval: 0
82
+
83
+ # Streaming behavior (SSE keep-alives + safe bootstrap retries).
84
+ # streaming:
85
+ # keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
86
+ # bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
87
+
88
+ # When true, enable official Codex instructions injection for Codex API requests.
89
+ # When false (default), CodexInstructionsForModel returns immediately without modification.
90
+ codex-instructions-enabled: false
91
+
92
+ # Gemini API keys
93
+ # gemini-api-key:
94
+ # - api-key: "AIzaSy...01"
95
+ # prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
96
+ # base-url: "https://generativelanguage.googleapis.com"
97
+ # headers:
98
+ # X-Custom-Header: "custom-value"
99
+ # proxy-url: "socks5://proxy.example.com:1080"
100
+ # models:
101
+ # - name: "gemini-2.5-flash" # upstream model name
102
+ # alias: "gemini-flash" # client alias mapped to the upstream model
103
+ # excluded-models:
104
+ # - "gemini-2.5-pro" # exclude specific models from this provider (exact match)
105
+ # - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)
106
+ # - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
107
+ # - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
108
+ # - api-key: "AIzaSy...02"
109
+
110
+ # Codex API keys
111
+ # codex-api-key:
112
+ # - api-key: "sk-atSM..."
113
+ # prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
114
+ # base-url: "https://www.example.com" # use the custom codex API endpoint
115
+ # headers:
116
+ # X-Custom-Header: "custom-value"
117
+ # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
118
+ # models:
119
+ # - name: "gpt-5-codex" # upstream model name
120
+ # alias: "codex-latest" # client alias mapped to the upstream model
121
+ # excluded-models:
122
+ # - "gpt-5.1" # exclude specific models (exact match)
123
+ # - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)
124
+ # - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
125
+ # - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)
126
+
127
+ # Claude API keys
128
+ # claude-api-key:
129
+ # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
130
+ # - api-key: "sk-atSM..."
131
+ # prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
132
+ # base-url: "https://www.example.com" # use the custom claude API endpoint
133
+ # headers:
134
+ # X-Custom-Header: "custom-value"
135
+ # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
136
+ # models:
137
+ # - name: "claude-3-5-sonnet-20241022" # upstream model name
138
+ # alias: "claude-sonnet-latest" # client alias mapped to the upstream model
139
+ # excluded-models:
140
+ # - "claude-opus-4-5-20251101" # exclude specific models (exact match)
141
+ # - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)
142
+ # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
143
+ # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
144
+ # cloak: # optional: request cloaking for non-Claude-Code clients
145
+ # mode: "auto" # "auto" (default): cloak only when client is not Claude Code
146
+ # # "always": always apply cloaking
147
+ # # "never": never apply cloaking
148
+ # strict-mode: false # false (default): prepend Claude Code prompt to user system messages
149
+ # # true: strip all user system messages, keep only Claude Code prompt
150
+ # sensitive-words: # optional: words to obfuscate with zero-width characters
151
+ # - "API"
152
+ # - "proxy"
153
+
154
+ # OpenAI compatibility providers
155
+ # openai-compatibility:
156
+ # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
157
+ # prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
158
+ # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
159
+ # headers:
160
+ # X-Custom-Header: "custom-value"
161
+ # api-key-entries:
162
+ # - api-key: "sk-or-v1-...b780"
163
+ # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
164
+ # - api-key: "sk-or-v1-...b781" # without proxy-url
165
+ # models: # The models supported by the provider.
166
+ # - name: "moonshotai/kimi-k2:free" # The actual model name.
167
+ # alias: "kimi-k2" # The alias used in the API.
168
+
169
+ # Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
170
+ # vertex-api-key:
171
+ # - api-key: "vk-123..." # x-goog-api-key header
172
+ # prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
173
+ # base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
174
+ # proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
175
+ # headers:
176
+ # X-Custom-Header: "custom-value"
177
+ # models: # optional: map aliases to upstream model names
178
+ # - name: "gemini-2.5-flash" # upstream model name
179
+ # alias: "vertex-flash" # client-visible alias
180
+ # - name: "gemini-2.5-pro"
181
+ # alias: "vertex-pro"
182
+
183
+ # Amp Integration
184
+ # ampcode:
185
+ # # Configure upstream URL for Amp CLI OAuth and management features
186
+ # upstream-url: "https://ampcode.com"
187
+ # # Optional: Override API key for Amp upstream (otherwise uses env or file)
188
+ # upstream-api-key: ""
189
+ # # Per-client upstream API key mapping
190
+ # # Maps client API keys (from top-level api-keys) to different Amp upstream API keys.
191
+ # # Useful when different clients need to use different Amp accounts/quotas.
192
+ # # If a client key isn't mapped, falls back to upstream-api-key (default behavior).
193
+ # upstream-api-keys:
194
+ # - upstream-api-key: "amp_key_for_team_a" # Upstream key to use for these clients
195
+ # api-keys: # Client keys that use this upstream key
196
+ # - "your-api-key-1"
197
+ # - "your-api-key-2"
198
+ # - upstream-api-key: "amp_key_for_team_b"
199
+ # api-keys:
200
+ # - "your-api-key-3"
201
+ # # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false)
202
+ # restrict-management-to-localhost: false
203
+ # # Force model mappings to run before checking local API keys (default: false)
204
+ # force-model-mappings: false
205
+ # # Amp Model Mappings
206
+ # # Route unavailable Amp models to alternative models available in your local proxy.
207
+ # # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
208
+ # # but you have a similar model available (e.g., Claude Sonnet 4).
209
+ # model-mappings:
210
+ # - from: "claude-opus-4-5-20251101" # Model requested by Amp CLI
211
+ # to: "gemini-claude-opus-4-5-thinking" # Route to this available model instead
212
+ # - from: "claude-sonnet-4-5-20250929"
213
+ # to: "gemini-claude-sonnet-4-5-thinking"
214
+ # - from: "claude-haiku-4-5-20251001"
215
+ # to: "gemini-2.5-flash"
216
+
217
+ # Global OAuth model name aliases (per channel)
218
+ # These aliases rename model IDs for both model listing and request routing.
219
+ # Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
220
+ # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
221
+ # You can repeat the same name with different aliases to expose multiple client model names.
222
+ oauth-model-alias:
223
+ antigravity:
224
+ - name: "rev19-uic3-1p"
225
+ alias: "gemini-2.5-computer-use-preview-10-2025"
226
+ - name: "gemini-3-pro-image"
227
+ alias: "gemini-3-pro-image-preview"
228
+ - name: "gemini-3-pro-high"
229
+ alias: "gemini-3-pro-preview"
230
+ - name: "gemini-3-flash"
231
+ alias: "gemini-3-flash-preview"
232
+ - name: "claude-sonnet-4-5"
233
+ alias: "gemini-claude-sonnet-4-5"
234
+ - name: "claude-sonnet-4-5-thinking"
235
+ alias: "gemini-claude-sonnet-4-5-thinking"
236
+ - name: "claude-opus-4-5-thinking"
237
+ alias: "gemini-claude-opus-4-5-thinking"
238
+ # gemini-cli:
239
+ # - name: "gemini-2.5-pro" # original model name under this channel
240
+ # alias: "g2.5p" # client-visible alias
241
+ # fork: true # when true, keep original and also add the alias as an extra model (default: false)
242
+ # vertex:
243
+ # - name: "gemini-2.5-pro"
244
+ # alias: "g2.5p"
245
+ # aistudio:
246
+ # - name: "gemini-2.5-pro"
247
+ # alias: "g2.5p"
248
+ # claude:
249
+ # - name: "claude-sonnet-4-5-20250929"
250
+ # alias: "cs4.5"
251
+ # codex:
252
+ # - name: "gpt-5"
253
+ # alias: "g5"
254
+ # qwen:
255
+ # - name: "qwen3-coder-plus"
256
+ # alias: "qwen-plus"
257
+ # iflow:
258
+ # - name: "glm-4.7"
259
+ # alias: "glm-god"
260
+
261
+ # OAuth provider excluded models
262
+ # oauth-excluded-models:
263
+ # gemini-cli:
264
+ # - "gemini-2.5-pro" # exclude specific models (exact match)
265
+ # - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)
266
+ # - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
267
+ # - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
268
+ # vertex:
269
+ # - "gemini-3-pro-preview"
270
+ # aistudio:
271
+ # - "gemini-3-pro-preview"
272
+ # antigravity:
273
+ # - "gemini-3-pro-preview"
274
+ # claude:
275
+ # - "claude-3-5-haiku-20241022"
276
+ # codex:
277
+ # - "gpt-5-codex-mini"
278
+ # qwen:
279
+ # - "vision-model"
280
+ # iflow:
281
+ # - "tstars2.0"
282
+
283
+ # Optional payload configuration
284
+ # payload:
285
+ # default: # Default rules only set parameters when they are missing in the payload.
286
+ # - models:
287
+ # - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
288
+ # protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
289
+ # params: # JSON path (gjson/sjson syntax) -> value
290
+ # "generationConfig.thinkingConfig.thinkingBudget": 32768
291
+ # default-raw: # Default raw rules set parameters using raw JSON when missing (must be valid JSON).
292
+ # - models:
293
+ # - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
294
+ # protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
295
+ # params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
296
+ # "generationConfig.responseJsonSchema": "{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}"
297
+ # override: # Override rules always set parameters, overwriting any existing values.
298
+ # - models:
299
+ # - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
300
+ # protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
301
+ # params: # JSON path (gjson/sjson syntax) -> value
302
+ # "reasoning.effort": "high"
303
+ # override-raw: # Override raw rules always set parameters using raw JSON (must be valid JSON).
304
+ # - models:
305
+ # - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
306
+ # protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
307
+ # params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
308
+ # "response_format": "{\"type\":\"json_schema\",\"json_schema\":{\"name\":\"answer\",\"schema\":{\"type\":\"object\"}}}"
docker-build.ps1 ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build.ps1 - Windows PowerShell Build Script
2
+ #
3
+ # This script automates the process of building and running the Docker container
4
+ # with version information dynamically injected at build time.
5
+
6
+ # Stop script execution on any error
7
+ $ErrorActionPreference = "Stop"
8
+
9
+ # --- Step 1: Choose Environment ---
10
+ Write-Host "Please select an option:"
11
+ Write-Host "1) Run using Pre-built Image (Recommended)"
12
+ Write-Host "2) Build from Source and Run (For Developers)"
13
+ $choice = Read-Host -Prompt "Enter choice [1-2]"
14
+
15
+ # --- Step 2: Execute based on choice ---
16
+ switch ($choice) {
17
+ "1" {
18
+ Write-Host "--- Running with Pre-built Image ---"
19
+ docker compose up -d --remove-orphans --no-build
20
+ Write-Host "Services are starting from remote image."
21
+ Write-Host "Run 'docker compose logs -f' to see the logs."
22
+ }
23
+ "2" {
24
+ Write-Host "--- Building from Source and Running ---"
25
+
26
+ # Get Version Information
27
+ $VERSION = (git describe --tags --always --dirty)
28
+ $COMMIT = (git rev-parse --short HEAD)
29
+ $BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
30
+
31
+ Write-Host "Building with the following info:"
32
+ Write-Host " Version: $VERSION"
33
+ Write-Host " Commit: $COMMIT"
34
+ Write-Host " Build Date: $BUILD_DATE"
35
+ Write-Host "----------------------------------------"
36
+
37
+ # Build and start the services with a local-only image tag
38
+ $env:CLI_PROXY_IMAGE = "cli-proxy-api:local"
39
+
40
+ Write-Host "Building the Docker image..."
41
+ docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
42
+
43
+ Write-Host "Starting the services..."
44
+ docker compose up -d --remove-orphans --pull never
45
+
46
+ Write-Host "Build complete. Services are starting."
47
+ Write-Host "Run 'docker compose logs -f' to see the logs."
48
+ }
49
+ default {
50
+ Write-Host "Invalid choice. Please enter 1 or 2."
51
+ exit 1
52
+ }
53
+ }
docker-build.sh ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # build.sh - Linux/macOS Build Script
4
+ #
5
+ # This script automates the process of building and running the Docker container
6
+ # with version information dynamically injected at build time.
7
+
8
+ # Hidden feature: Preserve usage statistics across rebuilds
9
+ # Usage: ./docker-build.sh --with-usage
10
+ # First run prompts for management API key, saved to temp/stats/.api_secret
11
+
12
+ set -euo pipefail
13
+
14
+ STATS_DIR="temp/stats"
15
+ STATS_FILE="${STATS_DIR}/.usage_backup.json"
16
+ SECRET_FILE="${STATS_DIR}/.api_secret"
17
+ WITH_USAGE=false
18
+
19
+ get_port() {
20
+ if [[ -f "config.yaml" ]]; then
21
+ grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/'
22
+ else
23
+ echo "8317"
24
+ fi
25
+ }
26
+
27
+ export_stats_api_secret() {
28
+ if [[ -f "${SECRET_FILE}" ]]; then
29
+ API_SECRET=$(cat "${SECRET_FILE}")
30
+ else
31
+ if [[ ! -d "${STATS_DIR}" ]]; then
32
+ mkdir -p "${STATS_DIR}"
33
+ fi
34
+ echo "First time using --with-usage. Management API key required."
35
+ read -r -p "Enter management key: " -s API_SECRET
36
+ echo
37
+ echo "${API_SECRET}" > "${SECRET_FILE}"
38
+ chmod 600 "${SECRET_FILE}"
39
+ fi
40
+ }
41
+
42
+ check_container_running() {
43
+ local port
44
+ port=$(get_port)
45
+
46
+ if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
47
+ echo "Error: cli-proxy-api service is not responding at localhost:${port}"
48
+ echo "Please start the container first or use without --with-usage flag."
49
+ exit 1
50
+ fi
51
+ }
52
+
53
+ export_stats() {
54
+ local port
55
+ port=$(get_port)
56
+
57
+ if [[ ! -d "${STATS_DIR}" ]]; then
58
+ mkdir -p "${STATS_DIR}"
59
+ fi
60
+ check_container_running
61
+ echo "Exporting usage statistics..."
62
+ EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \
63
+ "http://localhost:${port}/v0/management/usage/export")
64
+ HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1)
65
+ RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d')
66
+
67
+ if [[ "${HTTP_CODE}" != "200" ]]; then
68
+ echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}"
69
+ exit 1
70
+ fi
71
+
72
+ echo "${RESPONSE_BODY}" > "${STATS_FILE}"
73
+ echo "Statistics exported to ${STATS_FILE}"
74
+ }
75
+
76
+ import_stats() {
77
+ local port
78
+ port=$(get_port)
79
+
80
+ echo "Importing usage statistics..."
81
+ IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
82
+ -H "X-Management-Key: ${API_SECRET}" \
83
+ -H "Content-Type: application/json" \
84
+ -d @"${STATS_FILE}" \
85
+ "http://localhost:${port}/v0/management/usage/import")
86
+ IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1)
87
+ IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d')
88
+
89
+ if [[ "${IMPORT_CODE}" == "200" ]]; then
90
+ echo "Statistics imported successfully"
91
+ else
92
+ echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}"
93
+ fi
94
+
95
+ rm -f "${STATS_FILE}"
96
+ }
97
+
98
+ wait_for_service() {
99
+ local port
100
+ port=$(get_port)
101
+
102
+ echo "Waiting for service to be ready..."
103
+ for i in {1..30}; do
104
+ if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
105
+ break
106
+ fi
107
+ sleep 1
108
+ done
109
+ sleep 2
110
+ }
111
+
112
+ if [[ "${1:-}" == "--with-usage" ]]; then
113
+ WITH_USAGE=true
114
+ export_stats_api_secret
115
+ fi
116
+
117
+ # --- Step 1: Choose Environment ---
118
+ echo "Please select an option:"
119
+ echo "1) Run using Pre-built Image (Recommended)"
120
+ echo "2) Build from Source and Run (For Developers)"
121
+ read -r -p "Enter choice [1-2]: " choice
122
+
123
+ # --- Step 2: Execute based on choice ---
124
+ case "$choice" in
125
+ 1)
126
+ echo "--- Running with Pre-built Image ---"
127
+ if [[ "${WITH_USAGE}" == "true" ]]; then
128
+ export_stats
129
+ fi
130
+ docker compose up -d --remove-orphans --no-build
131
+ if [[ "${WITH_USAGE}" == "true" ]]; then
132
+ wait_for_service
133
+ import_stats
134
+ fi
135
+ echo "Services are starting from remote image."
136
+ echo "Run 'docker compose logs -f' to see the logs."
137
+ ;;
138
+ 2)
139
+ echo "--- Building from Source and Running ---"
140
+
141
+ # Get Version Information
142
+ VERSION="$(git describe --tags --always --dirty)"
143
+ COMMIT="$(git rev-parse --short HEAD)"
144
+ BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
145
+
146
+ echo "Building with the following info:"
147
+ echo " Version: ${VERSION}"
148
+ echo " Commit: ${COMMIT}"
149
+ echo " Build Date: ${BUILD_DATE}"
150
+ echo "----------------------------------------"
151
+
152
+ # Build and start the services with a local-only image tag
153
+ export CLI_PROXY_IMAGE="cli-proxy-api:local"
154
+
155
+ echo "Building the Docker image..."
156
+ docker compose build \
157
+ --build-arg VERSION="${VERSION}" \
158
+ --build-arg COMMIT="${COMMIT}" \
159
+ --build-arg BUILD_DATE="${BUILD_DATE}"
160
+
161
+ if [[ "${WITH_USAGE}" == "true" ]]; then
162
+ export_stats
163
+ fi
164
+
165
+ echo "Starting the services..."
166
+ docker compose up -d --remove-orphans --pull never
167
+
168
+ if [[ "${WITH_USAGE}" == "true" ]]; then
169
+ wait_for_service
170
+ import_stats
171
+ fi
172
+
173
+ echo "Build complete. Services are starting."
174
+ echo "Run 'docker compose logs -f' to see the logs."
175
+ ;;
176
+ *)
177
+ echo "Invalid choice. Please enter 1 or 2."
178
+ exit 1
179
+ ;;
180
+ esac
docker-compose.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ cli-proxy-api:
3
+ image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
4
+ pull_policy: always
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ args:
9
+ VERSION: ${VERSION:-dev}
10
+ COMMIT: ${COMMIT:-none}
11
+ BUILD_DATE: ${BUILD_DATE:-unknown}
12
+ container_name: cli-proxy-api
13
+ # env_file:
14
+ # - .env
15
+ environment:
16
+ DEPLOY: ${DEPLOY:-}
17
+ ports:
18
+ - "8317:8317"
19
+ - "8085:8085"
20
+ - "1455:1455"
21
+ - "54545:54545"
22
+ - "51121:51121"
23
+ - "11451:11451"
24
+ volumes:
25
+ - ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml
26
+ - ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
27
+ - ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
28
+ restart: unless-stopped
examples/custom-provider/main.go ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package main demonstrates how to create a custom AI provider executor
2
+ // and integrate it with the CLI Proxy API server. This example shows how to:
3
+ // - Create a custom executor that implements the Executor interface
4
+ // - Register custom translators for request/response transformation
5
+ // - Integrate the custom provider with the SDK server
6
+ // - Register custom models in the model registry
7
+ //
8
+ // This example uses a simple echo service (httpbin.org) as the upstream API
9
+ // for demonstration purposes. In a real implementation, you would replace
10
+ // this with your actual AI service provider.
11
+ package main
12
+
13
+ import (
14
+ "bytes"
15
+ "context"
16
+ "errors"
17
+ "fmt"
18
+ "io"
19
+ "net/http"
20
+ "net/url"
21
+ "os"
22
+ "path/filepath"
23
+ "strings"
24
+ "time"
25
+
26
+ "github.com/gin-gonic/gin"
27
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
28
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
29
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
30
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
31
+ clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
32
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
33
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
34
+ sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
35
+ )
36
+
37
+ const (
38
+ // providerKey is the identifier for our custom provider.
39
+ providerKey = "myprov"
40
+
41
+ // fOpenAI represents the OpenAI chat format.
42
+ fOpenAI = sdktr.Format("openai.chat")
43
+
44
+ // fMyProv represents our custom provider's chat format.
45
+ fMyProv = sdktr.Format("myprov.chat")
46
+ )
47
+
48
+ // init registers trivial translators for demonstration purposes.
49
+ // In a real implementation, you would implement proper request/response
50
+ // transformation logic between OpenAI format and your provider's format.
51
+ func init() {
52
+ sdktr.Register(fOpenAI, fMyProv,
53
+ func(model string, raw []byte, stream bool) []byte { return raw },
54
+ sdktr.ResponseTransform{
55
+ Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
56
+ return []string{string(raw)}
57
+ },
58
+ NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
59
+ return string(raw)
60
+ },
61
+ },
62
+ )
63
+ }
64
+
65
+ // MyExecutor is a minimal provider implementation for demonstration purposes.
66
+ // It implements the Executor interface to handle requests to a custom AI provider.
67
+ type MyExecutor struct{}
68
+
69
+ // Identifier returns the unique identifier for this executor.
70
+ func (MyExecutor) Identifier() string { return providerKey }
71
+
72
+ // PrepareRequest optionally injects credentials to raw HTTP requests.
73
+ // This method is called before each request to allow the executor to modify
74
+ // the HTTP request with authentication headers or other necessary modifications.
75
+ //
76
+ // Parameters:
77
+ // - req: The HTTP request to prepare
78
+ // - a: The authentication information
79
+ //
80
+ // Returns:
81
+ // - error: An error if request preparation fails
82
+ func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
83
+ if req == nil || a == nil {
84
+ return nil
85
+ }
86
+ if a.Attributes != nil {
87
+ if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
88
+ req.Header.Set("Authorization", "Bearer "+ak)
89
+ }
90
+ }
91
+ return nil
92
+ }
93
+
94
+ func buildHTTPClient(a *coreauth.Auth) *http.Client {
95
+ if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
96
+ return http.DefaultClient
97
+ }
98
+ u, err := url.Parse(a.ProxyURL)
99
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
100
+ return http.DefaultClient
101
+ }
102
+ return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
103
+ }
104
+
105
+ func upstreamEndpoint(a *coreauth.Auth) string {
106
+ if a != nil && a.Attributes != nil {
107
+ if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
108
+ return ep
109
+ }
110
+ }
111
+ // Demo echo endpoint; replace with your upstream.
112
+ return "https://httpbin.org/post"
113
+ }
114
+
115
+ func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
116
+ client := buildHTTPClient(a)
117
+ endpoint := upstreamEndpoint(a)
118
+
119
+ httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
120
+ if errNew != nil {
121
+ return clipexec.Response{}, errNew
122
+ }
123
+ httpReq.Header.Set("Content-Type", "application/json")
124
+
125
+ // Inject credentials via PrepareRequest hook.
126
+ if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
127
+ return clipexec.Response{}, errPrep
128
+ }
129
+
130
+ resp, errDo := client.Do(httpReq)
131
+ if errDo != nil {
132
+ return clipexec.Response{}, errDo
133
+ }
134
+ defer func() {
135
+ if errClose := resp.Body.Close(); errClose != nil {
136
+ fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose)
137
+ }
138
+ }()
139
+ body, _ := io.ReadAll(resp.Body)
140
+ return clipexec.Response{Payload: body}, nil
141
+ }
142
+
143
+ func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {
144
+ if req == nil {
145
+ return nil, fmt.Errorf("myprov executor: request is nil")
146
+ }
147
+ if ctx == nil {
148
+ ctx = req.Context()
149
+ }
150
+ httpReq := req.WithContext(ctx)
151
+ if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
152
+ return nil, errPrep
153
+ }
154
+ client := buildHTTPClient(a)
155
+ return client.Do(httpReq)
156
+ }
157
+
158
+ func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
159
+ return clipexec.Response{}, errors.New("count tokens not implemented")
160
+ }
161
+
162
+ func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
163
+ ch := make(chan clipexec.StreamChunk, 1)
164
+ go func() {
165
+ defer close(ch)
166
+ ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
167
+ }()
168
+ return ch, nil
169
+ }
170
+
171
+ func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
172
+ return a, nil
173
+ }
174
+
175
+ func main() {
176
+ cfg, err := config.LoadConfig("config.yaml")
177
+ if err != nil {
178
+ panic(err)
179
+ }
180
+
181
+ tokenStore := sdkAuth.GetTokenStore()
182
+ if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
183
+ dirSetter.SetBaseDir(cfg.AuthDir)
184
+ }
185
+ core := coreauth.NewManager(tokenStore, nil, nil)
186
+ core.RegisterExecutor(MyExecutor{})
187
+
188
+ hooks := cliproxy.Hooks{
189
+ OnAfterStart: func(s *cliproxy.Service) {
190
+ // Register demo models for the custom provider so they appear in /v1/models.
191
+ models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
192
+ for _, a := range core.List() {
193
+ if strings.EqualFold(a.Provider, providerKey) {
194
+ cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
195
+ }
196
+ }
197
+ },
198
+ }
199
+
200
+ svc, err := cliproxy.NewBuilder().
201
+ WithConfig(cfg).
202
+ WithConfigPath("config.yaml").
203
+ WithCoreAuthManager(core).
204
+ WithServerOptions(
205
+ // Optional: add a simple middleware + custom request logger
206
+ api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
207
+ api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
208
+ return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
209
+ }),
210
+ ).
211
+ WithHooks(hooks).
212
+ Build()
213
+ if err != nil {
214
+ panic(err)
215
+ }
216
+
217
+ ctx, cancel := context.WithCancel(context.Background())
218
+ defer cancel()
219
+
220
+ if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {
221
+ panic(errRun)
222
+ }
223
+ _ = os.Stderr // keep os import used (demo only)
224
+ _ = time.Second
225
+ }
examples/http-request/main.go ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest
2
+ // to execute arbitrary HTTP requests with provider credentials injected.
3
+ //
4
+ // This example registers a minimal custom executor that injects an Authorization
5
+ // header from auth.Attributes["api_key"], then performs two requests against
6
+ // httpbin.org to show the injected headers.
7
+ package main
8
+
9
+ import (
10
+ "bytes"
11
+ "context"
12
+ "errors"
13
+ "fmt"
14
+ "io"
15
+ "net/http"
16
+ "strings"
17
+ "time"
18
+
19
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
20
+ clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
21
+ log "github.com/sirupsen/logrus"
22
+ )
23
+
24
+ const providerKey = "echo"
25
+
26
+ // EchoExecutor is a minimal provider implementation for demonstration purposes.
27
+ type EchoExecutor struct{}
28
+
29
+ func (EchoExecutor) Identifier() string { return providerKey }
30
+
31
+ func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
32
+ if req == nil || auth == nil {
33
+ return nil
34
+ }
35
+ if auth.Attributes != nil {
36
+ if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" {
37
+ req.Header.Set("Authorization", "Bearer "+apiKey)
38
+ }
39
+ }
40
+ return nil
41
+ }
42
+
43
+ func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
44
+ if req == nil {
45
+ return nil, fmt.Errorf("echo executor: request is nil")
46
+ }
47
+ if ctx == nil {
48
+ ctx = req.Context()
49
+ }
50
+ httpReq := req.WithContext(ctx)
51
+ if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {
52
+ return nil, errPrep
53
+ }
54
+ return http.DefaultClient.Do(httpReq)
55
+ }
56
+
57
+ func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
58
+ return clipexec.Response{}, errors.New("echo executor: Execute not implemented")
59
+ }
60
+
61
+ func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (<-chan clipexec.StreamChunk, error) {
62
+ return nil, errors.New("echo executor: ExecuteStream not implemented")
63
+ }
64
+
65
+ func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
66
+ return nil, errors.New("echo executor: Refresh not implemented")
67
+ }
68
+
69
+ func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
70
+ return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented")
71
+ }
72
+
73
+ func main() {
74
+ log.SetLevel(log.InfoLevel)
75
+
76
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
77
+ defer cancel()
78
+
79
+ core := coreauth.NewManager(nil, nil, nil)
80
+ core.RegisterExecutor(EchoExecutor{})
81
+
82
+ auth := &coreauth.Auth{
83
+ ID: "demo-echo",
84
+ Provider: providerKey,
85
+ Attributes: map[string]string{
86
+ "api_key": "demo-api-key",
87
+ },
88
+ }
89
+
90
+ // Example 1: Build a prepared request and execute it using your own http.Client.
91
+ reqPrepared, errReqPrepared := core.NewHttpRequest(
92
+ ctx,
93
+ auth,
94
+ http.MethodGet,
95
+ "https://httpbin.org/anything",
96
+ nil,
97
+ http.Header{"X-Example": []string{"prepared"}},
98
+ )
99
+ if errReqPrepared != nil {
100
+ panic(errReqPrepared)
101
+ }
102
+ respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)
103
+ if errDoPrepared != nil {
104
+ panic(errDoPrepared)
105
+ }
106
+ defer func() {
107
+ if errClose := respPrepared.Body.Close(); errClose != nil {
108
+ log.Errorf("close response body error: %v", errClose)
109
+ }
110
+ }()
111
+ bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)
112
+ if errReadPrepared != nil {
113
+ panic(errReadPrepared)
114
+ }
115
+ fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared)
116
+
117
+ // Example 2: Execute a raw request via core.HttpRequest (auto inject + do).
118
+ rawBody := []byte(`{"hello":"world"}`)
119
+ rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody))
120
+ if errRawReq != nil {
121
+ panic(errRawReq)
122
+ }
123
+ rawReq.Header.Set("Content-Type", "application/json")
124
+ rawReq.Header.Set("X-Example", "executed")
125
+
126
+ respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)
127
+ if errDoExec != nil {
128
+ panic(errDoExec)
129
+ }
130
+ defer func() {
131
+ if errClose := respExec.Body.Close(); errClose != nil {
132
+ log.Errorf("close response body error: %v", errClose)
133
+ }
134
+ }()
135
+ bodyExec, errReadExec := io.ReadAll(respExec.Body)
136
+ if errReadExec != nil {
137
+ panic(errReadExec)
138
+ }
139
+ fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec)
140
+ }
examples/translator/main.go ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
8
+ _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin"
9
+ )
10
+
11
+ func main() {
12
+ rawRequest := []byte(`{"messages":[{"content":[{"text":"Hello! Gemini","type":"text"}],"role":"user"}],"model":"gemini-2.5-pro","stream":false}`)
13
+ fmt.Println("Has gemini->openai response translator:", translator.HasResponseTransformerByFormatName(
14
+ translator.FormatGemini,
15
+ translator.FormatOpenAI,
16
+ ))
17
+
18
+ translatedRequest := translator.TranslateRequestByFormatName(
19
+ translator.FormatOpenAI,
20
+ translator.FormatGemini,
21
+ "gemini-2.5-pro",
22
+ rawRequest,
23
+ false,
24
+ )
25
+
26
+ fmt.Printf("Translated request to Gemini format:\n%s\n\n", translatedRequest)
27
+
28
+ claudeResponse := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"Okay, here's what's going through my mind. I need to schedule a meeting"},{"thoughtSignature":"","functionCall":{"name":"schedule_meeting","args":{"topic":"Q3 planning","attendees":["Bob","Alice"],"time":"10:00","date":"2025-03-27"}}}]},"finishReason":"STOP","avgLogprobs":-0.50018133435930523}],"usageMetadata":{"promptTokenCount":117,"candidatesTokenCount":28,"totalTokenCount":474,"trafficType":"PROVISIONED_THROUGHPUT","promptTokensDetails":[{"modality":"TEXT","tokenCount":117}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":28}],"thoughtsTokenCount":329},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T04:12:55.249090Z","responseId":"x7OeaIKaD6CU48APvNXDyA4"}`)
29
+
30
+ convertedResponse := translator.TranslateNonStreamByFormatName(
31
+ context.Background(),
32
+ translator.FormatGemini,
33
+ translator.FormatOpenAI,
34
+ "gemini-2.5-pro",
35
+ rawRequest,
36
+ translatedRequest,
37
+ claudeResponse,
38
+ nil,
39
+ )
40
+
41
+ fmt.Printf("Converted response for OpenAI clients:\n%s\n", convertedResponse)
42
+ }
go.mod ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module github.com/router-for-me/CLIProxyAPI/v6
2
+
3
+ go 1.24.0
4
+
5
+ require (
6
+ github.com/andybalholm/brotli v1.0.6
7
+ github.com/fsnotify/fsnotify v1.9.0
8
+ github.com/gin-gonic/gin v1.10.1
9
+ github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
10
+ github.com/google/uuid v1.6.0
11
+ github.com/gorilla/websocket v1.5.3
12
+ github.com/jackc/pgx/v5 v5.7.6
13
+ github.com/joho/godotenv v1.5.1
14
+ github.com/klauspost/compress v1.17.4
15
+ github.com/minio/minio-go/v7 v7.0.66
16
+ github.com/sirupsen/logrus v1.9.3
17
+ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
18
+ github.com/tidwall/gjson v1.18.0
19
+ github.com/tidwall/sjson v1.2.5
20
+ github.com/tiktoken-go/tokenizer v0.7.0
21
+ golang.org/x/crypto v0.45.0
22
+ golang.org/x/net v0.47.0
23
+ golang.org/x/oauth2 v0.30.0
24
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
25
+ gopkg.in/yaml.v3 v3.0.1
26
+ )
27
+
28
+ require (
29
+ cloud.google.com/go/compute/metadata v0.3.0 // indirect
30
+ github.com/Microsoft/go-winio v0.6.2 // indirect
31
+ github.com/ProtonMail/go-crypto v1.3.0 // indirect
32
+ github.com/bytedance/sonic v1.11.6 // indirect
33
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
34
+ github.com/cloudflare/circl v1.6.1 // indirect
35
+ github.com/cloudwego/base64x v0.1.4 // indirect
36
+ github.com/cloudwego/iasm v0.2.0 // indirect
37
+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
38
+ github.com/dlclark/regexp2 v1.11.5 // indirect
39
+ github.com/dustin/go-humanize v1.0.1 // indirect
40
+ github.com/emirpasic/gods v1.18.1 // indirect
41
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
42
+ github.com/gin-contrib/sse v0.1.0 // indirect
43
+ github.com/go-git/gcfg/v2 v2.0.2 // indirect
44
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
45
+ github.com/go-playground/locales v0.14.1 // indirect
46
+ github.com/go-playground/universal-translator v0.18.1 // indirect
47
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
48
+ github.com/goccy/go-json v0.10.2 // indirect
49
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
50
+ github.com/jackc/pgpassfile v1.0.0 // indirect
51
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
52
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
53
+ github.com/json-iterator/go v1.1.12 // indirect
54
+ github.com/kevinburke/ssh_config v1.4.0 // indirect
55
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
56
+ github.com/leodido/go-urn v1.4.0 // indirect
57
+ github.com/mattn/go-isatty v0.0.20 // indirect
58
+ github.com/minio/md5-simd v1.1.2 // indirect
59
+ github.com/minio/sha256-simd v1.0.1 // indirect
60
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
61
+ github.com/modern-go/reflect2 v1.0.2 // indirect
62
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
63
+ github.com/pjbgf/sha1cd v0.5.0 // indirect
64
+ github.com/rs/xid v1.5.0 // indirect
65
+ github.com/sergi/go-diff v1.4.0 // indirect
66
+ github.com/tidwall/match v1.1.1 // indirect
67
+ github.com/tidwall/pretty v1.2.0 // indirect
68
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
69
+ github.com/ugorji/go/codec v1.2.12 // indirect
70
+ golang.org/x/arch v0.8.0 // indirect
71
+ golang.org/x/sync v0.18.0 // indirect
72
+ golang.org/x/sys v0.38.0 // indirect
73
+ golang.org/x/text v0.31.0 // indirect
74
+ google.golang.org/protobuf v1.34.1 // indirect
75
+ gopkg.in/ini.v1 v1.67.0 // indirect
76
+ )
go.sum ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
2
+ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
3
+ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
4
+ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
5
+ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
6
+ github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
7
+ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
8
+ github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
9
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
10
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
11
+ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
12
+ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
13
+ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
14
+ github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
15
+ github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
16
+ github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
17
+ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
18
+ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
19
+ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
20
+ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
21
+ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
22
+ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
23
+ github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
24
+ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
25
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
26
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
27
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
28
+ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
29
+ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
30
+ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
31
+ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
32
+ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
33
+ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
34
+ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
35
+ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
36
+ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
37
+ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
38
+ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
39
+ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
40
+ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
41
+ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
42
+ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
43
+ github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
44
+ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
45
+ github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
46
+ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
47
+ github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
48
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
49
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
50
+ github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
51
+ github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
52
+ github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=
53
+ github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=
54
+ github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
55
+ github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
56
+ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
57
+ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
58
+ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
59
+ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
60
+ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
61
+ github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
62
+ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
63
+ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
64
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
65
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
66
+ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
67
+ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
68
+ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
69
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
70
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
71
+ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
72
+ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
73
+ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
74
+ github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
75
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
76
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
77
+ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
78
+ github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
79
+ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
80
+ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
81
+ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
82
+ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
83
+ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
84
+ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
85
+ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
86
+ github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
87
+ github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
88
+ github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
89
+ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
90
+ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
91
+ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
92
+ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
93
+ github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
94
+ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
95
+ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
96
+ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
97
+ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
98
+ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
99
+ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
100
+ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
101
+ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
102
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
103
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
104
+ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
105
+ github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
106
+ github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
107
+ github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
108
+ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
109
+ github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
110
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
111
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
112
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
113
+ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
114
+ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
115
+ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
116
+ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
117
+ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
118
+ github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
119
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
120
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
121
+ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
122
+ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
123
+ github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
124
+ github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
125
+ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
126
+ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
127
+ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
128
+ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
129
+ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
130
+ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
131
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
132
+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
133
+ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
134
+ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
135
+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
136
+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
137
+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
138
+ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
139
+ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
140
+ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
141
+ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
142
+ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
143
+ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
144
+ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
145
+ github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
146
+ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
147
+ github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
148
+ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
149
+ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
150
+ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
151
+ github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
152
+ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
153
+ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
154
+ github.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw=
155
+ github.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
156
+ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
157
+ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
158
+ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
159
+ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
160
+ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
161
+ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
162
+ golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
163
+ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
164
+ golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
165
+ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
166
+ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
167
+ golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
168
+ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
169
+ golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
170
+ golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
171
+ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
172
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
173
+ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
174
+ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
175
+ golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
176
+ golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
177
+ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
178
+ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
179
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
180
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
181
+ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
182
+ google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
183
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
184
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
185
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
186
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
187
+ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
188
+ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
189
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
190
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
191
+ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
192
+ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
193
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
194
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
195
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
196
+ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
197
+ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
internal/access/config_access/provider.go ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package configaccess
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "strings"
7
+ "sync"
8
+
9
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
10
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
11
+ )
12
+
13
+ var registerOnce sync.Once
14
+
15
+ // Register ensures the config-access provider is available to the access manager.
16
+ func Register() {
17
+ registerOnce.Do(func() {
18
+ sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
19
+ })
20
+ }
21
+
22
+ type provider struct {
23
+ name string
24
+ keys map[string]struct{}
25
+ }
26
+
27
+ func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
28
+ name := cfg.Name
29
+ if name == "" {
30
+ name = sdkconfig.DefaultAccessProviderName
31
+ }
32
+ keys := make(map[string]struct{}, len(cfg.APIKeys))
33
+ for _, key := range cfg.APIKeys {
34
+ if key == "" {
35
+ continue
36
+ }
37
+ keys[key] = struct{}{}
38
+ }
39
+ return &provider{name: name, keys: keys}, nil
40
+ }
41
+
42
+ func (p *provider) Identifier() string {
43
+ if p == nil || p.name == "" {
44
+ return sdkconfig.DefaultAccessProviderName
45
+ }
46
+ return p.name
47
+ }
48
+
49
+ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) {
50
+ if p == nil {
51
+ return nil, sdkaccess.ErrNotHandled
52
+ }
53
+ if len(p.keys) == 0 {
54
+ return nil, sdkaccess.ErrNotHandled
55
+ }
56
+ authHeader := r.Header.Get("Authorization")
57
+ authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
58
+ authHeaderAnthropic := r.Header.Get("X-Api-Key")
59
+ queryKey := ""
60
+ queryAuthToken := ""
61
+ if r.URL != nil {
62
+ queryKey = r.URL.Query().Get("key")
63
+ queryAuthToken = r.URL.Query().Get("auth_token")
64
+ }
65
+ if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
66
+ return nil, sdkaccess.ErrNoCredentials
67
+ }
68
+
69
+ apiKey := extractBearerToken(authHeader)
70
+
71
+ candidates := []struct {
72
+ value string
73
+ source string
74
+ }{
75
+ {apiKey, "authorization"},
76
+ {authHeaderGoogle, "x-goog-api-key"},
77
+ {authHeaderAnthropic, "x-api-key"},
78
+ {queryKey, "query-key"},
79
+ {queryAuthToken, "query-auth-token"},
80
+ }
81
+
82
+ for _, candidate := range candidates {
83
+ if candidate.value == "" {
84
+ continue
85
+ }
86
+ if _, ok := p.keys[candidate.value]; ok {
87
+ return &sdkaccess.Result{
88
+ Provider: p.Identifier(),
89
+ Principal: candidate.value,
90
+ Metadata: map[string]string{
91
+ "source": candidate.source,
92
+ },
93
+ }, nil
94
+ }
95
+ }
96
+
97
+ return nil, sdkaccess.ErrInvalidCredential
98
+ }
99
+
100
+ func extractBearerToken(header string) string {
101
+ if header == "" {
102
+ return ""
103
+ }
104
+ parts := strings.SplitN(header, " ", 2)
105
+ if len(parts) != 2 {
106
+ return header
107
+ }
108
+ if strings.ToLower(parts[0]) != "bearer" {
109
+ return header
110
+ }
111
+ return strings.TrimSpace(parts[1])
112
+ }
internal/access/reconcile.go ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package access
2
+
3
+ import (
4
+ "fmt"
5
+ "reflect"
6
+ "sort"
7
+ "strings"
8
+
9
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
10
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
11
+ sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
12
+ log "github.com/sirupsen/logrus"
13
+ )
14
+
15
+ // ReconcileProviders builds the desired provider list by reusing existing providers when possible
16
+ // and creating or removing providers only when their configuration changed. It returns the final
17
+ // ordered provider slice along with the identifiers of providers that were added, updated, or
18
+ // removed compared to the previous configuration.
19
+ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
20
+ if newCfg == nil {
21
+ return nil, nil, nil, nil, nil
22
+ }
23
+
24
+ existingMap := make(map[string]sdkaccess.Provider, len(existing))
25
+ for _, provider := range existing {
26
+ if provider == nil {
27
+ continue
28
+ }
29
+ existingMap[provider.Identifier()] = provider
30
+ }
31
+
32
+ oldCfgMap := accessProviderMap(oldCfg)
33
+ newEntries := collectProviderEntries(newCfg)
34
+
35
+ result = make([]sdkaccess.Provider, 0, len(newEntries))
36
+ finalIDs := make(map[string]struct{}, len(newEntries))
37
+
38
+ isInlineProvider := func(id string) bool {
39
+ return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
40
+ }
41
+ appendChange := func(list *[]string, id string) {
42
+ if isInlineProvider(id) {
43
+ return
44
+ }
45
+ *list = append(*list, id)
46
+ }
47
+
48
+ for _, providerCfg := range newEntries {
49
+ key := providerIdentifier(providerCfg)
50
+ if key == "" {
51
+ continue
52
+ }
53
+
54
+ forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
55
+ if oldCfgProvider, ok := oldCfgMap[key]; ok {
56
+ isAliased := oldCfgProvider == providerCfg
57
+ if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
58
+ if existingProvider, okExisting := existingMap[key]; okExisting {
59
+ result = append(result, existingProvider)
60
+ finalIDs[key] = struct{}{}
61
+ continue
62
+ }
63
+ }
64
+ }
65
+
66
+ provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
67
+ if buildErr != nil {
68
+ return nil, nil, nil, nil, buildErr
69
+ }
70
+ if _, ok := oldCfgMap[key]; ok {
71
+ if _, existed := existingMap[key]; existed {
72
+ appendChange(&updated, key)
73
+ } else {
74
+ appendChange(&added, key)
75
+ }
76
+ } else {
77
+ appendChange(&added, key)
78
+ }
79
+ result = append(result, provider)
80
+ finalIDs[key] = struct{}{}
81
+ }
82
+
83
+ if len(result) == 0 {
84
+ if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
85
+ key := providerIdentifier(inline)
86
+ if key != "" {
87
+ if oldCfgProvider, ok := oldCfgMap[key]; ok {
88
+ if providerConfigEqual(oldCfgProvider, inline) {
89
+ if existingProvider, okExisting := existingMap[key]; okExisting {
90
+ result = append(result, existingProvider)
91
+ finalIDs[key] = struct{}{}
92
+ goto inlineDone
93
+ }
94
+ }
95
+ }
96
+ provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
97
+ if buildErr != nil {
98
+ return nil, nil, nil, nil, buildErr
99
+ }
100
+ if _, existed := existingMap[key]; existed {
101
+ appendChange(&updated, key)
102
+ } else if _, hadOld := oldCfgMap[key]; hadOld {
103
+ appendChange(&updated, key)
104
+ } else {
105
+ appendChange(&added, key)
106
+ }
107
+ result = append(result, provider)
108
+ finalIDs[key] = struct{}{}
109
+ }
110
+ }
111
+ inlineDone:
112
+ }
113
+
114
+ removedSet := make(map[string]struct{})
115
+ for id := range existingMap {
116
+ if _, ok := finalIDs[id]; !ok {
117
+ if isInlineProvider(id) {
118
+ continue
119
+ }
120
+ removedSet[id] = struct{}{}
121
+ }
122
+ }
123
+
124
+ removed = make([]string, 0, len(removedSet))
125
+ for id := range removedSet {
126
+ removed = append(removed, id)
127
+ }
128
+
129
+ sort.Strings(added)
130
+ sort.Strings(updated)
131
+ sort.Strings(removed)
132
+
133
+ return result, added, updated, removed, nil
134
+ }
135
+
136
+ // ApplyAccessProviders reconciles the configured access providers against the
137
+ // currently registered providers and updates the manager. It logs a concise
138
+ // summary of the detected changes and returns whether any provider changed.
139
+ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {
140
+ if manager == nil || newCfg == nil {
141
+ return false, nil
142
+ }
143
+
144
+ existing := manager.Providers()
145
+ providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
146
+ if err != nil {
147
+ log.Errorf("failed to reconcile request auth providers: %v", err)
148
+ return false, fmt.Errorf("reconciling access providers: %w", err)
149
+ }
150
+
151
+ manager.SetProviders(providers)
152
+
153
+ if len(added)+len(updated)+len(removed) > 0 {
154
+ log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
155
+ log.Debugf("auth providers changes details - added=%v updated=%v removed=%v", added, updated, removed)
156
+ return true, nil
157
+ }
158
+
159
+ log.Debug("auth providers unchanged after config update")
160
+ return false, nil
161
+ }
162
+
163
+ func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
164
+ result := make(map[string]*sdkConfig.AccessProvider)
165
+ if cfg == nil {
166
+ return result
167
+ }
168
+ for i := range cfg.Access.Providers {
169
+ providerCfg := &cfg.Access.Providers[i]
170
+ if providerCfg.Type == "" {
171
+ continue
172
+ }
173
+ key := providerIdentifier(providerCfg)
174
+ if key == "" {
175
+ continue
176
+ }
177
+ result[key] = providerCfg
178
+ }
179
+ if len(result) == 0 && len(cfg.APIKeys) > 0 {
180
+ if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
181
+ if key := providerIdentifier(provider); key != "" {
182
+ result[key] = provider
183
+ }
184
+ }
185
+ }
186
+ return result
187
+ }
188
+
189
+ func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
190
+ entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
191
+ for i := range cfg.Access.Providers {
192
+ providerCfg := &cfg.Access.Providers[i]
193
+ if providerCfg.Type == "" {
194
+ continue
195
+ }
196
+ if key := providerIdentifier(providerCfg); key != "" {
197
+ entries = append(entries, providerCfg)
198
+ }
199
+ }
200
+ if len(entries) == 0 && len(cfg.APIKeys) > 0 {
201
+ if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
202
+ entries = append(entries, inline)
203
+ }
204
+ }
205
+ return entries
206
+ }
207
+
208
+ func providerIdentifier(provider *sdkConfig.AccessProvider) string {
209
+ if provider == nil {
210
+ return ""
211
+ }
212
+ if name := strings.TrimSpace(provider.Name); name != "" {
213
+ return name
214
+ }
215
+ typ := strings.TrimSpace(provider.Type)
216
+ if typ == "" {
217
+ return ""
218
+ }
219
+ if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
220
+ return sdkConfig.DefaultAccessProviderName
221
+ }
222
+ return typ
223
+ }
224
+
225
+ func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
226
+ if a == nil || b == nil {
227
+ return a == nil && b == nil
228
+ }
229
+ if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
230
+ return false
231
+ }
232
+ if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
233
+ return false
234
+ }
235
+ if !stringSetEqual(a.APIKeys, b.APIKeys) {
236
+ return false
237
+ }
238
+ if len(a.Config) != len(b.Config) {
239
+ return false
240
+ }
241
+ if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
242
+ return false
243
+ }
244
+ return true
245
+ }
246
+
247
+ func stringSetEqual(a, b []string) bool {
248
+ if len(a) != len(b) {
249
+ return false
250
+ }
251
+ if len(a) == 0 {
252
+ return true
253
+ }
254
+ seen := make(map[string]int, len(a))
255
+ for _, val := range a {
256
+ seen[val]++
257
+ }
258
+ for _, val := range b {
259
+ count := seen[val]
260
+ if count == 0 {
261
+ return false
262
+ }
263
+ if count == 1 {
264
+ delete(seen, val)
265
+ } else {
266
+ seen[val] = count - 1
267
+ }
268
+ }
269
+ return len(seen) == 0
270
+ }
internal/api/handlers/management/api_tools.go ADDED
@@ -0,0 +1,704 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net"
9
+ "net/http"
10
+ "net/url"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/gin-gonic/gin"
15
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
16
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
17
+ log "github.com/sirupsen/logrus"
18
+ "golang.org/x/net/proxy"
19
+ "golang.org/x/oauth2"
20
+ "golang.org/x/oauth2/google"
21
+ )
22
+
23
+ const defaultAPICallTimeout = 60 * time.Second
24
+
25
+ const (
26
+ geminiOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
27
+ geminiOAuthClientSecret = "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"
28
+ )
29
+
30
+ var geminiOAuthScopes = []string{
31
+ "https://www.googleapis.com/auth/cloud-platform",
32
+ "https://www.googleapis.com/auth/userinfo.email",
33
+ "https://www.googleapis.com/auth/userinfo.profile",
34
+ }
35
+
36
+ const (
37
+ antigravityOAuthClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
38
+ antigravityOAuthClientSecret = "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"
39
+ )
40
+
41
+ var antigravityOAuthTokenURL = "https://oauth2.googleapis.com/token"
42
+
43
+ type apiCallRequest struct {
44
+ AuthIndexSnake *string `json:"auth_index"`
45
+ AuthIndexCamel *string `json:"authIndex"`
46
+ AuthIndexPascal *string `json:"AuthIndex"`
47
+ Method string `json:"method"`
48
+ URL string `json:"url"`
49
+ Header map[string]string `json:"header"`
50
+ Data string `json:"data"`
51
+ }
52
+
53
+ type apiCallResponse struct {
54
+ StatusCode int `json:"status_code"`
55
+ Header map[string][]string `json:"header"`
56
+ Body string `json:"body"`
57
+ }
58
+
59
+ // APICall makes a generic HTTP request on behalf of the management API caller.
60
+ // It is protected by the management middleware.
61
+ //
62
+ // Endpoint:
63
+ //
64
+ // POST /v0/management/api-call
65
+ //
66
+ // Authentication:
67
+ //
68
+ // Same as other management APIs (requires a management key and remote-management rules).
69
+ // You can provide the key via:
70
+ // - Authorization: Bearer <key>
71
+ // - X-Management-Key: <key>
72
+ //
73
+ // Request JSON:
74
+ // - auth_index / authIndex / AuthIndex (optional):
75
+ // The credential "auth_index" from GET /v0/management/auth-files (or other endpoints returning it).
76
+ // If omitted or not found, credential-specific proxy/token substitution is skipped.
77
+ // - method (required): HTTP method, e.g. GET, POST, PUT, PATCH, DELETE.
78
+ // - url (required): Absolute URL including scheme and host, e.g. "https://api.example.com/v1/ping".
79
+ // - header (optional): Request headers map.
80
+ // Supports magic variable "$TOKEN$" which is replaced using the selected credential:
81
+ // 1) metadata.access_token
82
+ // 2) attributes.api_key
83
+ // 3) metadata.token / metadata.id_token / metadata.cookie
84
+ // Example: {"Authorization":"Bearer $TOKEN$"}.
85
+ // Note: if you need to override the HTTP Host header, set header["Host"].
86
+ // - data (optional): Raw request body as string (useful for POST/PUT/PATCH).
87
+ //
88
+ // Proxy selection (highest priority first):
89
+ // 1. Selected credential proxy_url
90
+ // 2. Global config proxy-url
91
+ // 3. Direct connect (environment proxies are not used)
92
+ //
93
+ // Response JSON (returned with HTTP 200 when the APICall itself succeeds):
94
+ // - status_code: Upstream HTTP status code.
95
+ // - header: Upstream response headers.
96
+ // - body: Upstream response body as string.
97
+ //
98
+ // Example:
99
+ //
100
+ // curl -sS -X POST "http://127.0.0.1:8317/v0/management/api-call" \
101
+ // -H "Authorization: Bearer <MANAGEMENT_KEY>" \
102
+ // -H "Content-Type: application/json" \
103
+ // -d '{"auth_index":"<AUTH_INDEX>","method":"GET","url":"https://api.example.com/v1/ping","header":{"Authorization":"Bearer $TOKEN$"}}'
104
+ //
105
+ // curl -sS -X POST "http://127.0.0.1:8317/v0/management/api-call" \
106
+ // -H "Authorization: Bearer 831227" \
107
+ // -H "Content-Type: application/json" \
108
+ // -d '{"auth_index":"<AUTH_INDEX>","method":"POST","url":"https://api.example.com/v1/fetchAvailableModels","header":{"Authorization":"Bearer $TOKEN$","Content-Type":"application/json","User-Agent":"cliproxyapi"},"data":"{}"}'
109
+ func (h *Handler) APICall(c *gin.Context) {
110
+ var body apiCallRequest
111
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
112
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
113
+ return
114
+ }
115
+
116
+ method := strings.ToUpper(strings.TrimSpace(body.Method))
117
+ if method == "" {
118
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing method"})
119
+ return
120
+ }
121
+
122
+ urlStr := strings.TrimSpace(body.URL)
123
+ if urlStr == "" {
124
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
125
+ return
126
+ }
127
+ parsedURL, errParseURL := url.Parse(urlStr)
128
+ if errParseURL != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
129
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
130
+ return
131
+ }
132
+
133
+ authIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel, body.AuthIndexPascal)
134
+ auth := h.authByIndex(authIndex)
135
+
136
+ reqHeaders := body.Header
137
+ if reqHeaders == nil {
138
+ reqHeaders = map[string]string{}
139
+ }
140
+
141
+ var hostOverride string
142
+ var token string
143
+ var tokenResolved bool
144
+ var tokenErr error
145
+ for key, value := range reqHeaders {
146
+ if !strings.Contains(value, "$TOKEN$") {
147
+ continue
148
+ }
149
+ if !tokenResolved {
150
+ token, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth)
151
+ tokenResolved = true
152
+ }
153
+ if auth != nil && token == "" {
154
+ if tokenErr != nil {
155
+ c.JSON(http.StatusBadRequest, gin.H{"error": "auth token refresh failed"})
156
+ return
157
+ }
158
+ c.JSON(http.StatusBadRequest, gin.H{"error": "auth token not found"})
159
+ return
160
+ }
161
+ if token == "" {
162
+ continue
163
+ }
164
+ reqHeaders[key] = strings.ReplaceAll(value, "$TOKEN$", token)
165
+ }
166
+
167
+ var requestBody io.Reader
168
+ if body.Data != "" {
169
+ requestBody = strings.NewReader(body.Data)
170
+ }
171
+
172
+ req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), method, urlStr, requestBody)
173
+ if errNewRequest != nil {
174
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to build request"})
175
+ return
176
+ }
177
+
178
+ for key, value := range reqHeaders {
179
+ if strings.EqualFold(key, "host") {
180
+ hostOverride = strings.TrimSpace(value)
181
+ continue
182
+ }
183
+ req.Header.Set(key, value)
184
+ }
185
+ if hostOverride != "" {
186
+ req.Host = hostOverride
187
+ }
188
+
189
+ httpClient := &http.Client{
190
+ Timeout: defaultAPICallTimeout,
191
+ }
192
+ httpClient.Transport = h.apiCallTransport(auth)
193
+
194
+ resp, errDo := httpClient.Do(req)
195
+ if errDo != nil {
196
+ log.WithError(errDo).Debug("management APICall request failed")
197
+ c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
198
+ return
199
+ }
200
+ defer func() {
201
+ if errClose := resp.Body.Close(); errClose != nil {
202
+ log.Errorf("response body close error: %v", errClose)
203
+ }
204
+ }()
205
+
206
+ respBody, errReadAll := io.ReadAll(resp.Body)
207
+ if errReadAll != nil {
208
+ c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
209
+ return
210
+ }
211
+
212
+ c.JSON(http.StatusOK, apiCallResponse{
213
+ StatusCode: resp.StatusCode,
214
+ Header: resp.Header,
215
+ Body: string(respBody),
216
+ })
217
+ }
218
+
219
+ func firstNonEmptyString(values ...*string) string {
220
+ for _, v := range values {
221
+ if v == nil {
222
+ continue
223
+ }
224
+ if out := strings.TrimSpace(*v); out != "" {
225
+ return out
226
+ }
227
+ }
228
+ return ""
229
+ }
230
+
231
+ func tokenValueForAuth(auth *coreauth.Auth) string {
232
+ if auth == nil {
233
+ return ""
234
+ }
235
+ if v := tokenValueFromMetadata(auth.Metadata); v != "" {
236
+ return v
237
+ }
238
+ if auth.Attributes != nil {
239
+ if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
240
+ return v
241
+ }
242
+ }
243
+ if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {
244
+ if v := tokenValueFromMetadata(shared.MetadataSnapshot()); v != "" {
245
+ return v
246
+ }
247
+ }
248
+ return ""
249
+ }
250
+
251
+ func (h *Handler) resolveTokenForAuth(ctx context.Context, auth *coreauth.Auth) (string, error) {
252
+ if auth == nil {
253
+ return "", nil
254
+ }
255
+
256
+ provider := strings.ToLower(strings.TrimSpace(auth.Provider))
257
+ if provider == "gemini-cli" {
258
+ token, errToken := h.refreshGeminiOAuthAccessToken(ctx, auth)
259
+ return token, errToken
260
+ }
261
+ if provider == "antigravity" {
262
+ token, errToken := h.refreshAntigravityOAuthAccessToken(ctx, auth)
263
+ return token, errToken
264
+ }
265
+
266
+ return tokenValueForAuth(auth), nil
267
+ }
268
+
269
+ func (h *Handler) refreshGeminiOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {
270
+ if ctx == nil {
271
+ ctx = context.Background()
272
+ }
273
+ if auth == nil {
274
+ return "", nil
275
+ }
276
+
277
+ metadata, updater := geminiOAuthMetadata(auth)
278
+ if len(metadata) == 0 {
279
+ return "", fmt.Errorf("gemini oauth metadata missing")
280
+ }
281
+
282
+ base := make(map[string]any)
283
+ if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil {
284
+ base = cloneMap(tokenRaw)
285
+ }
286
+
287
+ var token oauth2.Token
288
+ if len(base) > 0 {
289
+ if raw, errMarshal := json.Marshal(base); errMarshal == nil {
290
+ _ = json.Unmarshal(raw, &token)
291
+ }
292
+ }
293
+
294
+ if token.AccessToken == "" {
295
+ token.AccessToken = stringValue(metadata, "access_token")
296
+ }
297
+ if token.RefreshToken == "" {
298
+ token.RefreshToken = stringValue(metadata, "refresh_token")
299
+ }
300
+ if token.TokenType == "" {
301
+ token.TokenType = stringValue(metadata, "token_type")
302
+ }
303
+ if token.Expiry.IsZero() {
304
+ if expiry := stringValue(metadata, "expiry"); expiry != "" {
305
+ if ts, errParseTime := time.Parse(time.RFC3339, expiry); errParseTime == nil {
306
+ token.Expiry = ts
307
+ }
308
+ }
309
+ }
310
+
311
+ conf := &oauth2.Config{
312
+ ClientID: geminiOAuthClientID,
313
+ ClientSecret: geminiOAuthClientSecret,
314
+ Scopes: geminiOAuthScopes,
315
+ Endpoint: google.Endpoint,
316
+ }
317
+
318
+ ctxToken := ctx
319
+ httpClient := &http.Client{
320
+ Timeout: defaultAPICallTimeout,
321
+ Transport: h.apiCallTransport(auth),
322
+ }
323
+ ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)
324
+
325
+ src := conf.TokenSource(ctxToken, &token)
326
+ currentToken, errToken := src.Token()
327
+ if errToken != nil {
328
+ return "", errToken
329
+ }
330
+
331
+ merged := buildOAuthTokenMap(base, currentToken)
332
+ fields := buildOAuthTokenFields(currentToken, merged)
333
+ if updater != nil {
334
+ updater(fields)
335
+ }
336
+ return strings.TrimSpace(currentToken.AccessToken), nil
337
+ }
338
+
339
+ func (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {
340
+ if ctx == nil {
341
+ ctx = context.Background()
342
+ }
343
+ if auth == nil {
344
+ return "", nil
345
+ }
346
+
347
+ metadata := auth.Metadata
348
+ if len(metadata) == 0 {
349
+ return "", fmt.Errorf("antigravity oauth metadata missing")
350
+ }
351
+
352
+ current := strings.TrimSpace(tokenValueFromMetadata(metadata))
353
+ if current != "" && !antigravityTokenNeedsRefresh(metadata) {
354
+ return current, nil
355
+ }
356
+
357
+ refreshToken := stringValue(metadata, "refresh_token")
358
+ if refreshToken == "" {
359
+ return "", fmt.Errorf("antigravity refresh token missing")
360
+ }
361
+
362
+ tokenURL := strings.TrimSpace(antigravityOAuthTokenURL)
363
+ if tokenURL == "" {
364
+ tokenURL = "https://oauth2.googleapis.com/token"
365
+ }
366
+ form := url.Values{}
367
+ form.Set("client_id", antigravityOAuthClientID)
368
+ form.Set("client_secret", antigravityOAuthClientSecret)
369
+ form.Set("grant_type", "refresh_token")
370
+ form.Set("refresh_token", refreshToken)
371
+
372
+ req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
373
+ if errReq != nil {
374
+ return "", errReq
375
+ }
376
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
377
+
378
+ httpClient := &http.Client{
379
+ Timeout: defaultAPICallTimeout,
380
+ Transport: h.apiCallTransport(auth),
381
+ }
382
+ resp, errDo := httpClient.Do(req)
383
+ if errDo != nil {
384
+ return "", errDo
385
+ }
386
+ defer func() {
387
+ if errClose := resp.Body.Close(); errClose != nil {
388
+ log.Errorf("response body close error: %v", errClose)
389
+ }
390
+ }()
391
+
392
+ bodyBytes, errRead := io.ReadAll(resp.Body)
393
+ if errRead != nil {
394
+ return "", errRead
395
+ }
396
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
397
+ return "", fmt.Errorf("antigravity oauth token refresh failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
398
+ }
399
+
400
+ var tokenResp struct {
401
+ AccessToken string `json:"access_token"`
402
+ RefreshToken string `json:"refresh_token"`
403
+ ExpiresIn int64 `json:"expires_in"`
404
+ TokenType string `json:"token_type"`
405
+ }
406
+ if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {
407
+ return "", errUnmarshal
408
+ }
409
+
410
+ if strings.TrimSpace(tokenResp.AccessToken) == "" {
411
+ return "", fmt.Errorf("antigravity oauth token refresh returned empty access_token")
412
+ }
413
+
414
+ if auth.Metadata == nil {
415
+ auth.Metadata = make(map[string]any)
416
+ }
417
+ now := time.Now()
418
+ auth.Metadata["access_token"] = strings.TrimSpace(tokenResp.AccessToken)
419
+ if strings.TrimSpace(tokenResp.RefreshToken) != "" {
420
+ auth.Metadata["refresh_token"] = strings.TrimSpace(tokenResp.RefreshToken)
421
+ }
422
+ if tokenResp.ExpiresIn > 0 {
423
+ auth.Metadata["expires_in"] = tokenResp.ExpiresIn
424
+ auth.Metadata["timestamp"] = now.UnixMilli()
425
+ auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
426
+ }
427
+ auth.Metadata["type"] = "antigravity"
428
+
429
+ if h != nil && h.authManager != nil {
430
+ auth.LastRefreshedAt = now
431
+ auth.UpdatedAt = now
432
+ _, _ = h.authManager.Update(ctx, auth)
433
+ }
434
+
435
+ return strings.TrimSpace(tokenResp.AccessToken), nil
436
+ }
437
+
438
+ func antigravityTokenNeedsRefresh(metadata map[string]any) bool {
439
+ // Refresh a bit early to avoid requests racing token expiry.
440
+ const skew = 30 * time.Second
441
+
442
+ if metadata == nil {
443
+ return true
444
+ }
445
+ if expStr, ok := metadata["expired"].(string); ok {
446
+ if ts, errParse := time.Parse(time.RFC3339, strings.TrimSpace(expStr)); errParse == nil {
447
+ return !ts.After(time.Now().Add(skew))
448
+ }
449
+ }
450
+ expiresIn := int64Value(metadata["expires_in"])
451
+ timestampMs := int64Value(metadata["timestamp"])
452
+ if expiresIn > 0 && timestampMs > 0 {
453
+ exp := time.UnixMilli(timestampMs).Add(time.Duration(expiresIn) * time.Second)
454
+ return !exp.After(time.Now().Add(skew))
455
+ }
456
+ return true
457
+ }
458
+
459
+ func int64Value(raw any) int64 {
460
+ switch typed := raw.(type) {
461
+ case int:
462
+ return int64(typed)
463
+ case int32:
464
+ return int64(typed)
465
+ case int64:
466
+ return typed
467
+ case uint:
468
+ return int64(typed)
469
+ case uint32:
470
+ return int64(typed)
471
+ case uint64:
472
+ if typed > uint64(^uint64(0)>>1) {
473
+ return 0
474
+ }
475
+ return int64(typed)
476
+ case float32:
477
+ return int64(typed)
478
+ case float64:
479
+ return int64(typed)
480
+ case json.Number:
481
+ if i, errParse := typed.Int64(); errParse == nil {
482
+ return i
483
+ }
484
+ case string:
485
+ if s := strings.TrimSpace(typed); s != "" {
486
+ if i, errParse := json.Number(s).Int64(); errParse == nil {
487
+ return i
488
+ }
489
+ }
490
+ }
491
+ return 0
492
+ }
493
+
494
+ func geminiOAuthMetadata(auth *coreauth.Auth) (map[string]any, func(map[string]any)) {
495
+ if auth == nil {
496
+ return nil, nil
497
+ }
498
+ if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {
499
+ snapshot := shared.MetadataSnapshot()
500
+ return snapshot, func(fields map[string]any) { shared.MergeMetadata(fields) }
501
+ }
502
+ return auth.Metadata, func(fields map[string]any) {
503
+ if auth.Metadata == nil {
504
+ auth.Metadata = make(map[string]any)
505
+ }
506
+ for k, v := range fields {
507
+ auth.Metadata[k] = v
508
+ }
509
+ }
510
+ }
511
+
512
+ func stringValue(metadata map[string]any, key string) string {
513
+ if len(metadata) == 0 || key == "" {
514
+ return ""
515
+ }
516
+ if v, ok := metadata[key].(string); ok {
517
+ return strings.TrimSpace(v)
518
+ }
519
+ return ""
520
+ }
521
+
522
+ func cloneMap(in map[string]any) map[string]any {
523
+ if len(in) == 0 {
524
+ return nil
525
+ }
526
+ out := make(map[string]any, len(in))
527
+ for k, v := range in {
528
+ out[k] = v
529
+ }
530
+ return out
531
+ }
532
+
533
+ func buildOAuthTokenMap(base map[string]any, tok *oauth2.Token) map[string]any {
534
+ merged := cloneMap(base)
535
+ if merged == nil {
536
+ merged = make(map[string]any)
537
+ }
538
+ if tok == nil {
539
+ return merged
540
+ }
541
+ if raw, errMarshal := json.Marshal(tok); errMarshal == nil {
542
+ var tokenMap map[string]any
543
+ if errUnmarshal := json.Unmarshal(raw, &tokenMap); errUnmarshal == nil {
544
+ for k, v := range tokenMap {
545
+ merged[k] = v
546
+ }
547
+ }
548
+ }
549
+ return merged
550
+ }
551
+
552
+ func buildOAuthTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any {
553
+ fields := make(map[string]any, 5)
554
+ if tok != nil && tok.AccessToken != "" {
555
+ fields["access_token"] = tok.AccessToken
556
+ }
557
+ if tok != nil && tok.TokenType != "" {
558
+ fields["token_type"] = tok.TokenType
559
+ }
560
+ if tok != nil && tok.RefreshToken != "" {
561
+ fields["refresh_token"] = tok.RefreshToken
562
+ }
563
+ if tok != nil && !tok.Expiry.IsZero() {
564
+ fields["expiry"] = tok.Expiry.Format(time.RFC3339)
565
+ }
566
+ if len(merged) > 0 {
567
+ fields["token"] = cloneMap(merged)
568
+ }
569
+ return fields
570
+ }
571
+
572
+ func tokenValueFromMetadata(metadata map[string]any) string {
573
+ if len(metadata) == 0 {
574
+ return ""
575
+ }
576
+ if v, ok := metadata["accessToken"].(string); ok && strings.TrimSpace(v) != "" {
577
+ return strings.TrimSpace(v)
578
+ }
579
+ if v, ok := metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" {
580
+ return strings.TrimSpace(v)
581
+ }
582
+ if tokenRaw, ok := metadata["token"]; ok && tokenRaw != nil {
583
+ switch typed := tokenRaw.(type) {
584
+ case string:
585
+ if v := strings.TrimSpace(typed); v != "" {
586
+ return v
587
+ }
588
+ case map[string]any:
589
+ if v, ok := typed["access_token"].(string); ok && strings.TrimSpace(v) != "" {
590
+ return strings.TrimSpace(v)
591
+ }
592
+ if v, ok := typed["accessToken"].(string); ok && strings.TrimSpace(v) != "" {
593
+ return strings.TrimSpace(v)
594
+ }
595
+ case map[string]string:
596
+ if v := strings.TrimSpace(typed["access_token"]); v != "" {
597
+ return v
598
+ }
599
+ if v := strings.TrimSpace(typed["accessToken"]); v != "" {
600
+ return v
601
+ }
602
+ }
603
+ }
604
+ if v, ok := metadata["token"].(string); ok && strings.TrimSpace(v) != "" {
605
+ return strings.TrimSpace(v)
606
+ }
607
+ if v, ok := metadata["id_token"].(string); ok && strings.TrimSpace(v) != "" {
608
+ return strings.TrimSpace(v)
609
+ }
610
+ if v, ok := metadata["cookie"].(string); ok && strings.TrimSpace(v) != "" {
611
+ return strings.TrimSpace(v)
612
+ }
613
+ return ""
614
+ }
615
+
616
+ func (h *Handler) authByIndex(authIndex string) *coreauth.Auth {
617
+ authIndex = strings.TrimSpace(authIndex)
618
+ if authIndex == "" || h == nil || h.authManager == nil {
619
+ return nil
620
+ }
621
+ auths := h.authManager.List()
622
+ for _, auth := range auths {
623
+ if auth == nil {
624
+ continue
625
+ }
626
+ auth.EnsureIndex()
627
+ if auth.Index == authIndex {
628
+ return auth
629
+ }
630
+ }
631
+ return nil
632
+ }
633
+
634
+ func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {
635
+ var proxyCandidates []string
636
+ if auth != nil {
637
+ if proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != "" {
638
+ proxyCandidates = append(proxyCandidates, proxyStr)
639
+ }
640
+ }
641
+ if h != nil && h.cfg != nil {
642
+ if proxyStr := strings.TrimSpace(h.cfg.ProxyURL); proxyStr != "" {
643
+ proxyCandidates = append(proxyCandidates, proxyStr)
644
+ }
645
+ }
646
+
647
+ for _, proxyStr := range proxyCandidates {
648
+ if transport := buildProxyTransport(proxyStr); transport != nil {
649
+ return transport
650
+ }
651
+ }
652
+
653
+ transport, ok := http.DefaultTransport.(*http.Transport)
654
+ if !ok || transport == nil {
655
+ return &http.Transport{Proxy: nil}
656
+ }
657
+ clone := transport.Clone()
658
+ clone.Proxy = nil
659
+ return clone
660
+ }
661
+
662
+ func buildProxyTransport(proxyStr string) *http.Transport {
663
+ proxyStr = strings.TrimSpace(proxyStr)
664
+ if proxyStr == "" {
665
+ return nil
666
+ }
667
+
668
+ proxyURL, errParse := url.Parse(proxyStr)
669
+ if errParse != nil {
670
+ log.WithError(errParse).Debug("parse proxy URL failed")
671
+ return nil
672
+ }
673
+ if proxyURL.Scheme == "" || proxyURL.Host == "" {
674
+ log.Debug("proxy URL missing scheme/host")
675
+ return nil
676
+ }
677
+
678
+ if proxyURL.Scheme == "socks5" {
679
+ var proxyAuth *proxy.Auth
680
+ if proxyURL.User != nil {
681
+ username := proxyURL.User.Username()
682
+ password, _ := proxyURL.User.Password()
683
+ proxyAuth = &proxy.Auth{User: username, Password: password}
684
+ }
685
+ dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
686
+ if errSOCKS5 != nil {
687
+ log.WithError(errSOCKS5).Debug("create SOCKS5 dialer failed")
688
+ return nil
689
+ }
690
+ return &http.Transport{
691
+ Proxy: nil,
692
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
693
+ return dialer.Dial(network, addr)
694
+ },
695
+ }
696
+ }
697
+
698
+ if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
699
+ return &http.Transport{Proxy: http.ProxyURL(proxyURL)}
700
+ }
701
+
702
+ log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
703
+ return nil
704
+ }
internal/api/handlers/management/api_tools_test.go ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "net/url"
10
+ "strings"
11
+ "sync"
12
+ "testing"
13
+ "time"
14
+
15
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
16
+ )
17
+
18
+ type memoryAuthStore struct {
19
+ mu sync.Mutex
20
+ items map[string]*coreauth.Auth
21
+ }
22
+
23
+ func (s *memoryAuthStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
24
+ _ = ctx
25
+ s.mu.Lock()
26
+ defer s.mu.Unlock()
27
+ out := make([]*coreauth.Auth, 0, len(s.items))
28
+ for _, a := range s.items {
29
+ out = append(out, a.Clone())
30
+ }
31
+ return out, nil
32
+ }
33
+
34
+ func (s *memoryAuthStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
35
+ _ = ctx
36
+ if auth == nil {
37
+ return "", nil
38
+ }
39
+ s.mu.Lock()
40
+ if s.items == nil {
41
+ s.items = make(map[string]*coreauth.Auth)
42
+ }
43
+ s.items[auth.ID] = auth.Clone()
44
+ s.mu.Unlock()
45
+ return auth.ID, nil
46
+ }
47
+
48
+ func (s *memoryAuthStore) Delete(ctx context.Context, id string) error {
49
+ _ = ctx
50
+ s.mu.Lock()
51
+ delete(s.items, id)
52
+ s.mu.Unlock()
53
+ return nil
54
+ }
55
+
56
+ func TestResolveTokenForAuth_Antigravity_RefreshesExpiredToken(t *testing.T) {
57
+ var callCount int
58
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59
+ callCount++
60
+ if r.Method != http.MethodPost {
61
+ t.Fatalf("expected POST, got %s", r.Method)
62
+ }
63
+ if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
64
+ t.Fatalf("unexpected content-type: %s", ct)
65
+ }
66
+ bodyBytes, _ := io.ReadAll(r.Body)
67
+ _ = r.Body.Close()
68
+ values, err := url.ParseQuery(string(bodyBytes))
69
+ if err != nil {
70
+ t.Fatalf("parse form: %v", err)
71
+ }
72
+ if values.Get("grant_type") != "refresh_token" {
73
+ t.Fatalf("unexpected grant_type: %s", values.Get("grant_type"))
74
+ }
75
+ if values.Get("refresh_token") != "rt" {
76
+ t.Fatalf("unexpected refresh_token: %s", values.Get("refresh_token"))
77
+ }
78
+ if values.Get("client_id") != antigravityOAuthClientID {
79
+ t.Fatalf("unexpected client_id: %s", values.Get("client_id"))
80
+ }
81
+ if values.Get("client_secret") != antigravityOAuthClientSecret {
82
+ t.Fatalf("unexpected client_secret")
83
+ }
84
+
85
+ w.Header().Set("Content-Type", "application/json")
86
+ _ = json.NewEncoder(w).Encode(map[string]any{
87
+ "access_token": "new-token",
88
+ "refresh_token": "rt2",
89
+ "expires_in": int64(3600),
90
+ "token_type": "Bearer",
91
+ })
92
+ }))
93
+ t.Cleanup(srv.Close)
94
+
95
+ originalURL := antigravityOAuthTokenURL
96
+ antigravityOAuthTokenURL = srv.URL
97
+ t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
98
+
99
+ store := &memoryAuthStore{}
100
+ manager := coreauth.NewManager(store, nil, nil)
101
+
102
+ auth := &coreauth.Auth{
103
+ ID: "antigravity-test.json",
104
+ FileName: "antigravity-test.json",
105
+ Provider: "antigravity",
106
+ Metadata: map[string]any{
107
+ "type": "antigravity",
108
+ "access_token": "old-token",
109
+ "refresh_token": "rt",
110
+ "expires_in": int64(3600),
111
+ "timestamp": time.Now().Add(-2 * time.Hour).UnixMilli(),
112
+ "expired": time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
113
+ },
114
+ }
115
+ if _, err := manager.Register(context.Background(), auth); err != nil {
116
+ t.Fatalf("register auth: %v", err)
117
+ }
118
+
119
+ h := &Handler{authManager: manager}
120
+ token, err := h.resolveTokenForAuth(context.Background(), auth)
121
+ if err != nil {
122
+ t.Fatalf("resolveTokenForAuth: %v", err)
123
+ }
124
+ if token != "new-token" {
125
+ t.Fatalf("expected refreshed token, got %q", token)
126
+ }
127
+ if callCount != 1 {
128
+ t.Fatalf("expected 1 refresh call, got %d", callCount)
129
+ }
130
+
131
+ updated, ok := manager.GetByID(auth.ID)
132
+ if !ok || updated == nil {
133
+ t.Fatalf("expected auth in manager after update")
134
+ }
135
+ if got := tokenValueFromMetadata(updated.Metadata); got != "new-token" {
136
+ t.Fatalf("expected manager metadata updated, got %q", got)
137
+ }
138
+ }
139
+
140
+ func TestResolveTokenForAuth_Antigravity_SkipsRefreshWhenTokenValid(t *testing.T) {
141
+ var callCount int
142
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143
+ callCount++
144
+ w.WriteHeader(http.StatusInternalServerError)
145
+ }))
146
+ t.Cleanup(srv.Close)
147
+
148
+ originalURL := antigravityOAuthTokenURL
149
+ antigravityOAuthTokenURL = srv.URL
150
+ t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
151
+
152
+ auth := &coreauth.Auth{
153
+ ID: "antigravity-valid.json",
154
+ FileName: "antigravity-valid.json",
155
+ Provider: "antigravity",
156
+ Metadata: map[string]any{
157
+ "type": "antigravity",
158
+ "access_token": "ok-token",
159
+ "expired": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
160
+ },
161
+ }
162
+ h := &Handler{}
163
+ token, err := h.resolveTokenForAuth(context.Background(), auth)
164
+ if err != nil {
165
+ t.Fatalf("resolveTokenForAuth: %v", err)
166
+ }
167
+ if token != "ok-token" {
168
+ t.Fatalf("expected existing token, got %q", token)
169
+ }
170
+ if callCount != 0 {
171
+ t.Fatalf("expected no refresh calls, got %d", callCount)
172
+ }
173
+ }
internal/api/handlers/management/auth_files.go ADDED
@@ -0,0 +1,2191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "net"
13
+ "net/http"
14
+ "os"
15
+ "path/filepath"
16
+ "sort"
17
+ "strconv"
18
+ "strings"
19
+ "sync"
20
+ "time"
21
+
22
+ "github.com/gin-gonic/gin"
23
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
24
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
25
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
26
+ geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
27
+ iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
28
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
29
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
30
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
31
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
32
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
33
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
34
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
35
+ log "github.com/sirupsen/logrus"
36
+ "github.com/tidwall/gjson"
37
+ "golang.org/x/oauth2"
38
+ "golang.org/x/oauth2/google"
39
+ )
40
+
41
+ var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
42
+
43
+ const (
44
+ anthropicCallbackPort = 54545
45
+ geminiCallbackPort = 8085
46
+ codexCallbackPort = 1455
47
+ geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
48
+ geminiCLIVersion = "v1internal"
49
+ geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
50
+ geminiCLIApiClient = "gl-node/22.17.0"
51
+ geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
52
+ )
53
+
54
+ type callbackForwarder struct {
55
+ provider string
56
+ server *http.Server
57
+ done chan struct{}
58
+ }
59
+
60
+ var (
61
+ callbackForwardersMu sync.Mutex
62
+ callbackForwarders = make(map[int]*callbackForwarder)
63
+ )
64
+
65
+ func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
66
+ if len(meta) == 0 {
67
+ return time.Time{}, false
68
+ }
69
+ for _, key := range lastRefreshKeys {
70
+ if val, ok := meta[key]; ok {
71
+ if ts, ok1 := parseLastRefreshValue(val); ok1 {
72
+ return ts, true
73
+ }
74
+ }
75
+ }
76
+ return time.Time{}, false
77
+ }
78
+
79
+ func parseLastRefreshValue(v any) (time.Time, bool) {
80
+ switch val := v.(type) {
81
+ case string:
82
+ s := strings.TrimSpace(val)
83
+ if s == "" {
84
+ return time.Time{}, false
85
+ }
86
+ layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
87
+ for _, layout := range layouts {
88
+ if ts, err := time.Parse(layout, s); err == nil {
89
+ return ts.UTC(), true
90
+ }
91
+ }
92
+ if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
93
+ return time.Unix(unix, 0).UTC(), true
94
+ }
95
+ case float64:
96
+ if val <= 0 {
97
+ return time.Time{}, false
98
+ }
99
+ return time.Unix(int64(val), 0).UTC(), true
100
+ case int64:
101
+ if val <= 0 {
102
+ return time.Time{}, false
103
+ }
104
+ return time.Unix(val, 0).UTC(), true
105
+ case int:
106
+ if val <= 0 {
107
+ return time.Time{}, false
108
+ }
109
+ return time.Unix(int64(val), 0).UTC(), true
110
+ case json.Number:
111
+ if i, err := val.Int64(); err == nil && i > 0 {
112
+ return time.Unix(i, 0).UTC(), true
113
+ }
114
+ }
115
+ return time.Time{}, false
116
+ }
117
+
118
+ func isWebUIRequest(c *gin.Context) bool {
119
+ raw := strings.TrimSpace(c.Query("is_webui"))
120
+ if raw == "" {
121
+ return false
122
+ }
123
+ switch strings.ToLower(raw) {
124
+ case "1", "true", "yes", "on":
125
+ return true
126
+ default:
127
+ return false
128
+ }
129
+ }
130
+
131
+ func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) {
132
+ callbackForwardersMu.Lock()
133
+ prev := callbackForwarders[port]
134
+ if prev != nil {
135
+ delete(callbackForwarders, port)
136
+ }
137
+ callbackForwardersMu.Unlock()
138
+
139
+ if prev != nil {
140
+ stopForwarderInstance(port, prev)
141
+ }
142
+
143
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
144
+ ln, err := net.Listen("tcp", addr)
145
+ if err != nil {
146
+ return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
147
+ }
148
+
149
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150
+ target := targetBase
151
+ if raw := r.URL.RawQuery; raw != "" {
152
+ if strings.Contains(target, "?") {
153
+ target = target + "&" + raw
154
+ } else {
155
+ target = target + "?" + raw
156
+ }
157
+ }
158
+ w.Header().Set("Cache-Control", "no-store")
159
+ http.Redirect(w, r, target, http.StatusFound)
160
+ })
161
+
162
+ srv := &http.Server{
163
+ Handler: handler,
164
+ ReadHeaderTimeout: 5 * time.Second,
165
+ WriteTimeout: 5 * time.Second,
166
+ }
167
+ done := make(chan struct{})
168
+
169
+ go func() {
170
+ if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
171
+ log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider)
172
+ }
173
+ close(done)
174
+ }()
175
+
176
+ forwarder := &callbackForwarder{
177
+ provider: provider,
178
+ server: srv,
179
+ done: done,
180
+ }
181
+
182
+ callbackForwardersMu.Lock()
183
+ callbackForwarders[port] = forwarder
184
+ callbackForwardersMu.Unlock()
185
+
186
+ log.Infof("callback forwarder for %s listening on %s", provider, addr)
187
+
188
+ return forwarder, nil
189
+ }
190
+
191
+ func stopCallbackForwarder(port int) {
192
+ callbackForwardersMu.Lock()
193
+ forwarder := callbackForwarders[port]
194
+ if forwarder != nil {
195
+ delete(callbackForwarders, port)
196
+ }
197
+ callbackForwardersMu.Unlock()
198
+
199
+ stopForwarderInstance(port, forwarder)
200
+ }
201
+
202
+ func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
203
+ if forwarder == nil {
204
+ return
205
+ }
206
+ callbackForwardersMu.Lock()
207
+ if current := callbackForwarders[port]; current == forwarder {
208
+ delete(callbackForwarders, port)
209
+ }
210
+ callbackForwardersMu.Unlock()
211
+
212
+ stopForwarderInstance(port, forwarder)
213
+ }
214
+
215
+ func stopForwarderInstance(port int, forwarder *callbackForwarder) {
216
+ if forwarder == nil || forwarder.server == nil {
217
+ return
218
+ }
219
+
220
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
221
+ defer cancel()
222
+
223
+ if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
224
+ log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port)
225
+ }
226
+
227
+ select {
228
+ case <-forwarder.done:
229
+ case <-time.After(2 * time.Second):
230
+ }
231
+
232
+ log.Infof("callback forwarder on port %d stopped", port)
233
+ }
234
+
235
+ func (h *Handler) managementCallbackURL(path string) (string, error) {
236
+ if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
237
+ return "", fmt.Errorf("server port is not configured")
238
+ }
239
+ if !strings.HasPrefix(path, "/") {
240
+ path = "/" + path
241
+ }
242
+ scheme := "http"
243
+ if h.cfg.TLS.Enable {
244
+ scheme = "https"
245
+ }
246
+ return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil
247
+ }
248
+
249
+ func (h *Handler) ListAuthFiles(c *gin.Context) {
250
+ if h == nil {
251
+ c.JSON(500, gin.H{"error": "handler not initialized"})
252
+ return
253
+ }
254
+ if h.authManager == nil {
255
+ h.listAuthFilesFromDisk(c)
256
+ return
257
+ }
258
+ auths := h.authManager.List()
259
+ files := make([]gin.H, 0, len(auths))
260
+ for _, auth := range auths {
261
+ if entry := h.buildAuthFileEntry(auth); entry != nil {
262
+ files = append(files, entry)
263
+ }
264
+ }
265
+ sort.Slice(files, func(i, j int) bool {
266
+ nameI, _ := files[i]["name"].(string)
267
+ nameJ, _ := files[j]["name"].(string)
268
+ return strings.ToLower(nameI) < strings.ToLower(nameJ)
269
+ })
270
+ c.JSON(200, gin.H{"files": files})
271
+ }
272
+
273
+ // GetAuthFileModels returns the models supported by a specific auth file
274
+ func (h *Handler) GetAuthFileModels(c *gin.Context) {
275
+ name := c.Query("name")
276
+ if name == "" {
277
+ c.JSON(400, gin.H{"error": "name is required"})
278
+ return
279
+ }
280
+
281
+ // Try to find auth ID via authManager
282
+ var authID string
283
+ if h.authManager != nil {
284
+ auths := h.authManager.List()
285
+ for _, auth := range auths {
286
+ if auth.FileName == name || auth.ID == name {
287
+ authID = auth.ID
288
+ break
289
+ }
290
+ }
291
+ }
292
+
293
+ if authID == "" {
294
+ authID = name // fallback to filename as ID
295
+ }
296
+
297
+ // Get models from registry
298
+ reg := registry.GetGlobalRegistry()
299
+ models := reg.GetModelsForClient(authID)
300
+
301
+ result := make([]gin.H, 0, len(models))
302
+ for _, m := range models {
303
+ entry := gin.H{
304
+ "id": m.ID,
305
+ }
306
+ if m.DisplayName != "" {
307
+ entry["display_name"] = m.DisplayName
308
+ }
309
+ if m.Type != "" {
310
+ entry["type"] = m.Type
311
+ }
312
+ if m.OwnedBy != "" {
313
+ entry["owned_by"] = m.OwnedBy
314
+ }
315
+ result = append(result, entry)
316
+ }
317
+
318
+ c.JSON(200, gin.H{"models": result})
319
+ }
320
+
321
+ // List auth files from disk when the auth manager is unavailable.
322
+ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
323
+ entries, err := os.ReadDir(h.cfg.AuthDir)
324
+ if err != nil {
325
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
326
+ return
327
+ }
328
+ files := make([]gin.H, 0)
329
+ for _, e := range entries {
330
+ if e.IsDir() {
331
+ continue
332
+ }
333
+ name := e.Name()
334
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
335
+ continue
336
+ }
337
+ if info, errInfo := e.Info(); errInfo == nil {
338
+ fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
339
+
340
+ // Read file to get type field
341
+ full := filepath.Join(h.cfg.AuthDir, name)
342
+ if data, errRead := os.ReadFile(full); errRead == nil {
343
+ typeValue := gjson.GetBytes(data, "type").String()
344
+ emailValue := gjson.GetBytes(data, "email").String()
345
+ fileData["type"] = typeValue
346
+ fileData["email"] = emailValue
347
+ }
348
+
349
+ files = append(files, fileData)
350
+ }
351
+ }
352
+ c.JSON(200, gin.H{"files": files})
353
+ }
354
+
355
+ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
356
+ if auth == nil {
357
+ return nil
358
+ }
359
+ auth.EnsureIndex()
360
+ runtimeOnly := isRuntimeOnlyAuth(auth)
361
+ if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
362
+ return nil
363
+ }
364
+ path := strings.TrimSpace(authAttribute(auth, "path"))
365
+ if path == "" && !runtimeOnly {
366
+ return nil
367
+ }
368
+ name := strings.TrimSpace(auth.FileName)
369
+ if name == "" {
370
+ name = auth.ID
371
+ }
372
+ entry := gin.H{
373
+ "id": auth.ID,
374
+ "auth_index": auth.Index,
375
+ "name": name,
376
+ "type": strings.TrimSpace(auth.Provider),
377
+ "provider": strings.TrimSpace(auth.Provider),
378
+ "label": auth.Label,
379
+ "status": auth.Status,
380
+ "status_message": auth.StatusMessage,
381
+ "disabled": auth.Disabled,
382
+ "unavailable": auth.Unavailable,
383
+ "runtime_only": runtimeOnly,
384
+ "source": "memory",
385
+ "size": int64(0),
386
+ }
387
+ if email := authEmail(auth); email != "" {
388
+ entry["email"] = email
389
+ }
390
+ if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
391
+ if accountType != "" {
392
+ entry["account_type"] = accountType
393
+ }
394
+ if account != "" {
395
+ entry["account"] = account
396
+ }
397
+ }
398
+ if !auth.CreatedAt.IsZero() {
399
+ entry["created_at"] = auth.CreatedAt
400
+ }
401
+ if !auth.UpdatedAt.IsZero() {
402
+ entry["modtime"] = auth.UpdatedAt
403
+ entry["updated_at"] = auth.UpdatedAt
404
+ }
405
+ if !auth.LastRefreshedAt.IsZero() {
406
+ entry["last_refresh"] = auth.LastRefreshedAt
407
+ }
408
+ if path != "" {
409
+ entry["path"] = path
410
+ entry["source"] = "file"
411
+ if info, err := os.Stat(path); err == nil {
412
+ entry["size"] = info.Size()
413
+ entry["modtime"] = info.ModTime()
414
+ } else if os.IsNotExist(err) {
415
+ // Hide credentials removed from disk but still lingering in memory.
416
+ if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
417
+ return nil
418
+ }
419
+ entry["source"] = "memory"
420
+ } else {
421
+ log.WithError(err).Warnf("failed to stat auth file %s", path)
422
+ }
423
+ }
424
+ if claims := extractCodexIDTokenClaims(auth); claims != nil {
425
+ entry["id_token"] = claims
426
+ }
427
+ return entry
428
+ }
429
+
430
+ func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
431
+ if auth == nil || auth.Metadata == nil {
432
+ return nil
433
+ }
434
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
435
+ return nil
436
+ }
437
+ idTokenRaw, ok := auth.Metadata["id_token"].(string)
438
+ if !ok {
439
+ return nil
440
+ }
441
+ idToken := strings.TrimSpace(idTokenRaw)
442
+ if idToken == "" {
443
+ return nil
444
+ }
445
+ claims, err := codex.ParseJWTToken(idToken)
446
+ if err != nil || claims == nil {
447
+ return nil
448
+ }
449
+
450
+ result := gin.H{}
451
+ if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
452
+ result["chatgpt_account_id"] = v
453
+ }
454
+ if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
455
+ result["plan_type"] = v
456
+ }
457
+ if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
458
+ result["chatgpt_subscription_active_start"] = v
459
+ }
460
+ if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
461
+ result["chatgpt_subscription_active_until"] = v
462
+ }
463
+
464
+ if len(result) == 0 {
465
+ return nil
466
+ }
467
+ return result
468
+ }
469
+
470
+ func authEmail(auth *coreauth.Auth) string {
471
+ if auth == nil {
472
+ return ""
473
+ }
474
+ if auth.Metadata != nil {
475
+ if v, ok := auth.Metadata["email"].(string); ok {
476
+ return strings.TrimSpace(v)
477
+ }
478
+ }
479
+ if auth.Attributes != nil {
480
+ if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
481
+ return v
482
+ }
483
+ if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
484
+ return v
485
+ }
486
+ }
487
+ return ""
488
+ }
489
+
490
+ func authAttribute(auth *coreauth.Auth, key string) string {
491
+ if auth == nil || len(auth.Attributes) == 0 {
492
+ return ""
493
+ }
494
+ return auth.Attributes[key]
495
+ }
496
+
497
+ func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
498
+ if auth == nil || len(auth.Attributes) == 0 {
499
+ return false
500
+ }
501
+ return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
502
+ }
503
+
504
+ // Download single auth file by name
505
+ func (h *Handler) DownloadAuthFile(c *gin.Context) {
506
+ name := c.Query("name")
507
+ if name == "" || strings.Contains(name, string(os.PathSeparator)) {
508
+ c.JSON(400, gin.H{"error": "invalid name"})
509
+ return
510
+ }
511
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
512
+ c.JSON(400, gin.H{"error": "name must end with .json"})
513
+ return
514
+ }
515
+ full := filepath.Join(h.cfg.AuthDir, name)
516
+ data, err := os.ReadFile(full)
517
+ if err != nil {
518
+ if os.IsNotExist(err) {
519
+ c.JSON(404, gin.H{"error": "file not found"})
520
+ } else {
521
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
522
+ }
523
+ return
524
+ }
525
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
526
+ c.Data(200, "application/json", data)
527
+ }
528
+
529
+ // Upload auth file: multipart or raw JSON with ?name=
530
+ func (h *Handler) UploadAuthFile(c *gin.Context) {
531
+ if h.authManager == nil {
532
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
533
+ return
534
+ }
535
+ ctx := c.Request.Context()
536
+ if file, err := c.FormFile("file"); err == nil && file != nil {
537
+ name := filepath.Base(file.Filename)
538
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
539
+ c.JSON(400, gin.H{"error": "file must be .json"})
540
+ return
541
+ }
542
+ dst := filepath.Join(h.cfg.AuthDir, name)
543
+ if !filepath.IsAbs(dst) {
544
+ if abs, errAbs := filepath.Abs(dst); errAbs == nil {
545
+ dst = abs
546
+ }
547
+ }
548
+ if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
549
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
550
+ return
551
+ }
552
+ data, errRead := os.ReadFile(dst)
553
+ if errRead != nil {
554
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
555
+ return
556
+ }
557
+ if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
558
+ c.JSON(500, gin.H{"error": errReg.Error()})
559
+ return
560
+ }
561
+ c.JSON(200, gin.H{"status": "ok"})
562
+ return
563
+ }
564
+ name := c.Query("name")
565
+ if name == "" || strings.Contains(name, string(os.PathSeparator)) {
566
+ c.JSON(400, gin.H{"error": "invalid name"})
567
+ return
568
+ }
569
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
570
+ c.JSON(400, gin.H{"error": "name must end with .json"})
571
+ return
572
+ }
573
+ data, err := io.ReadAll(c.Request.Body)
574
+ if err != nil {
575
+ c.JSON(400, gin.H{"error": "failed to read body"})
576
+ return
577
+ }
578
+ dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
579
+ if !filepath.IsAbs(dst) {
580
+ if abs, errAbs := filepath.Abs(dst); errAbs == nil {
581
+ dst = abs
582
+ }
583
+ }
584
+ if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
585
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
586
+ return
587
+ }
588
+ if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
589
+ c.JSON(500, gin.H{"error": err.Error()})
590
+ return
591
+ }
592
+ c.JSON(200, gin.H{"status": "ok"})
593
+ }
594
+
595
+ // Delete auth files: single by name or all
596
+ func (h *Handler) DeleteAuthFile(c *gin.Context) {
597
+ if h.authManager == nil {
598
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
599
+ return
600
+ }
601
+ ctx := c.Request.Context()
602
+ if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
603
+ entries, err := os.ReadDir(h.cfg.AuthDir)
604
+ if err != nil {
605
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
606
+ return
607
+ }
608
+ deleted := 0
609
+ for _, e := range entries {
610
+ if e.IsDir() {
611
+ continue
612
+ }
613
+ name := e.Name()
614
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
615
+ continue
616
+ }
617
+ full := filepath.Join(h.cfg.AuthDir, name)
618
+ if !filepath.IsAbs(full) {
619
+ if abs, errAbs := filepath.Abs(full); errAbs == nil {
620
+ full = abs
621
+ }
622
+ }
623
+ if err = os.Remove(full); err == nil {
624
+ if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
625
+ c.JSON(500, gin.H{"error": errDel.Error()})
626
+ return
627
+ }
628
+ deleted++
629
+ h.disableAuth(ctx, full)
630
+ }
631
+ }
632
+ c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
633
+ return
634
+ }
635
+ name := c.Query("name")
636
+ if name == "" || strings.Contains(name, string(os.PathSeparator)) {
637
+ c.JSON(400, gin.H{"error": "invalid name"})
638
+ return
639
+ }
640
+ full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
641
+ if !filepath.IsAbs(full) {
642
+ if abs, errAbs := filepath.Abs(full); errAbs == nil {
643
+ full = abs
644
+ }
645
+ }
646
+ if err := os.Remove(full); err != nil {
647
+ if os.IsNotExist(err) {
648
+ c.JSON(404, gin.H{"error": "file not found"})
649
+ } else {
650
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
651
+ }
652
+ return
653
+ }
654
+ if err := h.deleteTokenRecord(ctx, full); err != nil {
655
+ c.JSON(500, gin.H{"error": err.Error()})
656
+ return
657
+ }
658
+ h.disableAuth(ctx, full)
659
+ c.JSON(200, gin.H{"status": "ok"})
660
+ }
661
+
662
+ func (h *Handler) authIDForPath(path string) string {
663
+ path = strings.TrimSpace(path)
664
+ if path == "" {
665
+ return ""
666
+ }
667
+ if h == nil || h.cfg == nil {
668
+ return path
669
+ }
670
+ authDir := strings.TrimSpace(h.cfg.AuthDir)
671
+ if authDir == "" {
672
+ return path
673
+ }
674
+ if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" {
675
+ return rel
676
+ }
677
+ return path
678
+ }
679
+
680
+ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
681
+ if h.authManager == nil {
682
+ return nil
683
+ }
684
+ if path == "" {
685
+ return fmt.Errorf("auth path is empty")
686
+ }
687
+ if data == nil {
688
+ var err error
689
+ data, err = os.ReadFile(path)
690
+ if err != nil {
691
+ return fmt.Errorf("failed to read auth file: %w", err)
692
+ }
693
+ }
694
+ metadata := make(map[string]any)
695
+ if err := json.Unmarshal(data, &metadata); err != nil {
696
+ return fmt.Errorf("invalid auth file: %w", err)
697
+ }
698
+ provider, _ := metadata["type"].(string)
699
+ if provider == "" {
700
+ provider = "unknown"
701
+ }
702
+ label := provider
703
+ if email, ok := metadata["email"].(string); ok && email != "" {
704
+ label = email
705
+ }
706
+ lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
707
+
708
+ authID := h.authIDForPath(path)
709
+ if authID == "" {
710
+ authID = path
711
+ }
712
+ attr := map[string]string{
713
+ "path": path,
714
+ "source": path,
715
+ }
716
+ auth := &coreauth.Auth{
717
+ ID: authID,
718
+ Provider: provider,
719
+ FileName: filepath.Base(path),
720
+ Label: label,
721
+ Status: coreauth.StatusActive,
722
+ Attributes: attr,
723
+ Metadata: metadata,
724
+ CreatedAt: time.Now(),
725
+ UpdatedAt: time.Now(),
726
+ }
727
+ if hasLastRefresh {
728
+ auth.LastRefreshedAt = lastRefresh
729
+ }
730
+ if existing, ok := h.authManager.GetByID(authID); ok {
731
+ auth.CreatedAt = existing.CreatedAt
732
+ if !hasLastRefresh {
733
+ auth.LastRefreshedAt = existing.LastRefreshedAt
734
+ }
735
+ auth.NextRefreshAfter = existing.NextRefreshAfter
736
+ auth.Runtime = existing.Runtime
737
+ _, err := h.authManager.Update(ctx, auth)
738
+ return err
739
+ }
740
+ _, err := h.authManager.Register(ctx, auth)
741
+ return err
742
+ }
743
+
744
+ // PatchAuthFileStatus toggles the disabled state of an auth file
745
+ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
746
+ if h.authManager == nil {
747
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
748
+ return
749
+ }
750
+
751
+ var req struct {
752
+ Name string `json:"name"`
753
+ Disabled *bool `json:"disabled"`
754
+ }
755
+ if err := c.ShouldBindJSON(&req); err != nil {
756
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
757
+ return
758
+ }
759
+
760
+ name := strings.TrimSpace(req.Name)
761
+ if name == "" {
762
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
763
+ return
764
+ }
765
+ if req.Disabled == nil {
766
+ c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
767
+ return
768
+ }
769
+
770
+ ctx := c.Request.Context()
771
+
772
+ // Find auth by name or ID
773
+ var targetAuth *coreauth.Auth
774
+ if auth, ok := h.authManager.GetByID(name); ok {
775
+ targetAuth = auth
776
+ } else {
777
+ auths := h.authManager.List()
778
+ for _, auth := range auths {
779
+ if auth.FileName == name {
780
+ targetAuth = auth
781
+ break
782
+ }
783
+ }
784
+ }
785
+
786
+ if targetAuth == nil {
787
+ c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
788
+ return
789
+ }
790
+
791
+ // Update disabled state
792
+ targetAuth.Disabled = *req.Disabled
793
+ if *req.Disabled {
794
+ targetAuth.Status = coreauth.StatusDisabled
795
+ targetAuth.StatusMessage = "disabled via management API"
796
+ } else {
797
+ targetAuth.Status = coreauth.StatusActive
798
+ targetAuth.StatusMessage = ""
799
+ }
800
+ targetAuth.UpdatedAt = time.Now()
801
+
802
+ if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
803
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
804
+ return
805
+ }
806
+
807
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
808
+ }
809
+
810
+ func (h *Handler) disableAuth(ctx context.Context, id string) {
811
+ if h == nil || h.authManager == nil {
812
+ return
813
+ }
814
+ authID := h.authIDForPath(id)
815
+ if authID == "" {
816
+ authID = strings.TrimSpace(id)
817
+ }
818
+ if authID == "" {
819
+ return
820
+ }
821
+ if auth, ok := h.authManager.GetByID(authID); ok {
822
+ auth.Disabled = true
823
+ auth.Status = coreauth.StatusDisabled
824
+ auth.StatusMessage = "removed via management API"
825
+ auth.UpdatedAt = time.Now()
826
+ _, _ = h.authManager.Update(ctx, auth)
827
+ }
828
+ }
829
+
830
+ func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
831
+ if strings.TrimSpace(path) == "" {
832
+ return fmt.Errorf("auth path is empty")
833
+ }
834
+ store := h.tokenStoreWithBaseDir()
835
+ if store == nil {
836
+ return fmt.Errorf("token store unavailable")
837
+ }
838
+ return store.Delete(ctx, path)
839
+ }
840
+
841
+ func (h *Handler) tokenStoreWithBaseDir() coreauth.Store {
842
+ if h == nil {
843
+ return nil
844
+ }
845
+ store := h.tokenStore
846
+ if store == nil {
847
+ store = sdkAuth.GetTokenStore()
848
+ h.tokenStore = store
849
+ }
850
+ if h.cfg != nil {
851
+ if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
852
+ dirSetter.SetBaseDir(h.cfg.AuthDir)
853
+ }
854
+ }
855
+ return store
856
+ }
857
+
858
+ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
859
+ if record == nil {
860
+ return "", fmt.Errorf("token record is nil")
861
+ }
862
+ store := h.tokenStoreWithBaseDir()
863
+ if store == nil {
864
+ return "", fmt.Errorf("token store unavailable")
865
+ }
866
+ return store.Save(ctx, record)
867
+ }
868
+
869
+ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
870
+ ctx := context.Background()
871
+
872
+ fmt.Println("Initializing Claude authentication...")
873
+
874
+ // Generate PKCE codes
875
+ pkceCodes, err := claude.GeneratePKCECodes()
876
+ if err != nil {
877
+ log.Errorf("Failed to generate PKCE codes: %v", err)
878
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
879
+ return
880
+ }
881
+
882
+ // Generate random state parameter
883
+ state, err := misc.GenerateRandomState()
884
+ if err != nil {
885
+ log.Errorf("Failed to generate state parameter: %v", err)
886
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
887
+ return
888
+ }
889
+
890
+ // Initialize Claude auth service
891
+ anthropicAuth := claude.NewClaudeAuth(h.cfg)
892
+
893
+ // Generate authorization URL (then override redirect_uri to reuse server port)
894
+ authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
895
+ if err != nil {
896
+ log.Errorf("Failed to generate authorization URL: %v", err)
897
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
898
+ return
899
+ }
900
+
901
+ RegisterOAuthSession(state, "anthropic")
902
+
903
+ isWebUI := isWebUIRequest(c)
904
+ var forwarder *callbackForwarder
905
+ if isWebUI {
906
+ targetURL, errTarget := h.managementCallbackURL("/anthropic/callback")
907
+ if errTarget != nil {
908
+ log.WithError(errTarget).Error("failed to compute anthropic callback target")
909
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
910
+ return
911
+ }
912
+ var errStart error
913
+ if forwarder, errStart = startCallbackForwarder(anthropicCallbackPort, "anthropic", targetURL); errStart != nil {
914
+ log.WithError(errStart).Error("failed to start anthropic callback forwarder")
915
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
916
+ return
917
+ }
918
+ }
919
+
920
+ go func() {
921
+ if isWebUI {
922
+ defer stopCallbackForwarderInstance(anthropicCallbackPort, forwarder)
923
+ }
924
+
925
+ // Helper: wait for callback file
926
+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
927
+ waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
928
+ deadline := time.Now().Add(timeout)
929
+ for {
930
+ if !IsOAuthSessionPending(state, "anthropic") {
931
+ return nil, errOAuthSessionNotPending
932
+ }
933
+ if time.Now().After(deadline) {
934
+ SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
935
+ return nil, fmt.Errorf("timeout waiting for OAuth callback")
936
+ }
937
+ data, errRead := os.ReadFile(path)
938
+ if errRead == nil {
939
+ var m map[string]string
940
+ _ = json.Unmarshal(data, &m)
941
+ _ = os.Remove(path)
942
+ return m, nil
943
+ }
944
+ time.Sleep(500 * time.Millisecond)
945
+ }
946
+ }
947
+
948
+ fmt.Println("Waiting for authentication callback...")
949
+ // Wait up to 5 minutes
950
+ resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
951
+ if errWait != nil {
952
+ if errors.Is(errWait, errOAuthSessionNotPending) {
953
+ return
954
+ }
955
+ authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
956
+ log.Error(claude.GetUserFriendlyMessage(authErr))
957
+ return
958
+ }
959
+ if errStr := resultMap["error"]; errStr != "" {
960
+ oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
961
+ log.Error(claude.GetUserFriendlyMessage(oauthErr))
962
+ SetOAuthSessionError(state, "Bad request")
963
+ return
964
+ }
965
+ if resultMap["state"] != state {
966
+ authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
967
+ log.Error(claude.GetUserFriendlyMessage(authErr))
968
+ SetOAuthSessionError(state, "State code error")
969
+ return
970
+ }
971
+
972
+ // Parse code (Claude may append state after '#')
973
+ rawCode := resultMap["code"]
974
+ code := strings.Split(rawCode, "#")[0]
975
+
976
+ // Exchange code for tokens using internal auth service
977
+ bundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes)
978
+ if errExchange != nil {
979
+ authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange)
980
+ log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
981
+ SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
982
+ return
983
+ }
984
+
985
+ // Create token storage
986
+ tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
987
+ record := &coreauth.Auth{
988
+ ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
989
+ Provider: "claude",
990
+ FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
991
+ Storage: tokenStorage,
992
+ Metadata: map[string]any{"email": tokenStorage.Email},
993
+ }
994
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
995
+ if errSave != nil {
996
+ log.Errorf("Failed to save authentication tokens: %v", errSave)
997
+ SetOAuthSessionError(state, "Failed to save authentication tokens")
998
+ return
999
+ }
1000
+
1001
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
1002
+ if bundle.APIKey != "" {
1003
+ fmt.Println("API key obtained and saved")
1004
+ }
1005
+ fmt.Println("You can now use Claude services through this CLI")
1006
+ CompleteOAuthSession(state)
1007
+ CompleteOAuthSessionsByProvider("anthropic")
1008
+ }()
1009
+
1010
+ c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
1011
+ }
1012
+
1013
+ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
1014
+ ctx := context.Background()
1015
+ proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
1016
+ ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)
1017
+
1018
+ // Optional project ID from query
1019
+ projectID := c.Query("project_id")
1020
+
1021
+ fmt.Println("Initializing Google authentication...")
1022
+
1023
+ // OAuth2 configuration using exported constants from internal/auth/gemini
1024
+ conf := &oauth2.Config{
1025
+ ClientID: geminiAuth.ClientID,
1026
+ ClientSecret: geminiAuth.ClientSecret,
1027
+ RedirectURL: fmt.Sprintf("http://localhost:%d/oauth2callback", geminiAuth.DefaultCallbackPort),
1028
+ Scopes: geminiAuth.Scopes,
1029
+ Endpoint: google.Endpoint,
1030
+ }
1031
+
1032
+ // Build authorization URL and return it immediately
1033
+ state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
1034
+ authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
1035
+
1036
+ RegisterOAuthSession(state, "gemini")
1037
+
1038
+ isWebUI := isWebUIRequest(c)
1039
+ var forwarder *callbackForwarder
1040
+ if isWebUI {
1041
+ targetURL, errTarget := h.managementCallbackURL("/google/callback")
1042
+ if errTarget != nil {
1043
+ log.WithError(errTarget).Error("failed to compute gemini callback target")
1044
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
1045
+ return
1046
+ }
1047
+ var errStart error
1048
+ if forwarder, errStart = startCallbackForwarder(geminiCallbackPort, "gemini", targetURL); errStart != nil {
1049
+ log.WithError(errStart).Error("failed to start gemini callback forwarder")
1050
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
1051
+ return
1052
+ }
1053
+ }
1054
+
1055
+ go func() {
1056
+ if isWebUI {
1057
+ defer stopCallbackForwarderInstance(geminiCallbackPort, forwarder)
1058
+ }
1059
+
1060
+ // Wait for callback file written by server route
1061
+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
1062
+ fmt.Println("Waiting for authentication callback...")
1063
+ deadline := time.Now().Add(5 * time.Minute)
1064
+ var authCode string
1065
+ for {
1066
+ if !IsOAuthSessionPending(state, "gemini") {
1067
+ return
1068
+ }
1069
+ if time.Now().After(deadline) {
1070
+ log.Error("oauth flow timed out")
1071
+ SetOAuthSessionError(state, "OAuth flow timed out")
1072
+ return
1073
+ }
1074
+ if data, errR := os.ReadFile(waitFile); errR == nil {
1075
+ var m map[string]string
1076
+ _ = json.Unmarshal(data, &m)
1077
+ _ = os.Remove(waitFile)
1078
+ if errStr := m["error"]; errStr != "" {
1079
+ log.Errorf("Authentication failed: %s", errStr)
1080
+ SetOAuthSessionError(state, "Authentication failed")
1081
+ return
1082
+ }
1083
+ authCode = m["code"]
1084
+ if authCode == "" {
1085
+ log.Errorf("Authentication failed: code not found")
1086
+ SetOAuthSessionError(state, "Authentication failed: code not found")
1087
+ return
1088
+ }
1089
+ break
1090
+ }
1091
+ time.Sleep(500 * time.Millisecond)
1092
+ }
1093
+
1094
+ // Exchange authorization code for token
1095
+ token, err := conf.Exchange(ctx, authCode)
1096
+ if err != nil {
1097
+ log.Errorf("Failed to exchange token: %v", err)
1098
+ SetOAuthSessionError(state, "Failed to exchange token")
1099
+ return
1100
+ }
1101
+
1102
+ requestedProjectID := strings.TrimSpace(projectID)
1103
+
1104
+ // Create token storage (mirrors internal/auth/gemini createTokenStorage)
1105
+ authHTTPClient := conf.Client(ctx, token)
1106
+ req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
1107
+ if errNewRequest != nil {
1108
+ log.Errorf("Could not get user info: %v", errNewRequest)
1109
+ SetOAuthSessionError(state, "Could not get user info")
1110
+ return
1111
+ }
1112
+ req.Header.Set("Content-Type", "application/json")
1113
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
1114
+
1115
+ resp, errDo := authHTTPClient.Do(req)
1116
+ if errDo != nil {
1117
+ log.Errorf("Failed to execute request: %v", errDo)
1118
+ SetOAuthSessionError(state, "Failed to execute request")
1119
+ return
1120
+ }
1121
+ defer func() {
1122
+ if errClose := resp.Body.Close(); errClose != nil {
1123
+ log.Printf("warn: failed to close response body: %v", errClose)
1124
+ }
1125
+ }()
1126
+
1127
+ bodyBytes, _ := io.ReadAll(resp.Body)
1128
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
1129
+ log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
1130
+ SetOAuthSessionError(state, fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode))
1131
+ return
1132
+ }
1133
+
1134
+ email := gjson.GetBytes(bodyBytes, "email").String()
1135
+ if email != "" {
1136
+ fmt.Printf("Authenticated user email: %s\n", email)
1137
+ } else {
1138
+ fmt.Println("Failed to get user email from token")
1139
+ }
1140
+
1141
+ // Marshal/unmarshal oauth2.Token to generic map and enrich fields
1142
+ var ifToken map[string]any
1143
+ jsonData, _ := json.Marshal(token)
1144
+ if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
1145
+ log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
1146
+ SetOAuthSessionError(state, "Failed to unmarshal token")
1147
+ return
1148
+ }
1149
+
1150
+ ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
1151
+ ifToken["client_id"] = geminiAuth.ClientID
1152
+ ifToken["client_secret"] = geminiAuth.ClientSecret
1153
+ ifToken["scopes"] = geminiAuth.Scopes
1154
+ ifToken["universe_domain"] = "googleapis.com"
1155
+
1156
+ ts := geminiAuth.GeminiTokenStorage{
1157
+ Token: ifToken,
1158
+ ProjectID: requestedProjectID,
1159
+ Email: email,
1160
+ Auto: requestedProjectID == "",
1161
+ }
1162
+
1163
+ // Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
1164
+ gemAuth := geminiAuth.NewGeminiAuth()
1165
+ gemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, &geminiAuth.WebLoginOptions{
1166
+ NoBrowser: true,
1167
+ })
1168
+ if errGetClient != nil {
1169
+ log.Errorf("failed to get authenticated client: %v", errGetClient)
1170
+ SetOAuthSessionError(state, "Failed to get authenticated client")
1171
+ return
1172
+ }
1173
+ fmt.Println("Authentication successful.")
1174
+
1175
+ if strings.EqualFold(requestedProjectID, "ALL") {
1176
+ ts.Auto = false
1177
+ projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts)
1178
+ if errAll != nil {
1179
+ log.Errorf("Failed to complete Gemini CLI onboarding: %v", errAll)
1180
+ SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
1181
+ return
1182
+ }
1183
+ if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil {
1184
+ log.Errorf("Failed to verify Cloud AI API status: %v", errVerify)
1185
+ SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
1186
+ return
1187
+ }
1188
+ ts.ProjectID = strings.Join(projects, ",")
1189
+ ts.Checked = true
1190
+ } else {
1191
+ if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
1192
+ log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
1193
+ SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
1194
+ return
1195
+ }
1196
+
1197
+ if strings.TrimSpace(ts.ProjectID) == "" {
1198
+ log.Error("Onboarding did not return a project ID")
1199
+ SetOAuthSessionError(state, "Failed to resolve project ID")
1200
+ return
1201
+ }
1202
+
1203
+ isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
1204
+ if errCheck != nil {
1205
+ log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
1206
+ SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
1207
+ return
1208
+ }
1209
+ ts.Checked = isChecked
1210
+ if !isChecked {
1211
+ log.Error("Cloud AI API is not enabled for the selected project")
1212
+ SetOAuthSessionError(state, "Cloud AI API not enabled")
1213
+ return
1214
+ }
1215
+ }
1216
+
1217
+ recordMetadata := map[string]any{
1218
+ "email": ts.Email,
1219
+ "project_id": ts.ProjectID,
1220
+ "auto": ts.Auto,
1221
+ "checked": ts.Checked,
1222
+ }
1223
+
1224
+ fileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true)
1225
+ record := &coreauth.Auth{
1226
+ ID: fileName,
1227
+ Provider: "gemini",
1228
+ FileName: fileName,
1229
+ Storage: &ts,
1230
+ Metadata: recordMetadata,
1231
+ }
1232
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
1233
+ if errSave != nil {
1234
+ log.Errorf("Failed to save token to file: %v", errSave)
1235
+ SetOAuthSessionError(state, "Failed to save token to file")
1236
+ return
1237
+ }
1238
+
1239
+ CompleteOAuthSession(state)
1240
+ CompleteOAuthSessionsByProvider("gemini")
1241
+ fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
1242
+ }()
1243
+
1244
+ c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
1245
+ }
1246
+
1247
+ func (h *Handler) RequestCodexToken(c *gin.Context) {
1248
+ ctx := context.Background()
1249
+
1250
+ fmt.Println("Initializing Codex authentication...")
1251
+
1252
+ // Generate PKCE codes
1253
+ pkceCodes, err := codex.GeneratePKCECodes()
1254
+ if err != nil {
1255
+ log.Errorf("Failed to generate PKCE codes: %v", err)
1256
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
1257
+ return
1258
+ }
1259
+
1260
+ // Generate random state parameter
1261
+ state, err := misc.GenerateRandomState()
1262
+ if err != nil {
1263
+ log.Errorf("Failed to generate state parameter: %v", err)
1264
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
1265
+ return
1266
+ }
1267
+
1268
+ // Initialize Codex auth service
1269
+ openaiAuth := codex.NewCodexAuth(h.cfg)
1270
+
1271
+ // Generate authorization URL
1272
+ authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
1273
+ if err != nil {
1274
+ log.Errorf("Failed to generate authorization URL: %v", err)
1275
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
1276
+ return
1277
+ }
1278
+
1279
+ RegisterOAuthSession(state, "codex")
1280
+
1281
+ isWebUI := isWebUIRequest(c)
1282
+ var forwarder *callbackForwarder
1283
+ if isWebUI {
1284
+ targetURL, errTarget := h.managementCallbackURL("/codex/callback")
1285
+ if errTarget != nil {
1286
+ log.WithError(errTarget).Error("failed to compute codex callback target")
1287
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
1288
+ return
1289
+ }
1290
+ var errStart error
1291
+ if forwarder, errStart = startCallbackForwarder(codexCallbackPort, "codex", targetURL); errStart != nil {
1292
+ log.WithError(errStart).Error("failed to start codex callback forwarder")
1293
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
1294
+ return
1295
+ }
1296
+ }
1297
+
1298
+ go func() {
1299
+ if isWebUI {
1300
+ defer stopCallbackForwarderInstance(codexCallbackPort, forwarder)
1301
+ }
1302
+
1303
+ // Wait for callback file
1304
+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
1305
+ deadline := time.Now().Add(5 * time.Minute)
1306
+ var code string
1307
+ for {
1308
+ if !IsOAuthSessionPending(state, "codex") {
1309
+ return
1310
+ }
1311
+ if time.Now().After(deadline) {
1312
+ authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
1313
+ log.Error(codex.GetUserFriendlyMessage(authErr))
1314
+ SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
1315
+ return
1316
+ }
1317
+ if data, errR := os.ReadFile(waitFile); errR == nil {
1318
+ var m map[string]string
1319
+ _ = json.Unmarshal(data, &m)
1320
+ _ = os.Remove(waitFile)
1321
+ if errStr := m["error"]; errStr != "" {
1322
+ oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
1323
+ log.Error(codex.GetUserFriendlyMessage(oauthErr))
1324
+ SetOAuthSessionError(state, "Bad Request")
1325
+ return
1326
+ }
1327
+ if m["state"] != state {
1328
+ authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
1329
+ SetOAuthSessionError(state, "State code error")
1330
+ log.Error(codex.GetUserFriendlyMessage(authErr))
1331
+ return
1332
+ }
1333
+ code = m["code"]
1334
+ break
1335
+ }
1336
+ time.Sleep(500 * time.Millisecond)
1337
+ }
1338
+
1339
+ log.Debug("Authorization code received, exchanging for tokens...")
1340
+ // Exchange code for tokens using internal auth service
1341
+ bundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes)
1342
+ if errExchange != nil {
1343
+ authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange)
1344
+ SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
1345
+ log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
1346
+ return
1347
+ }
1348
+
1349
+ // Extract additional info for filename generation
1350
+ claims, _ := codex.ParseJWTToken(bundle.TokenData.IDToken)
1351
+ planType := ""
1352
+ hashAccountID := ""
1353
+ if claims != nil {
1354
+ planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
1355
+ if accountID := claims.GetAccountID(); accountID != "" {
1356
+ digest := sha256.Sum256([]byte(accountID))
1357
+ hashAccountID = hex.EncodeToString(digest[:])[:8]
1358
+ }
1359
+ }
1360
+
1361
+ // Create token storage and persist
1362
+ tokenStorage := openaiAuth.CreateTokenStorage(bundle)
1363
+ fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
1364
+ record := &coreauth.Auth{
1365
+ ID: fileName,
1366
+ Provider: "codex",
1367
+ FileName: fileName,
1368
+ Storage: tokenStorage,
1369
+ Metadata: map[string]any{
1370
+ "email": tokenStorage.Email,
1371
+ "account_id": tokenStorage.AccountID,
1372
+ },
1373
+ }
1374
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
1375
+ if errSave != nil {
1376
+ SetOAuthSessionError(state, "Failed to save authentication tokens")
1377
+ log.Errorf("Failed to save authentication tokens: %v", errSave)
1378
+ return
1379
+ }
1380
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
1381
+ if bundle.APIKey != "" {
1382
+ fmt.Println("API key obtained and saved")
1383
+ }
1384
+ fmt.Println("You can now use Codex services through this CLI")
1385
+ CompleteOAuthSession(state)
1386
+ CompleteOAuthSessionsByProvider("codex")
1387
+ }()
1388
+
1389
+ c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
1390
+ }
1391
+
1392
+ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
1393
+ ctx := context.Background()
1394
+
1395
+ fmt.Println("Initializing Antigravity authentication...")
1396
+
1397
+ authSvc := antigravity.NewAntigravityAuth(h.cfg, nil)
1398
+
1399
+ state, errState := misc.GenerateRandomState()
1400
+ if errState != nil {
1401
+ log.Errorf("Failed to generate state parameter: %v", errState)
1402
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
1403
+ return
1404
+ }
1405
+
1406
+ redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", antigravity.CallbackPort)
1407
+ authURL := authSvc.BuildAuthURL(state, redirectURI)
1408
+
1409
+ RegisterOAuthSession(state, "antigravity")
1410
+
1411
+ isWebUI := isWebUIRequest(c)
1412
+ var forwarder *callbackForwarder
1413
+ if isWebUI {
1414
+ targetURL, errTarget := h.managementCallbackURL("/antigravity/callback")
1415
+ if errTarget != nil {
1416
+ log.WithError(errTarget).Error("failed to compute antigravity callback target")
1417
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
1418
+ return
1419
+ }
1420
+ var errStart error
1421
+ if forwarder, errStart = startCallbackForwarder(antigravity.CallbackPort, "antigravity", targetURL); errStart != nil {
1422
+ log.WithError(errStart).Error("failed to start antigravity callback forwarder")
1423
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
1424
+ return
1425
+ }
1426
+ }
1427
+
1428
+ go func() {
1429
+ if isWebUI {
1430
+ defer stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder)
1431
+ }
1432
+
1433
+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state))
1434
+ deadline := time.Now().Add(5 * time.Minute)
1435
+ var authCode string
1436
+ for {
1437
+ if !IsOAuthSessionPending(state, "antigravity") {
1438
+ return
1439
+ }
1440
+ if time.Now().After(deadline) {
1441
+ log.Error("oauth flow timed out")
1442
+ SetOAuthSessionError(state, "OAuth flow timed out")
1443
+ return
1444
+ }
1445
+ if data, errReadFile := os.ReadFile(waitFile); errReadFile == nil {
1446
+ var payload map[string]string
1447
+ _ = json.Unmarshal(data, &payload)
1448
+ _ = os.Remove(waitFile)
1449
+ if errStr := strings.TrimSpace(payload["error"]); errStr != "" {
1450
+ log.Errorf("Authentication failed: %s", errStr)
1451
+ SetOAuthSessionError(state, "Authentication failed")
1452
+ return
1453
+ }
1454
+ if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state {
1455
+ log.Errorf("Authentication failed: state mismatch")
1456
+ SetOAuthSessionError(state, "Authentication failed: state mismatch")
1457
+ return
1458
+ }
1459
+ authCode = strings.TrimSpace(payload["code"])
1460
+ if authCode == "" {
1461
+ log.Error("Authentication failed: code not found")
1462
+ SetOAuthSessionError(state, "Authentication failed: code not found")
1463
+ return
1464
+ }
1465
+ break
1466
+ }
1467
+ time.Sleep(500 * time.Millisecond)
1468
+ }
1469
+
1470
+ tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI)
1471
+ if errToken != nil {
1472
+ log.Errorf("Failed to exchange token: %v", errToken)
1473
+ SetOAuthSessionError(state, "Failed to exchange token")
1474
+ return
1475
+ }
1476
+
1477
+ accessToken := strings.TrimSpace(tokenResp.AccessToken)
1478
+ if accessToken == "" {
1479
+ log.Error("antigravity: token exchange returned empty access token")
1480
+ SetOAuthSessionError(state, "Failed to exchange token")
1481
+ return
1482
+ }
1483
+
1484
+ email, errInfo := authSvc.FetchUserInfo(ctx, accessToken)
1485
+ if errInfo != nil {
1486
+ log.Errorf("Failed to fetch user info: %v", errInfo)
1487
+ SetOAuthSessionError(state, "Failed to fetch user info")
1488
+ return
1489
+ }
1490
+ email = strings.TrimSpace(email)
1491
+ if email == "" {
1492
+ log.Error("antigravity: user info returned empty email")
1493
+ SetOAuthSessionError(state, "Failed to fetch user info")
1494
+ return
1495
+ }
1496
+
1497
+ projectID := ""
1498
+ if accessToken != "" {
1499
+ fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)
1500
+ if errProject != nil {
1501
+ log.Warnf("antigravity: failed to fetch project ID: %v", errProject)
1502
+ } else {
1503
+ projectID = fetchedProjectID
1504
+ log.Infof("antigravity: obtained project ID %s", projectID)
1505
+ }
1506
+ }
1507
+
1508
+ now := time.Now()
1509
+ metadata := map[string]any{
1510
+ "type": "antigravity",
1511
+ "access_token": tokenResp.AccessToken,
1512
+ "refresh_token": tokenResp.RefreshToken,
1513
+ "expires_in": tokenResp.ExpiresIn,
1514
+ "timestamp": now.UnixMilli(),
1515
+ "expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
1516
+ }
1517
+ if email != "" {
1518
+ metadata["email"] = email
1519
+ }
1520
+ if projectID != "" {
1521
+ metadata["project_id"] = projectID
1522
+ }
1523
+
1524
+ fileName := antigravity.CredentialFileName(email)
1525
+ label := strings.TrimSpace(email)
1526
+ if label == "" {
1527
+ label = "antigravity"
1528
+ }
1529
+
1530
+ record := &coreauth.Auth{
1531
+ ID: fileName,
1532
+ Provider: "antigravity",
1533
+ FileName: fileName,
1534
+ Label: label,
1535
+ Metadata: metadata,
1536
+ }
1537
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
1538
+ if errSave != nil {
1539
+ log.Errorf("Failed to save token to file: %v", errSave)
1540
+ SetOAuthSessionError(state, "Failed to save token to file")
1541
+ return
1542
+ }
1543
+
1544
+ CompleteOAuthSession(state)
1545
+ CompleteOAuthSessionsByProvider("antigravity")
1546
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
1547
+ if projectID != "" {
1548
+ fmt.Printf("Using GCP project: %s\n", projectID)
1549
+ }
1550
+ fmt.Println("You can now use Antigravity services through this CLI")
1551
+ }()
1552
+
1553
+ c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
1554
+ }
1555
+
1556
+ func (h *Handler) RequestQwenToken(c *gin.Context) {
1557
+ ctx := context.Background()
1558
+
1559
+ fmt.Println("Initializing Qwen authentication...")
1560
+
1561
+ state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
1562
+ // Initialize Qwen auth service
1563
+ qwenAuth := qwen.NewQwenAuth(h.cfg)
1564
+
1565
+ // Generate authorization URL
1566
+ deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
1567
+ if err != nil {
1568
+ log.Errorf("Failed to generate authorization URL: %v", err)
1569
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
1570
+ return
1571
+ }
1572
+ authURL := deviceFlow.VerificationURIComplete
1573
+
1574
+ RegisterOAuthSession(state, "qwen")
1575
+
1576
+ go func() {
1577
+ fmt.Println("Waiting for authentication...")
1578
+ tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
1579
+ if errPollForToken != nil {
1580
+ SetOAuthSessionError(state, "Authentication failed")
1581
+ fmt.Printf("Authentication failed: %v\n", errPollForToken)
1582
+ return
1583
+ }
1584
+
1585
+ // Create token storage
1586
+ tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
1587
+
1588
+ tokenStorage.Email = fmt.Sprintf("%d", time.Now().UnixMilli())
1589
+ record := &coreauth.Auth{
1590
+ ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
1591
+ Provider: "qwen",
1592
+ FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
1593
+ Storage: tokenStorage,
1594
+ Metadata: map[string]any{"email": tokenStorage.Email},
1595
+ }
1596
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
1597
+ if errSave != nil {
1598
+ log.Errorf("Failed to save authentication tokens: %v", errSave)
1599
+ SetOAuthSessionError(state, "Failed to save authentication tokens")
1600
+ return
1601
+ }
1602
+
1603
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
1604
+ fmt.Println("You can now use Qwen services through this CLI")
1605
+ CompleteOAuthSession(state)
1606
+ }()
1607
+
1608
+ c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
1609
+ }
1610
+
1611
+ func (h *Handler) RequestIFlowToken(c *gin.Context) {
1612
+ ctx := context.Background()
1613
+
1614
+ fmt.Println("Initializing iFlow authentication...")
1615
+
1616
+ state := fmt.Sprintf("ifl-%d", time.Now().UnixNano())
1617
+ authSvc := iflowauth.NewIFlowAuth(h.cfg)
1618
+ authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort)
1619
+
1620
+ RegisterOAuthSession(state, "iflow")
1621
+
1622
+ isWebUI := isWebUIRequest(c)
1623
+ var forwarder *callbackForwarder
1624
+ if isWebUI {
1625
+ targetURL, errTarget := h.managementCallbackURL("/iflow/callback")
1626
+ if errTarget != nil {
1627
+ log.WithError(errTarget).Error("failed to compute iflow callback target")
1628
+ c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "callback server unavailable"})
1629
+ return
1630
+ }
1631
+ var errStart error
1632
+ if forwarder, errStart = startCallbackForwarder(iflowauth.CallbackPort, "iflow", targetURL); errStart != nil {
1633
+ log.WithError(errStart).Error("failed to start iflow callback forwarder")
1634
+ c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start callback server"})
1635
+ return
1636
+ }
1637
+ }
1638
+
1639
+ go func() {
1640
+ if isWebUI {
1641
+ defer stopCallbackForwarderInstance(iflowauth.CallbackPort, forwarder)
1642
+ }
1643
+ fmt.Println("Waiting for authentication...")
1644
+
1645
+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state))
1646
+ deadline := time.Now().Add(5 * time.Minute)
1647
+ var resultMap map[string]string
1648
+ for {
1649
+ if !IsOAuthSessionPending(state, "iflow") {
1650
+ return
1651
+ }
1652
+ if time.Now().After(deadline) {
1653
+ SetOAuthSessionError(state, "Authentication failed")
1654
+ fmt.Println("Authentication failed: timeout waiting for callback")
1655
+ return
1656
+ }
1657
+ if data, errR := os.ReadFile(waitFile); errR == nil {
1658
+ _ = os.Remove(waitFile)
1659
+ _ = json.Unmarshal(data, &resultMap)
1660
+ break
1661
+ }
1662
+ time.Sleep(500 * time.Millisecond)
1663
+ }
1664
+
1665
+ if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" {
1666
+ SetOAuthSessionError(state, "Authentication failed")
1667
+ fmt.Printf("Authentication failed: %s\n", errStr)
1668
+ return
1669
+ }
1670
+ if resultState := strings.TrimSpace(resultMap["state"]); resultState != state {
1671
+ SetOAuthSessionError(state, "Authentication failed")
1672
+ fmt.Println("Authentication failed: state mismatch")
1673
+ return
1674
+ }
1675
+
1676
+ code := strings.TrimSpace(resultMap["code"])
1677
+ if code == "" {
1678
+ SetOAuthSessionError(state, "Authentication failed")
1679
+ fmt.Println("Authentication failed: code missing")
1680
+ return
1681
+ }
1682
+
1683
+ tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)
1684
+ if errExchange != nil {
1685
+ SetOAuthSessionError(state, "Authentication failed")
1686
+ fmt.Printf("Authentication failed: %v\n", errExchange)
1687
+ return
1688
+ }
1689
+
1690
+ tokenStorage := authSvc.CreateTokenStorage(tokenData)
1691
+ identifier := strings.TrimSpace(tokenStorage.Email)
1692
+ if identifier == "" {
1693
+ identifier = fmt.Sprintf("%d", time.Now().UnixMilli())
1694
+ tokenStorage.Email = identifier
1695
+ }
1696
+ record := &coreauth.Auth{
1697
+ ID: fmt.Sprintf("iflow-%s.json", identifier),
1698
+ Provider: "iflow",
1699
+ FileName: fmt.Sprintf("iflow-%s.json", identifier),
1700
+ Storage: tokenStorage,
1701
+ Metadata: map[string]any{"email": identifier, "api_key": tokenStorage.APIKey},
1702
+ Attributes: map[string]string{"api_key": tokenStorage.APIKey},
1703
+ }
1704
+
1705
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
1706
+ if errSave != nil {
1707
+ SetOAuthSessionError(state, "Failed to save authentication tokens")
1708
+ log.Errorf("Failed to save authentication tokens: %v", errSave)
1709
+ return
1710
+ }
1711
+
1712
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
1713
+ if tokenStorage.APIKey != "" {
1714
+ fmt.Println("API key obtained and saved")
1715
+ }
1716
+ fmt.Println("You can now use iFlow services through this CLI")
1717
+ CompleteOAuthSession(state)
1718
+ CompleteOAuthSessionsByProvider("iflow")
1719
+ }()
1720
+
1721
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
1722
+ }
1723
+
1724
+ func (h *Handler) RequestIFlowCookieToken(c *gin.Context) {
1725
+ ctx := context.Background()
1726
+
1727
+ var payload struct {
1728
+ Cookie string `json:"cookie"`
1729
+ }
1730
+ if err := c.ShouldBindJSON(&payload); err != nil {
1731
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"})
1732
+ return
1733
+ }
1734
+
1735
+ cookieValue := strings.TrimSpace(payload.Cookie)
1736
+
1737
+ if cookieValue == "" {
1738
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"})
1739
+ return
1740
+ }
1741
+
1742
+ cookieValue, errNormalize := iflowauth.NormalizeCookie(cookieValue)
1743
+ if errNormalize != nil {
1744
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errNormalize.Error()})
1745
+ return
1746
+ }
1747
+
1748
+ // Check for duplicate BXAuth before authentication
1749
+ bxAuth := iflowauth.ExtractBXAuth(cookieValue)
1750
+ if existingFile, err := iflowauth.CheckDuplicateBXAuth(h.cfg.AuthDir, bxAuth); err != nil {
1751
+ c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to check duplicate"})
1752
+ return
1753
+ } else if existingFile != "" {
1754
+ existingFileName := filepath.Base(existingFile)
1755
+ c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "duplicate BXAuth found", "existing_file": existingFileName})
1756
+ return
1757
+ }
1758
+
1759
+ authSvc := iflowauth.NewIFlowAuth(h.cfg)
1760
+ tokenData, errAuth := authSvc.AuthenticateWithCookie(ctx, cookieValue)
1761
+ if errAuth != nil {
1762
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errAuth.Error()})
1763
+ return
1764
+ }
1765
+
1766
+ tokenData.Cookie = cookieValue
1767
+
1768
+ tokenStorage := authSvc.CreateCookieTokenStorage(tokenData)
1769
+ email := strings.TrimSpace(tokenStorage.Email)
1770
+ if email == "" {
1771
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to extract email from token"})
1772
+ return
1773
+ }
1774
+
1775
+ fileName := iflowauth.SanitizeIFlowFileName(email)
1776
+ if fileName == "" {
1777
+ fileName = fmt.Sprintf("iflow-%d", time.Now().UnixMilli())
1778
+ } else {
1779
+ fileName = fmt.Sprintf("iflow-%s", fileName)
1780
+ }
1781
+
1782
+ tokenStorage.Email = email
1783
+ timestamp := time.Now().Unix()
1784
+
1785
+ record := &coreauth.Auth{
1786
+ ID: fmt.Sprintf("%s-%d.json", fileName, timestamp),
1787
+ Provider: "iflow",
1788
+ FileName: fmt.Sprintf("%s-%d.json", fileName, timestamp),
1789
+ Storage: tokenStorage,
1790
+ Metadata: map[string]any{
1791
+ "email": email,
1792
+ "api_key": tokenStorage.APIKey,
1793
+ "expired": tokenStorage.Expire,
1794
+ "cookie": tokenStorage.Cookie,
1795
+ "type": tokenStorage.Type,
1796
+ "last_refresh": tokenStorage.LastRefresh,
1797
+ },
1798
+ Attributes: map[string]string{
1799
+ "api_key": tokenStorage.APIKey,
1800
+ },
1801
+ }
1802
+
1803
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
1804
+ if errSave != nil {
1805
+ c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to save authentication tokens"})
1806
+ return
1807
+ }
1808
+
1809
+ fmt.Printf("iFlow cookie authentication successful. Token saved to %s\n", savedPath)
1810
+ c.JSON(http.StatusOK, gin.H{
1811
+ "status": "ok",
1812
+ "saved_path": savedPath,
1813
+ "email": email,
1814
+ "expired": tokenStorage.Expire,
1815
+ "type": tokenStorage.Type,
1816
+ })
1817
+ }
1818
+
1819
+ type projectSelectionRequiredError struct{}
1820
+
1821
+ func (e *projectSelectionRequiredError) Error() string {
1822
+ return "gemini cli: project selection required"
1823
+ }
1824
+
1825
+ func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
1826
+ if storage == nil {
1827
+ return fmt.Errorf("gemini storage is nil")
1828
+ }
1829
+
1830
+ trimmedRequest := strings.TrimSpace(requestedProject)
1831
+ if trimmedRequest == "" {
1832
+ projects, errProjects := fetchGCPProjects(ctx, httpClient)
1833
+ if errProjects != nil {
1834
+ return fmt.Errorf("fetch project list: %w", errProjects)
1835
+ }
1836
+ if len(projects) == 0 {
1837
+ return fmt.Errorf("no Google Cloud projects available for this account")
1838
+ }
1839
+ trimmedRequest = strings.TrimSpace(projects[0].ProjectID)
1840
+ if trimmedRequest == "" {
1841
+ return fmt.Errorf("resolved project id is empty")
1842
+ }
1843
+ storage.Auto = true
1844
+ } else {
1845
+ storage.Auto = false
1846
+ }
1847
+
1848
+ if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {
1849
+ return err
1850
+ }
1851
+
1852
+ if strings.TrimSpace(storage.ProjectID) == "" {
1853
+ storage.ProjectID = trimmedRequest
1854
+ }
1855
+
1856
+ return nil
1857
+ }
1858
+
1859
+ func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) {
1860
+ projects, errProjects := fetchGCPProjects(ctx, httpClient)
1861
+ if errProjects != nil {
1862
+ return nil, fmt.Errorf("fetch project list: %w", errProjects)
1863
+ }
1864
+ if len(projects) == 0 {
1865
+ return nil, fmt.Errorf("no Google Cloud projects available for this account")
1866
+ }
1867
+ activated := make([]string, 0, len(projects))
1868
+ seen := make(map[string]struct{}, len(projects))
1869
+ for _, project := range projects {
1870
+ candidate := strings.TrimSpace(project.ProjectID)
1871
+ if candidate == "" {
1872
+ continue
1873
+ }
1874
+ if _, dup := seen[candidate]; dup {
1875
+ continue
1876
+ }
1877
+ if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil {
1878
+ return nil, fmt.Errorf("onboard project %s: %w", candidate, err)
1879
+ }
1880
+ finalID := strings.TrimSpace(storage.ProjectID)
1881
+ if finalID == "" {
1882
+ finalID = candidate
1883
+ }
1884
+ activated = append(activated, finalID)
1885
+ seen[candidate] = struct{}{}
1886
+ }
1887
+ if len(activated) == 0 {
1888
+ return nil, fmt.Errorf("no Google Cloud projects available for this account")
1889
+ }
1890
+ return activated, nil
1891
+ }
1892
+
1893
+ func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error {
1894
+ for _, pid := range projectIDs {
1895
+ trimmed := strings.TrimSpace(pid)
1896
+ if trimmed == "" {
1897
+ continue
1898
+ }
1899
+ isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed)
1900
+ if errCheck != nil {
1901
+ return fmt.Errorf("project %s: %w", trimmed, errCheck)
1902
+ }
1903
+ if !isChecked {
1904
+ return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed)
1905
+ }
1906
+ }
1907
+ return nil
1908
+ }
1909
+
1910
+ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
1911
+ metadata := map[string]string{
1912
+ "ideType": "IDE_UNSPECIFIED",
1913
+ "platform": "PLATFORM_UNSPECIFIED",
1914
+ "pluginType": "GEMINI",
1915
+ }
1916
+
1917
+ trimmedRequest := strings.TrimSpace(requestedProject)
1918
+ explicitProject := trimmedRequest != ""
1919
+
1920
+ loadReqBody := map[string]any{
1921
+ "metadata": metadata,
1922
+ }
1923
+ if explicitProject {
1924
+ loadReqBody["cloudaicompanionProject"] = trimmedRequest
1925
+ }
1926
+
1927
+ var loadResp map[string]any
1928
+ if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
1929
+ return fmt.Errorf("load code assist: %w", errLoad)
1930
+ }
1931
+
1932
+ tierID := "legacy-tier"
1933
+ if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
1934
+ for _, rawTier := range tiers {
1935
+ tier, okTier := rawTier.(map[string]any)
1936
+ if !okTier {
1937
+ continue
1938
+ }
1939
+ if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
1940
+ if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
1941
+ tierID = strings.TrimSpace(id)
1942
+ break
1943
+ }
1944
+ }
1945
+ }
1946
+ }
1947
+
1948
+ projectID := trimmedRequest
1949
+ if projectID == "" {
1950
+ if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
1951
+ projectID = strings.TrimSpace(id)
1952
+ }
1953
+ if projectID == "" {
1954
+ if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
1955
+ if id, okID := projectMap["id"].(string); okID {
1956
+ projectID = strings.TrimSpace(id)
1957
+ }
1958
+ }
1959
+ }
1960
+ }
1961
+ if projectID == "" {
1962
+ return &projectSelectionRequiredError{}
1963
+ }
1964
+
1965
+ onboardReqBody := map[string]any{
1966
+ "tierId": tierID,
1967
+ "metadata": metadata,
1968
+ "cloudaicompanionProject": projectID,
1969
+ }
1970
+
1971
+ storage.ProjectID = projectID
1972
+
1973
+ for {
1974
+ var onboardResp map[string]any
1975
+ if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
1976
+ return fmt.Errorf("onboard user: %w", errOnboard)
1977
+ }
1978
+
1979
+ if done, okDone := onboardResp["done"].(bool); okDone && done {
1980
+ responseProjectID := ""
1981
+ if resp, okResp := onboardResp["response"].(map[string]any); okResp {
1982
+ switch projectValue := resp["cloudaicompanionProject"].(type) {
1983
+ case map[string]any:
1984
+ if id, okID := projectValue["id"].(string); okID {
1985
+ responseProjectID = strings.TrimSpace(id)
1986
+ }
1987
+ case string:
1988
+ responseProjectID = strings.TrimSpace(projectValue)
1989
+ }
1990
+ }
1991
+
1992
+ finalProjectID := projectID
1993
+ if responseProjectID != "" {
1994
+ if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
1995
+ // Check if this is a free user (gen-lang-client projects or free/legacy tier)
1996
+ isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
1997
+ strings.EqualFold(tierID, "FREE") ||
1998
+ strings.EqualFold(tierID, "LEGACY")
1999
+
2000
+ if isFreeUser {
2001
+ // For free users, use backend project ID for preview model access
2002
+ log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
2003
+ log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
2004
+ finalProjectID = responseProjectID
2005
+ } else {
2006
+ // Pro users: keep requested project ID (original behavior)
2007
+ log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
2008
+ }
2009
+ } else {
2010
+ finalProjectID = responseProjectID
2011
+ }
2012
+ }
2013
+
2014
+ storage.ProjectID = strings.TrimSpace(finalProjectID)
2015
+ if storage.ProjectID == "" {
2016
+ storage.ProjectID = strings.TrimSpace(projectID)
2017
+ }
2018
+ if storage.ProjectID == "" {
2019
+ return fmt.Errorf("onboard user completed without project id")
2020
+ }
2021
+ log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
2022
+ return nil
2023
+ }
2024
+
2025
+ log.Println("Onboarding in progress, waiting 5 seconds...")
2026
+ time.Sleep(5 * time.Second)
2027
+ }
2028
+ }
2029
+
2030
+ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
2031
+ endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
2032
+ if strings.HasPrefix(endpoint, "operations/") {
2033
+ endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
2034
+ }
2035
+
2036
+ var reader io.Reader
2037
+ if body != nil {
2038
+ rawBody, errMarshal := json.Marshal(body)
2039
+ if errMarshal != nil {
2040
+ return fmt.Errorf("marshal request body: %w", errMarshal)
2041
+ }
2042
+ reader = bytes.NewReader(rawBody)
2043
+ }
2044
+
2045
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)
2046
+ if errRequest != nil {
2047
+ return fmt.Errorf("create request: %w", errRequest)
2048
+ }
2049
+ req.Header.Set("Content-Type", "application/json")
2050
+ req.Header.Set("User-Agent", geminiCLIUserAgent)
2051
+ req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
2052
+ req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
2053
+
2054
+ resp, errDo := httpClient.Do(req)
2055
+ if errDo != nil {
2056
+ return fmt.Errorf("execute request: %w", errDo)
2057
+ }
2058
+ defer func() {
2059
+ if errClose := resp.Body.Close(); errClose != nil {
2060
+ log.Errorf("response body close error: %v", errClose)
2061
+ }
2062
+ }()
2063
+
2064
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
2065
+ bodyBytes, _ := io.ReadAll(resp.Body)
2066
+ return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
2067
+ }
2068
+
2069
+ if result == nil {
2070
+ _, _ = io.Copy(io.Discard, resp.Body)
2071
+ return nil
2072
+ }
2073
+
2074
+ if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
2075
+ return fmt.Errorf("decode response body: %w", errDecode)
2076
+ }
2077
+
2078
+ return nil
2079
+ }
2080
+
2081
+ func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
2082
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
2083
+ if errRequest != nil {
2084
+ return nil, fmt.Errorf("could not create project list request: %w", errRequest)
2085
+ }
2086
+
2087
+ resp, errDo := httpClient.Do(req)
2088
+ if errDo != nil {
2089
+ return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
2090
+ }
2091
+ defer func() {
2092
+ if errClose := resp.Body.Close(); errClose != nil {
2093
+ log.Errorf("response body close error: %v", errClose)
2094
+ }
2095
+ }()
2096
+
2097
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
2098
+ bodyBytes, _ := io.ReadAll(resp.Body)
2099
+ return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
2100
+ }
2101
+
2102
+ var projects interfaces.GCPProject
2103
+ if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
2104
+ return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
2105
+ }
2106
+
2107
+ return projects.Projects, nil
2108
+ }
2109
+
2110
+ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
2111
+ serviceUsageURL := "https://serviceusage.googleapis.com"
2112
+ requiredServices := []string{
2113
+ "cloudaicompanion.googleapis.com",
2114
+ }
2115
+ for _, service := range requiredServices {
2116
+ checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
2117
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
2118
+ if errRequest != nil {
2119
+ return false, fmt.Errorf("failed to create request: %w", errRequest)
2120
+ }
2121
+ req.Header.Set("Content-Type", "application/json")
2122
+ req.Header.Set("User-Agent", geminiCLIUserAgent)
2123
+ resp, errDo := httpClient.Do(req)
2124
+ if errDo != nil {
2125
+ return false, fmt.Errorf("failed to execute request: %w", errDo)
2126
+ }
2127
+
2128
+ if resp.StatusCode == http.StatusOK {
2129
+ bodyBytes, _ := io.ReadAll(resp.Body)
2130
+ if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
2131
+ _ = resp.Body.Close()
2132
+ continue
2133
+ }
2134
+ }
2135
+ _ = resp.Body.Close()
2136
+
2137
+ enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
2138
+ req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}"))
2139
+ if errRequest != nil {
2140
+ return false, fmt.Errorf("failed to create request: %w", errRequest)
2141
+ }
2142
+ req.Header.Set("Content-Type", "application/json")
2143
+ req.Header.Set("User-Agent", geminiCLIUserAgent)
2144
+ resp, errDo = httpClient.Do(req)
2145
+ if errDo != nil {
2146
+ return false, fmt.Errorf("failed to execute request: %w", errDo)
2147
+ }
2148
+
2149
+ bodyBytes, _ := io.ReadAll(resp.Body)
2150
+ errMessage := string(bodyBytes)
2151
+ errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
2152
+ if errMessageResult.Exists() {
2153
+ errMessage = errMessageResult.String()
2154
+ }
2155
+ if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
2156
+ _ = resp.Body.Close()
2157
+ continue
2158
+ } else if resp.StatusCode == http.StatusBadRequest {
2159
+ _ = resp.Body.Close()
2160
+ if strings.Contains(strings.ToLower(errMessage), "already enabled") {
2161
+ continue
2162
+ }
2163
+ }
2164
+ _ = resp.Body.Close()
2165
+ return false, fmt.Errorf("project activation required: %s", errMessage)
2166
+ }
2167
+ return true, nil
2168
+ }
2169
+
2170
+ func (h *Handler) GetAuthStatus(c *gin.Context) {
2171
+ state := strings.TrimSpace(c.Query("state"))
2172
+ if state == "" {
2173
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
2174
+ return
2175
+ }
2176
+ if err := ValidateOAuthState(state); err != nil {
2177
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
2178
+ return
2179
+ }
2180
+
2181
+ _, status, ok := GetOAuthSession(state)
2182
+ if !ok {
2183
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
2184
+ return
2185
+ }
2186
+ if status != "" {
2187
+ c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
2188
+ return
2189
+ }
2190
+ c.JSON(http.StatusOK, gin.H{"status": "wait"})
2191
+ }
internal/api/handlers/management/config_basic.go ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/gin-gonic/gin"
14
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
16
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
17
+ log "github.com/sirupsen/logrus"
18
+ "gopkg.in/yaml.v3"
19
+ )
20
+
21
+ const (
22
+ latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
23
+ latestReleaseUserAgent = "CLIProxyAPI"
24
+ )
25
+
26
+ func (h *Handler) GetConfig(c *gin.Context) {
27
+ if h == nil || h.cfg == nil {
28
+ c.JSON(200, gin.H{})
29
+ return
30
+ }
31
+ cfgCopy := *h.cfg
32
+ c.JSON(200, &cfgCopy)
33
+ }
34
+
35
+ type releaseInfo struct {
36
+ TagName string `json:"tag_name"`
37
+ Name string `json:"name"`
38
+ }
39
+
40
+ // GetLatestVersion returns the latest release version from GitHub without downloading assets.
41
+ func (h *Handler) GetLatestVersion(c *gin.Context) {
42
+ client := &http.Client{Timeout: 10 * time.Second}
43
+ proxyURL := ""
44
+ if h != nil && h.cfg != nil {
45
+ proxyURL = strings.TrimSpace(h.cfg.ProxyURL)
46
+ }
47
+ if proxyURL != "" {
48
+ sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL}
49
+ util.SetProxy(sdkCfg, client)
50
+ }
51
+
52
+ req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
53
+ if err != nil {
54
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
55
+ return
56
+ }
57
+ req.Header.Set("Accept", "application/vnd.github+json")
58
+ req.Header.Set("User-Agent", latestReleaseUserAgent)
59
+
60
+ resp, err := client.Do(req)
61
+ if err != nil {
62
+ c.JSON(http.StatusBadGateway, gin.H{"error": "request_failed", "message": err.Error()})
63
+ return
64
+ }
65
+ defer func() {
66
+ if errClose := resp.Body.Close(); errClose != nil {
67
+ log.WithError(errClose).Debug("failed to close latest version response body")
68
+ }
69
+ }()
70
+
71
+ if resp.StatusCode != http.StatusOK {
72
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
73
+ c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected_status", "message": fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))})
74
+ return
75
+ }
76
+
77
+ var info releaseInfo
78
+ if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {
79
+ c.JSON(http.StatusBadGateway, gin.H{"error": "decode_failed", "message": errDecode.Error()})
80
+ return
81
+ }
82
+
83
+ version := strings.TrimSpace(info.TagName)
84
+ if version == "" {
85
+ version = strings.TrimSpace(info.Name)
86
+ }
87
+ if version == "" {
88
+ c.JSON(http.StatusBadGateway, gin.H{"error": "invalid_response", "message": "missing release version"})
89
+ return
90
+ }
91
+
92
+ c.JSON(http.StatusOK, gin.H{"latest-version": version})
93
+ }
94
+
95
+ func WriteConfig(path string, data []byte) error {
96
+ data = config.NormalizeCommentIndentation(data)
97
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
98
+ if err != nil {
99
+ return err
100
+ }
101
+ if _, errWrite := f.Write(data); errWrite != nil {
102
+ _ = f.Close()
103
+ return errWrite
104
+ }
105
+ if errSync := f.Sync(); errSync != nil {
106
+ _ = f.Close()
107
+ return errSync
108
+ }
109
+ return f.Close()
110
+ }
111
+
112
+ func (h *Handler) PutConfigYAML(c *gin.Context) {
113
+ body, err := io.ReadAll(c.Request.Body)
114
+ if err != nil {
115
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": "cannot read request body"})
116
+ return
117
+ }
118
+ var cfg config.Config
119
+ if err = yaml.Unmarshal(body, &cfg); err != nil {
120
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()})
121
+ return
122
+ }
123
+ // Validate config using LoadConfigOptional with optional=false to enforce parsing
124
+ tmpDir := filepath.Dir(h.configFilePath)
125
+ tmpFile, err := os.CreateTemp(tmpDir, "config-validate-*.yaml")
126
+ if err != nil {
127
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()})
128
+ return
129
+ }
130
+ tempFile := tmpFile.Name()
131
+ if _, errWrite := tmpFile.Write(body); errWrite != nil {
132
+ _ = tmpFile.Close()
133
+ _ = os.Remove(tempFile)
134
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errWrite.Error()})
135
+ return
136
+ }
137
+ if errClose := tmpFile.Close(); errClose != nil {
138
+ _ = os.Remove(tempFile)
139
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errClose.Error()})
140
+ return
141
+ }
142
+ defer func() {
143
+ _ = os.Remove(tempFile)
144
+ }()
145
+ _, err = config.LoadConfigOptional(tempFile, false)
146
+ if err != nil {
147
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()})
148
+ return
149
+ }
150
+ h.mu.Lock()
151
+ defer h.mu.Unlock()
152
+ if WriteConfig(h.configFilePath, body) != nil {
153
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": "failed to write config"})
154
+ return
155
+ }
156
+ // Reload into handler to keep memory in sync
157
+ newCfg, err := config.LoadConfig(h.configFilePath)
158
+ if err != nil {
159
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "reload_failed", "message": err.Error()})
160
+ return
161
+ }
162
+ h.cfg = newCfg
163
+ c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
164
+ }
165
+
166
+ // GetConfigYAML returns the raw config.yaml file bytes without re-encoding.
167
+ // It preserves comments and original formatting/styles.
168
+ func (h *Handler) GetConfigYAML(c *gin.Context) {
169
+ data, err := os.ReadFile(h.configFilePath)
170
+ if err != nil {
171
+ if os.IsNotExist(err) {
172
+ c.JSON(http.StatusNotFound, gin.H{"error": "not_found", "message": "config file not found"})
173
+ return
174
+ }
175
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()})
176
+ return
177
+ }
178
+ c.Header("Content-Type", "application/yaml; charset=utf-8")
179
+ c.Header("Cache-Control", "no-store")
180
+ c.Header("X-Content-Type-Options", "nosniff")
181
+ // Write raw bytes as-is
182
+ _, _ = c.Writer.Write(data)
183
+ }
184
+
185
+ // Debug
186
+ func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
187
+ func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
188
+
189
+ // UsageStatisticsEnabled
190
+ func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
191
+ c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
192
+ }
193
+ func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
194
+ h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
195
+ }
196
+
197
+ // UsageStatisticsEnabled
198
+ func (h *Handler) GetLoggingToFile(c *gin.Context) {
199
+ c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
200
+ }
201
+ func (h *Handler) PutLoggingToFile(c *gin.Context) {
202
+ h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
203
+ }
204
+
205
+ // LogsMaxTotalSizeMB
206
+ func (h *Handler) GetLogsMaxTotalSizeMB(c *gin.Context) {
207
+ c.JSON(200, gin.H{"logs-max-total-size-mb": h.cfg.LogsMaxTotalSizeMB})
208
+ }
209
+ func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {
210
+ var body struct {
211
+ Value *int `json:"value"`
212
+ }
213
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
214
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
215
+ return
216
+ }
217
+ value := *body.Value
218
+ if value < 0 {
219
+ value = 0
220
+ }
221
+ h.cfg.LogsMaxTotalSizeMB = value
222
+ h.persist(c)
223
+ }
224
+
225
+ // Request log
226
+ func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
227
+ func (h *Handler) PutRequestLog(c *gin.Context) {
228
+ h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
229
+ }
230
+
231
+ // Websocket auth
232
+ func (h *Handler) GetWebsocketAuth(c *gin.Context) {
233
+ c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
234
+ }
235
+ func (h *Handler) PutWebsocketAuth(c *gin.Context) {
236
+ h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
237
+ }
238
+
239
+ // Request retry
240
+ func (h *Handler) GetRequestRetry(c *gin.Context) {
241
+ c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
242
+ }
243
+ func (h *Handler) PutRequestRetry(c *gin.Context) {
244
+ h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
245
+ }
246
+
247
+ // Max retry interval
248
+ func (h *Handler) GetMaxRetryInterval(c *gin.Context) {
249
+ c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval})
250
+ }
251
+ func (h *Handler) PutMaxRetryInterval(c *gin.Context) {
252
+ h.updateIntField(c, func(v int) { h.cfg.MaxRetryInterval = v })
253
+ }
254
+
255
+ // ForceModelPrefix
256
+ func (h *Handler) GetForceModelPrefix(c *gin.Context) {
257
+ c.JSON(200, gin.H{"force-model-prefix": h.cfg.ForceModelPrefix})
258
+ }
259
+ func (h *Handler) PutForceModelPrefix(c *gin.Context) {
260
+ h.updateBoolField(c, func(v bool) { h.cfg.ForceModelPrefix = v })
261
+ }
262
+
263
+ func normalizeRoutingStrategy(strategy string) (string, bool) {
264
+ normalized := strings.ToLower(strings.TrimSpace(strategy))
265
+ switch normalized {
266
+ case "", "round-robin", "roundrobin", "rr":
267
+ return "round-robin", true
268
+ case "fill-first", "fillfirst", "ff":
269
+ return "fill-first", true
270
+ default:
271
+ return "", false
272
+ }
273
+ }
274
+
275
+ // RoutingStrategy
276
+ func (h *Handler) GetRoutingStrategy(c *gin.Context) {
277
+ strategy, ok := normalizeRoutingStrategy(h.cfg.Routing.Strategy)
278
+ if !ok {
279
+ c.JSON(200, gin.H{"strategy": strings.TrimSpace(h.cfg.Routing.Strategy)})
280
+ return
281
+ }
282
+ c.JSON(200, gin.H{"strategy": strategy})
283
+ }
284
+ func (h *Handler) PutRoutingStrategy(c *gin.Context) {
285
+ var body struct {
286
+ Value *string `json:"value"`
287
+ }
288
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
289
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
290
+ return
291
+ }
292
+ normalized, ok := normalizeRoutingStrategy(*body.Value)
293
+ if !ok {
294
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid strategy"})
295
+ return
296
+ }
297
+ h.cfg.Routing.Strategy = normalized
298
+ h.persist(c)
299
+ }
300
+
301
+ // Proxy URL
302
+ func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
303
+ func (h *Handler) PutProxyURL(c *gin.Context) {
304
+ h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
305
+ }
306
+ func (h *Handler) DeleteProxyURL(c *gin.Context) {
307
+ h.cfg.ProxyURL = ""
308
+ h.persist(c)
309
+ }
internal/api/handlers/management/config_lists.go ADDED
@@ -0,0 +1,1365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
10
+ )
11
+
12
+ // Generic helpers for list[string]
13
+ func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
14
+ data, err := c.GetRawData()
15
+ if err != nil {
16
+ c.JSON(400, gin.H{"error": "failed to read body"})
17
+ return
18
+ }
19
+ var arr []string
20
+ if err = json.Unmarshal(data, &arr); err != nil {
21
+ var obj struct {
22
+ Items []string `json:"items"`
23
+ }
24
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
25
+ c.JSON(400, gin.H{"error": "invalid body"})
26
+ return
27
+ }
28
+ arr = obj.Items
29
+ }
30
+ set(arr)
31
+ if after != nil {
32
+ after()
33
+ }
34
+ h.persist(c)
35
+ }
36
+
37
+ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
38
+ var body struct {
39
+ Old *string `json:"old"`
40
+ New *string `json:"new"`
41
+ Index *int `json:"index"`
42
+ Value *string `json:"value"`
43
+ }
44
+ if err := c.ShouldBindJSON(&body); err != nil {
45
+ c.JSON(400, gin.H{"error": "invalid body"})
46
+ return
47
+ }
48
+ if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
49
+ (*target)[*body.Index] = *body.Value
50
+ if after != nil {
51
+ after()
52
+ }
53
+ h.persist(c)
54
+ return
55
+ }
56
+ if body.Old != nil && body.New != nil {
57
+ for i := range *target {
58
+ if (*target)[i] == *body.Old {
59
+ (*target)[i] = *body.New
60
+ if after != nil {
61
+ after()
62
+ }
63
+ h.persist(c)
64
+ return
65
+ }
66
+ }
67
+ *target = append(*target, *body.New)
68
+ if after != nil {
69
+ after()
70
+ }
71
+ h.persist(c)
72
+ return
73
+ }
74
+ c.JSON(400, gin.H{"error": "missing fields"})
75
+ }
76
+
77
+ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
78
+ if idxStr := c.Query("index"); idxStr != "" {
79
+ var idx int
80
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
81
+ if err == nil && idx >= 0 && idx < len(*target) {
82
+ *target = append((*target)[:idx], (*target)[idx+1:]...)
83
+ if after != nil {
84
+ after()
85
+ }
86
+ h.persist(c)
87
+ return
88
+ }
89
+ }
90
+ if val := strings.TrimSpace(c.Query("value")); val != "" {
91
+ out := make([]string, 0, len(*target))
92
+ for _, v := range *target {
93
+ if strings.TrimSpace(v) != val {
94
+ out = append(out, v)
95
+ }
96
+ }
97
+ *target = out
98
+ if after != nil {
99
+ after()
100
+ }
101
+ h.persist(c)
102
+ return
103
+ }
104
+ c.JSON(400, gin.H{"error": "missing index or value"})
105
+ }
106
+
107
+ // api-keys
108
+ func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
109
+ func (h *Handler) PutAPIKeys(c *gin.Context) {
110
+ h.putStringList(c, func(v []string) {
111
+ h.cfg.APIKeys = append([]string(nil), v...)
112
+ h.cfg.Access.Providers = nil
113
+ }, nil)
114
+ }
115
+ func (h *Handler) PatchAPIKeys(c *gin.Context) {
116
+ h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
117
+ }
118
+ func (h *Handler) DeleteAPIKeys(c *gin.Context) {
119
+ h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
120
+ }
121
+
122
+ // gemini-api-key: []GeminiKey
123
+ func (h *Handler) GetGeminiKeys(c *gin.Context) {
124
+ c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
125
+ }
126
+ func (h *Handler) PutGeminiKeys(c *gin.Context) {
127
+ data, err := c.GetRawData()
128
+ if err != nil {
129
+ c.JSON(400, gin.H{"error": "failed to read body"})
130
+ return
131
+ }
132
+ var arr []config.GeminiKey
133
+ if err = json.Unmarshal(data, &arr); err != nil {
134
+ var obj struct {
135
+ Items []config.GeminiKey `json:"items"`
136
+ }
137
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
138
+ c.JSON(400, gin.H{"error": "invalid body"})
139
+ return
140
+ }
141
+ arr = obj.Items
142
+ }
143
+ h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
144
+ h.cfg.SanitizeGeminiKeys()
145
+ h.persist(c)
146
+ }
147
+ func (h *Handler) PatchGeminiKey(c *gin.Context) {
148
+ type geminiKeyPatch struct {
149
+ APIKey *string `json:"api-key"`
150
+ Prefix *string `json:"prefix"`
151
+ BaseURL *string `json:"base-url"`
152
+ ProxyURL *string `json:"proxy-url"`
153
+ Headers *map[string]string `json:"headers"`
154
+ ExcludedModels *[]string `json:"excluded-models"`
155
+ }
156
+ var body struct {
157
+ Index *int `json:"index"`
158
+ Match *string `json:"match"`
159
+ Value *geminiKeyPatch `json:"value"`
160
+ }
161
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
162
+ c.JSON(400, gin.H{"error": "invalid body"})
163
+ return
164
+ }
165
+ targetIndex := -1
166
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
167
+ targetIndex = *body.Index
168
+ }
169
+ if targetIndex == -1 && body.Match != nil {
170
+ match := strings.TrimSpace(*body.Match)
171
+ if match != "" {
172
+ for i := range h.cfg.GeminiKey {
173
+ if h.cfg.GeminiKey[i].APIKey == match {
174
+ targetIndex = i
175
+ break
176
+ }
177
+ }
178
+ }
179
+ }
180
+ if targetIndex == -1 {
181
+ c.JSON(404, gin.H{"error": "item not found"})
182
+ return
183
+ }
184
+
185
+ entry := h.cfg.GeminiKey[targetIndex]
186
+ if body.Value.APIKey != nil {
187
+ trimmed := strings.TrimSpace(*body.Value.APIKey)
188
+ if trimmed == "" {
189
+ h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
190
+ h.cfg.SanitizeGeminiKeys()
191
+ h.persist(c)
192
+ return
193
+ }
194
+ entry.APIKey = trimmed
195
+ }
196
+ if body.Value.Prefix != nil {
197
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
198
+ }
199
+ if body.Value.BaseURL != nil {
200
+ entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
201
+ }
202
+ if body.Value.ProxyURL != nil {
203
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
204
+ }
205
+ if body.Value.Headers != nil {
206
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
207
+ }
208
+ if body.Value.ExcludedModels != nil {
209
+ entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
210
+ }
211
+ h.cfg.GeminiKey[targetIndex] = entry
212
+ h.cfg.SanitizeGeminiKeys()
213
+ h.persist(c)
214
+ }
215
+
216
+ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
217
+ if val := strings.TrimSpace(c.Query("api-key")); val != "" {
218
+ out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
219
+ for _, v := range h.cfg.GeminiKey {
220
+ if v.APIKey != val {
221
+ out = append(out, v)
222
+ }
223
+ }
224
+ if len(out) != len(h.cfg.GeminiKey) {
225
+ h.cfg.GeminiKey = out
226
+ h.cfg.SanitizeGeminiKeys()
227
+ h.persist(c)
228
+ } else {
229
+ c.JSON(404, gin.H{"error": "item not found"})
230
+ }
231
+ return
232
+ }
233
+ if idxStr := c.Query("index"); idxStr != "" {
234
+ var idx int
235
+ if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
236
+ h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
237
+ h.cfg.SanitizeGeminiKeys()
238
+ h.persist(c)
239
+ return
240
+ }
241
+ }
242
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
243
+ }
244
+
245
+ // claude-api-key: []ClaudeKey
246
+ func (h *Handler) GetClaudeKeys(c *gin.Context) {
247
+ c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
248
+ }
249
+ func (h *Handler) PutClaudeKeys(c *gin.Context) {
250
+ data, err := c.GetRawData()
251
+ if err != nil {
252
+ c.JSON(400, gin.H{"error": "failed to read body"})
253
+ return
254
+ }
255
+ var arr []config.ClaudeKey
256
+ if err = json.Unmarshal(data, &arr); err != nil {
257
+ var obj struct {
258
+ Items []config.ClaudeKey `json:"items"`
259
+ }
260
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
261
+ c.JSON(400, gin.H{"error": "invalid body"})
262
+ return
263
+ }
264
+ arr = obj.Items
265
+ }
266
+ for i := range arr {
267
+ normalizeClaudeKey(&arr[i])
268
+ }
269
+ h.cfg.ClaudeKey = arr
270
+ h.cfg.SanitizeClaudeKeys()
271
+ h.persist(c)
272
+ }
273
+ func (h *Handler) PatchClaudeKey(c *gin.Context) {
274
+ type claudeKeyPatch struct {
275
+ APIKey *string `json:"api-key"`
276
+ Prefix *string `json:"prefix"`
277
+ BaseURL *string `json:"base-url"`
278
+ ProxyURL *string `json:"proxy-url"`
279
+ Models *[]config.ClaudeModel `json:"models"`
280
+ Headers *map[string]string `json:"headers"`
281
+ ExcludedModels *[]string `json:"excluded-models"`
282
+ }
283
+ var body struct {
284
+ Index *int `json:"index"`
285
+ Match *string `json:"match"`
286
+ Value *claudeKeyPatch `json:"value"`
287
+ }
288
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
289
+ c.JSON(400, gin.H{"error": "invalid body"})
290
+ return
291
+ }
292
+ targetIndex := -1
293
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
294
+ targetIndex = *body.Index
295
+ }
296
+ if targetIndex == -1 && body.Match != nil {
297
+ match := strings.TrimSpace(*body.Match)
298
+ for i := range h.cfg.ClaudeKey {
299
+ if h.cfg.ClaudeKey[i].APIKey == match {
300
+ targetIndex = i
301
+ break
302
+ }
303
+ }
304
+ }
305
+ if targetIndex == -1 {
306
+ c.JSON(404, gin.H{"error": "item not found"})
307
+ return
308
+ }
309
+
310
+ entry := h.cfg.ClaudeKey[targetIndex]
311
+ if body.Value.APIKey != nil {
312
+ entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
313
+ }
314
+ if body.Value.Prefix != nil {
315
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
316
+ }
317
+ if body.Value.BaseURL != nil {
318
+ entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
319
+ }
320
+ if body.Value.ProxyURL != nil {
321
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
322
+ }
323
+ if body.Value.Models != nil {
324
+ entry.Models = append([]config.ClaudeModel(nil), (*body.Value.Models)...)
325
+ }
326
+ if body.Value.Headers != nil {
327
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
328
+ }
329
+ if body.Value.ExcludedModels != nil {
330
+ entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
331
+ }
332
+ normalizeClaudeKey(&entry)
333
+ h.cfg.ClaudeKey[targetIndex] = entry
334
+ h.cfg.SanitizeClaudeKeys()
335
+ h.persist(c)
336
+ }
337
+
338
+ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
339
+ if val := c.Query("api-key"); val != "" {
340
+ out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
341
+ for _, v := range h.cfg.ClaudeKey {
342
+ if v.APIKey != val {
343
+ out = append(out, v)
344
+ }
345
+ }
346
+ h.cfg.ClaudeKey = out
347
+ h.cfg.SanitizeClaudeKeys()
348
+ h.persist(c)
349
+ return
350
+ }
351
+ if idxStr := c.Query("index"); idxStr != "" {
352
+ var idx int
353
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
354
+ if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
355
+ h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
356
+ h.cfg.SanitizeClaudeKeys()
357
+ h.persist(c)
358
+ return
359
+ }
360
+ }
361
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
362
+ }
363
+
364
+ // openai-compatibility: []OpenAICompatibility
365
+ func (h *Handler) GetOpenAICompat(c *gin.Context) {
366
+ c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
367
+ }
368
+ func (h *Handler) PutOpenAICompat(c *gin.Context) {
369
+ data, err := c.GetRawData()
370
+ if err != nil {
371
+ c.JSON(400, gin.H{"error": "failed to read body"})
372
+ return
373
+ }
374
+ var arr []config.OpenAICompatibility
375
+ if err = json.Unmarshal(data, &arr); err != nil {
376
+ var obj struct {
377
+ Items []config.OpenAICompatibility `json:"items"`
378
+ }
379
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
380
+ c.JSON(400, gin.H{"error": "invalid body"})
381
+ return
382
+ }
383
+ arr = obj.Items
384
+ }
385
+ filtered := make([]config.OpenAICompatibility, 0, len(arr))
386
+ for i := range arr {
387
+ normalizeOpenAICompatibilityEntry(&arr[i])
388
+ if strings.TrimSpace(arr[i].BaseURL) != "" {
389
+ filtered = append(filtered, arr[i])
390
+ }
391
+ }
392
+ h.cfg.OpenAICompatibility = filtered
393
+ h.cfg.SanitizeOpenAICompatibility()
394
+ h.persist(c)
395
+ }
396
+ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
397
+ type openAICompatPatch struct {
398
+ Name *string `json:"name"`
399
+ Prefix *string `json:"prefix"`
400
+ BaseURL *string `json:"base-url"`
401
+ APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
402
+ Models *[]config.OpenAICompatibilityModel `json:"models"`
403
+ Headers *map[string]string `json:"headers"`
404
+ }
405
+ var body struct {
406
+ Name *string `json:"name"`
407
+ Index *int `json:"index"`
408
+ Value *openAICompatPatch `json:"value"`
409
+ }
410
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
411
+ c.JSON(400, gin.H{"error": "invalid body"})
412
+ return
413
+ }
414
+ targetIndex := -1
415
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
416
+ targetIndex = *body.Index
417
+ }
418
+ if targetIndex == -1 && body.Name != nil {
419
+ match := strings.TrimSpace(*body.Name)
420
+ for i := range h.cfg.OpenAICompatibility {
421
+ if h.cfg.OpenAICompatibility[i].Name == match {
422
+ targetIndex = i
423
+ break
424
+ }
425
+ }
426
+ }
427
+ if targetIndex == -1 {
428
+ c.JSON(404, gin.H{"error": "item not found"})
429
+ return
430
+ }
431
+
432
+ entry := h.cfg.OpenAICompatibility[targetIndex]
433
+ if body.Value.Name != nil {
434
+ entry.Name = strings.TrimSpace(*body.Value.Name)
435
+ }
436
+ if body.Value.Prefix != nil {
437
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
438
+ }
439
+ if body.Value.BaseURL != nil {
440
+ trimmed := strings.TrimSpace(*body.Value.BaseURL)
441
+ if trimmed == "" {
442
+ h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
443
+ h.cfg.SanitizeOpenAICompatibility()
444
+ h.persist(c)
445
+ return
446
+ }
447
+ entry.BaseURL = trimmed
448
+ }
449
+ if body.Value.APIKeyEntries != nil {
450
+ entry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*body.Value.APIKeyEntries)...)
451
+ }
452
+ if body.Value.Models != nil {
453
+ entry.Models = append([]config.OpenAICompatibilityModel(nil), (*body.Value.Models)...)
454
+ }
455
+ if body.Value.Headers != nil {
456
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
457
+ }
458
+ normalizeOpenAICompatibilityEntry(&entry)
459
+ h.cfg.OpenAICompatibility[targetIndex] = entry
460
+ h.cfg.SanitizeOpenAICompatibility()
461
+ h.persist(c)
462
+ }
463
+
464
+ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
465
+ if name := c.Query("name"); name != "" {
466
+ out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
467
+ for _, v := range h.cfg.OpenAICompatibility {
468
+ if v.Name != name {
469
+ out = append(out, v)
470
+ }
471
+ }
472
+ h.cfg.OpenAICompatibility = out
473
+ h.cfg.SanitizeOpenAICompatibility()
474
+ h.persist(c)
475
+ return
476
+ }
477
+ if idxStr := c.Query("index"); idxStr != "" {
478
+ var idx int
479
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
480
+ if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
481
+ h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
482
+ h.cfg.SanitizeOpenAICompatibility()
483
+ h.persist(c)
484
+ return
485
+ }
486
+ }
487
+ c.JSON(400, gin.H{"error": "missing name or index"})
488
+ }
489
+
490
+ // vertex-api-key: []VertexCompatKey
491
+ func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
492
+ c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
493
+ }
494
+ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
495
+ data, err := c.GetRawData()
496
+ if err != nil {
497
+ c.JSON(400, gin.H{"error": "failed to read body"})
498
+ return
499
+ }
500
+ var arr []config.VertexCompatKey
501
+ if err = json.Unmarshal(data, &arr); err != nil {
502
+ var obj struct {
503
+ Items []config.VertexCompatKey `json:"items"`
504
+ }
505
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
506
+ c.JSON(400, gin.H{"error": "invalid body"})
507
+ return
508
+ }
509
+ arr = obj.Items
510
+ }
511
+ for i := range arr {
512
+ normalizeVertexCompatKey(&arr[i])
513
+ }
514
+ h.cfg.VertexCompatAPIKey = arr
515
+ h.cfg.SanitizeVertexCompatKeys()
516
+ h.persist(c)
517
+ }
518
+ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
519
+ type vertexCompatPatch struct {
520
+ APIKey *string `json:"api-key"`
521
+ Prefix *string `json:"prefix"`
522
+ BaseURL *string `json:"base-url"`
523
+ ProxyURL *string `json:"proxy-url"`
524
+ Headers *map[string]string `json:"headers"`
525
+ Models *[]config.VertexCompatModel `json:"models"`
526
+ }
527
+ var body struct {
528
+ Index *int `json:"index"`
529
+ Match *string `json:"match"`
530
+ Value *vertexCompatPatch `json:"value"`
531
+ }
532
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
533
+ c.JSON(400, gin.H{"error": "invalid body"})
534
+ return
535
+ }
536
+ targetIndex := -1
537
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
538
+ targetIndex = *body.Index
539
+ }
540
+ if targetIndex == -1 && body.Match != nil {
541
+ match := strings.TrimSpace(*body.Match)
542
+ if match != "" {
543
+ for i := range h.cfg.VertexCompatAPIKey {
544
+ if h.cfg.VertexCompatAPIKey[i].APIKey == match {
545
+ targetIndex = i
546
+ break
547
+ }
548
+ }
549
+ }
550
+ }
551
+ if targetIndex == -1 {
552
+ c.JSON(404, gin.H{"error": "item not found"})
553
+ return
554
+ }
555
+
556
+ entry := h.cfg.VertexCompatAPIKey[targetIndex]
557
+ if body.Value.APIKey != nil {
558
+ trimmed := strings.TrimSpace(*body.Value.APIKey)
559
+ if trimmed == "" {
560
+ h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
561
+ h.cfg.SanitizeVertexCompatKeys()
562
+ h.persist(c)
563
+ return
564
+ }
565
+ entry.APIKey = trimmed
566
+ }
567
+ if body.Value.Prefix != nil {
568
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
569
+ }
570
+ if body.Value.BaseURL != nil {
571
+ trimmed := strings.TrimSpace(*body.Value.BaseURL)
572
+ if trimmed == "" {
573
+ h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
574
+ h.cfg.SanitizeVertexCompatKeys()
575
+ h.persist(c)
576
+ return
577
+ }
578
+ entry.BaseURL = trimmed
579
+ }
580
+ if body.Value.ProxyURL != nil {
581
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
582
+ }
583
+ if body.Value.Headers != nil {
584
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
585
+ }
586
+ if body.Value.Models != nil {
587
+ entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
588
+ }
589
+ normalizeVertexCompatKey(&entry)
590
+ h.cfg.VertexCompatAPIKey[targetIndex] = entry
591
+ h.cfg.SanitizeVertexCompatKeys()
592
+ h.persist(c)
593
+ }
594
+
595
+ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
596
+ if val := strings.TrimSpace(c.Query("api-key")); val != "" {
597
+ out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
598
+ for _, v := range h.cfg.VertexCompatAPIKey {
599
+ if v.APIKey != val {
600
+ out = append(out, v)
601
+ }
602
+ }
603
+ h.cfg.VertexCompatAPIKey = out
604
+ h.cfg.SanitizeVertexCompatKeys()
605
+ h.persist(c)
606
+ return
607
+ }
608
+ if idxStr := c.Query("index"); idxStr != "" {
609
+ var idx int
610
+ _, errScan := fmt.Sscanf(idxStr, "%d", &idx)
611
+ if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
612
+ h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
613
+ h.cfg.SanitizeVertexCompatKeys()
614
+ h.persist(c)
615
+ return
616
+ }
617
+ }
618
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
619
+ }
620
+
621
+ // oauth-excluded-models: map[string][]string
622
+ func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
623
+ c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
624
+ }
625
+
626
+ func (h *Handler) PutOAuthExcludedModels(c *gin.Context) {
627
+ data, err := c.GetRawData()
628
+ if err != nil {
629
+ c.JSON(400, gin.H{"error": "failed to read body"})
630
+ return
631
+ }
632
+ var entries map[string][]string
633
+ if err = json.Unmarshal(data, &entries); err != nil {
634
+ var wrapper struct {
635
+ Items map[string][]string `json:"items"`
636
+ }
637
+ if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
638
+ c.JSON(400, gin.H{"error": "invalid body"})
639
+ return
640
+ }
641
+ entries = wrapper.Items
642
+ }
643
+ h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)
644
+ h.persist(c)
645
+ }
646
+
647
+ func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
648
+ var body struct {
649
+ Provider *string `json:"provider"`
650
+ Models []string `json:"models"`
651
+ }
652
+ if err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil {
653
+ c.JSON(400, gin.H{"error": "invalid body"})
654
+ return
655
+ }
656
+ provider := strings.ToLower(strings.TrimSpace(*body.Provider))
657
+ if provider == "" {
658
+ c.JSON(400, gin.H{"error": "invalid provider"})
659
+ return
660
+ }
661
+ normalized := config.NormalizeExcludedModels(body.Models)
662
+ if len(normalized) == 0 {
663
+ if h.cfg.OAuthExcludedModels == nil {
664
+ c.JSON(404, gin.H{"error": "provider not found"})
665
+ return
666
+ }
667
+ if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
668
+ c.JSON(404, gin.H{"error": "provider not found"})
669
+ return
670
+ }
671
+ delete(h.cfg.OAuthExcludedModels, provider)
672
+ if len(h.cfg.OAuthExcludedModels) == 0 {
673
+ h.cfg.OAuthExcludedModels = nil
674
+ }
675
+ h.persist(c)
676
+ return
677
+ }
678
+ if h.cfg.OAuthExcludedModels == nil {
679
+ h.cfg.OAuthExcludedModels = make(map[string][]string)
680
+ }
681
+ h.cfg.OAuthExcludedModels[provider] = normalized
682
+ h.persist(c)
683
+ }
684
+
685
+ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
686
+ provider := strings.ToLower(strings.TrimSpace(c.Query("provider")))
687
+ if provider == "" {
688
+ c.JSON(400, gin.H{"error": "missing provider"})
689
+ return
690
+ }
691
+ if h.cfg.OAuthExcludedModels == nil {
692
+ c.JSON(404, gin.H{"error": "provider not found"})
693
+ return
694
+ }
695
+ if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
696
+ c.JSON(404, gin.H{"error": "provider not found"})
697
+ return
698
+ }
699
+ delete(h.cfg.OAuthExcludedModels, provider)
700
+ if len(h.cfg.OAuthExcludedModels) == 0 {
701
+ h.cfg.OAuthExcludedModels = nil
702
+ }
703
+ h.persist(c)
704
+ }
705
+
706
+ // oauth-model-alias: map[string][]OAuthModelAlias
707
+ func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
708
+ c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
709
+ }
710
+
711
+ func (h *Handler) PutOAuthModelAlias(c *gin.Context) {
712
+ data, err := c.GetRawData()
713
+ if err != nil {
714
+ c.JSON(400, gin.H{"error": "failed to read body"})
715
+ return
716
+ }
717
+ var entries map[string][]config.OAuthModelAlias
718
+ if err = json.Unmarshal(data, &entries); err != nil {
719
+ var wrapper struct {
720
+ Items map[string][]config.OAuthModelAlias `json:"items"`
721
+ }
722
+ if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
723
+ c.JSON(400, gin.H{"error": "invalid body"})
724
+ return
725
+ }
726
+ entries = wrapper.Items
727
+ }
728
+ h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)
729
+ h.persist(c)
730
+ }
731
+
732
+ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
733
+ var body struct {
734
+ Provider *string `json:"provider"`
735
+ Channel *string `json:"channel"`
736
+ Aliases []config.OAuthModelAlias `json:"aliases"`
737
+ }
738
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
739
+ c.JSON(400, gin.H{"error": "invalid body"})
740
+ return
741
+ }
742
+ channelRaw := ""
743
+ if body.Channel != nil {
744
+ channelRaw = *body.Channel
745
+ } else if body.Provider != nil {
746
+ channelRaw = *body.Provider
747
+ }
748
+ channel := strings.ToLower(strings.TrimSpace(channelRaw))
749
+ if channel == "" {
750
+ c.JSON(400, gin.H{"error": "invalid channel"})
751
+ return
752
+ }
753
+
754
+ normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
755
+ normalized := normalizedMap[channel]
756
+ if len(normalized) == 0 {
757
+ if h.cfg.OAuthModelAlias == nil {
758
+ c.JSON(404, gin.H{"error": "channel not found"})
759
+ return
760
+ }
761
+ if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
762
+ c.JSON(404, gin.H{"error": "channel not found"})
763
+ return
764
+ }
765
+ delete(h.cfg.OAuthModelAlias, channel)
766
+ if len(h.cfg.OAuthModelAlias) == 0 {
767
+ h.cfg.OAuthModelAlias = nil
768
+ }
769
+ h.persist(c)
770
+ return
771
+ }
772
+ if h.cfg.OAuthModelAlias == nil {
773
+ h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
774
+ }
775
+ h.cfg.OAuthModelAlias[channel] = normalized
776
+ h.persist(c)
777
+ }
778
+
779
+ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
780
+ channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
781
+ if channel == "" {
782
+ channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
783
+ }
784
+ if channel == "" {
785
+ c.JSON(400, gin.H{"error": "missing channel"})
786
+ return
787
+ }
788
+ if h.cfg.OAuthModelAlias == nil {
789
+ c.JSON(404, gin.H{"error": "channel not found"})
790
+ return
791
+ }
792
+ if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
793
+ c.JSON(404, gin.H{"error": "channel not found"})
794
+ return
795
+ }
796
+ delete(h.cfg.OAuthModelAlias, channel)
797
+ if len(h.cfg.OAuthModelAlias) == 0 {
798
+ h.cfg.OAuthModelAlias = nil
799
+ }
800
+ h.persist(c)
801
+ }
802
+
803
+ // codex-api-key: []CodexKey
804
+ func (h *Handler) GetCodexKeys(c *gin.Context) {
805
+ c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
806
+ }
807
+ func (h *Handler) PutCodexKeys(c *gin.Context) {
808
+ data, err := c.GetRawData()
809
+ if err != nil {
810
+ c.JSON(400, gin.H{"error": "failed to read body"})
811
+ return
812
+ }
813
+ var arr []config.CodexKey
814
+ if err = json.Unmarshal(data, &arr); err != nil {
815
+ var obj struct {
816
+ Items []config.CodexKey `json:"items"`
817
+ }
818
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
819
+ c.JSON(400, gin.H{"error": "invalid body"})
820
+ return
821
+ }
822
+ arr = obj.Items
823
+ }
824
+ // Filter out codex entries with empty base-url (treat as removed)
825
+ filtered := make([]config.CodexKey, 0, len(arr))
826
+ for i := range arr {
827
+ entry := arr[i]
828
+ normalizeCodexKey(&entry)
829
+ if entry.BaseURL == "" {
830
+ continue
831
+ }
832
+ filtered = append(filtered, entry)
833
+ }
834
+ h.cfg.CodexKey = filtered
835
+ h.cfg.SanitizeCodexKeys()
836
+ h.persist(c)
837
+ }
838
+ func (h *Handler) PatchCodexKey(c *gin.Context) {
839
+ type codexKeyPatch struct {
840
+ APIKey *string `json:"api-key"`
841
+ Prefix *string `json:"prefix"`
842
+ BaseURL *string `json:"base-url"`
843
+ ProxyURL *string `json:"proxy-url"`
844
+ Models *[]config.CodexModel `json:"models"`
845
+ Headers *map[string]string `json:"headers"`
846
+ ExcludedModels *[]string `json:"excluded-models"`
847
+ }
848
+ var body struct {
849
+ Index *int `json:"index"`
850
+ Match *string `json:"match"`
851
+ Value *codexKeyPatch `json:"value"`
852
+ }
853
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
854
+ c.JSON(400, gin.H{"error": "invalid body"})
855
+ return
856
+ }
857
+ targetIndex := -1
858
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
859
+ targetIndex = *body.Index
860
+ }
861
+ if targetIndex == -1 && body.Match != nil {
862
+ match := strings.TrimSpace(*body.Match)
863
+ for i := range h.cfg.CodexKey {
864
+ if h.cfg.CodexKey[i].APIKey == match {
865
+ targetIndex = i
866
+ break
867
+ }
868
+ }
869
+ }
870
+ if targetIndex == -1 {
871
+ c.JSON(404, gin.H{"error": "item not found"})
872
+ return
873
+ }
874
+
875
+ entry := h.cfg.CodexKey[targetIndex]
876
+ if body.Value.APIKey != nil {
877
+ entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
878
+ }
879
+ if body.Value.Prefix != nil {
880
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
881
+ }
882
+ if body.Value.BaseURL != nil {
883
+ trimmed := strings.TrimSpace(*body.Value.BaseURL)
884
+ if trimmed == "" {
885
+ h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
886
+ h.cfg.SanitizeCodexKeys()
887
+ h.persist(c)
888
+ return
889
+ }
890
+ entry.BaseURL = trimmed
891
+ }
892
+ if body.Value.ProxyURL != nil {
893
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
894
+ }
895
+ if body.Value.Models != nil {
896
+ entry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...)
897
+ }
898
+ if body.Value.Headers != nil {
899
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
900
+ }
901
+ if body.Value.ExcludedModels != nil {
902
+ entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
903
+ }
904
+ normalizeCodexKey(&entry)
905
+ h.cfg.CodexKey[targetIndex] = entry
906
+ h.cfg.SanitizeCodexKeys()
907
+ h.persist(c)
908
+ }
909
+
910
+ func (h *Handler) DeleteCodexKey(c *gin.Context) {
911
+ if val := c.Query("api-key"); val != "" {
912
+ out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
913
+ for _, v := range h.cfg.CodexKey {
914
+ if v.APIKey != val {
915
+ out = append(out, v)
916
+ }
917
+ }
918
+ h.cfg.CodexKey = out
919
+ h.cfg.SanitizeCodexKeys()
920
+ h.persist(c)
921
+ return
922
+ }
923
+ if idxStr := c.Query("index"); idxStr != "" {
924
+ var idx int
925
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
926
+ if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
927
+ h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
928
+ h.cfg.SanitizeCodexKeys()
929
+ h.persist(c)
930
+ return
931
+ }
932
+ }
933
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
934
+ }
935
+
936
+ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
937
+ if entry == nil {
938
+ return
939
+ }
940
+ // Trim base-url; empty base-url indicates provider should be removed by sanitization
941
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
942
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
943
+ existing := make(map[string]struct{}, len(entry.APIKeyEntries))
944
+ for i := range entry.APIKeyEntries {
945
+ trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
946
+ entry.APIKeyEntries[i].APIKey = trimmed
947
+ if trimmed != "" {
948
+ existing[trimmed] = struct{}{}
949
+ }
950
+ }
951
+ }
952
+
953
+ func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
954
+ if len(entries) == 0 {
955
+ return nil
956
+ }
957
+ out := make([]config.OpenAICompatibility, len(entries))
958
+ for i := range entries {
959
+ copyEntry := entries[i]
960
+ if len(copyEntry.APIKeyEntries) > 0 {
961
+ copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
962
+ }
963
+ normalizeOpenAICompatibilityEntry(&copyEntry)
964
+ out[i] = copyEntry
965
+ }
966
+ return out
967
+ }
968
+
969
+ func normalizeClaudeKey(entry *config.ClaudeKey) {
970
+ if entry == nil {
971
+ return
972
+ }
973
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
974
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
975
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
976
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
977
+ entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
978
+ if len(entry.Models) == 0 {
979
+ return
980
+ }
981
+ normalized := make([]config.ClaudeModel, 0, len(entry.Models))
982
+ for i := range entry.Models {
983
+ model := entry.Models[i]
984
+ model.Name = strings.TrimSpace(model.Name)
985
+ model.Alias = strings.TrimSpace(model.Alias)
986
+ if model.Name == "" && model.Alias == "" {
987
+ continue
988
+ }
989
+ normalized = append(normalized, model)
990
+ }
991
+ entry.Models = normalized
992
+ }
993
+
994
+ func normalizeCodexKey(entry *config.CodexKey) {
995
+ if entry == nil {
996
+ return
997
+ }
998
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
999
+ entry.Prefix = strings.TrimSpace(entry.Prefix)
1000
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
1001
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
1002
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1003
+ entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
1004
+ if len(entry.Models) == 0 {
1005
+ return
1006
+ }
1007
+ normalized := make([]config.CodexModel, 0, len(entry.Models))
1008
+ for i := range entry.Models {
1009
+ model := entry.Models[i]
1010
+ model.Name = strings.TrimSpace(model.Name)
1011
+ model.Alias = strings.TrimSpace(model.Alias)
1012
+ if model.Name == "" && model.Alias == "" {
1013
+ continue
1014
+ }
1015
+ normalized = append(normalized, model)
1016
+ }
1017
+ entry.Models = normalized
1018
+ }
1019
+
1020
+ func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
1021
+ if entry == nil {
1022
+ return
1023
+ }
1024
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
1025
+ entry.Prefix = strings.TrimSpace(entry.Prefix)
1026
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
1027
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
1028
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1029
+ if len(entry.Models) == 0 {
1030
+ return
1031
+ }
1032
+ normalized := make([]config.VertexCompatModel, 0, len(entry.Models))
1033
+ for i := range entry.Models {
1034
+ model := entry.Models[i]
1035
+ model.Name = strings.TrimSpace(model.Name)
1036
+ model.Alias = strings.TrimSpace(model.Alias)
1037
+ if model.Name == "" || model.Alias == "" {
1038
+ continue
1039
+ }
1040
+ normalized = append(normalized, model)
1041
+ }
1042
+ entry.Models = normalized
1043
+ }
1044
+
1045
+ func sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias {
1046
+ if len(entries) == 0 {
1047
+ return nil
1048
+ }
1049
+ copied := make(map[string][]config.OAuthModelAlias, len(entries))
1050
+ for channel, aliases := range entries {
1051
+ if len(aliases) == 0 {
1052
+ continue
1053
+ }
1054
+ copied[channel] = append([]config.OAuthModelAlias(nil), aliases...)
1055
+ }
1056
+ if len(copied) == 0 {
1057
+ return nil
1058
+ }
1059
+ cfg := config.Config{OAuthModelAlias: copied}
1060
+ cfg.SanitizeOAuthModelAlias()
1061
+ if len(cfg.OAuthModelAlias) == 0 {
1062
+ return nil
1063
+ }
1064
+ return cfg.OAuthModelAlias
1065
+ }
1066
+
1067
+ // GetAmpCode returns the complete ampcode configuration.
1068
+ func (h *Handler) GetAmpCode(c *gin.Context) {
1069
+ if h == nil || h.cfg == nil {
1070
+ c.JSON(200, gin.H{"ampcode": config.AmpCode{}})
1071
+ return
1072
+ }
1073
+ c.JSON(200, gin.H{"ampcode": h.cfg.AmpCode})
1074
+ }
1075
+
1076
+ // GetAmpUpstreamURL returns the ampcode upstream URL.
1077
+ func (h *Handler) GetAmpUpstreamURL(c *gin.Context) {
1078
+ if h == nil || h.cfg == nil {
1079
+ c.JSON(200, gin.H{"upstream-url": ""})
1080
+ return
1081
+ }
1082
+ c.JSON(200, gin.H{"upstream-url": h.cfg.AmpCode.UpstreamURL})
1083
+ }
1084
+
1085
+ // PutAmpUpstreamURL updates the ampcode upstream URL.
1086
+ func (h *Handler) PutAmpUpstreamURL(c *gin.Context) {
1087
+ h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })
1088
+ }
1089
+
1090
+ // DeleteAmpUpstreamURL clears the ampcode upstream URL.
1091
+ func (h *Handler) DeleteAmpUpstreamURL(c *gin.Context) {
1092
+ h.cfg.AmpCode.UpstreamURL = ""
1093
+ h.persist(c)
1094
+ }
1095
+
1096
+ // GetAmpUpstreamAPIKey returns the ampcode upstream API key.
1097
+ func (h *Handler) GetAmpUpstreamAPIKey(c *gin.Context) {
1098
+ if h == nil || h.cfg == nil {
1099
+ c.JSON(200, gin.H{"upstream-api-key": ""})
1100
+ return
1101
+ }
1102
+ c.JSON(200, gin.H{"upstream-api-key": h.cfg.AmpCode.UpstreamAPIKey})
1103
+ }
1104
+
1105
+ // PutAmpUpstreamAPIKey updates the ampcode upstream API key.
1106
+ func (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {
1107
+ h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })
1108
+ }
1109
+
1110
+ // DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.
1111
+ func (h *Handler) DeleteAmpUpstreamAPIKey(c *gin.Context) {
1112
+ h.cfg.AmpCode.UpstreamAPIKey = ""
1113
+ h.persist(c)
1114
+ }
1115
+
1116
+ // GetAmpRestrictManagementToLocalhost returns the localhost restriction setting.
1117
+ func (h *Handler) GetAmpRestrictManagementToLocalhost(c *gin.Context) {
1118
+ if h == nil || h.cfg == nil {
1119
+ c.JSON(200, gin.H{"restrict-management-to-localhost": true})
1120
+ return
1121
+ }
1122
+ c.JSON(200, gin.H{"restrict-management-to-localhost": h.cfg.AmpCode.RestrictManagementToLocalhost})
1123
+ }
1124
+
1125
+ // PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.
1126
+ func (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {
1127
+ h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })
1128
+ }
1129
+
1130
+ // GetAmpModelMappings returns the ampcode model mappings.
1131
+ func (h *Handler) GetAmpModelMappings(c *gin.Context) {
1132
+ if h == nil || h.cfg == nil {
1133
+ c.JSON(200, gin.H{"model-mappings": []config.AmpModelMapping{}})
1134
+ return
1135
+ }
1136
+ c.JSON(200, gin.H{"model-mappings": h.cfg.AmpCode.ModelMappings})
1137
+ }
1138
+
1139
+ // PutAmpModelMappings replaces all ampcode model mappings.
1140
+ func (h *Handler) PutAmpModelMappings(c *gin.Context) {
1141
+ var body struct {
1142
+ Value []config.AmpModelMapping `json:"value"`
1143
+ }
1144
+ if err := c.ShouldBindJSON(&body); err != nil {
1145
+ c.JSON(400, gin.H{"error": "invalid body"})
1146
+ return
1147
+ }
1148
+ h.cfg.AmpCode.ModelMappings = body.Value
1149
+ h.persist(c)
1150
+ }
1151
+
1152
+ // PatchAmpModelMappings adds or updates model mappings.
1153
+ func (h *Handler) PatchAmpModelMappings(c *gin.Context) {
1154
+ var body struct {
1155
+ Value []config.AmpModelMapping `json:"value"`
1156
+ }
1157
+ if err := c.ShouldBindJSON(&body); err != nil {
1158
+ c.JSON(400, gin.H{"error": "invalid body"})
1159
+ return
1160
+ }
1161
+
1162
+ existing := make(map[string]int)
1163
+ for i, m := range h.cfg.AmpCode.ModelMappings {
1164
+ existing[strings.TrimSpace(m.From)] = i
1165
+ }
1166
+
1167
+ for _, newMapping := range body.Value {
1168
+ from := strings.TrimSpace(newMapping.From)
1169
+ if idx, ok := existing[from]; ok {
1170
+ h.cfg.AmpCode.ModelMappings[idx] = newMapping
1171
+ } else {
1172
+ h.cfg.AmpCode.ModelMappings = append(h.cfg.AmpCode.ModelMappings, newMapping)
1173
+ existing[from] = len(h.cfg.AmpCode.ModelMappings) - 1
1174
+ }
1175
+ }
1176
+ h.persist(c)
1177
+ }
1178
+
1179
+ // DeleteAmpModelMappings removes specified model mappings by "from" field.
1180
+ func (h *Handler) DeleteAmpModelMappings(c *gin.Context) {
1181
+ var body struct {
1182
+ Value []string `json:"value"`
1183
+ }
1184
+ if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
1185
+ h.cfg.AmpCode.ModelMappings = nil
1186
+ h.persist(c)
1187
+ return
1188
+ }
1189
+
1190
+ toRemove := make(map[string]bool)
1191
+ for _, from := range body.Value {
1192
+ toRemove[strings.TrimSpace(from)] = true
1193
+ }
1194
+
1195
+ newMappings := make([]config.AmpModelMapping, 0, len(h.cfg.AmpCode.ModelMappings))
1196
+ for _, m := range h.cfg.AmpCode.ModelMappings {
1197
+ if !toRemove[strings.TrimSpace(m.From)] {
1198
+ newMappings = append(newMappings, m)
1199
+ }
1200
+ }
1201
+ h.cfg.AmpCode.ModelMappings = newMappings
1202
+ h.persist(c)
1203
+ }
1204
+
1205
+ // GetAmpForceModelMappings returns whether model mappings are forced.
1206
+ func (h *Handler) GetAmpForceModelMappings(c *gin.Context) {
1207
+ if h == nil || h.cfg == nil {
1208
+ c.JSON(200, gin.H{"force-model-mappings": false})
1209
+ return
1210
+ }
1211
+ c.JSON(200, gin.H{"force-model-mappings": h.cfg.AmpCode.ForceModelMappings})
1212
+ }
1213
+
1214
+ // PutAmpForceModelMappings updates the force model mappings setting.
1215
+ func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
1216
+ h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
1217
+ }
1218
+
1219
+ // GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
1220
+ func (h *Handler) GetAmpUpstreamAPIKeys(c *gin.Context) {
1221
+ if h == nil || h.cfg == nil {
1222
+ c.JSON(200, gin.H{"upstream-api-keys": []config.AmpUpstreamAPIKeyEntry{}})
1223
+ return
1224
+ }
1225
+ c.JSON(200, gin.H{"upstream-api-keys": h.cfg.AmpCode.UpstreamAPIKeys})
1226
+ }
1227
+
1228
+ // PutAmpUpstreamAPIKeys replaces all ampcode upstream API keys mappings.
1229
+ func (h *Handler) PutAmpUpstreamAPIKeys(c *gin.Context) {
1230
+ var body struct {
1231
+ Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
1232
+ }
1233
+ if err := c.ShouldBindJSON(&body); err != nil {
1234
+ c.JSON(400, gin.H{"error": "invalid body"})
1235
+ return
1236
+ }
1237
+ // Normalize entries: trim whitespace, filter empty
1238
+ normalized := normalizeAmpUpstreamAPIKeyEntries(body.Value)
1239
+ h.cfg.AmpCode.UpstreamAPIKeys = normalized
1240
+ h.persist(c)
1241
+ }
1242
+
1243
+ // PatchAmpUpstreamAPIKeys adds or updates upstream API keys entries.
1244
+ // Matching is done by upstream-api-key value.
1245
+ func (h *Handler) PatchAmpUpstreamAPIKeys(c *gin.Context) {
1246
+ var body struct {
1247
+ Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
1248
+ }
1249
+ if err := c.ShouldBindJSON(&body); err != nil {
1250
+ c.JSON(400, gin.H{"error": "invalid body"})
1251
+ return
1252
+ }
1253
+
1254
+ existing := make(map[string]int)
1255
+ for i, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
1256
+ existing[strings.TrimSpace(entry.UpstreamAPIKey)] = i
1257
+ }
1258
+
1259
+ for _, newEntry := range body.Value {
1260
+ upstreamKey := strings.TrimSpace(newEntry.UpstreamAPIKey)
1261
+ if upstreamKey == "" {
1262
+ continue
1263
+ }
1264
+ normalizedEntry := config.AmpUpstreamAPIKeyEntry{
1265
+ UpstreamAPIKey: upstreamKey,
1266
+ APIKeys: normalizeAPIKeysList(newEntry.APIKeys),
1267
+ }
1268
+ if idx, ok := existing[upstreamKey]; ok {
1269
+ h.cfg.AmpCode.UpstreamAPIKeys[idx] = normalizedEntry
1270
+ } else {
1271
+ h.cfg.AmpCode.UpstreamAPIKeys = append(h.cfg.AmpCode.UpstreamAPIKeys, normalizedEntry)
1272
+ existing[upstreamKey] = len(h.cfg.AmpCode.UpstreamAPIKeys) - 1
1273
+ }
1274
+ }
1275
+ h.persist(c)
1276
+ }
1277
+
1278
+ // DeleteAmpUpstreamAPIKeys removes specified upstream API keys entries.
1279
+ // Body must be JSON: {"value": ["<upstream-api-key>", ...]}.
1280
+ // If "value" is an empty array, clears all entries.
1281
+ // If JSON is invalid or "value" is missing/null, returns 400 and does not persist any change.
1282
+ func (h *Handler) DeleteAmpUpstreamAPIKeys(c *gin.Context) {
1283
+ var body struct {
1284
+ Value []string `json:"value"`
1285
+ }
1286
+ if err := c.ShouldBindJSON(&body); err != nil {
1287
+ c.JSON(400, gin.H{"error": "invalid body"})
1288
+ return
1289
+ }
1290
+
1291
+ if body.Value == nil {
1292
+ c.JSON(400, gin.H{"error": "missing value"})
1293
+ return
1294
+ }
1295
+
1296
+ // Empty array means clear all
1297
+ if len(body.Value) == 0 {
1298
+ h.cfg.AmpCode.UpstreamAPIKeys = nil
1299
+ h.persist(c)
1300
+ return
1301
+ }
1302
+
1303
+ toRemove := make(map[string]bool)
1304
+ for _, key := range body.Value {
1305
+ trimmed := strings.TrimSpace(key)
1306
+ if trimmed == "" {
1307
+ continue
1308
+ }
1309
+ toRemove[trimmed] = true
1310
+ }
1311
+ if len(toRemove) == 0 {
1312
+ c.JSON(400, gin.H{"error": "empty value"})
1313
+ return
1314
+ }
1315
+
1316
+ newEntries := make([]config.AmpUpstreamAPIKeyEntry, 0, len(h.cfg.AmpCode.UpstreamAPIKeys))
1317
+ for _, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
1318
+ if !toRemove[strings.TrimSpace(entry.UpstreamAPIKey)] {
1319
+ newEntries = append(newEntries, entry)
1320
+ }
1321
+ }
1322
+ h.cfg.AmpCode.UpstreamAPIKeys = newEntries
1323
+ h.persist(c)
1324
+ }
1325
+
1326
+ // normalizeAmpUpstreamAPIKeyEntries normalizes a list of upstream API key entries.
1327
+ func normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry {
1328
+ if len(entries) == 0 {
1329
+ return nil
1330
+ }
1331
+ out := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries))
1332
+ for _, entry := range entries {
1333
+ upstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)
1334
+ if upstreamKey == "" {
1335
+ continue
1336
+ }
1337
+ apiKeys := normalizeAPIKeysList(entry.APIKeys)
1338
+ out = append(out, config.AmpUpstreamAPIKeyEntry{
1339
+ UpstreamAPIKey: upstreamKey,
1340
+ APIKeys: apiKeys,
1341
+ })
1342
+ }
1343
+ if len(out) == 0 {
1344
+ return nil
1345
+ }
1346
+ return out
1347
+ }
1348
+
1349
+ // normalizeAPIKeysList trims and filters empty strings from a list of API keys.
1350
+ func normalizeAPIKeysList(keys []string) []string {
1351
+ if len(keys) == 0 {
1352
+ return nil
1353
+ }
1354
+ out := make([]string, 0, len(keys))
1355
+ for _, k := range keys {
1356
+ trimmed := strings.TrimSpace(k)
1357
+ if trimmed != "" {
1358
+ out = append(out, trimmed)
1359
+ }
1360
+ }
1361
+ if len(out) == 0 {
1362
+ return nil
1363
+ }
1364
+ return out
1365
+ }
internal/api/handlers/management/handler.go ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package management provides the management API handlers and middleware
2
+ // for configuring the server and managing auth files.
3
+ package management
4
+
5
+ import (
6
+ "crypto/subtle"
7
+ "fmt"
8
+ "net/http"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/gin-gonic/gin"
16
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
17
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
18
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
19
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
20
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
21
+ "golang.org/x/crypto/bcrypt"
22
+ )
23
+
24
+ type attemptInfo struct {
25
+ count int
26
+ blockedUntil time.Time
27
+ lastActivity time.Time // track last activity for cleanup
28
+ }
29
+
30
+ // attemptCleanupInterval controls how often stale IP entries are purged
31
+ const attemptCleanupInterval = 1 * time.Hour
32
+
33
+ // attemptMaxIdleTime controls how long an IP can be idle before cleanup
34
+ const attemptMaxIdleTime = 2 * time.Hour
35
+
36
+ // Handler aggregates config reference, persistence path and helpers.
37
+ type Handler struct {
38
+ cfg *config.Config
39
+ configFilePath string
40
+ mu sync.Mutex
41
+ attemptsMu sync.Mutex
42
+ failedAttempts map[string]*attemptInfo // keyed by client IP
43
+ authManager *coreauth.Manager
44
+ usageStats *usage.RequestStatistics
45
+ tokenStore coreauth.Store
46
+ localPassword string
47
+ allowRemoteOverride bool
48
+ envSecret string
49
+ logDir string
50
+ }
51
+
52
+ // NewHandler creates a new management handler instance.
53
+ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
54
+ envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
55
+ envSecret = strings.TrimSpace(envSecret)
56
+
57
+ h := &Handler{
58
+ cfg: cfg,
59
+ configFilePath: configFilePath,
60
+ failedAttempts: make(map[string]*attemptInfo),
61
+ authManager: manager,
62
+ usageStats: usage.GetRequestStatistics(),
63
+ tokenStore: sdkAuth.GetTokenStore(),
64
+ allowRemoteOverride: envSecret != "",
65
+ envSecret: envSecret,
66
+ }
67
+ h.startAttemptCleanup()
68
+ return h
69
+ }
70
+
71
+ // startAttemptCleanup launches a background goroutine that periodically
72
+ // removes stale IP entries from failedAttempts to prevent memory leaks.
73
+ func (h *Handler) startAttemptCleanup() {
74
+ go func() {
75
+ ticker := time.NewTicker(attemptCleanupInterval)
76
+ defer ticker.Stop()
77
+ for range ticker.C {
78
+ h.purgeStaleAttempts()
79
+ }
80
+ }()
81
+ }
82
+
83
+ // purgeStaleAttempts removes IP entries that have been idle beyond attemptMaxIdleTime
84
+ // and whose ban (if any) has expired.
85
+ func (h *Handler) purgeStaleAttempts() {
86
+ now := time.Now()
87
+ h.attemptsMu.Lock()
88
+ defer h.attemptsMu.Unlock()
89
+ for ip, ai := range h.failedAttempts {
90
+ // Skip if still banned
91
+ if !ai.blockedUntil.IsZero() && now.Before(ai.blockedUntil) {
92
+ continue
93
+ }
94
+ // Remove if idle too long
95
+ if now.Sub(ai.lastActivity) > attemptMaxIdleTime {
96
+ delete(h.failedAttempts, ip)
97
+ }
98
+ }
99
+ }
100
+
101
+ // NewHandler creates a new management handler instance.
102
+ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler {
103
+ return NewHandler(cfg, "", manager)
104
+ }
105
+
106
+ // SetConfig updates the in-memory config reference when the server hot-reloads.
107
+ func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
108
+
109
+ // SetAuthManager updates the auth manager reference used by management endpoints.
110
+ func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
111
+
112
+ // SetUsageStatistics allows replacing the usage statistics reference.
113
+ func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
114
+
115
+ // SetLocalPassword configures the runtime-local password accepted for localhost requests.
116
+ func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
117
+
118
+ // SetLogDirectory updates the directory where main.log should be looked up.
119
+ func (h *Handler) SetLogDirectory(dir string) {
120
+ if dir == "" {
121
+ return
122
+ }
123
+ if !filepath.IsAbs(dir) {
124
+ if abs, err := filepath.Abs(dir); err == nil {
125
+ dir = abs
126
+ }
127
+ }
128
+ h.logDir = dir
129
+ }
130
+
131
+ // Middleware enforces access control for management endpoints.
132
+ // All requests (local and remote) require a valid management key.
133
+ // Additionally, remote access requires allow-remote-management=true.
134
+ func (h *Handler) Middleware() gin.HandlerFunc {
135
+ const maxFailures = 5
136
+ const banDuration = 30 * time.Minute
137
+
138
+ return func(c *gin.Context) {
139
+ c.Header("X-CPA-VERSION", buildinfo.Version)
140
+ c.Header("X-CPA-COMMIT", buildinfo.Commit)
141
+ c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate)
142
+
143
+ clientIP := c.ClientIP()
144
+ localClient := clientIP == "127.0.0.1" || clientIP == "::1"
145
+ cfg := h.cfg
146
+ var (
147
+ allowRemote bool
148
+ secretHash string
149
+ )
150
+ if cfg != nil {
151
+ allowRemote = cfg.RemoteManagement.AllowRemote
152
+ secretHash = cfg.RemoteManagement.SecretKey
153
+ }
154
+ if h.allowRemoteOverride {
155
+ allowRemote = true
156
+ }
157
+ envSecret := h.envSecret
158
+
159
+ fail := func() {}
160
+ if !localClient {
161
+ h.attemptsMu.Lock()
162
+ ai := h.failedAttempts[clientIP]
163
+ if ai != nil {
164
+ if !ai.blockedUntil.IsZero() {
165
+ if time.Now().Before(ai.blockedUntil) {
166
+ remaining := time.Until(ai.blockedUntil).Round(time.Second)
167
+ h.attemptsMu.Unlock()
168
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
169
+ return
170
+ }
171
+ // Ban expired, reset state
172
+ ai.blockedUntil = time.Time{}
173
+ ai.count = 0
174
+ }
175
+ }
176
+ h.attemptsMu.Unlock()
177
+
178
+ if !allowRemote {
179
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
180
+ return
181
+ }
182
+
183
+ fail = func() {
184
+ h.attemptsMu.Lock()
185
+ aip := h.failedAttempts[clientIP]
186
+ if aip == nil {
187
+ aip = &attemptInfo{}
188
+ h.failedAttempts[clientIP] = aip
189
+ }
190
+ aip.count++
191
+ aip.lastActivity = time.Now()
192
+ if aip.count >= maxFailures {
193
+ aip.blockedUntil = time.Now().Add(banDuration)
194
+ aip.count = 0
195
+ }
196
+ h.attemptsMu.Unlock()
197
+ }
198
+ }
199
+ if secretHash == "" && envSecret == "" {
200
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
201
+ return
202
+ }
203
+
204
+ // Accept either Authorization: Bearer <key> or X-Management-Key
205
+ var provided string
206
+ if ah := c.GetHeader("Authorization"); ah != "" {
207
+ parts := strings.SplitN(ah, " ", 2)
208
+ if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
209
+ provided = parts[1]
210
+ } else {
211
+ provided = ah
212
+ }
213
+ }
214
+ if provided == "" {
215
+ provided = c.GetHeader("X-Management-Key")
216
+ }
217
+
218
+ if provided == "" {
219
+ if !localClient {
220
+ fail()
221
+ }
222
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
223
+ return
224
+ }
225
+
226
+ if localClient {
227
+ if lp := h.localPassword; lp != "" {
228
+ if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
229
+ c.Next()
230
+ return
231
+ }
232
+ }
233
+ }
234
+
235
+ if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
236
+ if !localClient {
237
+ h.attemptsMu.Lock()
238
+ if ai := h.failedAttempts[clientIP]; ai != nil {
239
+ ai.count = 0
240
+ ai.blockedUntil = time.Time{}
241
+ }
242
+ h.attemptsMu.Unlock()
243
+ }
244
+ c.Next()
245
+ return
246
+ }
247
+
248
+ if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
249
+ if !localClient {
250
+ fail()
251
+ }
252
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
253
+ return
254
+ }
255
+
256
+ if !localClient {
257
+ h.attemptsMu.Lock()
258
+ if ai := h.failedAttempts[clientIP]; ai != nil {
259
+ ai.count = 0
260
+ ai.blockedUntil = time.Time{}
261
+ }
262
+ h.attemptsMu.Unlock()
263
+ }
264
+
265
+ c.Next()
266
+ }
267
+ }
268
+
269
+ // persist saves the current in-memory config to disk.
270
+ func (h *Handler) persist(c *gin.Context) bool {
271
+ h.mu.Lock()
272
+ defer h.mu.Unlock()
273
+ // Preserve comments when writing
274
+ if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
275
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
276
+ return false
277
+ }
278
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
279
+ return true
280
+ }
281
+
282
+ // Helper methods for simple types
283
+ func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
284
+ var body struct {
285
+ Value *bool `json:"value"`
286
+ }
287
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
288
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
289
+ return
290
+ }
291
+ set(*body.Value)
292
+ h.persist(c)
293
+ }
294
+
295
+ func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
296
+ var body struct {
297
+ Value *int `json:"value"`
298
+ }
299
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
300
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
301
+ return
302
+ }
303
+ set(*body.Value)
304
+ h.persist(c)
305
+ }
306
+
307
+ func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
308
+ var body struct {
309
+ Value *string `json:"value"`
310
+ }
311
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
312
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
313
+ return
314
+ }
315
+ set(*body.Value)
316
+ h.persist(c)
317
+ }
internal/api/handlers/management/logs.go ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "bufio"
5
+ "fmt"
6
+ "math"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "sort"
11
+ "strconv"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/gin-gonic/gin"
16
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
17
+ )
18
+
19
+ const (
20
+ defaultLogFileName = "main.log"
21
+ logScannerInitialBuffer = 64 * 1024
22
+ logScannerMaxBuffer = 8 * 1024 * 1024
23
+ )
24
+
25
+ // GetLogs returns log lines with optional incremental loading.
26
+ func (h *Handler) GetLogs(c *gin.Context) {
27
+ if h == nil {
28
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
29
+ return
30
+ }
31
+ if h.cfg == nil {
32
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
33
+ return
34
+ }
35
+ if !h.cfg.LoggingToFile {
36
+ c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
37
+ return
38
+ }
39
+
40
+ logDir := h.logDirectory()
41
+ if strings.TrimSpace(logDir) == "" {
42
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
43
+ return
44
+ }
45
+
46
+ files, err := h.collectLogFiles(logDir)
47
+ if err != nil {
48
+ if os.IsNotExist(err) {
49
+ cutoff := parseCutoff(c.Query("after"))
50
+ c.JSON(http.StatusOK, gin.H{
51
+ "lines": []string{},
52
+ "line-count": 0,
53
+ "latest-timestamp": cutoff,
54
+ })
55
+ return
56
+ }
57
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)})
58
+ return
59
+ }
60
+
61
+ limit, errLimit := parseLimit(c.Query("limit"))
62
+ if errLimit != nil {
63
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid limit: %v", errLimit)})
64
+ return
65
+ }
66
+
67
+ cutoff := parseCutoff(c.Query("after"))
68
+ acc := newLogAccumulator(cutoff, limit)
69
+ for i := range files {
70
+ if errProcess := acc.consumeFile(files[i]); errProcess != nil {
71
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)})
72
+ return
73
+ }
74
+ }
75
+
76
+ lines, total, latest := acc.result()
77
+ if latest == 0 || latest < cutoff {
78
+ latest = cutoff
79
+ }
80
+ c.JSON(http.StatusOK, gin.H{
81
+ "lines": lines,
82
+ "line-count": total,
83
+ "latest-timestamp": latest,
84
+ })
85
+ }
86
+
87
+ // DeleteLogs removes all rotated log files and truncates the active log.
88
+ func (h *Handler) DeleteLogs(c *gin.Context) {
89
+ if h == nil {
90
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
91
+ return
92
+ }
93
+ if h.cfg == nil {
94
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
95
+ return
96
+ }
97
+ if !h.cfg.LoggingToFile {
98
+ c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
99
+ return
100
+ }
101
+
102
+ dir := h.logDirectory()
103
+ if strings.TrimSpace(dir) == "" {
104
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
105
+ return
106
+ }
107
+
108
+ entries, err := os.ReadDir(dir)
109
+ if err != nil {
110
+ if os.IsNotExist(err) {
111
+ c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
112
+ return
113
+ }
114
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
115
+ return
116
+ }
117
+
118
+ removed := 0
119
+ for _, entry := range entries {
120
+ if entry.IsDir() {
121
+ continue
122
+ }
123
+ name := entry.Name()
124
+ fullPath := filepath.Join(dir, name)
125
+ if name == defaultLogFileName {
126
+ if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {
127
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)})
128
+ return
129
+ }
130
+ continue
131
+ }
132
+ if isRotatedLogFile(name) {
133
+ if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {
134
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)})
135
+ return
136
+ }
137
+ removed++
138
+ }
139
+ }
140
+
141
+ c.JSON(http.StatusOK, gin.H{
142
+ "success": true,
143
+ "message": "Logs cleared successfully",
144
+ "removed": removed,
145
+ })
146
+ }
147
+
148
+ // GetRequestErrorLogs lists error request log files when RequestLog is disabled.
149
+ // It returns an empty list when RequestLog is enabled.
150
+ func (h *Handler) GetRequestErrorLogs(c *gin.Context) {
151
+ if h == nil {
152
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
153
+ return
154
+ }
155
+ if h.cfg == nil {
156
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
157
+ return
158
+ }
159
+ if h.cfg.RequestLog {
160
+ c.JSON(http.StatusOK, gin.H{"files": []any{}})
161
+ return
162
+ }
163
+
164
+ dir := h.logDirectory()
165
+ if strings.TrimSpace(dir) == "" {
166
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
167
+ return
168
+ }
169
+
170
+ entries, err := os.ReadDir(dir)
171
+ if err != nil {
172
+ if os.IsNotExist(err) {
173
+ c.JSON(http.StatusOK, gin.H{"files": []any{}})
174
+ return
175
+ }
176
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list request error logs: %v", err)})
177
+ return
178
+ }
179
+
180
+ type errorLog struct {
181
+ Name string `json:"name"`
182
+ Size int64 `json:"size"`
183
+ Modified int64 `json:"modified"`
184
+ }
185
+
186
+ files := make([]errorLog, 0, len(entries))
187
+ for _, entry := range entries {
188
+ if entry.IsDir() {
189
+ continue
190
+ }
191
+ name := entry.Name()
192
+ if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
193
+ continue
194
+ }
195
+ info, errInfo := entry.Info()
196
+ if errInfo != nil {
197
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log info for %s: %v", name, errInfo)})
198
+ return
199
+ }
200
+ files = append(files, errorLog{
201
+ Name: name,
202
+ Size: info.Size(),
203
+ Modified: info.ModTime().Unix(),
204
+ })
205
+ }
206
+
207
+ sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
208
+
209
+ c.JSON(http.StatusOK, gin.H{"files": files})
210
+ }
211
+
212
+ // GetRequestLogByID finds and downloads a request log file by its request ID.
213
+ // The ID is matched against the suffix of log file names (format: *-{requestID}.log).
214
+ func (h *Handler) GetRequestLogByID(c *gin.Context) {
215
+ if h == nil {
216
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
217
+ return
218
+ }
219
+ if h.cfg == nil {
220
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
221
+ return
222
+ }
223
+
224
+ dir := h.logDirectory()
225
+ if strings.TrimSpace(dir) == "" {
226
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
227
+ return
228
+ }
229
+
230
+ requestID := strings.TrimSpace(c.Param("id"))
231
+ if requestID == "" {
232
+ requestID = strings.TrimSpace(c.Query("id"))
233
+ }
234
+ if requestID == "" {
235
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing request ID"})
236
+ return
237
+ }
238
+ if strings.ContainsAny(requestID, "/\\") {
239
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request ID"})
240
+ return
241
+ }
242
+
243
+ entries, err := os.ReadDir(dir)
244
+ if err != nil {
245
+ if os.IsNotExist(err) {
246
+ c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
247
+ return
248
+ }
249
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
250
+ return
251
+ }
252
+
253
+ suffix := "-" + requestID + ".log"
254
+ var matchedFile string
255
+ for _, entry := range entries {
256
+ if entry.IsDir() {
257
+ continue
258
+ }
259
+ name := entry.Name()
260
+ if strings.HasSuffix(name, suffix) {
261
+ matchedFile = name
262
+ break
263
+ }
264
+ }
265
+
266
+ if matchedFile == "" {
267
+ c.JSON(http.StatusNotFound, gin.H{"error": "log file not found for the given request ID"})
268
+ return
269
+ }
270
+
271
+ dirAbs, errAbs := filepath.Abs(dir)
272
+ if errAbs != nil {
273
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
274
+ return
275
+ }
276
+ fullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile))
277
+ prefix := dirAbs + string(os.PathSeparator)
278
+ if !strings.HasPrefix(fullPath, prefix) {
279
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
280
+ return
281
+ }
282
+
283
+ info, errStat := os.Stat(fullPath)
284
+ if errStat != nil {
285
+ if os.IsNotExist(errStat) {
286
+ c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
287
+ return
288
+ }
289
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
290
+ return
291
+ }
292
+ if info.IsDir() {
293
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
294
+ return
295
+ }
296
+
297
+ c.FileAttachment(fullPath, matchedFile)
298
+ }
299
+
300
+ // DownloadRequestErrorLog downloads a specific error request log file by name.
301
+ func (h *Handler) DownloadRequestErrorLog(c *gin.Context) {
302
+ if h == nil {
303
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
304
+ return
305
+ }
306
+ if h.cfg == nil {
307
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
308
+ return
309
+ }
310
+
311
+ dir := h.logDirectory()
312
+ if strings.TrimSpace(dir) == "" {
313
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
314
+ return
315
+ }
316
+
317
+ name := strings.TrimSpace(c.Param("name"))
318
+ if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") {
319
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file name"})
320
+ return
321
+ }
322
+ if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
323
+ c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
324
+ return
325
+ }
326
+
327
+ dirAbs, errAbs := filepath.Abs(dir)
328
+ if errAbs != nil {
329
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
330
+ return
331
+ }
332
+ fullPath := filepath.Clean(filepath.Join(dirAbs, name))
333
+ prefix := dirAbs + string(os.PathSeparator)
334
+ if !strings.HasPrefix(fullPath, prefix) {
335
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
336
+ return
337
+ }
338
+
339
+ info, errStat := os.Stat(fullPath)
340
+ if errStat != nil {
341
+ if os.IsNotExist(errStat) {
342
+ c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
343
+ return
344
+ }
345
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
346
+ return
347
+ }
348
+ if info.IsDir() {
349
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
350
+ return
351
+ }
352
+
353
+ c.FileAttachment(fullPath, name)
354
+ }
355
+
356
+ func (h *Handler) logDirectory() string {
357
+ if h == nil {
358
+ return ""
359
+ }
360
+ if h.logDir != "" {
361
+ return h.logDir
362
+ }
363
+ return logging.ResolveLogDirectory(h.cfg)
364
+ }
365
+
366
+ func (h *Handler) collectLogFiles(dir string) ([]string, error) {
367
+ entries, err := os.ReadDir(dir)
368
+ if err != nil {
369
+ return nil, err
370
+ }
371
+ type candidate struct {
372
+ path string
373
+ order int64
374
+ }
375
+ cands := make([]candidate, 0, len(entries))
376
+ for _, entry := range entries {
377
+ if entry.IsDir() {
378
+ continue
379
+ }
380
+ name := entry.Name()
381
+ if name == defaultLogFileName {
382
+ cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})
383
+ continue
384
+ }
385
+ if order, ok := rotationOrder(name); ok {
386
+ cands = append(cands, candidate{path: filepath.Join(dir, name), order: order})
387
+ }
388
+ }
389
+ if len(cands) == 0 {
390
+ return []string{}, nil
391
+ }
392
+ sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })
393
+ paths := make([]string, 0, len(cands))
394
+ for i := len(cands) - 1; i >= 0; i-- {
395
+ paths = append(paths, cands[i].path)
396
+ }
397
+ return paths, nil
398
+ }
399
+
400
+ type logAccumulator struct {
401
+ cutoff int64
402
+ limit int
403
+ lines []string
404
+ total int
405
+ latest int64
406
+ include bool
407
+ }
408
+
409
+ func newLogAccumulator(cutoff int64, limit int) *logAccumulator {
410
+ capacity := 256
411
+ if limit > 0 && limit < capacity {
412
+ capacity = limit
413
+ }
414
+ return &logAccumulator{
415
+ cutoff: cutoff,
416
+ limit: limit,
417
+ lines: make([]string, 0, capacity),
418
+ }
419
+ }
420
+
421
+ func (acc *logAccumulator) consumeFile(path string) error {
422
+ file, err := os.Open(path)
423
+ if err != nil {
424
+ if os.IsNotExist(err) {
425
+ return nil
426
+ }
427
+ return err
428
+ }
429
+ defer func() {
430
+ _ = file.Close()
431
+ }()
432
+
433
+ scanner := bufio.NewScanner(file)
434
+ buf := make([]byte, 0, logScannerInitialBuffer)
435
+ scanner.Buffer(buf, logScannerMaxBuffer)
436
+ for scanner.Scan() {
437
+ acc.addLine(scanner.Text())
438
+ }
439
+ if errScan := scanner.Err(); errScan != nil {
440
+ return errScan
441
+ }
442
+ return nil
443
+ }
444
+
445
+ func (acc *logAccumulator) addLine(raw string) {
446
+ line := strings.TrimRight(raw, "\r")
447
+ acc.total++
448
+ ts := parseTimestamp(line)
449
+ if ts > acc.latest {
450
+ acc.latest = ts
451
+ }
452
+ if ts > 0 {
453
+ acc.include = acc.cutoff == 0 || ts > acc.cutoff
454
+ if acc.cutoff == 0 || acc.include {
455
+ acc.append(line)
456
+ }
457
+ return
458
+ }
459
+ if acc.cutoff == 0 || acc.include {
460
+ acc.append(line)
461
+ }
462
+ }
463
+
464
+ func (acc *logAccumulator) append(line string) {
465
+ acc.lines = append(acc.lines, line)
466
+ if acc.limit > 0 && len(acc.lines) > acc.limit {
467
+ acc.lines = acc.lines[len(acc.lines)-acc.limit:]
468
+ }
469
+ }
470
+
471
+ func (acc *logAccumulator) result() ([]string, int, int64) {
472
+ if acc.lines == nil {
473
+ acc.lines = []string{}
474
+ }
475
+ return acc.lines, acc.total, acc.latest
476
+ }
477
+
478
+ func parseCutoff(raw string) int64 {
479
+ value := strings.TrimSpace(raw)
480
+ if value == "" {
481
+ return 0
482
+ }
483
+ ts, err := strconv.ParseInt(value, 10, 64)
484
+ if err != nil || ts <= 0 {
485
+ return 0
486
+ }
487
+ return ts
488
+ }
489
+
490
+ func parseLimit(raw string) (int, error) {
491
+ value := strings.TrimSpace(raw)
492
+ if value == "" {
493
+ return 0, nil
494
+ }
495
+ limit, err := strconv.Atoi(value)
496
+ if err != nil {
497
+ return 0, fmt.Errorf("must be a positive integer")
498
+ }
499
+ if limit <= 0 {
500
+ return 0, fmt.Errorf("must be greater than zero")
501
+ }
502
+ return limit, nil
503
+ }
504
+
505
+ func parseTimestamp(line string) int64 {
506
+ if strings.HasPrefix(line, "[") {
507
+ line = line[1:]
508
+ }
509
+ if len(line) < 19 {
510
+ return 0
511
+ }
512
+ candidate := line[:19]
513
+ t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local)
514
+ if err != nil {
515
+ return 0
516
+ }
517
+ return t.Unix()
518
+ }
519
+
520
+ func isRotatedLogFile(name string) bool {
521
+ if _, ok := rotationOrder(name); ok {
522
+ return true
523
+ }
524
+ return false
525
+ }
526
+
527
+ func rotationOrder(name string) (int64, bool) {
528
+ if order, ok := numericRotationOrder(name); ok {
529
+ return order, true
530
+ }
531
+ if order, ok := timestampRotationOrder(name); ok {
532
+ return order, true
533
+ }
534
+ return 0, false
535
+ }
536
+
537
+ func numericRotationOrder(name string) (int64, bool) {
538
+ if !strings.HasPrefix(name, defaultLogFileName+".") {
539
+ return 0, false
540
+ }
541
+ suffix := strings.TrimPrefix(name, defaultLogFileName+".")
542
+ if suffix == "" {
543
+ return 0, false
544
+ }
545
+ n, err := strconv.Atoi(suffix)
546
+ if err != nil {
547
+ return 0, false
548
+ }
549
+ return int64(n), true
550
+ }
551
+
552
+ func timestampRotationOrder(name string) (int64, bool) {
553
+ ext := filepath.Ext(defaultLogFileName)
554
+ base := strings.TrimSuffix(defaultLogFileName, ext)
555
+ if base == "" {
556
+ return 0, false
557
+ }
558
+ prefix := base + "-"
559
+ if !strings.HasPrefix(name, prefix) {
560
+ return 0, false
561
+ }
562
+ clean := strings.TrimPrefix(name, prefix)
563
+ if strings.HasSuffix(clean, ".gz") {
564
+ clean = strings.TrimSuffix(clean, ".gz")
565
+ }
566
+ if ext != "" {
567
+ if !strings.HasSuffix(clean, ext) {
568
+ return 0, false
569
+ }
570
+ clean = strings.TrimSuffix(clean, ext)
571
+ }
572
+ if clean == "" {
573
+ return 0, false
574
+ }
575
+ if idx := strings.IndexByte(clean, '.'); idx != -1 {
576
+ clean = clean[:idx]
577
+ }
578
+ parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local)
579
+ if err != nil {
580
+ return 0, false
581
+ }
582
+ return math.MaxInt64 - parsed.Unix(), true
583
+ }
internal/api/handlers/management/model_definitions.go ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+
7
+ "github.com/gin-gonic/gin"
8
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
9
+ )
10
+
11
+ // GetStaticModelDefinitions returns static model metadata for a given channel.
12
+ // Channel is provided via path param (:channel) or query param (?channel=...).
13
+ func (h *Handler) GetStaticModelDefinitions(c *gin.Context) {
14
+ channel := strings.TrimSpace(c.Param("channel"))
15
+ if channel == "" {
16
+ channel = strings.TrimSpace(c.Query("channel"))
17
+ }
18
+ if channel == "" {
19
+ c.JSON(http.StatusBadRequest, gin.H{"error": "channel is required"})
20
+ return
21
+ }
22
+
23
+ models := registry.GetStaticModelDefinitionsByChannel(channel)
24
+ if models == nil {
25
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unknown channel", "channel": channel})
26
+ return
27
+ }
28
+
29
+ c.JSON(http.StatusOK, gin.H{
30
+ "channel": strings.ToLower(strings.TrimSpace(channel)),
31
+ "models": models,
32
+ })
33
+ }
internal/api/handlers/management/oauth_callback.go ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "errors"
5
+ "net/http"
6
+ "net/url"
7
+ "strings"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ )
11
+
12
+ type oauthCallbackRequest struct {
13
+ Provider string `json:"provider"`
14
+ RedirectURL string `json:"redirect_url"`
15
+ Code string `json:"code"`
16
+ State string `json:"state"`
17
+ Error string `json:"error"`
18
+ }
19
+
20
+ func (h *Handler) PostOAuthCallback(c *gin.Context) {
21
+ if h == nil || h.cfg == nil {
22
+ c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "handler not initialized"})
23
+ return
24
+ }
25
+
26
+ var req oauthCallbackRequest
27
+ if err := c.ShouldBindJSON(&req); err != nil {
28
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"})
29
+ return
30
+ }
31
+
32
+ canonicalProvider, err := NormalizeOAuthProvider(req.Provider)
33
+ if err != nil {
34
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unsupported provider"})
35
+ return
36
+ }
37
+
38
+ state := strings.TrimSpace(req.State)
39
+ code := strings.TrimSpace(req.Code)
40
+ errMsg := strings.TrimSpace(req.Error)
41
+
42
+ if rawRedirect := strings.TrimSpace(req.RedirectURL); rawRedirect != "" {
43
+ u, errParse := url.Parse(rawRedirect)
44
+ if errParse != nil {
45
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid redirect_url"})
46
+ return
47
+ }
48
+ q := u.Query()
49
+ if state == "" {
50
+ state = strings.TrimSpace(q.Get("state"))
51
+ }
52
+ if code == "" {
53
+ code = strings.TrimSpace(q.Get("code"))
54
+ }
55
+ if errMsg == "" {
56
+ errMsg = strings.TrimSpace(q.Get("error"))
57
+ if errMsg == "" {
58
+ errMsg = strings.TrimSpace(q.Get("error_description"))
59
+ }
60
+ }
61
+ }
62
+
63
+ if state == "" {
64
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "state is required"})
65
+ return
66
+ }
67
+ if err := ValidateOAuthState(state); err != nil {
68
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
69
+ return
70
+ }
71
+ if code == "" && errMsg == "" {
72
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "code or error is required"})
73
+ return
74
+ }
75
+
76
+ sessionProvider, sessionStatus, ok := GetOAuthSession(state)
77
+ if !ok {
78
+ c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
79
+ return
80
+ }
81
+ if sessionStatus != "" {
82
+ c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
83
+ return
84
+ }
85
+ if !strings.EqualFold(sessionProvider, canonicalProvider) {
86
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "provider does not match state"})
87
+ return
88
+ }
89
+
90
+ if _, errWrite := WriteOAuthCallbackFileForPendingSession(h.cfg.AuthDir, canonicalProvider, state, code, errMsg); errWrite != nil {
91
+ if errors.Is(errWrite, errOAuthSessionNotPending) {
92
+ c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
93
+ return
94
+ }
95
+ c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to persist oauth callback"})
96
+ return
97
+ }
98
+
99
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
100
+ }
internal/api/handlers/management/oauth_sessions.go ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync"
11
+ "time"
12
+ )
13
+
14
+ const (
15
+ oauthSessionTTL = 10 * time.Minute
16
+ maxOAuthStateLength = 128
17
+ )
18
+
19
+ var (
20
+ errInvalidOAuthState = errors.New("invalid oauth state")
21
+ errUnsupportedOAuthFlow = errors.New("unsupported oauth provider")
22
+ errOAuthSessionNotPending = errors.New("oauth session is not pending")
23
+ )
24
+
25
+ type oauthSession struct {
26
+ Provider string
27
+ Status string
28
+ CreatedAt time.Time
29
+ ExpiresAt time.Time
30
+ }
31
+
32
+ type oauthSessionStore struct {
33
+ mu sync.RWMutex
34
+ ttl time.Duration
35
+ sessions map[string]oauthSession
36
+ }
37
+
38
+ func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
39
+ if ttl <= 0 {
40
+ ttl = oauthSessionTTL
41
+ }
42
+ return &oauthSessionStore{
43
+ ttl: ttl,
44
+ sessions: make(map[string]oauthSession),
45
+ }
46
+ }
47
+
48
+ func (s *oauthSessionStore) purgeExpiredLocked(now time.Time) {
49
+ for state, session := range s.sessions {
50
+ if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
51
+ delete(s.sessions, state)
52
+ }
53
+ }
54
+ }
55
+
56
+ func (s *oauthSessionStore) Register(state, provider string) {
57
+ state = strings.TrimSpace(state)
58
+ provider = strings.ToLower(strings.TrimSpace(provider))
59
+ if state == "" || provider == "" {
60
+ return
61
+ }
62
+ now := time.Now()
63
+
64
+ s.mu.Lock()
65
+ defer s.mu.Unlock()
66
+
67
+ s.purgeExpiredLocked(now)
68
+ s.sessions[state] = oauthSession{
69
+ Provider: provider,
70
+ Status: "",
71
+ CreatedAt: now,
72
+ ExpiresAt: now.Add(s.ttl),
73
+ }
74
+ }
75
+
76
+ func (s *oauthSessionStore) SetError(state, message string) {
77
+ state = strings.TrimSpace(state)
78
+ message = strings.TrimSpace(message)
79
+ if state == "" {
80
+ return
81
+ }
82
+ if message == "" {
83
+ message = "Authentication failed"
84
+ }
85
+ now := time.Now()
86
+
87
+ s.mu.Lock()
88
+ defer s.mu.Unlock()
89
+
90
+ s.purgeExpiredLocked(now)
91
+ session, ok := s.sessions[state]
92
+ if !ok {
93
+ return
94
+ }
95
+ session.Status = message
96
+ session.ExpiresAt = now.Add(s.ttl)
97
+ s.sessions[state] = session
98
+ }
99
+
100
+ func (s *oauthSessionStore) Complete(state string) {
101
+ state = strings.TrimSpace(state)
102
+ if state == "" {
103
+ return
104
+ }
105
+ now := time.Now()
106
+
107
+ s.mu.Lock()
108
+ defer s.mu.Unlock()
109
+
110
+ s.purgeExpiredLocked(now)
111
+ delete(s.sessions, state)
112
+ }
113
+
114
+ func (s *oauthSessionStore) CompleteProvider(provider string) int {
115
+ provider = strings.ToLower(strings.TrimSpace(provider))
116
+ if provider == "" {
117
+ return 0
118
+ }
119
+ now := time.Now()
120
+
121
+ s.mu.Lock()
122
+ defer s.mu.Unlock()
123
+
124
+ s.purgeExpiredLocked(now)
125
+ removed := 0
126
+ for state, session := range s.sessions {
127
+ if strings.EqualFold(session.Provider, provider) {
128
+ delete(s.sessions, state)
129
+ removed++
130
+ }
131
+ }
132
+ return removed
133
+ }
134
+
135
+ func (s *oauthSessionStore) Get(state string) (oauthSession, bool) {
136
+ state = strings.TrimSpace(state)
137
+ now := time.Now()
138
+
139
+ s.mu.Lock()
140
+ defer s.mu.Unlock()
141
+
142
+ s.purgeExpiredLocked(now)
143
+ session, ok := s.sessions[state]
144
+ return session, ok
145
+ }
146
+
147
+ func (s *oauthSessionStore) IsPending(state, provider string) bool {
148
+ state = strings.TrimSpace(state)
149
+ provider = strings.ToLower(strings.TrimSpace(provider))
150
+ now := time.Now()
151
+
152
+ s.mu.Lock()
153
+ defer s.mu.Unlock()
154
+
155
+ s.purgeExpiredLocked(now)
156
+ session, ok := s.sessions[state]
157
+ if !ok {
158
+ return false
159
+ }
160
+ if session.Status != "" {
161
+ return false
162
+ }
163
+ if provider == "" {
164
+ return true
165
+ }
166
+ return strings.EqualFold(session.Provider, provider)
167
+ }
168
+
169
+ var oauthSessions = newOAuthSessionStore(oauthSessionTTL)
170
+
171
+ func RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) }
172
+
173
+ func SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) }
174
+
175
+ func CompleteOAuthSession(state string) { oauthSessions.Complete(state) }
176
+
177
+ func CompleteOAuthSessionsByProvider(provider string) int {
178
+ return oauthSessions.CompleteProvider(provider)
179
+ }
180
+
181
+ func GetOAuthSession(state string) (provider string, status string, ok bool) {
182
+ session, ok := oauthSessions.Get(state)
183
+ if !ok {
184
+ return "", "", false
185
+ }
186
+ return session.Provider, session.Status, true
187
+ }
188
+
189
+ func IsOAuthSessionPending(state, provider string) bool {
190
+ return oauthSessions.IsPending(state, provider)
191
+ }
192
+
193
+ func ValidateOAuthState(state string) error {
194
+ trimmed := strings.TrimSpace(state)
195
+ if trimmed == "" {
196
+ return fmt.Errorf("%w: empty", errInvalidOAuthState)
197
+ }
198
+ if len(trimmed) > maxOAuthStateLength {
199
+ return fmt.Errorf("%w: too long", errInvalidOAuthState)
200
+ }
201
+ if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") {
202
+ return fmt.Errorf("%w: contains path separator", errInvalidOAuthState)
203
+ }
204
+ if strings.Contains(trimmed, "..") {
205
+ return fmt.Errorf("%w: contains '..'", errInvalidOAuthState)
206
+ }
207
+ for _, r := range trimmed {
208
+ switch {
209
+ case r >= 'a' && r <= 'z':
210
+ case r >= 'A' && r <= 'Z':
211
+ case r >= '0' && r <= '9':
212
+ case r == '-' || r == '_' || r == '.':
213
+ default:
214
+ return fmt.Errorf("%w: invalid character", errInvalidOAuthState)
215
+ }
216
+ }
217
+ return nil
218
+ }
219
+
220
+ func NormalizeOAuthProvider(provider string) (string, error) {
221
+ switch strings.ToLower(strings.TrimSpace(provider)) {
222
+ case "anthropic", "claude":
223
+ return "anthropic", nil
224
+ case "codex", "openai":
225
+ return "codex", nil
226
+ case "gemini", "google":
227
+ return "gemini", nil
228
+ case "iflow", "i-flow":
229
+ return "iflow", nil
230
+ case "antigravity", "anti-gravity":
231
+ return "antigravity", nil
232
+ case "qwen":
233
+ return "qwen", nil
234
+ default:
235
+ return "", errUnsupportedOAuthFlow
236
+ }
237
+ }
238
+
239
+ type oauthCallbackFilePayload struct {
240
+ Code string `json:"code"`
241
+ State string `json:"state"`
242
+ Error string `json:"error"`
243
+ }
244
+
245
+ func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {
246
+ if strings.TrimSpace(authDir) == "" {
247
+ return "", fmt.Errorf("auth dir is empty")
248
+ }
249
+ canonicalProvider, err := NormalizeOAuthProvider(provider)
250
+ if err != nil {
251
+ return "", err
252
+ }
253
+ if err := ValidateOAuthState(state); err != nil {
254
+ return "", err
255
+ }
256
+
257
+ fileName := fmt.Sprintf(".oauth-%s-%s.oauth", canonicalProvider, state)
258
+ filePath := filepath.Join(authDir, fileName)
259
+ payload := oauthCallbackFilePayload{
260
+ Code: strings.TrimSpace(code),
261
+ State: strings.TrimSpace(state),
262
+ Error: strings.TrimSpace(errorMessage),
263
+ }
264
+ data, err := json.Marshal(payload)
265
+ if err != nil {
266
+ return "", fmt.Errorf("marshal oauth callback payload: %w", err)
267
+ }
268
+ if err := os.WriteFile(filePath, data, 0o600); err != nil {
269
+ return "", fmt.Errorf("write oauth callback file: %w", err)
270
+ }
271
+ return filePath, nil
272
+ }
273
+
274
+ func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {
275
+ canonicalProvider, err := NormalizeOAuthProvider(provider)
276
+ if err != nil {
277
+ return "", err
278
+ }
279
+ if !IsOAuthSessionPending(state, canonicalProvider) {
280
+ return "", errOAuthSessionNotPending
281
+ }
282
+ return WriteOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage)
283
+ }
internal/api/handlers/management/quota.go ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import "github.com/gin-gonic/gin"
4
+
5
+ // Quota exceeded toggles
6
+ func (h *Handler) GetSwitchProject(c *gin.Context) {
7
+ c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
8
+ }
9
+ func (h *Handler) PutSwitchProject(c *gin.Context) {
10
+ h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
11
+ }
12
+
13
+ func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
14
+ c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
15
+ }
16
+ func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
17
+ h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
18
+ }
internal/api/handlers/management/usage.go ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "time"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
10
+ )
11
+
12
+ type usageExportPayload struct {
13
+ Version int `json:"version"`
14
+ ExportedAt time.Time `json:"exported_at"`
15
+ Usage usage.StatisticsSnapshot `json:"usage"`
16
+ }
17
+
18
+ type usageImportPayload struct {
19
+ Version int `json:"version"`
20
+ Usage usage.StatisticsSnapshot `json:"usage"`
21
+ }
22
+
23
+ // GetUsageStatistics returns the in-memory request statistics snapshot.
24
+ func (h *Handler) GetUsageStatistics(c *gin.Context) {
25
+ var snapshot usage.StatisticsSnapshot
26
+ if h != nil && h.usageStats != nil {
27
+ snapshot = h.usageStats.Snapshot()
28
+ }
29
+ c.JSON(http.StatusOK, gin.H{
30
+ "usage": snapshot,
31
+ "failed_requests": snapshot.FailureCount,
32
+ })
33
+ }
34
+
35
+ // ExportUsageStatistics returns a complete usage snapshot for backup/migration.
36
+ func (h *Handler) ExportUsageStatistics(c *gin.Context) {
37
+ var snapshot usage.StatisticsSnapshot
38
+ if h != nil && h.usageStats != nil {
39
+ snapshot = h.usageStats.Snapshot()
40
+ }
41
+ c.JSON(http.StatusOK, usageExportPayload{
42
+ Version: 1,
43
+ ExportedAt: time.Now().UTC(),
44
+ Usage: snapshot,
45
+ })
46
+ }
47
+
48
+ // ImportUsageStatistics merges a previously exported usage snapshot into memory.
49
+ func (h *Handler) ImportUsageStatistics(c *gin.Context) {
50
+ if h == nil || h.usageStats == nil {
51
+ c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"})
52
+ return
53
+ }
54
+
55
+ data, err := c.GetRawData()
56
+ if err != nil {
57
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
58
+ return
59
+ }
60
+
61
+ var payload usageImportPayload
62
+ if err := json.Unmarshal(data, &payload); err != nil {
63
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
64
+ return
65
+ }
66
+ if payload.Version != 0 && payload.Version != 1 {
67
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
68
+ return
69
+ }
70
+
71
+ result := h.usageStats.MergeSnapshot(payload.Usage)
72
+ snapshot := h.usageStats.Snapshot()
73
+ c.JSON(http.StatusOK, gin.H{
74
+ "added": result.Added,
75
+ "skipped": result.Skipped,
76
+ "total_requests": snapshot.TotalRequests,
77
+ "failed_requests": snapshot.FailureCount,
78
+ })
79
+ }
internal/api/handlers/management/vertex_import.go ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "strings"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
13
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
14
+ )
15
+
16
+ // ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.
17
+ func (h *Handler) ImportVertexCredential(c *gin.Context) {
18
+ if h == nil || h.cfg == nil {
19
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "config unavailable"})
20
+ return
21
+ }
22
+ if h.cfg.AuthDir == "" {
23
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth directory not configured"})
24
+ return
25
+ }
26
+
27
+ fileHeader, err := c.FormFile("file")
28
+ if err != nil {
29
+ c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
30
+ return
31
+ }
32
+
33
+ file, err := fileHeader.Open()
34
+ if err != nil {
35
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
36
+ return
37
+ }
38
+ defer file.Close()
39
+
40
+ data, err := io.ReadAll(file)
41
+ if err != nil {
42
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
43
+ return
44
+ }
45
+
46
+ var serviceAccount map[string]any
47
+ if err := json.Unmarshal(data, &serviceAccount); err != nil {
48
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json", "message": err.Error()})
49
+ return
50
+ }
51
+
52
+ normalizedSA, err := vertex.NormalizeServiceAccountMap(serviceAccount)
53
+ if err != nil {
54
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service account", "message": err.Error()})
55
+ return
56
+ }
57
+ serviceAccount = normalizedSA
58
+
59
+ projectID := strings.TrimSpace(valueAsString(serviceAccount["project_id"]))
60
+ if projectID == "" {
61
+ c.JSON(http.StatusBadRequest, gin.H{"error": "project_id missing"})
62
+ return
63
+ }
64
+ email := strings.TrimSpace(valueAsString(serviceAccount["client_email"]))
65
+
66
+ location := strings.TrimSpace(c.PostForm("location"))
67
+ if location == "" {
68
+ location = strings.TrimSpace(c.Query("location"))
69
+ }
70
+ if location == "" {
71
+ location = "us-central1"
72
+ }
73
+
74
+ fileName := fmt.Sprintf("vertex-%s.json", sanitizeVertexFilePart(projectID))
75
+ label := labelForVertex(projectID, email)
76
+ storage := &vertex.VertexCredentialStorage{
77
+ ServiceAccount: serviceAccount,
78
+ ProjectID: projectID,
79
+ Email: email,
80
+ Location: location,
81
+ Type: "vertex",
82
+ }
83
+ metadata := map[string]any{
84
+ "service_account": serviceAccount,
85
+ "project_id": projectID,
86
+ "email": email,
87
+ "location": location,
88
+ "type": "vertex",
89
+ "label": label,
90
+ }
91
+ record := &coreauth.Auth{
92
+ ID: fileName,
93
+ Provider: "vertex",
94
+ FileName: fileName,
95
+ Storage: storage,
96
+ Label: label,
97
+ Metadata: metadata,
98
+ }
99
+
100
+ ctx := context.Background()
101
+ if reqCtx := c.Request.Context(); reqCtx != nil {
102
+ ctx = reqCtx
103
+ }
104
+ savedPath, err := h.saveTokenRecord(ctx, record)
105
+ if err != nil {
106
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "save_failed", "message": err.Error()})
107
+ return
108
+ }
109
+
110
+ c.JSON(http.StatusOK, gin.H{
111
+ "status": "ok",
112
+ "auth-file": savedPath,
113
+ "project_id": projectID,
114
+ "email": email,
115
+ "location": location,
116
+ })
117
+ }
118
+
119
+ func valueAsString(v any) string {
120
+ if v == nil {
121
+ return ""
122
+ }
123
+ switch t := v.(type) {
124
+ case string:
125
+ return t
126
+ default:
127
+ return fmt.Sprint(t)
128
+ }
129
+ }
130
+
131
+ func sanitizeVertexFilePart(s string) string {
132
+ out := strings.TrimSpace(s)
133
+ replacers := []string{"/", "_", "\\", "_", ":", "_", " ", "-"}
134
+ for i := 0; i < len(replacers); i += 2 {
135
+ out = strings.ReplaceAll(out, replacers[i], replacers[i+1])
136
+ }
137
+ if out == "" {
138
+ return "vertex"
139
+ }
140
+ return out
141
+ }
142
+
143
+ func labelForVertex(projectID, email string) string {
144
+ p := strings.TrimSpace(projectID)
145
+ e := strings.TrimSpace(email)
146
+ if p != "" && e != "" {
147
+ return fmt.Sprintf("%s (%s)", p, e)
148
+ }
149
+ if p != "" {
150
+ return p
151
+ }
152
+ if e != "" {
153
+ return e
154
+ }
155
+ return "vertex"
156
+ }
internal/api/middleware/request_logging.go ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package middleware provides HTTP middleware components for the CLI Proxy API server.
2
+ // This file contains the request logging middleware that captures comprehensive
3
+ // request and response data when enabled through configuration.
4
+ package middleware
5
+
6
+ import (
7
+ "bytes"
8
+ "io"
9
+ "net/http"
10
+ "strings"
11
+
12
+ "github.com/gin-gonic/gin"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
14
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
15
+ )
16
+
17
+ // RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.
18
+ // It captures detailed information about the request and response, including headers and body,
19
+ // and uses the provided RequestLogger to record this data. When logging is disabled in the
20
+ // logger, it still captures data so that upstream errors can be persisted.
21
+ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
22
+ return func(c *gin.Context) {
23
+ if logger == nil {
24
+ c.Next()
25
+ return
26
+ }
27
+
28
+ if c.Request.Method == http.MethodGet {
29
+ c.Next()
30
+ return
31
+ }
32
+
33
+ path := c.Request.URL.Path
34
+ if !shouldLogRequest(path) {
35
+ c.Next()
36
+ return
37
+ }
38
+
39
+ // Capture request information
40
+ requestInfo, err := captureRequestInfo(c)
41
+ if err != nil {
42
+ // Log error but continue processing
43
+ // In a real implementation, you might want to use a proper logger here
44
+ c.Next()
45
+ return
46
+ }
47
+
48
+ // Create response writer wrapper
49
+ wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)
50
+ if !logger.IsEnabled() {
51
+ wrapper.logOnErrorOnly = true
52
+ }
53
+ c.Writer = wrapper
54
+
55
+ // Process the request
56
+ c.Next()
57
+
58
+ // Finalize logging after request processing
59
+ if err = wrapper.Finalize(c); err != nil {
60
+ // Log error but don't interrupt the response
61
+ // In a real implementation, you might want to use a proper logger here
62
+ }
63
+ }
64
+ }
65
+
66
+ // captureRequestInfo extracts relevant information from the incoming HTTP request.
67
+ // It captures the URL, method, headers, and body. The request body is read and then
68
+ // restored so that it can be processed by subsequent handlers.
69
+ func captureRequestInfo(c *gin.Context) (*RequestInfo, error) {
70
+ // Capture URL with sensitive query parameters masked
71
+ maskedQuery := util.MaskSensitiveQuery(c.Request.URL.RawQuery)
72
+ url := c.Request.URL.Path
73
+ if maskedQuery != "" {
74
+ url += "?" + maskedQuery
75
+ }
76
+
77
+ // Capture method
78
+ method := c.Request.Method
79
+
80
+ // Capture headers
81
+ headers := make(map[string][]string)
82
+ for key, values := range c.Request.Header {
83
+ headers[key] = values
84
+ }
85
+
86
+ // Capture request body
87
+ var body []byte
88
+ if c.Request.Body != nil {
89
+ // Read the body
90
+ bodyBytes, err := io.ReadAll(c.Request.Body)
91
+ if err != nil {
92
+ return nil, err
93
+ }
94
+
95
+ // Restore the body for the actual request processing
96
+ c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
97
+ body = bodyBytes
98
+ }
99
+
100
+ return &RequestInfo{
101
+ URL: url,
102
+ Method: method,
103
+ Headers: headers,
104
+ Body: body,
105
+ RequestID: logging.GetGinRequestID(c),
106
+ }, nil
107
+ }
108
+
109
+ // shouldLogRequest determines whether the request should be logged.
110
+ // It skips management endpoints to avoid leaking secrets but allows
111
+ // all other routes, including module-provided ones, to honor request-log.
112
+ func shouldLogRequest(path string) bool {
113
+ if strings.HasPrefix(path, "/v0/management") || strings.HasPrefix(path, "/management") {
114
+ return false
115
+ }
116
+
117
+ if strings.HasPrefix(path, "/api") {
118
+ return strings.HasPrefix(path, "/api/provider")
119
+ }
120
+
121
+ return true
122
+ }
internal/api/middleware/response_writer.go ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package middleware provides Gin HTTP middleware for the CLI Proxy API server.
2
+ // It includes a sophisticated response writer wrapper designed to capture and log request and response data,
3
+ // including support for streaming responses, without impacting latency.
4
+ package middleware
5
+
6
+ import (
7
+ "bytes"
8
+ "net/http"
9
+ "strings"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
14
+ )
15
+
16
+ // RequestInfo holds essential details of an incoming HTTP request for logging purposes.
17
+ type RequestInfo struct {
18
+ URL string // URL is the request URL.
19
+ Method string // Method is the HTTP method (e.g., GET, POST).
20
+ Headers map[string][]string // Headers contains the request headers.
21
+ Body []byte // Body is the raw request body.
22
+ RequestID string // RequestID is the unique identifier for the request.
23
+ }
24
+
25
+ // ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data.
26
+ // It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.
27
+ type ResponseWriterWrapper struct {
28
+ gin.ResponseWriter
29
+ body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses.
30
+ isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
31
+ streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
32
+ chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
33
+ streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
34
+ logger logging.RequestLogger // logger is the instance of the request logger service.
35
+ requestInfo *RequestInfo // requestInfo holds the details of the original request.
36
+ statusCode int // statusCode stores the HTTP status code of the response.
37
+ headers map[string][]string // headers stores the response headers.
38
+ logOnErrorOnly bool // logOnErrorOnly enables logging only when an error response is detected.
39
+ }
40
+
41
+ // NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.
42
+ // It takes the original gin.ResponseWriter, a logger instance, and request information.
43
+ //
44
+ // Parameters:
45
+ // - w: The original gin.ResponseWriter to wrap.
46
+ // - logger: The logging service to use for recording requests.
47
+ // - requestInfo: The pre-captured information about the incoming request.
48
+ //
49
+ // Returns:
50
+ // - A pointer to a new ResponseWriterWrapper.
51
+ func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
52
+ return &ResponseWriterWrapper{
53
+ ResponseWriter: w,
54
+ body: &bytes.Buffer{},
55
+ logger: logger,
56
+ requestInfo: requestInfo,
57
+ headers: make(map[string][]string),
58
+ }
59
+ }
60
+
61
+ // Write wraps the underlying ResponseWriter's Write method to capture response data.
62
+ // For non-streaming responses, it writes to an internal buffer. For streaming responses,
63
+ // it sends data chunks to a non-blocking channel for asynchronous logging.
64
+ // CRITICAL: This method prioritizes writing to the client to ensure zero latency,
65
+ // handling logging operations subsequently.
66
+ func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
67
+ // Ensure headers are captured before first write
68
+ // This is critical because Write() may trigger WriteHeader() internally
69
+ w.ensureHeadersCaptured()
70
+
71
+ // CRITICAL: Write to client first (zero latency)
72
+ n, err := w.ResponseWriter.Write(data)
73
+
74
+ // THEN: Handle logging based on response type
75
+ if w.isStreaming && w.chunkChannel != nil {
76
+ // For streaming responses: Send to async logging channel (non-blocking)
77
+ select {
78
+ case w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy
79
+ default: // Channel full, skip logging to avoid blocking
80
+ }
81
+ return n, err
82
+ }
83
+
84
+ if w.shouldBufferResponseBody() {
85
+ w.body.Write(data)
86
+ }
87
+
88
+ return n, err
89
+ }
90
+
91
+ func (w *ResponseWriterWrapper) shouldBufferResponseBody() bool {
92
+ if w.logger != nil && w.logger.IsEnabled() {
93
+ return true
94
+ }
95
+ if !w.logOnErrorOnly {
96
+ return false
97
+ }
98
+ status := w.statusCode
99
+ if status == 0 {
100
+ if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok && statusWriter != nil {
101
+ status = statusWriter.Status()
102
+ } else {
103
+ status = http.StatusOK
104
+ }
105
+ }
106
+ return status >= http.StatusBadRequest
107
+ }
108
+
109
+ // WriteString wraps the underlying ResponseWriter's WriteString method to capture response data.
110
+ // Some handlers (and fmt/io helpers) write via io.StringWriter; without this override, those writes
111
+ // bypass Write() and would be missing from request logs.
112
+ func (w *ResponseWriterWrapper) WriteString(data string) (int, error) {
113
+ w.ensureHeadersCaptured()
114
+
115
+ // CRITICAL: Write to client first (zero latency)
116
+ n, err := w.ResponseWriter.WriteString(data)
117
+
118
+ // THEN: Capture for logging
119
+ if w.isStreaming && w.chunkChannel != nil {
120
+ select {
121
+ case w.chunkChannel <- []byte(data):
122
+ default:
123
+ }
124
+ return n, err
125
+ }
126
+
127
+ if w.shouldBufferResponseBody() {
128
+ w.body.WriteString(data)
129
+ }
130
+ return n, err
131
+ }
132
+
133
+ // WriteHeader wraps the underlying ResponseWriter's WriteHeader method.
134
+ // It captures the status code, detects if the response is streaming based on the Content-Type header,
135
+ // and initializes the appropriate logging mechanism (standard or streaming).
136
+ func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
137
+ w.statusCode = statusCode
138
+
139
+ // Capture response headers using the new method
140
+ w.captureCurrentHeaders()
141
+
142
+ // Detect streaming based on Content-Type
143
+ contentType := w.ResponseWriter.Header().Get("Content-Type")
144
+ w.isStreaming = w.detectStreaming(contentType)
145
+
146
+ // If streaming, initialize streaming log writer
147
+ if w.isStreaming && w.logger.IsEnabled() {
148
+ streamWriter, err := w.logger.LogStreamingRequest(
149
+ w.requestInfo.URL,
150
+ w.requestInfo.Method,
151
+ w.requestInfo.Headers,
152
+ w.requestInfo.Body,
153
+ w.requestInfo.RequestID,
154
+ )
155
+ if err == nil {
156
+ w.streamWriter = streamWriter
157
+ w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
158
+ doneChan := make(chan struct{})
159
+ w.streamDone = doneChan
160
+
161
+ // Start async chunk processor
162
+ go w.processStreamingChunks(doneChan)
163
+
164
+ // Write status immediately
165
+ _ = streamWriter.WriteStatus(statusCode, w.headers)
166
+ }
167
+ }
168
+
169
+ // Call original WriteHeader
170
+ w.ResponseWriter.WriteHeader(statusCode)
171
+ }
172
+
173
+ // ensureHeadersCaptured is a helper function to make sure response headers are captured.
174
+ // It is safe to call this method multiple times; it will always refresh the headers
175
+ // with the latest state from the underlying ResponseWriter.
176
+ func (w *ResponseWriterWrapper) ensureHeadersCaptured() {
177
+ // Always capture the current headers to ensure we have the latest state
178
+ w.captureCurrentHeaders()
179
+ }
180
+
181
+ // captureCurrentHeaders reads all headers from the underlying ResponseWriter and stores them
182
+ // in the wrapper's headers map. It creates copies of the header values to prevent race conditions.
183
+ func (w *ResponseWriterWrapper) captureCurrentHeaders() {
184
+ // Initialize headers map if needed
185
+ if w.headers == nil {
186
+ w.headers = make(map[string][]string)
187
+ }
188
+
189
+ // Capture all current headers from the underlying ResponseWriter
190
+ for key, values := range w.ResponseWriter.Header() {
191
+ // Make a copy of the values slice to avoid reference issues
192
+ headerValues := make([]string, len(values))
193
+ copy(headerValues, values)
194
+ w.headers[key] = headerValues
195
+ }
196
+ }
197
+
198
+ // detectStreaming determines if a response should be treated as a streaming response.
199
+ // It checks for a "text/event-stream" Content-Type or a '"stream": true'
200
+ // field in the original request body.
201
+ func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
202
+ // Check Content-Type for Server-Sent Events
203
+ if strings.Contains(contentType, "text/event-stream") {
204
+ return true
205
+ }
206
+
207
+ // If a concrete Content-Type is already set (e.g., application/json for error responses),
208
+ // treat it as non-streaming instead of inferring from the request payload.
209
+ if strings.TrimSpace(contentType) != "" {
210
+ return false
211
+ }
212
+
213
+ // Only fall back to request payload hints when Content-Type is not set yet.
214
+ if w.requestInfo != nil && len(w.requestInfo.Body) > 0 {
215
+ bodyStr := string(w.requestInfo.Body)
216
+ return strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`)
217
+ }
218
+
219
+ return false
220
+ }
221
+
222
+ // processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
223
+ // It asynchronously writes each chunk to the streaming log writer.
224
+ func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
225
+ if done == nil {
226
+ return
227
+ }
228
+
229
+ defer close(done)
230
+
231
+ if w.streamWriter == nil || w.chunkChannel == nil {
232
+ return
233
+ }
234
+
235
+ for chunk := range w.chunkChannel {
236
+ w.streamWriter.WriteChunkAsync(chunk)
237
+ }
238
+ }
239
+
240
+ // Finalize completes the logging process for the request and response.
241
+ // For streaming responses, it closes the chunk channel and the stream writer.
242
+ // For non-streaming responses, it logs the complete request and response details,
243
+ // including any API-specific request/response data stored in the Gin context.
244
+ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
245
+ if w.logger == nil {
246
+ return nil
247
+ }
248
+
249
+ finalStatusCode := w.statusCode
250
+ if finalStatusCode == 0 {
251
+ if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {
252
+ finalStatusCode = statusWriter.Status()
253
+ } else {
254
+ finalStatusCode = 200
255
+ }
256
+ }
257
+
258
+ var slicesAPIResponseError []*interfaces.ErrorMessage
259
+ apiResponseError, isExist := c.Get("API_RESPONSE_ERROR")
260
+ if isExist {
261
+ if apiErrors, ok := apiResponseError.([]*interfaces.ErrorMessage); ok {
262
+ slicesAPIResponseError = apiErrors
263
+ }
264
+ }
265
+
266
+ hasAPIError := len(slicesAPIResponseError) > 0 || finalStatusCode >= http.StatusBadRequest
267
+ forceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()
268
+ if !w.logger.IsEnabled() && !forceLog {
269
+ return nil
270
+ }
271
+
272
+ if w.isStreaming && w.streamWriter != nil {
273
+ if w.chunkChannel != nil {
274
+ close(w.chunkChannel)
275
+ w.chunkChannel = nil
276
+ }
277
+
278
+ if w.streamDone != nil {
279
+ <-w.streamDone
280
+ w.streamDone = nil
281
+ }
282
+
283
+ // Write API Request and Response to the streaming log before closing
284
+ apiRequest := w.extractAPIRequest(c)
285
+ if len(apiRequest) > 0 {
286
+ _ = w.streamWriter.WriteAPIRequest(apiRequest)
287
+ }
288
+ apiResponse := w.extractAPIResponse(c)
289
+ if len(apiResponse) > 0 {
290
+ _ = w.streamWriter.WriteAPIResponse(apiResponse)
291
+ }
292
+ if err := w.streamWriter.Close(); err != nil {
293
+ w.streamWriter = nil
294
+ return err
295
+ }
296
+ w.streamWriter = nil
297
+ return nil
298
+ }
299
+
300
+ return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
301
+ }
302
+
303
+ func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
304
+ w.ensureHeadersCaptured()
305
+
306
+ finalHeaders := make(map[string][]string, len(w.headers))
307
+ for key, values := range w.headers {
308
+ headerValues := make([]string, len(values))
309
+ copy(headerValues, values)
310
+ finalHeaders[key] = headerValues
311
+ }
312
+
313
+ return finalHeaders
314
+ }
315
+
316
+ func (w *ResponseWriterWrapper) extractAPIRequest(c *gin.Context) []byte {
317
+ apiRequest, isExist := c.Get("API_REQUEST")
318
+ if !isExist {
319
+ return nil
320
+ }
321
+ data, ok := apiRequest.([]byte)
322
+ if !ok || len(data) == 0 {
323
+ return nil
324
+ }
325
+ return data
326
+ }
327
+
328
+ func (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte {
329
+ apiResponse, isExist := c.Get("API_RESPONSE")
330
+ if !isExist {
331
+ return nil
332
+ }
333
+ data, ok := apiResponse.([]byte)
334
+ if !ok || len(data) == 0 {
335
+ return nil
336
+ }
337
+ return data
338
+ }
339
+
340
+ func (w *ResponseWriterWrapper) logRequest(statusCode int, headers map[string][]string, body []byte, apiRequestBody, apiResponseBody []byte, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
341
+ if w.requestInfo == nil {
342
+ return nil
343
+ }
344
+
345
+ var requestBody []byte
346
+ if len(w.requestInfo.Body) > 0 {
347
+ requestBody = w.requestInfo.Body
348
+ }
349
+
350
+ if loggerWithOptions, ok := w.logger.(interface {
351
+ LogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool, string) error
352
+ }); ok {
353
+ return loggerWithOptions.LogRequestWithOptions(
354
+ w.requestInfo.URL,
355
+ w.requestInfo.Method,
356
+ w.requestInfo.Headers,
357
+ requestBody,
358
+ statusCode,
359
+ headers,
360
+ body,
361
+ apiRequestBody,
362
+ apiResponseBody,
363
+ apiResponseErrors,
364
+ forceLog,
365
+ w.requestInfo.RequestID,
366
+ )
367
+ }
368
+
369
+ return w.logger.LogRequest(
370
+ w.requestInfo.URL,
371
+ w.requestInfo.Method,
372
+ w.requestInfo.Headers,
373
+ requestBody,
374
+ statusCode,
375
+ headers,
376
+ body,
377
+ apiRequestBody,
378
+ apiResponseBody,
379
+ apiResponseErrors,
380
+ w.requestInfo.RequestID,
381
+ )
382
+ }
internal/api/modules/amp/amp.go ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package amp implements the Amp CLI routing module, providing OAuth-based
2
+ // integration with Amp CLI for ChatGPT and Anthropic subscriptions.
3
+ package amp
4
+
5
+ import (
6
+ "fmt"
7
+ "net/http/httputil"
8
+ "strings"
9
+ "sync"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
15
+ log "github.com/sirupsen/logrus"
16
+ )
17
+
18
+ // Option configures the AmpModule.
19
+ type Option func(*AmpModule)
20
+
21
+ // AmpModule implements the RouteModuleV2 interface for Amp CLI integration.
22
+ // It provides:
23
+ // - Reverse proxy to Amp control plane for OAuth/management
24
+ // - Provider-specific route aliases (/api/provider/{provider}/...)
25
+ // - Automatic gzip decompression for misconfigured upstreams
26
+ // - Model mapping for routing unavailable models to alternatives
27
+ type AmpModule struct {
28
+ secretSource SecretSource
29
+ proxy *httputil.ReverseProxy
30
+ proxyMu sync.RWMutex // protects proxy for hot-reload
31
+ accessManager *sdkaccess.Manager
32
+ authMiddleware_ gin.HandlerFunc
33
+ modelMapper *DefaultModelMapper
34
+ enabled bool
35
+ registerOnce sync.Once
36
+
37
+ // restrictToLocalhost controls localhost-only access for management routes (hot-reloadable)
38
+ restrictToLocalhost bool
39
+ restrictMu sync.RWMutex
40
+
41
+ // configMu protects lastConfig for partial reload comparison
42
+ configMu sync.RWMutex
43
+ lastConfig *config.AmpCode
44
+ }
45
+
46
+ // New creates a new Amp routing module with the given options.
47
+ // This is the preferred constructor using the Option pattern.
48
+ //
49
+ // Example:
50
+ //
51
+ // ampModule := amp.New(
52
+ // amp.WithAccessManager(accessManager),
53
+ // amp.WithAuthMiddleware(authMiddleware),
54
+ // amp.WithSecretSource(customSecret),
55
+ // )
56
+ func New(opts ...Option) *AmpModule {
57
+ m := &AmpModule{
58
+ secretSource: nil, // Will be created on demand if not provided
59
+ }
60
+ for _, opt := range opts {
61
+ opt(m)
62
+ }
63
+ return m
64
+ }
65
+
66
+ // NewLegacy creates a new Amp routing module using the legacy constructor signature.
67
+ // This is provided for backwards compatibility.
68
+ //
69
+ // DEPRECATED: Use New with options instead.
70
+ func NewLegacy(accessManager *sdkaccess.Manager, authMiddleware gin.HandlerFunc) *AmpModule {
71
+ return New(
72
+ WithAccessManager(accessManager),
73
+ WithAuthMiddleware(authMiddleware),
74
+ )
75
+ }
76
+
77
+ // WithSecretSource sets a custom secret source for the module.
78
+ func WithSecretSource(source SecretSource) Option {
79
+ return func(m *AmpModule) {
80
+ m.secretSource = source
81
+ }
82
+ }
83
+
84
+ // WithAccessManager sets the access manager for the module.
85
+ func WithAccessManager(am *sdkaccess.Manager) Option {
86
+ return func(m *AmpModule) {
87
+ m.accessManager = am
88
+ }
89
+ }
90
+
91
+ // WithAuthMiddleware sets the authentication middleware for provider routes.
92
+ func WithAuthMiddleware(middleware gin.HandlerFunc) Option {
93
+ return func(m *AmpModule) {
94
+ m.authMiddleware_ = middleware
95
+ }
96
+ }
97
+
98
+ // Name returns the module identifier
99
+ func (m *AmpModule) Name() string {
100
+ return "amp-routing"
101
+ }
102
+
103
+ // forceModelMappings returns whether model mappings should take precedence over local API keys
104
+ func (m *AmpModule) forceModelMappings() bool {
105
+ m.configMu.RLock()
106
+ defer m.configMu.RUnlock()
107
+ if m.lastConfig == nil {
108
+ return false
109
+ }
110
+ return m.lastConfig.ForceModelMappings
111
+ }
112
+
113
+ // Register sets up Amp routes if configured.
114
+ // This implements the RouteModuleV2 interface with Context.
115
+ // Routes are registered only once via sync.Once for idempotent behavior.
116
+ func (m *AmpModule) Register(ctx modules.Context) error {
117
+ settings := ctx.Config.AmpCode
118
+ upstreamURL := strings.TrimSpace(settings.UpstreamURL)
119
+
120
+ // Determine auth middleware (from module or context)
121
+ auth := m.getAuthMiddleware(ctx)
122
+
123
+ // Use registerOnce to ensure routes are only registered once
124
+ var regErr error
125
+ m.registerOnce.Do(func() {
126
+ // Initialize model mapper from config (for routing unavailable models to alternatives)
127
+ m.modelMapper = NewModelMapper(settings.ModelMappings)
128
+
129
+ // Store initial config for partial reload comparison
130
+ settingsCopy := settings
131
+ m.lastConfig = &settingsCopy
132
+
133
+ // Initialize localhost restriction setting (hot-reloadable)
134
+ m.setRestrictToLocalhost(settings.RestrictManagementToLocalhost)
135
+
136
+ // Always register provider aliases - these work without an upstream
137
+ m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
138
+
139
+ // Register management proxy routes once; middleware will gate access when upstream is unavailable.
140
+ // Pass auth middleware to require valid API key for all management routes.
141
+ m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, auth)
142
+
143
+ // If no upstream URL, skip proxy routes but provider aliases are still available
144
+ if upstreamURL == "" {
145
+ log.Debug("amp upstream proxy disabled (no upstream URL configured)")
146
+ log.Debug("amp provider alias routes registered")
147
+ m.enabled = false
148
+ return
149
+ }
150
+
151
+ if err := m.enableUpstreamProxy(upstreamURL, &settings); err != nil {
152
+ regErr = fmt.Errorf("failed to create amp proxy: %w", err)
153
+ return
154
+ }
155
+
156
+ log.Debug("amp provider alias routes registered")
157
+ })
158
+
159
+ return regErr
160
+ }
161
+
162
+ // getAuthMiddleware returns the authentication middleware, preferring the
163
+ // module's configured middleware, then the context middleware, then a fallback.
164
+ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc {
165
+ if m.authMiddleware_ != nil {
166
+ return m.authMiddleware_
167
+ }
168
+ if ctx.AuthMiddleware != nil {
169
+ return ctx.AuthMiddleware
170
+ }
171
+ // Fallback: no authentication (should not happen in production)
172
+ log.Warn("amp module: no auth middleware provided, allowing all requests")
173
+ return func(c *gin.Context) {
174
+ c.Next()
175
+ }
176
+ }
177
+
178
+ // OnConfigUpdated handles configuration updates with partial reload support.
179
+ // Only updates components that have actually changed to avoid unnecessary work.
180
+ // Supports hot-reload for: model-mappings, upstream-api-key, upstream-url, restrict-management-to-localhost.
181
+ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
182
+ newSettings := cfg.AmpCode
183
+
184
+ // Get previous config for comparison
185
+ m.configMu.RLock()
186
+ oldSettings := m.lastConfig
187
+ m.configMu.RUnlock()
188
+
189
+ if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {
190
+ m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)
191
+ }
192
+
193
+ newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)
194
+ oldUpstreamURL := ""
195
+ if oldSettings != nil {
196
+ oldUpstreamURL = strings.TrimSpace(oldSettings.UpstreamURL)
197
+ }
198
+
199
+ if !m.enabled && newUpstreamURL != "" {
200
+ if err := m.enableUpstreamProxy(newUpstreamURL, &newSettings); err != nil {
201
+ log.Errorf("amp config: failed to enable upstream proxy for %s: %v", newUpstreamURL, err)
202
+ }
203
+ }
204
+
205
+ // Check model mappings change
206
+ modelMappingsChanged := m.hasModelMappingsChanged(oldSettings, &newSettings)
207
+ if modelMappingsChanged {
208
+ if m.modelMapper != nil {
209
+ m.modelMapper.UpdateMappings(newSettings.ModelMappings)
210
+ } else if m.enabled {
211
+ log.Warnf("amp model mapper not initialized, skipping model mapping update")
212
+ }
213
+ }
214
+
215
+ if m.enabled {
216
+ // Check upstream URL change - now supports hot-reload
217
+ if newUpstreamURL == "" && oldUpstreamURL != "" {
218
+ m.setProxy(nil)
219
+ m.enabled = false
220
+ } else if oldUpstreamURL != "" && newUpstreamURL != oldUpstreamURL && newUpstreamURL != "" {
221
+ // Recreate proxy with new URL
222
+ proxy, err := createReverseProxy(newUpstreamURL, m.secretSource)
223
+ if err != nil {
224
+ log.Errorf("amp config: failed to create proxy for new upstream URL %s: %v", newUpstreamURL, err)
225
+ } else {
226
+ m.setProxy(proxy)
227
+ }
228
+ }
229
+
230
+ // Check API key change (both default and per-client mappings)
231
+ apiKeyChanged := m.hasAPIKeyChanged(oldSettings, &newSettings)
232
+ upstreamAPIKeysChanged := m.hasUpstreamAPIKeysChanged(oldSettings, &newSettings)
233
+ if apiKeyChanged || upstreamAPIKeysChanged {
234
+ if m.secretSource != nil {
235
+ if ms, ok := m.secretSource.(*MappedSecretSource); ok {
236
+ if apiKeyChanged {
237
+ ms.UpdateDefaultExplicitKey(newSettings.UpstreamAPIKey)
238
+ ms.InvalidateCache()
239
+ }
240
+ if upstreamAPIKeysChanged {
241
+ ms.UpdateMappings(newSettings.UpstreamAPIKeys)
242
+ }
243
+ } else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
244
+ ms.UpdateExplicitKey(newSettings.UpstreamAPIKey)
245
+ ms.InvalidateCache()
246
+ }
247
+ }
248
+ }
249
+
250
+ }
251
+
252
+ // Store current config for next comparison
253
+ m.configMu.Lock()
254
+ settingsCopy := newSettings // copy struct
255
+ m.lastConfig = &settingsCopy
256
+ m.configMu.Unlock()
257
+
258
+ return nil
259
+ }
260
+
261
+ func (m *AmpModule) enableUpstreamProxy(upstreamURL string, settings *config.AmpCode) error {
262
+ if m.secretSource == nil {
263
+ // Create MultiSourceSecret as the default source, then wrap with MappedSecretSource
264
+ defaultSource := NewMultiSourceSecret(settings.UpstreamAPIKey, 0 /* default 5min */)
265
+ mappedSource := NewMappedSecretSource(defaultSource)
266
+ mappedSource.UpdateMappings(settings.UpstreamAPIKeys)
267
+ m.secretSource = mappedSource
268
+ } else if ms, ok := m.secretSource.(*MappedSecretSource); ok {
269
+ ms.UpdateDefaultExplicitKey(settings.UpstreamAPIKey)
270
+ ms.InvalidateCache()
271
+ ms.UpdateMappings(settings.UpstreamAPIKeys)
272
+ } else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
273
+ // Legacy path: wrap existing MultiSourceSecret with MappedSecretSource
274
+ ms.UpdateExplicitKey(settings.UpstreamAPIKey)
275
+ ms.InvalidateCache()
276
+ mappedSource := NewMappedSecretSource(ms)
277
+ mappedSource.UpdateMappings(settings.UpstreamAPIKeys)
278
+ m.secretSource = mappedSource
279
+ }
280
+
281
+ proxy, err := createReverseProxy(upstreamURL, m.secretSource)
282
+ if err != nil {
283
+ return err
284
+ }
285
+
286
+ m.setProxy(proxy)
287
+ m.enabled = true
288
+
289
+ log.Infof("amp upstream proxy enabled for: %s", upstreamURL)
290
+ return nil
291
+ }
292
+
293
+ // hasModelMappingsChanged compares old and new model mappings.
294
+ func (m *AmpModule) hasModelMappingsChanged(old *config.AmpCode, new *config.AmpCode) bool {
295
+ if old == nil {
296
+ return len(new.ModelMappings) > 0
297
+ }
298
+
299
+ if len(old.ModelMappings) != len(new.ModelMappings) {
300
+ return true
301
+ }
302
+
303
+ // Build map for efficient and robust comparison
304
+ type mappingInfo struct {
305
+ to string
306
+ regex bool
307
+ }
308
+ oldMap := make(map[string]mappingInfo, len(old.ModelMappings))
309
+ for _, mapping := range old.ModelMappings {
310
+ oldMap[strings.TrimSpace(mapping.From)] = mappingInfo{
311
+ to: strings.TrimSpace(mapping.To),
312
+ regex: mapping.Regex,
313
+ }
314
+ }
315
+
316
+ for _, mapping := range new.ModelMappings {
317
+ from := strings.TrimSpace(mapping.From)
318
+ to := strings.TrimSpace(mapping.To)
319
+ if oldVal, exists := oldMap[from]; !exists || oldVal.to != to || oldVal.regex != mapping.Regex {
320
+ return true
321
+ }
322
+ }
323
+
324
+ return false
325
+ }
326
+
327
+ // hasAPIKeyChanged compares old and new API keys.
328
+ func (m *AmpModule) hasAPIKeyChanged(old *config.AmpCode, new *config.AmpCode) bool {
329
+ oldKey := ""
330
+ if old != nil {
331
+ oldKey = strings.TrimSpace(old.UpstreamAPIKey)
332
+ }
333
+ newKey := strings.TrimSpace(new.UpstreamAPIKey)
334
+ return oldKey != newKey
335
+ }
336
+
337
+ // hasUpstreamAPIKeysChanged compares old and new per-client upstream API key mappings.
338
+ func (m *AmpModule) hasUpstreamAPIKeysChanged(old *config.AmpCode, new *config.AmpCode) bool {
339
+ if old == nil {
340
+ return len(new.UpstreamAPIKeys) > 0
341
+ }
342
+
343
+ if len(old.UpstreamAPIKeys) != len(new.UpstreamAPIKeys) {
344
+ return true
345
+ }
346
+
347
+ // Build map for comparison: upstreamKey -> set of clientKeys
348
+ type entryInfo struct {
349
+ upstreamKey string
350
+ clientKeys map[string]struct{}
351
+ }
352
+ oldEntries := make([]entryInfo, len(old.UpstreamAPIKeys))
353
+ for i, entry := range old.UpstreamAPIKeys {
354
+ clientKeys := make(map[string]struct{}, len(entry.APIKeys))
355
+ for _, k := range entry.APIKeys {
356
+ trimmed := strings.TrimSpace(k)
357
+ if trimmed == "" {
358
+ continue
359
+ }
360
+ clientKeys[trimmed] = struct{}{}
361
+ }
362
+ oldEntries[i] = entryInfo{
363
+ upstreamKey: strings.TrimSpace(entry.UpstreamAPIKey),
364
+ clientKeys: clientKeys,
365
+ }
366
+ }
367
+
368
+ for i, newEntry := range new.UpstreamAPIKeys {
369
+ if i >= len(oldEntries) {
370
+ return true
371
+ }
372
+ oldE := oldEntries[i]
373
+ if strings.TrimSpace(newEntry.UpstreamAPIKey) != oldE.upstreamKey {
374
+ return true
375
+ }
376
+ newKeys := make(map[string]struct{}, len(newEntry.APIKeys))
377
+ for _, k := range newEntry.APIKeys {
378
+ trimmed := strings.TrimSpace(k)
379
+ if trimmed == "" {
380
+ continue
381
+ }
382
+ newKeys[trimmed] = struct{}{}
383
+ }
384
+ if len(newKeys) != len(oldE.clientKeys) {
385
+ return true
386
+ }
387
+ for k := range newKeys {
388
+ if _, ok := oldE.clientKeys[k]; !ok {
389
+ return true
390
+ }
391
+ }
392
+ }
393
+
394
+ return false
395
+ }
396
+
397
+ // GetModelMapper returns the model mapper instance (for testing/debugging).
398
+ func (m *AmpModule) GetModelMapper() *DefaultModelMapper {
399
+ return m.modelMapper
400
+ }
401
+
402
+ // getProxy returns the current proxy instance (thread-safe for hot-reload).
403
+ func (m *AmpModule) getProxy() *httputil.ReverseProxy {
404
+ m.proxyMu.RLock()
405
+ defer m.proxyMu.RUnlock()
406
+ return m.proxy
407
+ }
408
+
409
+ // setProxy updates the proxy instance (thread-safe for hot-reload).
410
+ func (m *AmpModule) setProxy(proxy *httputil.ReverseProxy) {
411
+ m.proxyMu.Lock()
412
+ defer m.proxyMu.Unlock()
413
+ m.proxy = proxy
414
+ }
415
+
416
+ // IsRestrictedToLocalhost returns whether management routes are restricted to localhost.
417
+ func (m *AmpModule) IsRestrictedToLocalhost() bool {
418
+ m.restrictMu.RLock()
419
+ defer m.restrictMu.RUnlock()
420
+ return m.restrictToLocalhost
421
+ }
422
+
423
+ // setRestrictToLocalhost updates the localhost restriction setting.
424
+ func (m *AmpModule) setRestrictToLocalhost(restrict bool) {
425
+ m.restrictMu.Lock()
426
+ defer m.restrictMu.Unlock()
427
+ m.restrictToLocalhost = restrict
428
+ }
internal/api/modules/amp/amp_test.go ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package amp
2
+
3
+ import (
4
+ "context"
5
+ "net/http/httptest"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
15
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
16
+ )
17
+
18
+ func TestAmpModule_Name(t *testing.T) {
19
+ m := New()
20
+ if m.Name() != "amp-routing" {
21
+ t.Fatalf("want amp-routing, got %s", m.Name())
22
+ }
23
+ }
24
+
25
+ func TestAmpModule_New(t *testing.T) {
26
+ accessManager := sdkaccess.NewManager()
27
+ authMiddleware := func(c *gin.Context) { c.Next() }
28
+
29
+ m := NewLegacy(accessManager, authMiddleware)
30
+
31
+ if m.accessManager != accessManager {
32
+ t.Fatal("accessManager not set")
33
+ }
34
+ if m.authMiddleware_ == nil {
35
+ t.Fatal("authMiddleware not set")
36
+ }
37
+ if m.enabled {
38
+ t.Fatal("enabled should be false initially")
39
+ }
40
+ if m.proxy != nil {
41
+ t.Fatal("proxy should be nil initially")
42
+ }
43
+ }
44
+
45
+ func TestAmpModule_Register_WithUpstream(t *testing.T) {
46
+ gin.SetMode(gin.TestMode)
47
+ r := gin.New()
48
+
49
+ // Fake upstream to ensure URL is valid
50
+ upstream := httptest.NewServer(nil)
51
+ defer upstream.Close()
52
+
53
+ accessManager := sdkaccess.NewManager()
54
+ base := &handlers.BaseAPIHandler{}
55
+
56
+ m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
57
+
58
+ cfg := &config.Config{
59
+ AmpCode: config.AmpCode{
60
+ UpstreamURL: upstream.URL,
61
+ UpstreamAPIKey: "test-key",
62
+ },
63
+ }
64
+
65
+ ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
66
+ if err := m.Register(ctx); err != nil {
67
+ t.Fatalf("register error: %v", err)
68
+ }
69
+
70
+ if !m.enabled {
71
+ t.Fatal("module should be enabled with upstream URL")
72
+ }
73
+ if m.proxy == nil {
74
+ t.Fatal("proxy should be initialized")
75
+ }
76
+ if m.secretSource == nil {
77
+ t.Fatal("secretSource should be initialized")
78
+ }
79
+ }
80
+
81
+ func TestAmpModule_Register_WithoutUpstream(t *testing.T) {
82
+ gin.SetMode(gin.TestMode)
83
+ r := gin.New()
84
+
85
+ accessManager := sdkaccess.NewManager()
86
+ base := &handlers.BaseAPIHandler{}
87
+
88
+ m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
89
+
90
+ cfg := &config.Config{
91
+ AmpCode: config.AmpCode{
92
+ UpstreamURL: "", // No upstream
93
+ },
94
+ }
95
+
96
+ ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
97
+ if err := m.Register(ctx); err != nil {
98
+ t.Fatalf("register should not error without upstream: %v", err)
99
+ }
100
+
101
+ if m.enabled {
102
+ t.Fatal("module should be disabled without upstream URL")
103
+ }
104
+ if m.proxy != nil {
105
+ t.Fatal("proxy should not be initialized without upstream")
106
+ }
107
+
108
+ // But provider aliases should still be registered
109
+ req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
110
+ w := httptest.NewRecorder()
111
+ r.ServeHTTP(w, req)
112
+
113
+ if w.Code == 404 {
114
+ t.Fatal("provider aliases should be registered even without upstream")
115
+ }
116
+ }
117
+
118
+ func TestAmpModule_Register_InvalidUpstream(t *testing.T) {
119
+ gin.SetMode(gin.TestMode)
120
+ r := gin.New()
121
+
122
+ accessManager := sdkaccess.NewManager()
123
+ base := &handlers.BaseAPIHandler{}
124
+
125
+ m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
126
+
127
+ cfg := &config.Config{
128
+ AmpCode: config.AmpCode{
129
+ UpstreamURL: "://invalid-url",
130
+ },
131
+ }
132
+
133
+ ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
134
+ if err := m.Register(ctx); err == nil {
135
+ t.Fatal("expected error for invalid upstream URL")
136
+ }
137
+ }
138
+
139
+ func TestAmpModule_OnConfigUpdated_CacheInvalidation(t *testing.T) {
140
+ tmpDir := t.TempDir()
141
+ p := filepath.Join(tmpDir, "secrets.json")
142
+ if err := os.WriteFile(p, []byte(`{"apiKey@https://ampcode.com/":"v1"}`), 0600); err != nil {
143
+ t.Fatal(err)
144
+ }
145
+
146
+ m := &AmpModule{enabled: true}
147
+ ms := NewMultiSourceSecretWithPath("", p, time.Minute)
148
+ m.secretSource = ms
149
+ m.lastConfig = &config.AmpCode{
150
+ UpstreamAPIKey: "old-key",
151
+ }
152
+
153
+ // Warm the cache
154
+ if _, err := ms.Get(context.Background()); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+
158
+ if ms.cache == nil {
159
+ t.Fatal("expected cache to be set")
160
+ }
161
+
162
+ // Update config - should invalidate cache
163
+ if err := m.OnConfigUpdated(&config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://x", UpstreamAPIKey: "new-key"}}); err != nil {
164
+ t.Fatal(err)
165
+ }
166
+
167
+ if ms.cache != nil {
168
+ t.Fatal("expected cache to be invalidated")
169
+ }
170
+ }
171
+
172
+ func TestAmpModule_OnConfigUpdated_NotEnabled(t *testing.T) {
173
+ m := &AmpModule{enabled: false}
174
+
175
+ // Should not error or panic when disabled
176
+ if err := m.OnConfigUpdated(&config.Config{}); err != nil {
177
+ t.Fatalf("unexpected error: %v", err)
178
+ }
179
+ }
180
+
181
+ func TestAmpModule_OnConfigUpdated_URLRemoved(t *testing.T) {
182
+ m := &AmpModule{enabled: true}
183
+ ms := NewMultiSourceSecret("", 0)
184
+ m.secretSource = ms
185
+
186
+ // Config update with empty URL - should log warning but not error
187
+ cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: ""}}
188
+
189
+ if err := m.OnConfigUpdated(cfg); err != nil {
190
+ t.Fatalf("unexpected error: %v", err)
191
+ }
192
+ }
193
+
194
+ func TestAmpModule_OnConfigUpdated_NonMultiSourceSecret(t *testing.T) {
195
+ // Test that OnConfigUpdated doesn't panic with StaticSecretSource
196
+ m := &AmpModule{enabled: true}
197
+ m.secretSource = NewStaticSecretSource("static-key")
198
+
199
+ cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://example.com"}}
200
+
201
+ // Should not error or panic
202
+ if err := m.OnConfigUpdated(cfg); err != nil {
203
+ t.Fatalf("unexpected error: %v", err)
204
+ }
205
+ }
206
+
207
+ func TestAmpModule_AuthMiddleware_Fallback(t *testing.T) {
208
+ gin.SetMode(gin.TestMode)
209
+ r := gin.New()
210
+
211
+ // Create module with no auth middleware
212
+ m := &AmpModule{authMiddleware_: nil}
213
+
214
+ // Get the fallback middleware via getAuthMiddleware
215
+ ctx := modules.Context{Engine: r, AuthMiddleware: nil}
216
+ middleware := m.getAuthMiddleware(ctx)
217
+
218
+ if middleware == nil {
219
+ t.Fatal("getAuthMiddleware should return a fallback, not nil")
220
+ }
221
+
222
+ // Test that it works
223
+ called := false
224
+ r.GET("/test", middleware, func(c *gin.Context) {
225
+ called = true
226
+ c.String(200, "ok")
227
+ })
228
+
229
+ req := httptest.NewRequest("GET", "/test", nil)
230
+ w := httptest.NewRecorder()
231
+ r.ServeHTTP(w, req)
232
+
233
+ if !called {
234
+ t.Fatal("fallback middleware should allow requests through")
235
+ }
236
+ }
237
+
238
+ func TestAmpModule_SecretSource_FromConfig(t *testing.T) {
239
+ gin.SetMode(gin.TestMode)
240
+ r := gin.New()
241
+
242
+ upstream := httptest.NewServer(nil)
243
+ defer upstream.Close()
244
+
245
+ accessManager := sdkaccess.NewManager()
246
+ base := &handlers.BaseAPIHandler{}
247
+
248
+ m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
249
+
250
+ // Config with explicit API key
251
+ cfg := &config.Config{
252
+ AmpCode: config.AmpCode{
253
+ UpstreamURL: upstream.URL,
254
+ UpstreamAPIKey: "config-key",
255
+ },
256
+ }
257
+
258
+ ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
259
+ if err := m.Register(ctx); err != nil {
260
+ t.Fatalf("register error: %v", err)
261
+ }
262
+
263
+ // Secret source should be MultiSourceSecret with config key
264
+ if m.secretSource == nil {
265
+ t.Fatal("secretSource should be set")
266
+ }
267
+
268
+ // Verify it returns the config key
269
+ key, err := m.secretSource.Get(context.Background())
270
+ if err != nil {
271
+ t.Fatalf("Get error: %v", err)
272
+ }
273
+ if key != "config-key" {
274
+ t.Fatalf("want config-key, got %s", key)
275
+ }
276
+ }
277
+
278
+ func TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) {
279
+ gin.SetMode(gin.TestMode)
280
+
281
+ scenarios := []struct {
282
+ name string
283
+ configURL string
284
+ }{
285
+ {"with_upstream", "http://example.com"},
286
+ {"without_upstream", ""},
287
+ }
288
+
289
+ for _, scenario := range scenarios {
290
+ t.Run(scenario.name, func(t *testing.T) {
291
+ r := gin.New()
292
+ accessManager := sdkaccess.NewManager()
293
+ base := &handlers.BaseAPIHandler{}
294
+
295
+ m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
296
+
297
+ cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: scenario.configURL}}
298
+
299
+ ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
300
+ if err := m.Register(ctx); err != nil && scenario.configURL != "" {
301
+ t.Fatalf("register error: %v", err)
302
+ }
303
+
304
+ // Provider aliases should always be available
305
+ req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
306
+ w := httptest.NewRecorder()
307
+ r.ServeHTTP(w, req)
308
+
309
+ if w.Code == 404 {
310
+ t.Fatal("provider aliases should be registered")
311
+ }
312
+ })
313
+ }
314
+ }
315
+
316
+ func TestAmpModule_hasUpstreamAPIKeysChanged_DetectsRemovedKeyWithDuplicateInput(t *testing.T) {
317
+ m := &AmpModule{}
318
+
319
+ oldCfg := &config.AmpCode{
320
+ UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
321
+ {UpstreamAPIKey: "u1", APIKeys: []string{"k1", "k2"}},
322
+ },
323
+ }
324
+ newCfg := &config.AmpCode{
325
+ UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
326
+ {UpstreamAPIKey: "u1", APIKeys: []string{"k1", "k1"}},
327
+ },
328
+ }
329
+
330
+ if !m.hasUpstreamAPIKeysChanged(oldCfg, newCfg) {
331
+ t.Fatal("expected change to be detected when k2 is removed but new list contains duplicates")
332
+ }
333
+ }
334
+
335
+ func TestAmpModule_hasUpstreamAPIKeysChanged_IgnoresEmptyAndWhitespaceKeys(t *testing.T) {
336
+ m := &AmpModule{}
337
+
338
+ oldCfg := &config.AmpCode{
339
+ UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
340
+ {UpstreamAPIKey: "u1", APIKeys: []string{"k1", "k2"}},
341
+ },
342
+ }
343
+ newCfg := &config.AmpCode{
344
+ UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
345
+ {UpstreamAPIKey: "u1", APIKeys: []string{" k1 ", "", "k2", " "}},
346
+ },
347
+ }
348
+
349
+ if m.hasUpstreamAPIKeysChanged(oldCfg, newCfg) {
350
+ t.Fatal("expected no change when only whitespace/empty entries differ")
351
+ }
352
+ }
internal/api/modules/amp/fallback_handlers.go ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package amp
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "net/http/httputil"
7
+ "strings"
8
+ "time"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
13
+ log "github.com/sirupsen/logrus"
14
+ "github.com/tidwall/gjson"
15
+ "github.com/tidwall/sjson"
16
+ )
17
+
18
+ // AmpRouteType represents the type of routing decision made for an Amp request
19
+ type AmpRouteType string
20
+
21
+ const (
22
+ // RouteTypeLocalProvider indicates the request is handled by a local OAuth provider (free)
23
+ RouteTypeLocalProvider AmpRouteType = "LOCAL_PROVIDER"
24
+ // RouteTypeModelMapping indicates the request was remapped to another available model (free)
25
+ RouteTypeModelMapping AmpRouteType = "MODEL_MAPPING"
26
+ // RouteTypeAmpCredits indicates the request is forwarded to ampcode.com (uses Amp credits)
27
+ RouteTypeAmpCredits AmpRouteType = "AMP_CREDITS"
28
+ // RouteTypeNoProvider indicates no provider or fallback available
29
+ RouteTypeNoProvider AmpRouteType = "NO_PROVIDER"
30
+ )
31
+
32
+ // MappedModelContextKey is the Gin context key for passing mapped model names.
33
+ const MappedModelContextKey = "mapped_model"
34
+
35
+ // logAmpRouting logs the routing decision for an Amp request with structured fields
36
+ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {
37
+ fields := log.Fields{
38
+ "component": "amp-routing",
39
+ "route_type": string(routeType),
40
+ "requested_model": requestedModel,
41
+ "path": path,
42
+ "timestamp": time.Now().Format(time.RFC3339),
43
+ }
44
+
45
+ if resolvedModel != "" && resolvedModel != requestedModel {
46
+ fields["resolved_model"] = resolvedModel
47
+ }
48
+ if provider != "" {
49
+ fields["provider"] = provider
50
+ }
51
+
52
+ switch routeType {
53
+ case RouteTypeLocalProvider:
54
+ fields["cost"] = "free"
55
+ fields["source"] = "local_oauth"
56
+ log.WithFields(fields).Debugf("amp using local provider for model: %s", requestedModel)
57
+
58
+ case RouteTypeModelMapping:
59
+ fields["cost"] = "free"
60
+ fields["source"] = "local_oauth"
61
+ fields["mapping"] = requestedModel + " -> " + resolvedModel
62
+ // model mapping already logged in mapper; avoid duplicate here
63
+
64
+ case RouteTypeAmpCredits:
65
+ fields["cost"] = "amp_credits"
66
+ fields["source"] = "ampcode.com"
67
+ fields["model_id"] = requestedModel // Explicit model_id for easy config reference
68
+ log.WithFields(fields).Warnf("forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local provider, add to config: ampcode.model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel)
69
+
70
+ case RouteTypeNoProvider:
71
+ fields["cost"] = "none"
72
+ fields["source"] = "error"
73
+ fields["model_id"] = requestedModel // Explicit model_id for easy config reference
74
+ log.WithFields(fields).Warnf("no provider available for model_id: %s", requestedModel)
75
+ }
76
+ }
77
+
78
+ // FallbackHandler wraps a standard handler with fallback logic to ampcode.com
79
+ // when the model's provider is not available in CLIProxyAPI
80
+ type FallbackHandler struct {
81
+ getProxy func() *httputil.ReverseProxy
82
+ modelMapper ModelMapper
83
+ forceModelMappings func() bool
84
+ }
85
+
86
+ // NewFallbackHandler creates a new fallback handler wrapper
87
+ // The getProxy function allows lazy evaluation of the proxy (useful when proxy is created after routes)
88
+ func NewFallbackHandler(getProxy func() *httputil.ReverseProxy) *FallbackHandler {
89
+ return &FallbackHandler{
90
+ getProxy: getProxy,
91
+ forceModelMappings: func() bool { return false },
92
+ }
93
+ }
94
+
95
+ // NewFallbackHandlerWithMapper creates a new fallback handler with model mapping support
96
+ func NewFallbackHandlerWithMapper(getProxy func() *httputil.ReverseProxy, mapper ModelMapper, forceModelMappings func() bool) *FallbackHandler {
97
+ if forceModelMappings == nil {
98
+ forceModelMappings = func() bool { return false }
99
+ }
100
+ return &FallbackHandler{
101
+ getProxy: getProxy,
102
+ modelMapper: mapper,
103
+ forceModelMappings: forceModelMappings,
104
+ }
105
+ }
106
+
107
+ // SetModelMapper sets the model mapper for this handler (allows late binding)
108
+ func (fh *FallbackHandler) SetModelMapper(mapper ModelMapper) {
109
+ fh.modelMapper = mapper
110
+ }
111
+
112
+ // WrapHandler wraps a gin.HandlerFunc with fallback logic
113
+ // If the model's provider is not configured in CLIProxyAPI, it forwards to ampcode.com
114
+ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc {
115
+ return func(c *gin.Context) {
116
+ requestPath := c.Request.URL.Path
117
+
118
+ // Read the request body to extract the model name
119
+ bodyBytes, err := io.ReadAll(c.Request.Body)
120
+ if err != nil {
121
+ log.Errorf("amp fallback: failed to read request body: %v", err)
122
+ handler(c)
123
+ return
124
+ }
125
+
126
+ // Restore the body for the handler to read
127
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
128
+
129
+ // Try to extract model from request body or URL path (for Gemini)
130
+ modelName := extractModelFromRequest(bodyBytes, c)
131
+ if modelName == "" {
132
+ // Can't determine model, proceed with normal handler
133
+ handler(c)
134
+ return
135
+ }
136
+
137
+ // Normalize model (handles dynamic thinking suffixes)
138
+ suffixResult := thinking.ParseSuffix(modelName)
139
+ normalizedModel := suffixResult.ModelName
140
+ thinkingSuffix := ""
141
+ if suffixResult.HasSuffix {
142
+ thinkingSuffix = "(" + suffixResult.RawSuffix + ")"
143
+ }
144
+
145
+ resolveMappedModel := func() (string, []string) {
146
+ if fh.modelMapper == nil {
147
+ return "", nil
148
+ }
149
+
150
+ mappedModel := fh.modelMapper.MapModel(modelName)
151
+ if mappedModel == "" {
152
+ mappedModel = fh.modelMapper.MapModel(normalizedModel)
153
+ }
154
+ mappedModel = strings.TrimSpace(mappedModel)
155
+ if mappedModel == "" {
156
+ return "", nil
157
+ }
158
+
159
+ // Preserve dynamic thinking suffix (e.g. "(xhigh)") when mapping applies, unless the target
160
+ // already specifies its own thinking suffix.
161
+ if thinkingSuffix != "" {
162
+ mappedSuffixResult := thinking.ParseSuffix(mappedModel)
163
+ if !mappedSuffixResult.HasSuffix {
164
+ mappedModel += thinkingSuffix
165
+ }
166
+ }
167
+
168
+ mappedBaseModel := thinking.ParseSuffix(mappedModel).ModelName
169
+ mappedProviders := util.GetProviderName(mappedBaseModel)
170
+ if len(mappedProviders) == 0 {
171
+ return "", nil
172
+ }
173
+
174
+ return mappedModel, mappedProviders
175
+ }
176
+
177
+ // Track resolved model for logging (may change if mapping is applied)
178
+ resolvedModel := normalizedModel
179
+ usedMapping := false
180
+ var providers []string
181
+
182
+ // Check if model mappings should be forced ahead of local API keys
183
+ forceMappings := fh.forceModelMappings != nil && fh.forceModelMappings()
184
+
185
+ if forceMappings {
186
+ // FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)
187
+ // This allows users to route Amp requests to their preferred OAuth providers
188
+ if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
189
+ // Mapping found and provider available - rewrite the model in request body
190
+ bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
191
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
192
+ // Store mapped model in context for handlers that check it (like gemini bridge)
193
+ c.Set(MappedModelContextKey, mappedModel)
194
+ resolvedModel = mappedModel
195
+ usedMapping = true
196
+ providers = mappedProviders
197
+ }
198
+
199
+ // If no mapping applied, check for local providers
200
+ if !usedMapping {
201
+ providers = util.GetProviderName(normalizedModel)
202
+ }
203
+ } else {
204
+ // DEFAULT MODE: Check local providers first, then mappings as fallback
205
+ providers = util.GetProviderName(normalizedModel)
206
+
207
+ if len(providers) == 0 {
208
+ // No providers configured - check if we have a model mapping
209
+ if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
210
+ // Mapping found and provider available - rewrite the model in request body
211
+ bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
212
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
213
+ // Store mapped model in context for handlers that check it (like gemini bridge)
214
+ c.Set(MappedModelContextKey, mappedModel)
215
+ resolvedModel = mappedModel
216
+ usedMapping = true
217
+ providers = mappedProviders
218
+ }
219
+ }
220
+ }
221
+
222
+ // If no providers available, fallback to ampcode.com
223
+ if len(providers) == 0 {
224
+ proxy := fh.getProxy()
225
+ if proxy != nil {
226
+ // Log: Forwarding to ampcode.com (uses Amp credits)
227
+ logAmpRouting(RouteTypeAmpCredits, modelName, "", "", requestPath)
228
+
229
+ // Restore body again for the proxy
230
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
231
+
232
+ // Forward to ampcode.com
233
+ proxy.ServeHTTP(c.Writer, c.Request)
234
+ return
235
+ }
236
+
237
+ // No proxy available, let the normal handler return the error
238
+ logAmpRouting(RouteTypeNoProvider, modelName, "", "", requestPath)
239
+ }
240
+
241
+ // Log the routing decision
242
+ providerName := ""
243
+ if len(providers) > 0 {
244
+ providerName = providers[0]
245
+ }
246
+
247
+ if usedMapping {
248
+ // Log: Model was mapped to another model
249
+ log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
250
+ logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
251
+ rewriter := NewResponseRewriter(c.Writer, modelName)
252
+ c.Writer = rewriter
253
+ // Filter Anthropic-Beta header only for local handling paths
254
+ filterAntropicBetaHeader(c)
255
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
256
+ handler(c)
257
+ rewriter.Flush()
258
+ log.Debugf("amp model mapping: response %s -> %s", resolvedModel, modelName)
259
+ } else if len(providers) > 0 {
260
+ // Log: Using local provider (free)
261
+ logAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)
262
+ // Filter Anthropic-Beta header only for local handling paths
263
+ filterAntropicBetaHeader(c)
264
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
265
+ handler(c)
266
+ } else {
267
+ // No provider, no mapping, no proxy: fall back to the wrapped handler so it can return an error response
268
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
269
+ handler(c)
270
+ }
271
+ }
272
+ }
273
+
274
+ // filterAntropicBetaHeader filters Anthropic-Beta header to remove features requiring special subscription
275
+ // This is needed when using local providers (bypassing the Amp proxy)
276
+ func filterAntropicBetaHeader(c *gin.Context) {
277
+ if betaHeader := c.Request.Header.Get("Anthropic-Beta"); betaHeader != "" {
278
+ if filtered := filterBetaFeatures(betaHeader, "context-1m-2025-08-07"); filtered != "" {
279
+ c.Request.Header.Set("Anthropic-Beta", filtered)
280
+ } else {
281
+ c.Request.Header.Del("Anthropic-Beta")
282
+ }
283
+ }
284
+ }
285
+
286
+ // rewriteModelInRequest replaces the model name in a JSON request body
287
+ func rewriteModelInRequest(body []byte, newModel string) []byte {
288
+ if !gjson.GetBytes(body, "model").Exists() {
289
+ return body
290
+ }
291
+ result, err := sjson.SetBytes(body, "model", newModel)
292
+ if err != nil {
293
+ log.Warnf("amp model mapping: failed to rewrite model in request body: %v", err)
294
+ return body
295
+ }
296
+ return result
297
+ }
298
+
299
+ // extractModelFromRequest attempts to extract the model name from various request formats
300
+ func extractModelFromRequest(body []byte, c *gin.Context) string {
301
+ // First try to parse from JSON body (OpenAI, Claude, etc.)
302
+ // Check common model field names
303
+ if result := gjson.GetBytes(body, "model"); result.Exists() && result.Type == gjson.String {
304
+ return result.String()
305
+ }
306
+
307
+ // For Gemini requests, model is in the URL path
308
+ // Standard format: /models/{model}:generateContent -> :action parameter
309
+ if action := c.Param("action"); action != "" {
310
+ // Split by colon to get model name (e.g., "gemini-pro:generateContent" -> "gemini-pro")
311
+ parts := strings.Split(action, ":")
312
+ if len(parts) > 0 && parts[0] != "" {
313
+ return parts[0]
314
+ }
315
+ }
316
+
317
+ // AMP CLI format: /publishers/google/models/{model}:method -> *path parameter
318
+ // Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
319
+ if path := c.Param("path"); path != "" {
320
+ // Look for /models/{model}:method pattern
321
+ if idx := strings.Index(path, "/models/"); idx >= 0 {
322
+ modelPart := path[idx+8:] // Skip "/models/"
323
+ // Split by colon to get model name
324
+ if colonIdx := strings.Index(modelPart, ":"); colonIdx > 0 {
325
+ return modelPart[:colonIdx]
326
+ }
327
+ }
328
+ }
329
+
330
+ return ""
331
+ }
internal/api/modules/amp/fallback_handlers_test.go ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package amp
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "net/http/httputil"
9
+ "testing"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
14
+ )
15
+
16
+ func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
17
+ gin.SetMode(gin.TestMode)
18
+
19
+ reg := registry.GetGlobalRegistry()
20
+ reg.RegisterClient("test-client-amp-fallback", "codex", []*registry.ModelInfo{
21
+ {ID: "test/gpt-5.2", OwnedBy: "openai", Type: "codex"},
22
+ })
23
+ defer reg.UnregisterClient("test-client-amp-fallback")
24
+
25
+ mapper := NewModelMapper([]config.AmpModelMapping{
26
+ {From: "gpt-5.2", To: "test/gpt-5.2"},
27
+ })
28
+
29
+ fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy { return nil }, mapper, nil)
30
+
31
+ handler := func(c *gin.Context) {
32
+ var req struct {
33
+ Model string `json:"model"`
34
+ }
35
+ if err := c.ShouldBindJSON(&req); err != nil {
36
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
37
+ return
38
+ }
39
+
40
+ c.JSON(http.StatusOK, gin.H{
41
+ "model": req.Model,
42
+ "seen_model": req.Model,
43
+ })
44
+ }
45
+
46
+ r := gin.New()
47
+ r.POST("/chat/completions", fallback.WrapHandler(handler))
48
+
49
+ reqBody := []byte(`{"model":"gpt-5.2(xhigh)"}`)
50
+ req := httptest.NewRequest(http.MethodPost, "/chat/completions", bytes.NewReader(reqBody))
51
+ req.Header.Set("Content-Type", "application/json")
52
+ w := httptest.NewRecorder()
53
+ r.ServeHTTP(w, req)
54
+
55
+ if w.Code != http.StatusOK {
56
+ t.Fatalf("Expected status 200, got %d", w.Code)
57
+ }
58
+
59
+ var resp struct {
60
+ Model string `json:"model"`
61
+ SeenModel string `json:"seen_model"`
62
+ }
63
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
64
+ t.Fatalf("Failed to parse response JSON: %v", err)
65
+ }
66
+
67
+ if resp.Model != "gpt-5.2(xhigh)" {
68
+ t.Errorf("Expected response model gpt-5.2(xhigh), got %s", resp.Model)
69
+ }
70
+ if resp.SeenModel != "test/gpt-5.2(xhigh)" {
71
+ t.Errorf("Expected handler to see test/gpt-5.2(xhigh), got %s", resp.SeenModel)
72
+ }
73
+ }
internal/api/modules/amp/gemini_bridge.go ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package amp
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ // createGeminiBridgeHandler creates a handler that bridges AMP CLI's non-standard Gemini paths
10
+ // to our standard Gemini handler by rewriting the request context.
11
+ //
12
+ // AMP CLI format: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
13
+ // Standard format: /models/gemini-3-pro-preview:streamGenerateContent
14
+ //
15
+ // This extracts the model+method from the AMP path and sets it as the :action parameter
16
+ // so the standard Gemini handler can process it.
17
+ //
18
+ // The handler parameter should be a Gemini-compatible handler that expects the :action param.
19
+ func createGeminiBridgeHandler(handler gin.HandlerFunc) gin.HandlerFunc {
20
+ return func(c *gin.Context) {
21
+ // Get the full path from the catch-all parameter
22
+ path := c.Param("path")
23
+
24
+ // Extract model:method from AMP CLI path format
25
+ // Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
26
+ const modelsPrefix = "/models/"
27
+ if idx := strings.Index(path, modelsPrefix); idx >= 0 {
28
+ // Extract everything after modelsPrefix
29
+ actionPart := path[idx+len(modelsPrefix):]
30
+
31
+ // Check if model was mapped by FallbackHandler
32
+ if mappedModel, exists := c.Get(MappedModelContextKey); exists {
33
+ if strModel, ok := mappedModel.(string); ok && strModel != "" {
34
+ // Replace the model part in the action
35
+ // actionPart is like "model-name:method"
36
+ if colonIdx := strings.Index(actionPart, ":"); colonIdx > 0 {
37
+ method := actionPart[colonIdx:] // ":method"
38
+ actionPart = strModel + method
39
+ }
40
+ }
41
+ }
42
+
43
+ // Set this as the :action parameter that the Gemini handler expects
44
+ c.Params = append(c.Params, gin.Param{
45
+ Key: "action",
46
+ Value: actionPart,
47
+ })
48
+
49
+ // Call the handler
50
+ handler(c)
51
+ return
52
+ }
53
+
54
+ // If we can't parse the path, return 400
55
+ c.JSON(400, gin.H{
56
+ "error": "Invalid Gemini API path format",
57
+ })
58
+ }
59
+ }