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

Clean deploy to HF Space

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 +95 -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 +51 -0
  11. .goreleaser.yml +39 -0
  12. Dockerfile +88 -0
  13. Dockerfile.hf-spaces +88 -0
  14. LICENSE +22 -0
  15. README.hf-spaces.md +115 -0
  16. README.md +167 -0
  17. README_CN.md +164 -0
  18. assets/cubence.png +0 -0
  19. assets/packycode.png +0 -0
  20. auths/.gitkeep +0 -0
  21. cmd/server/main.go +482 -0
  22. docker-build.ps1 +53 -0
  23. docker-build.sh +180 -0
  24. docker-compose.yml +28 -0
  25. docs/sdk-access.md +176 -0
  26. docs/sdk-access_CN.md +176 -0
  27. docs/sdk-advanced.md +138 -0
  28. docs/sdk-advanced_CN.md +131 -0
  29. docs/sdk-usage.md +163 -0
  30. docs/sdk-usage_CN.md +164 -0
  31. docs/sdk-watcher.md +32 -0
  32. docs/sdk-watcher_CN.md +32 -0
  33. examples/custom-provider/main.go +225 -0
  34. examples/http-request/main.go +140 -0
  35. examples/translator/main.go +42 -0
  36. go.mod +79 -0
  37. go.sum +197 -0
  38. internal/access/config_access/provider.go +112 -0
  39. internal/access/reconcile.go +270 -0
  40. internal/api/handlers/management/api_tools.go +704 -0
  41. internal/api/handlers/management/api_tools_test.go +173 -0
  42. internal/api/handlers/management/auth_files.go +2191 -0
  43. internal/api/handlers/management/config_basic.go +309 -0
  44. internal/api/handlers/management/config_lists.go +1522 -0
  45. internal/api/handlers/management/handler.go +317 -0
  46. internal/api/handlers/management/logs.go +583 -0
  47. internal/api/handlers/management/model_definitions.go +33 -0
  48. internal/api/handlers/management/oauth_callback.go +100 -0
  49. internal/api/handlers/management/oauth_sessions.go +283 -0
  50. internal/api/handlers/management/quota.go +18 -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,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "customModels": [
3
+ {
4
+ "model": "gpt-5.1-codex-max",
5
+ "displayName": "gpt-5.1-codex-max",
6
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
7
+ "apiKey": "shin",
8
+ "provider": "generic-chat-completion-api"
9
+ },
10
+ {
11
+ "model": "GPT-5.1",
12
+ "displayName": "GPT-5.1",
13
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
14
+ "apiKey": "shin",
15
+ "provider": "generic-chat-completion-api"
16
+ },
17
+ {
18
+ "model": "gpt-5.2-codex",
19
+ "displayName": "gpt-5.2-codex",
20
+ "baseUrl": "https://shinmen07.up.railway.app/v1",
21
+ "apiKey": "shin",
22
+ "provider": "generic-chat-completion-api"
23
+ },
24
+ {
25
+ "model": "gpt-5.2",
26
+ "displayName": "gpt-5.2",
27
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
28
+ "apiKey": "shin",
29
+ "provider": "generic-chat-completion-api"
30
+ },
31
+ {
32
+ "model": "gpt-5.1-codex",
33
+ "displayName": "gpt-5.1-codex",
34
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
35
+ "apiKey": "shin",
36
+ "provider": "generic-chat-completion-api"
37
+ },
38
+ {
39
+ "model": "gemini-claude-sonnet-4-5",
40
+ "displayName": "gemini-claude-sonnet-4-5",
41
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
42
+ "apiKey": "shin",
43
+ "provider": "generic-chat-completion-api"
44
+ },
45
+ {
46
+ "model": "gemini-claude-opus-4-5-thinking",
47
+ "displayName": "gemini-claude-opus-4-5-thinking",
48
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
49
+ "apiKey": "shin",
50
+ "provider": "generic-chat-completion-api"
51
+ },
52
+ {
53
+ "model": "gemini-3-pro-preview",
54
+ "displayName": "gemini-3-pro-preview",
55
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
56
+ "apiKey": "shin",
57
+ "provider": "generic-chat-completion-api"
58
+ },
59
+ {
60
+ "model": "z-ai/glm4.7",
61
+ "displayName": "z-ai/glm4.7",
62
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
63
+ "apiKey": "shin",
64
+ "provider": "generic-chat-completion-api"
65
+ },
66
+ {
67
+ "model": "glm-4.7",
68
+ "displayName": "glm-4.7",
69
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
70
+ "apiKey": "shin",
71
+ "provider": "generic-chat-completion-api"
72
+ },
73
+ {
74
+ "model": "minimaxai/minimax-m2.1",
75
+ "displayName": "minimaxai/minimax-m2.1",
76
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
77
+ "apiKey": "shin",
78
+ "provider": "generic-chat-completion-api"
79
+ },
80
+ {
81
+ "model": "claude-haiku-4.5",
82
+ "displayName": "claude-haiku-4.5",
83
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
84
+ "apiKey": "shin",
85
+ "provider": "generic-chat-completion-api"
86
+ },
87
+ {
88
+ "model": "claude-opus-4.5",
89
+ "displayName": "claude-opus-4.5",
90
+ "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
91
+ "apiKey": "shin",
92
+ "provider": "generic-chat-completion-api"
93
+ }
94
+ ]
95
+ }
.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,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ._*
51
+ shin
.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,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces
2
+ # This Dockerfile is optimized for running CLIProxyAPI with Kiro integration on HF Spaces
3
+
4
+ FROM golang:1.24-alpine AS builder
5
+
6
+ WORKDIR /app
7
+
8
+ COPY go.mod go.sum ./
9
+
10
+ RUN go mod download
11
+
12
+ COPY . .
13
+
14
+ ARG VERSION=dev
15
+ ARG COMMIT=none
16
+ ARG BUILD_DATE=unknown
17
+
18
+ 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/
19
+
20
+ FROM python:3.11-slim
21
+
22
+ # Install necessary packages
23
+ RUN apt-get update && apt-get install -y --no-install-recommends \
24
+ tzdata \
25
+ ca-certificates \
26
+ curl \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # Create app directory
30
+ RUN mkdir -p /app
31
+
32
+ # Copy the Go binary from builder
33
+ COPY --from=builder /app/CLIProxyAPI /app/CLIProxyAPI
34
+
35
+ # Copy config files
36
+ COPY config.example.yaml /app/config.example.yaml
37
+
38
+ # Copy kiro-gateway for Python-based Kiro authentication utilities (optional)
39
+ COPY kiro-gateway/requirements.txt /app/kiro-requirements.txt
40
+ RUN pip install --no-cache-dir -r /app/kiro-requirements.txt 2>/dev/null || true
41
+
42
+ # Copy kiro-gateway source for reference utilities
43
+ COPY kiro-gateway/kiro /app/kiro/
44
+
45
+ WORKDIR /app
46
+
47
+ # HF Spaces requires port 7860
48
+ EXPOSE 7860
49
+
50
+ # Set environment variables for HF Spaces
51
+ ENV TZ=UTC
52
+ ENV PORT=7860
53
+ ENV HOST=0.0.0.0
54
+
55
+ # Create a startup script that handles HF Spaces configuration
56
+ RUN echo '#!/bin/sh\n\
57
+ set -e\n\
58
+ \n\
59
+ # Create config from environment variables if provided\n\
60
+ # Only KIRO_REFRESH_TOKEN is required - profile-arn is auto-fetched from token refresh\n\
61
+ if [ -n "$KIRO_REFRESH_TOKEN" ]; then\n\
62
+ cat > /app/config.yaml << EOF\n\
63
+ host: "0.0.0.0"\n\
64
+ port: 7860\n\
65
+ auth-dir: "/app/auth"\n\
66
+ debug: ${DEBUG:-false}\n\
67
+ \n\
68
+ api-keys:\n\
69
+ - "${API_KEY:-default-key}"\n\
70
+ \n\
71
+ kiro-api-key:\n\
72
+ - refresh-token: "$KIRO_REFRESH_TOKEN"\n\
73
+ region: "${KIRO_REGION:-us-east-1}"\n\
74
+ EOF\n\
75
+ echo "Config created from environment variables"\n\
76
+ elif [ -f /app/config.yaml ]; then\n\
77
+ echo "Using existing config.yaml"\n\
78
+ else\n\
79
+ cp /app/config.example.yaml /app/config.yaml\n\
80
+ echo "Using example config"\n\
81
+ fi\n\
82
+ \n\
83
+ mkdir -p /app/auth\n\
84
+ \n\
85
+ exec ./CLIProxyAPI -c /app/config.yaml\n\
86
+ ' > /app/start.sh && chmod +x /app/start.sh
87
+
88
+ CMD ["/app/start.sh"]
Dockerfile.hf-spaces ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces
2
+ # This Dockerfile is optimized for running CLIProxyAPI with Kiro integration on HF Spaces
3
+
4
+ FROM golang:1.24-alpine AS builder
5
+
6
+ WORKDIR /app
7
+
8
+ COPY go.mod go.sum ./
9
+
10
+ RUN go mod download
11
+
12
+ COPY . .
13
+
14
+ ARG VERSION=dev
15
+ ARG COMMIT=none
16
+ ARG BUILD_DATE=unknown
17
+
18
+ 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/
19
+
20
+ FROM python:3.11-slim
21
+
22
+ # Install necessary packages
23
+ RUN apt-get update && apt-get install -y --no-install-recommends \
24
+ tzdata \
25
+ ca-certificates \
26
+ curl \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # Create app directory
30
+ RUN mkdir -p /app
31
+
32
+ # Copy the Go binary from builder
33
+ COPY --from=builder /app/CLIProxyAPI /app/CLIProxyAPI
34
+
35
+ # Copy config files
36
+ COPY config.example.yaml /app/config.example.yaml
37
+
38
+ # Copy kiro-gateway for Python-based Kiro authentication utilities (optional)
39
+ COPY kiro-gateway/requirements.txt /app/kiro-requirements.txt
40
+ RUN pip install --no-cache-dir -r /app/kiro-requirements.txt 2>/dev/null || true
41
+
42
+ # Copy kiro-gateway source for reference utilities
43
+ COPY kiro-gateway/kiro /app/kiro/
44
+
45
+ WORKDIR /app
46
+
47
+ # HF Spaces requires port 7860
48
+ EXPOSE 7860
49
+
50
+ # Set environment variables for HF Spaces
51
+ ENV TZ=UTC
52
+ ENV PORT=7860
53
+ ENV HOST=0.0.0.0
54
+
55
+ # Create a startup script that handles HF Spaces configuration
56
+ RUN echo '#!/bin/sh\n\
57
+ set -e\n\
58
+ \n\
59
+ # Create config from environment variables if provided\n\
60
+ # Only KIRO_REFRESH_TOKEN is required - profile-arn is auto-fetched from token refresh\n\
61
+ if [ -n "$KIRO_REFRESH_TOKEN" ]; then\n\
62
+ cat > /app/config.yaml << EOF\n\
63
+ host: "0.0.0.0"\n\
64
+ port: 7860\n\
65
+ auth-dir: "/app/auth"\n\
66
+ debug: ${DEBUG:-false}\n\
67
+ \n\
68
+ api-keys:\n\
69
+ - "${API_KEY:-default-key}"\n\
70
+ \n\
71
+ kiro-api-key:\n\
72
+ - refresh-token: "$KIRO_REFRESH_TOKEN"\n\
73
+ region: "${KIRO_REGION:-us-east-1}"\n\
74
+ EOF\n\
75
+ echo "Config created from environment variables"\n\
76
+ elif [ -f /app/config.yaml ]; then\n\
77
+ echo "Using existing config.yaml"\n\
78
+ else\n\
79
+ cp /app/config.example.yaml /app/config.yaml\n\
80
+ echo "Using example config"\n\
81
+ fi\n\
82
+ \n\
83
+ mkdir -p /app/auth\n\
84
+ \n\
85
+ exec ./CLIProxyAPI -c /app/config.yaml\n\
86
+ ' > /app/start.sh && chmod +x /app/start.sh
87
+
88
+ CMD ["/app/start.sh"]
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.hf-spaces.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying CLIProxyAPI on Hugging Face Spaces
2
+
3
+ This guide explains how to deploy CLIProxyAPI with Kiro integration on Hugging Face Spaces.
4
+
5
+ ## Quick Start
6
+
7
+ 1. Create a new Space on Hugging Face with "Docker" SDK
8
+ 2. Set the following Secrets in your Space settings:
9
+
10
+ ### Required Secrets
11
+
12
+ | Secret Name | Description |
13
+ |-------------|-------------|
14
+ | `KIRO_REFRESH_TOKEN` | Your Kiro refresh token (this is all you need!) |
15
+ | `API_KEY` | API key for authenticating requests to the proxy |
16
+
17
+ ### Optional Secrets
18
+
19
+ | Secret Name | Default | Description |
20
+ |-------------|---------|-------------|
21
+ | `KIRO_REGION` | `us-east-1` | AWS region for Kiro API |
22
+ | `DEBUG` | `false` | Enable debug logging |
23
+
24
+ **Note:** `profile-arn` is automatically fetched from the token refresh API - you don't need to provide it!
25
+
26
+ ## Getting Kiro Credentials
27
+
28
+ ### Option 1: From Kiro Desktop App
29
+
30
+ 1. Open Kiro IDE/Desktop app
31
+ 2. Sign in to your account
32
+ 3. Locate the credentials file (usually in `~/.kiro/` or similar)
33
+ 4. Extract the `refreshToken` value
34
+
35
+ ### Option 2: From kiro-cli
36
+
37
+ 1. Install kiro-cli: `npm install -g @kiro/cli`
38
+ 2. Login: `kiro login`
39
+ 3. Find the database: `~/.local/share/kiro-cli/data.sqlite3`
40
+ 4. Extract credentials using SQLite
41
+
42
+ ### Option 3: Using AWS SSO
43
+
44
+ If you have AWS SSO configured for CodeWhisperer:
45
+ 1. Get your SSO credentials
46
+ 2. Use the AWS SSO OIDC flow for token refresh
47
+
48
+ ## Dockerfile
49
+
50
+ Use `Dockerfile.hf-spaces` for HF Spaces deployment:
51
+
52
+ ```bash
53
+ # Rename for HF Spaces
54
+ cp Dockerfile.hf-spaces Dockerfile
55
+ ```
56
+
57
+ ## Environment Variables
58
+
59
+ The startup script will automatically create a `config.yaml` from environment variables:
60
+
61
+ ```yaml
62
+ host: "0.0.0.0"
63
+ port: 7860
64
+ auth-dir: "/app/auth"
65
+ debug: false
66
+
67
+ api-keys:
68
+ - "your-api-key"
69
+
70
+ kiro-api-key:
71
+ - refresh-token: "your-kiro-refresh-token"
72
+ region: "us-east-1"
73
+ # profile-arn is auto-fetched from token refresh!
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ Once deployed, use the Space URL as your API base URL:
79
+
80
+ ```bash
81
+ # OpenAI-compatible endpoint
82
+ curl https://your-username-your-space.hf.space/v1/chat/completions \
83
+ -H "Authorization: Bearer your-api-key" \
84
+ -H "Content-Type: application/json" \
85
+ -d '{
86
+ "model": "claude-sonnet-4.5",
87
+ "messages": [{"role": "user", "content": "Hello!"}]
88
+ }'
89
+ ```
90
+
91
+ ## Supported Models (via Kiro)
92
+
93
+ | Model ID | Description |
94
+ |----------|-------------|
95
+ | `auto` | Automatic model selection |
96
+ | `claude-sonnet-4` | Claude 4 Sonnet |
97
+ | `claude-sonnet-4.5` | Claude 4.5 Sonnet |
98
+ | `claude-haiku-4.5` | Claude 4.5 Haiku |
99
+ | `claude-opus-4.5` | Claude 4.5 Opus (may require paid tier) |
100
+ | `claude-3.7-sonnet` | Claude 3.7 Sonnet (legacy, hidden but functional) |
101
+
102
+ ## Troubleshooting
103
+
104
+ ### Token Refresh Errors
105
+ - Ensure your refresh token is valid
106
+ - Check that the profile ARN matches your account
107
+ - Verify the region is correct
108
+
109
+ ### 403 Errors
110
+ - Token may have expired - restart the Space to trigger refresh
111
+ - Check if your Kiro account has access to the requested models
112
+
113
+ ### Connection Issues
114
+ - If behind a firewall, you may need to configure proxy settings
115
+ - Ensure port 7860 is the exposed port (HF Spaces default)
README.md ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CLIProxyAPI
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # CLI Proxy API
12
+
13
+ English | [中文](README_CN.md)
14
+
15
+ A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
16
+
17
+ It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
18
+
19
+ So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
20
+
21
+ ## Sponsor
22
+
23
+ [![z.ai](https://assets.router-for.me/english-4.7.png)](https://z.ai/subscribe?ic=8JVLJQFSKB)
24
+
25
+ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
26
+
27
+ 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.
28
+
29
+ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
30
+
31
+ ---
32
+
33
+ <table>
34
+ <tbody>
35
+ <tr>
36
+ <td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
37
+ <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>
38
+ </tr>
39
+ <tr>
40
+ <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
41
+ <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>
42
+ </tr>
43
+ </tbody>
44
+ </table>
45
+
46
+ ## Overview
47
+
48
+ - OpenAI/Gemini/Claude compatible API endpoints for CLI models
49
+ - OpenAI Codex support (GPT models) via OAuth login
50
+ - Claude Code support via OAuth login
51
+ - Qwen Code support via OAuth login
52
+ - iFlow support via OAuth login
53
+ - Amp CLI and IDE extensions support with provider routing
54
+ - Streaming and non-streaming responses
55
+ - Function calling/tools support
56
+ - Multimodal input support (text and images)
57
+ - Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
58
+ - Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
59
+ - Generative Language API Key support
60
+ - AI Studio Build multi-account load balancing
61
+ - Gemini CLI multi-account load balancing
62
+ - Claude Code multi-account load balancing
63
+ - Qwen Code multi-account load balancing
64
+ - iFlow multi-account load balancing
65
+ - OpenAI Codex multi-account load balancing
66
+ - OpenAI-compatible upstream providers via config (e.g., OpenRouter)
67
+ - Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
68
+
69
+ ## Getting Started
70
+
71
+ CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
72
+
73
+ ## Management API
74
+
75
+ see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
76
+
77
+ ## Amp CLI Support
78
+
79
+ 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:
80
+
81
+ - Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
82
+ - Management proxy for OAuth authentication and account features
83
+ - Smart model fallback with automatic routing
84
+ - **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
85
+ - Security-first design with localhost-only management endpoints
86
+
87
+ **→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
88
+
89
+ ## SDK Docs
90
+
91
+ - Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
92
+ - Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
93
+ - Access: [docs/sdk-access.md](docs/sdk-access.md)
94
+ - Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
95
+ - Custom Provider Example: `examples/custom-provider`
96
+
97
+ ## Contributing
98
+
99
+ Contributions are welcome! Please feel free to submit a Pull Request.
100
+
101
+ 1. Fork the repository
102
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
103
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
104
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
105
+ 5. Open a Pull Request
106
+
107
+ ## Who is with us?
108
+
109
+ Those projects are based on CLIProxyAPI:
110
+
111
+ ### [vibeproxy](https://github.com/automazeio/vibeproxy)
112
+
113
+ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
114
+
115
+ ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
116
+
117
+ Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
118
+
119
+ ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
120
+
121
+ CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
122
+
123
+ ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
124
+
125
+ Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
126
+
127
+ ### [Quotio](https://github.com/nguyenphutrong/quotio)
128
+
129
+ 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.
130
+
131
+ ### [CodMate](https://github.com/loocor/CodMate)
132
+
133
+ 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.
134
+
135
+ ### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
136
+
137
+ Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
138
+
139
+ ### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
140
+
141
+ VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.
142
+
143
+ ### [ZeroLimit](https://github.com/0xtbug/zero-limit)
144
+
145
+ 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.
146
+
147
+ ### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
148
+
149
+ 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.
150
+
151
+ > [!NOTE]
152
+ > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
153
+
154
+ ## More choices
155
+
156
+ Those projects are ports of CLIProxyAPI or inspired by it:
157
+
158
+ ### [9Router](https://github.com/decolua/9router)
159
+
160
+ 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.
161
+
162
+ > [!NOTE]
163
+ > If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
164
+
165
+ ## License
166
+
167
+ 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
+ }
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
docs/sdk-access.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # @sdk/access SDK Reference
2
+
3
+ The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime.
4
+
5
+ ## Importing
6
+
7
+ ```go
8
+ import (
9
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
10
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11
+ )
12
+ ```
13
+
14
+ Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
15
+
16
+ ## Manager Lifecycle
17
+
18
+ ```go
19
+ manager := sdkaccess.NewManager()
20
+ providers, err := sdkaccess.BuildProviders(cfg)
21
+ if err != nil {
22
+ return err
23
+ }
24
+ manager.SetProviders(providers)
25
+ ```
26
+
27
+ * `NewManager` constructs an empty manager.
28
+ * `SetProviders` replaces the provider slice using a defensive copy.
29
+ * `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
30
+ * `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider.
31
+
32
+ ## Authenticating Requests
33
+
34
+ ```go
35
+ result, err := manager.Authenticate(ctx, req)
36
+ switch {
37
+ case err == nil:
38
+ // Authentication succeeded; result describes the provider and principal.
39
+ case errors.Is(err, sdkaccess.ErrNoCredentials):
40
+ // No recognizable credentials were supplied.
41
+ case errors.Is(err, sdkaccess.ErrInvalidCredential):
42
+ // Supplied credentials were present but rejected.
43
+ default:
44
+ // Transport-level failure was returned by a provider.
45
+ }
46
+ ```
47
+
48
+ `Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting.
49
+
50
+ If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors.
51
+
52
+ Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
53
+
54
+ ## Configuration Layout
55
+
56
+ The manager expects access providers under the `auth.providers` key inside `config.yaml`:
57
+
58
+ ```yaml
59
+ auth:
60
+ providers:
61
+ - name: inline-api
62
+ type: config-api-key
63
+ api-keys:
64
+ - sk-test-123
65
+ - sk-prod-456
66
+ ```
67
+
68
+ Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options.
69
+
70
+ ### Loading providers from external SDK modules
71
+
72
+ To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect:
73
+
74
+ ```yaml
75
+ auth:
76
+ providers:
77
+ - name: partner-auth
78
+ type: partner-token
79
+ sdk: github.com/acme/xplatform/sdk/access/providers/partner
80
+ config:
81
+ region: us-west-2
82
+ audience: cli-proxy
83
+ ```
84
+
85
+ ```go
86
+ import (
87
+ _ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
88
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
89
+ )
90
+ ```
91
+
92
+ The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
93
+
94
+ ## Built-in Providers
95
+
96
+ The SDK ships with one provider out of the box:
97
+
98
+ - `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found.
99
+
100
+ Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
101
+
102
+ ### Metadata and auditing
103
+
104
+ `Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing.
105
+
106
+ ## Writing Custom Providers
107
+
108
+ ```go
109
+ type customProvider struct{}
110
+
111
+ func (p *customProvider) Identifier() string { return "my-provider" }
112
+
113
+ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
114
+ token := r.Header.Get("X-Custom")
115
+ if token == "" {
116
+ return nil, sdkaccess.ErrNoCredentials
117
+ }
118
+ if token != "expected" {
119
+ return nil, sdkaccess.ErrInvalidCredential
120
+ }
121
+ return &sdkaccess.Result{
122
+ Provider: p.Identifier(),
123
+ Principal: "service-user",
124
+ Metadata: map[string]string{"source": "x-custom"},
125
+ }, nil
126
+ }
127
+
128
+ func init() {
129
+ sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
130
+ return &customProvider{}, nil
131
+ })
132
+ }
133
+ ```
134
+
135
+ A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs.
136
+
137
+ ## Error Semantics
138
+
139
+ - `ErrNoCredentials`: no credentials were present or recognized by any provider.
140
+ - `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
141
+ - `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
142
+
143
+ Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked.
144
+
145
+ ## Integration with cliproxy Service
146
+
147
+ `sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers:
148
+
149
+ ```go
150
+ coreCfg, _ := config.LoadConfig("config.yaml")
151
+ providers, _ := sdkaccess.BuildProviders(coreCfg)
152
+ manager := sdkaccess.NewManager()
153
+ manager.SetProviders(providers)
154
+
155
+ svc, _ := cliproxy.NewBuilder().
156
+ WithConfig(coreCfg).
157
+ WithAccessManager(manager).
158
+ Build()
159
+ ```
160
+
161
+ The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary.
162
+
163
+ ### Hot reloading providers
164
+
165
+ When configuration changes, rebuild providers and swap them into the manager:
166
+
167
+ ```go
168
+ providers, err := sdkaccess.BuildProviders(newCfg)
169
+ if err != nil {
170
+ log.Errorf("reload auth providers failed: %v", err)
171
+ return
172
+ }
173
+ accessManager.SetProviders(providers)
174
+ ```
175
+
176
+ This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process.
docs/sdk-access_CN.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # @sdk/access 开发指引
2
+
3
+ `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。
4
+
5
+ ## 引用方式
6
+
7
+ ```go
8
+ import (
9
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
10
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11
+ )
12
+ ```
13
+
14
+ 通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
15
+
16
+ ## 管理器生命周期
17
+
18
+ ```go
19
+ manager := sdkaccess.NewManager()
20
+ providers, err := sdkaccess.BuildProviders(cfg)
21
+ if err != nil {
22
+ return err
23
+ }
24
+ manager.SetProviders(providers)
25
+ ```
26
+
27
+ - `NewManager` 创建空管理器。
28
+ - `SetProviders` 替换提供者切片并做防御性拷贝。
29
+ - `Providers` 返回适合并发读取的快照。
30
+ - `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
31
+
32
+ ## 认证请求
33
+
34
+ ```go
35
+ result, err := manager.Authenticate(ctx, req)
36
+ switch {
37
+ case err == nil:
38
+ // Authentication succeeded; result carries provider and principal.
39
+ case errors.Is(err, sdkaccess.ErrNoCredentials):
40
+ // No recognizable credentials were supplied.
41
+ case errors.Is(err, sdkaccess.ErrInvalidCredential):
42
+ // Credentials were present but rejected.
43
+ default:
44
+ // Provider surfaced a transport-level failure.
45
+ }
46
+ ```
47
+
48
+ `Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。
49
+
50
+ 若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
51
+
52
+ `Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
53
+
54
+ ## 配置结构
55
+
56
+ 在 `config.yaml` 的 `auth.providers` 下定义访问提供者:
57
+
58
+ ```yaml
59
+ auth:
60
+ providers:
61
+ - name: inline-api
62
+ type: config-api-key
63
+ api-keys:
64
+ - sk-test-123
65
+ - sk-prod-456
66
+ ```
67
+
68
+ 条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
69
+
70
+ ### 引入外部 SDK 提供者
71
+
72
+ 若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
73
+
74
+ ```yaml
75
+ auth:
76
+ providers:
77
+ - name: partner-auth
78
+ type: partner-token
79
+ sdk: github.com/acme/xplatform/sdk/access/providers/partner
80
+ config:
81
+ region: us-west-2
82
+ audience: cli-proxy
83
+ ```
84
+
85
+ ```go
86
+ import (
87
+ _ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
88
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
89
+ )
90
+ ```
91
+
92
+ 通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。
93
+
94
+ ## 内建提供者
95
+
96
+ 当前 SDK 默认内置:
97
+
98
+ - `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。
99
+
100
+ 导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
101
+
102
+ ### 元数据与审计
103
+
104
+ `Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key` 或 `query-key`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。
105
+
106
+ ## 编写自定义提供者
107
+
108
+ ```go
109
+ type customProvider struct{}
110
+
111
+ func (p *customProvider) Identifier() string { return "my-provider" }
112
+
113
+ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
114
+ token := r.Header.Get("X-Custom")
115
+ if token == "" {
116
+ return nil, sdkaccess.ErrNoCredentials
117
+ }
118
+ if token != "expected" {
119
+ return nil, sdkaccess.ErrInvalidCredential
120
+ }
121
+ return &sdkaccess.Result{
122
+ Provider: p.Identifier(),
123
+ Principal: "service-user",
124
+ Metadata: map[string]string{"source": "x-custom"},
125
+ }, nil
126
+ }
127
+
128
+ func init() {
129
+ sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
130
+ return &customProvider{}, nil
131
+ })
132
+ }
133
+ ```
134
+
135
+ 自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。
136
+
137
+ ## 错误语义
138
+
139
+ - `ErrNoCredentials`:任何提供者都未识别到凭证。
140
+ - `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
141
+ - `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。
142
+
143
+ 自定义错误(例如网络异常)会马上冒泡返回。
144
+
145
+ ## 与 cliproxy 集成
146
+
147
+ 使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
148
+
149
+ ```go
150
+ coreCfg, _ := config.LoadConfig("config.yaml")
151
+ providers, _ := sdkaccess.BuildProviders(coreCfg)
152
+ manager := sdkaccess.NewManager()
153
+ manager.SetProviders(providers)
154
+
155
+ svc, _ := cliproxy.NewBuilder().
156
+ WithConfig(coreCfg).
157
+ WithAccessManager(manager).
158
+ Build()
159
+ ```
160
+
161
+ 服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。
162
+
163
+ ### 动态热更新提供者
164
+
165
+ 当配置发生变化时,可以重新构建提供者并替换当前列表:
166
+
167
+ ```go
168
+ providers, err := sdkaccess.BuildProviders(newCfg)
169
+ if err != nil {
170
+ log.Errorf("reload auth providers failed: %v", err)
171
+ return
172
+ }
173
+ accessManager.SetProviders(providers)
174
+ ```
175
+
176
+ 这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。
docs/sdk-advanced.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SDK Advanced: Executors & Translators
2
+
3
+ This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
4
+ - Implement a provider executor that talks to your upstream API
5
+ - Register request/response translators for schema conversion
6
+ - Register models so they appear in `/v1/models`
7
+
8
+ The examples use Go 1.24+ and the v6 module path.
9
+
10
+ ## Concepts
11
+
12
+ - Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests.
13
+ - Translator registry: schema conversion functions routed by `sdk/translator`. The built‑in handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones.
14
+ - Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
15
+
16
+ ## 1) Implement a Provider Executor
17
+
18
+ Create a type that satisfies `auth.ProviderExecutor`.
19
+
20
+ ```go
21
+ package myprov
22
+
23
+ import (
24
+ "context"
25
+ "net/http"
26
+
27
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
28
+ clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
29
+ )
30
+
31
+ type Executor struct{}
32
+
33
+ func (Executor) Identifier() string { return "myprov" }
34
+
35
+ // Optional: mutate outbound HTTP requests with credentials
36
+ func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
37
+ // Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
38
+ return nil
39
+ }
40
+
41
+ func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
42
+ // Build HTTP request based on req.Payload (already translated into provider format)
43
+ // Use per‑auth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
44
+ // Perform call and return provider JSON payload
45
+ return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
46
+ }
47
+
48
+ func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
49
+ ch := make(chan clipexec.StreamChunk, 1)
50
+ go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
51
+ return ch, nil
52
+ }
53
+
54
+ func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
55
+ // Optionally refresh tokens and return updated auth
56
+ return a, nil
57
+ }
58
+ ```
59
+
60
+ Register the executor with the core manager before starting the service:
61
+
62
+ ```go
63
+ core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
64
+ core.RegisterExecutor(myprov.Executor{})
65
+ svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
66
+ ```
67
+
68
+ If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
69
+
70
+ ## 2) Register Translators
71
+
72
+ The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`’s default registry.
73
+
74
+ Direction matters:
75
+ - Request: register from inbound schema to provider schema
76
+ - Response: register from provider schema back to inbound schema
77
+
78
+ Example: Convert OpenAI Chat → MyProv Chat and back.
79
+
80
+ ```go
81
+ package myprov
82
+
83
+ import (
84
+ "context"
85
+ sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
86
+ )
87
+
88
+ const (
89
+ FOpenAI = sdktr.Format("openai.chat")
90
+ FMyProv = sdktr.Format("myprov.chat")
91
+ )
92
+
93
+ func init() {
94
+ sdktr.Register(FOpenAI, FMyProv,
95
+ // Request transform (model, rawJSON, stream)
96
+ func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
97
+ // Response transform (stream & non‑stream)
98
+ sdktr.ResponseTransform{
99
+ Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
100
+ return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
101
+ },
102
+ NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
103
+ return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
104
+ },
105
+ },
106
+ )
107
+ }
108
+ ```
109
+
110
+ When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
111
+
112
+ ## 3) Register Models
113
+
114
+ Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
115
+
116
+ ```go
117
+ models := []*cliproxy.ModelInfo{
118
+ { ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
119
+ }
120
+ cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
121
+ ```
122
+
123
+ The embedded server calls this automatically for built‑in providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks.
124
+
125
+ ## Credentials & Transports
126
+
127
+ - Use `Manager.SetRoundTripperProvider` to inject per‑auth `*http.Transport` (e.g., proxy):
128
+ ```go
129
+ core.SetRoundTripperProvider(myProvider) // returns transport per auth
130
+ ```
131
+ - For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
132
+
133
+ ## Testing Tips
134
+
135
+ - Enable request logging: Management API GET/PUT `/v0/management/request-log`
136
+ - Toggle debug logs: Management API GET/PUT `/v0/management/debug`
137
+ - Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher
138
+
docs/sdk-advanced_CN.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SDK 高级指南:执行器与翻译器
2
+
3
+ 本文介绍如何使用 SDK 扩展内嵌代理:
4
+ - 实现自定义 Provider 执行器以调用你的上游 API
5
+ - 注册请求/响应翻译器进行协议转换
6
+ - 注册模型以出现在 `/v1/models`
7
+
8
+ 示例基于 Go 1.24+ 与 v6 模块路径。
9
+
10
+ ## 概念
11
+
12
+ - Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key(如 `gemini`、`claude`、`codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
13
+ - 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
14
+ - 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
15
+
16
+ ## 1) 实现 Provider 执行器
17
+
18
+ 创建类型满足 `auth.ProviderExecutor` 接口。
19
+
20
+ ```go
21
+ package myprov
22
+
23
+ import (
24
+ "context"
25
+ "net/http"
26
+
27
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
28
+ clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
29
+ )
30
+
31
+ type Executor struct{}
32
+
33
+ func (Executor) Identifier() string { return "myprov" }
34
+
35
+ // 可选:在原始 HTTP 请求上注入凭据
36
+ func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
37
+ // 例如:req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
38
+ return nil
39
+ }
40
+
41
+ func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
42
+ // 基于 req.Payload 构造上游请求,返回上游 JSON 负载
43
+ return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
44
+ }
45
+
46
+ func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
47
+ ch := make(chan clipexec.StreamChunk, 1)
48
+ go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
49
+ return ch, nil
50
+ }
51
+
52
+ func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
53
+ ```
54
+
55
+ 在启动服务前将执行器注册到核心管理器:
56
+
57
+ ```go
58
+ core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
59
+ core.RegisterExecutor(myprov.Executor{})
60
+ svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
61
+ ```
62
+
63
+ 当凭据的 `Provider` 为 `"myprov"` 时,管理器会将请求路由到你的执行器。
64
+
65
+ ## 2) 注册翻译器
66
+
67
+ 内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
68
+
69
+ 方向很重要:
70
+ - 请求:从“入站格式”转换为“provider 格式”
71
+ - 响应:从“provider 格式”转换回“入站格式”
72
+
73
+ 示例:OpenAI Chat → MyProv Chat 及其反向。
74
+
75
+ ```go
76
+ package myprov
77
+
78
+ import (
79
+ "context"
80
+ sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
81
+ )
82
+
83
+ const (
84
+ FOpenAI = sdktr.Format("openai.chat")
85
+ FMyProv = sdktr.Format("myprov.chat")
86
+ )
87
+
88
+ func init() {
89
+ sdktr.Register(FOpenAI, FMyProv,
90
+ func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
91
+ sdktr.ResponseTransform{
92
+ Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
93
+ return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
94
+ },
95
+ NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
96
+ return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
97
+ },
98
+ },
99
+ )
100
+ }
101
+ ```
102
+
103
+ 当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
104
+
105
+ ## 3) 注册模型
106
+
107
+ 通过全局模型注册表将模型暴露到 `/v1/models`:
108
+
109
+ ```go
110
+ models := []*cliproxy.ModelInfo{
111
+ { ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
112
+ }
113
+ cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
114
+ ```
115
+
116
+ 内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
117
+
118
+ ## 凭据与传输
119
+
120
+ - 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
121
+ ```go
122
+ core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
123
+ ```
124
+ - 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
125
+
126
+ ## 测试建议
127
+
128
+ - 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
129
+ - 切换调试日志:管理 API GET/PUT `/v0/management/debug`
130
+ - 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用
131
+
docs/sdk-usage.md ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLI Proxy SDK Guide
2
+
3
+ The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hot‑reload, and translation layers without depending on the CLI binary.
4
+
5
+ ## Install & Import
6
+
7
+ ```bash
8
+ go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
9
+ ```
10
+
11
+ ```go
12
+ import (
13
+ "context"
14
+ "errors"
15
+ "time"
16
+
17
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
18
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
19
+ )
20
+ ```
21
+
22
+ Note the `/v6` module path.
23
+
24
+ ## Minimal Embed
25
+
26
+ ```go
27
+ cfg, err := config.LoadConfig("config.yaml")
28
+ if err != nil { panic(err) }
29
+
30
+ svc, err := cliproxy.NewBuilder().
31
+ WithConfig(cfg).
32
+ WithConfigPath("config.yaml"). // absolute or working-dir relative
33
+ Build()
34
+ if err != nil { panic(err) }
35
+
36
+ ctx, cancel := context.WithCancel(context.Background())
37
+ defer cancel()
38
+
39
+ if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
40
+ panic(err)
41
+ }
42
+ ```
43
+
44
+ The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
45
+
46
+ ## Server Options (middleware, routes, logs)
47
+
48
+ The server accepts options via `WithServerOptions`:
49
+
50
+ ```go
51
+ svc, _ := cliproxy.NewBuilder().
52
+ WithConfig(cfg).
53
+ WithConfigPath("config.yaml").
54
+ WithServerOptions(
55
+ // Add global middleware
56
+ cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
57
+ // Tweak gin engine early (CORS, trusted proxies, etc.)
58
+ cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
59
+ // Add your own routes after defaults
60
+ cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
61
+ e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
62
+ }),
63
+ // Override request log writer/dir
64
+ cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
65
+ return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
66
+ }),
67
+ ).
68
+ Build()
69
+ ```
70
+
71
+ These options mirror the internals used by the CLI server.
72
+
73
+ ## Management API (when embedded)
74
+
75
+ - Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
76
+ - Remote access additionally requires `remote-management.allow-remote: true`.
77
+ - See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
78
+
79
+ ## Using the Core Auth Manager
80
+
81
+ The service uses a core `auth.Manager` for selection, execution, and auto‑refresh. When embedding, you can provide your own manager to customize transports or hooks:
82
+
83
+ ```go
84
+ core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
85
+ core.SetRoundTripperProvider(myRTProvider) // per‑auth *http.Transport
86
+
87
+ svc, _ := cliproxy.NewBuilder().
88
+ WithConfig(cfg).
89
+ WithConfigPath("config.yaml").
90
+ WithCoreAuthManager(core).
91
+ Build()
92
+ ```
93
+
94
+ Implement a custom per‑auth transport:
95
+
96
+ ```go
97
+ type myRTProvider struct{}
98
+ func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
99
+ if a == nil || a.ProxyURL == "" { return nil }
100
+ u, _ := url.Parse(a.ProxyURL)
101
+ return &http.Transport{ Proxy: http.ProxyURL(u) }
102
+ }
103
+ ```
104
+
105
+ Programmatic execution is available on the manager:
106
+
107
+ ```go
108
+ // Non‑streaming
109
+ resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
110
+
111
+ // Streaming
112
+ chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
113
+ for ch := range chunks { /* ... */ }
114
+ ```
115
+
116
+ Note: Built‑in provider executors are wired automatically when you run the `Service`. If you want to use `Manager` stand‑alone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`.
117
+
118
+ ## Custom Client Sources
119
+
120
+ Replace the default loaders if your creds live outside the local filesystem:
121
+
122
+ ```go
123
+ type memoryTokenProvider struct{}
124
+ func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
125
+ // Populate from memory/remote store and return counts
126
+ return &cliproxy.TokenClientResult{}, nil
127
+ }
128
+
129
+ svc, _ := cliproxy.NewBuilder().
130
+ WithConfig(cfg).
131
+ WithConfigPath("config.yaml").
132
+ WithTokenClientProvider(&memoryTokenProvider{}).
133
+ WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
134
+ Build()
135
+ ```
136
+
137
+ ## Hooks
138
+
139
+ Observe lifecycle without patching internals:
140
+
141
+ ```go
142
+ hooks := cliproxy.Hooks{
143
+ OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
144
+ OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
145
+ }
146
+ svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
147
+ ```
148
+
149
+ ## Shutdown
150
+
151
+ `Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
152
+
153
+ ```go
154
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
155
+ defer cancel()
156
+ _ = svc.Shutdown(ctx)
157
+ ```
158
+
159
+ ## Notes
160
+
161
+ - Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
162
+ - Request logging can be toggled at runtime via the Management API.
163
+ - Gemini Web features (`gemini-web.*`) are honored in the embedded server.
docs/sdk-usage_CN.md ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLI Proxy SDK 使用指南
2
+
3
+ `sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
4
+
5
+ ## 安装与导入
6
+
7
+ ```bash
8
+ go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
9
+ ```
10
+
11
+ ```go
12
+ import (
13
+ "context"
14
+ "errors"
15
+ "time"
16
+
17
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
18
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
19
+ )
20
+ ```
21
+
22
+ 注意模块路径包含 `/v6`。
23
+
24
+ ## 最小可用示例
25
+
26
+ ```go
27
+ cfg, err := config.LoadConfig("config.yaml")
28
+ if err != nil { panic(err) }
29
+
30
+ svc, err := cliproxy.NewBuilder().
31
+ WithConfig(cfg).
32
+ WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
33
+ Build()
34
+ if err != nil { panic(err) }
35
+
36
+ ctx, cancel := context.WithCancel(context.Background())
37
+ defer cancel()
38
+
39
+ if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
40
+ panic(err)
41
+ }
42
+ ```
43
+
44
+ 服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
45
+
46
+ ## 服务器可选项(中间件、路由、日志)
47
+
48
+ 通过 `WithServerOptions` 自定义:
49
+
50
+ ```go
51
+ svc, _ := cliproxy.NewBuilder().
52
+ WithConfig(cfg).
53
+ WithConfigPath("config.yaml").
54
+ WithServerOptions(
55
+ // 追加全局中间件
56
+ cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
57
+ // 提前调整 gin 引擎(如 CORS、trusted proxies)
58
+ cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
59
+ // 在默认路由之后追加自定义路由
60
+ cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
61
+ e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
62
+ }),
63
+ // 覆盖请求日志的创建(启用/目录)
64
+ cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
65
+ return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
66
+ }),
67
+ ).
68
+ Build()
69
+ ```
70
+
71
+ 这些选项与 CLI 服务器内部用法保持一致。
72
+
73
+ ## 管理 API(内嵌时)
74
+
75
+ - 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
76
+ - 远程访问还需要 `remote-management.allow-remote: true`。
77
+ - 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`。
78
+
79
+ ## 使用核心鉴权管理器
80
+
81
+ 服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
82
+
83
+ ```go
84
+ core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
85
+ core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
86
+
87
+ svc, _ := cliproxy.NewBuilder().
88
+ WithConfig(cfg).
89
+ WithConfigPath("config.yaml").
90
+ WithCoreAuthManager(core).
91
+ Build()
92
+ ```
93
+
94
+ 实现每个账户的自定义传输:
95
+
96
+ ```go
97
+ type myRTProvider struct{}
98
+ func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
99
+ if a == nil || a.ProxyURL == "" { return nil }
100
+ u, _ := url.Parse(a.ProxyURL)
101
+ return &http.Transport{ Proxy: http.ProxyURL(u) }
102
+ }
103
+ ```
104
+
105
+ 管理器提供编程式执行接口:
106
+
107
+ ```go
108
+ // 非流式
109
+ resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
110
+
111
+ // 流式
112
+ chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
113
+ for ch := range chunks { /* ... */ }
114
+ ```
115
+
116
+ 说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
117
+
118
+ ## 自定义凭据来源
119
+
120
+ 当凭据不在本地文件系统时,替换默认加载器:
121
+
122
+ ```go
123
+ type memoryTokenProvider struct{}
124
+ func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
125
+ // 从内存/远端加载并返回数量统计
126
+ return &cliproxy.TokenClientResult{}, nil
127
+ }
128
+
129
+ svc, _ := cliproxy.NewBuilder().
130
+ WithConfig(cfg).
131
+ WithConfigPath("config.yaml").
132
+ WithTokenClientProvider(&memoryTokenProvider{}).
133
+ WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
134
+ Build()
135
+ ```
136
+
137
+ ## 启动钩子
138
+
139
+ 无需修改内部代码即可观察生命周期:
140
+
141
+ ```go
142
+ hooks := cliproxy.Hooks{
143
+ OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
144
+ OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
145
+ }
146
+ svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
147
+ ```
148
+
149
+ ## 关闭
150
+
151
+ `Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
152
+
153
+ ```go
154
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
155
+ defer cancel()
156
+ _ = svc.Shutdown(ctx)
157
+ ```
158
+
159
+ ## 说明
160
+
161
+ - 热更新:`config.yaml` 与 `auths/` 变化会被自动侦测并应用。
162
+ - 请求日志可通过管理 API 在运行时开关。
163
+ - `gemini-web.*` 相关配置在内嵌服务器中会被遵循。
164
+
docs/sdk-watcher.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SDK Watcher Integration
2
+
3
+ The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled.
4
+
5
+ ## Update Queue Contract
6
+
7
+ - `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`.
8
+ - `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts.
9
+ - The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop.
10
+
11
+ ## Watcher Behaviour
12
+
13
+ - `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals.
14
+ - Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream.
15
+ - The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly.
16
+
17
+ ## High-Frequency Change Handling
18
+
19
+ - The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
20
+ - Back-pressure is absorbed in two places:
21
+ - The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
22
+ - The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
23
+ - If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states.
24
+
25
+ ## Usage Checklist
26
+
27
+ 1. Instantiate the SDK service (builder or manual construction).
28
+ 2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
29
+ 3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
30
+ 4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`.
31
+
32
+ Following this flow keeps auth changes responsive while avoiding full reloads for every edit.
docs/sdk-watcher_CN.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SDK Watcher集成说明
2
+
3
+ 本文档介绍SDK服务与文件监控器之间的增量更新队列,包括接口契约、高频变更下的处理策略以及接入步骤。
4
+
5
+ ## 更新队列契约
6
+
7
+ - `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add`、`modify`或`delete`,`ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`。
8
+ - `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher,必须在watcher启动前完成。
9
+ - 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道,并在`consumeAuthUpdates`中使用专职goroutine消费;消费侧会主动“抽干”积压事件,降低切换开销。
10
+
11
+ ## Watcher行为
12
+
13
+ - `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
14
+ - 以凭据ID为维度对更新进行合并,同一凭据在短时间内的多次变更只会保留最新状态(例如先写后删只会下发`delete`)。
15
+ - watcher内部运行异步分发循环:生产者只向内存缓冲追加事件并唤醒分发协程,即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环,确保协程正常退出。
16
+
17
+ ## 高频变更处理
18
+
19
+ - 分发循环与服务消费协程相互独立,因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
20
+ - 背压通过两级缓冲吸收:
21
+ - 分发缓冲(map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
22
+ - 服务端通道的256容量加上消费侧的“抽干”逻辑,可平稳处理多个突发批次。
23
+ - 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
24
+
25
+ ## 接入步骤
26
+
27
+ 1. 实例化SDK Service(构建器或手工创建)。
28
+ 2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
29
+ 3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道,然后再启动watcher。
30
+ 4. Reload回调专注于配置更新;认证增量会通过队列送达,并由`handleAuthUpdate`自动应用。
31
+
32
+ 遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。
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,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/stretchr/testify v1.11.1
19
+ github.com/tidwall/gjson v1.18.0
20
+ github.com/tidwall/sjson v1.2.5
21
+ github.com/tiktoken-go/tokenizer v0.7.0
22
+ golang.org/x/crypto v0.45.0
23
+ golang.org/x/net v0.47.0
24
+ golang.org/x/oauth2 v0.30.0
25
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
26
+ gopkg.in/yaml.v3 v3.0.1
27
+ )
28
+
29
+ require (
30
+ cloud.google.com/go/compute/metadata v0.3.0 // indirect
31
+ github.com/Microsoft/go-winio v0.6.2 // indirect
32
+ github.com/ProtonMail/go-crypto v1.3.0 // indirect
33
+ github.com/bytedance/sonic v1.11.6 // indirect
34
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
35
+ github.com/cloudflare/circl v1.6.1 // indirect
36
+ github.com/cloudwego/base64x v0.1.4 // indirect
37
+ github.com/cloudwego/iasm v0.2.0 // indirect
38
+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
39
+ github.com/davecgh/go-spew v1.1.1 // indirect
40
+ github.com/dlclark/regexp2 v1.11.5 // indirect
41
+ github.com/dustin/go-humanize v1.0.1 // indirect
42
+ github.com/emirpasic/gods v1.18.1 // indirect
43
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
44
+ github.com/gin-contrib/sse v0.1.0 // indirect
45
+ github.com/go-git/gcfg/v2 v2.0.2 // indirect
46
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
47
+ github.com/go-playground/locales v0.14.1 // indirect
48
+ github.com/go-playground/universal-translator v0.18.1 // indirect
49
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
50
+ github.com/goccy/go-json v0.10.2 // indirect
51
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
52
+ github.com/jackc/pgpassfile v1.0.0 // indirect
53
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
54
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
55
+ github.com/json-iterator/go v1.1.12 // indirect
56
+ github.com/kevinburke/ssh_config v1.4.0 // indirect
57
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
58
+ github.com/leodido/go-urn v1.4.0 // indirect
59
+ github.com/mattn/go-isatty v0.0.20 // indirect
60
+ github.com/minio/md5-simd v1.1.2 // indirect
61
+ github.com/minio/sha256-simd v1.0.1 // indirect
62
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
63
+ github.com/modern-go/reflect2 v1.0.2 // indirect
64
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
65
+ github.com/pjbgf/sha1cd v0.5.0 // indirect
66
+ github.com/pmezard/go-difflib v1.0.0 // indirect
67
+ github.com/rs/xid v1.5.0 // indirect
68
+ github.com/sergi/go-diff v1.4.0 // indirect
69
+ github.com/tidwall/match v1.1.1 // indirect
70
+ github.com/tidwall/pretty v1.2.0 // indirect
71
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
72
+ github.com/ugorji/go/codec v1.2.12 // indirect
73
+ golang.org/x/arch v0.8.0 // indirect
74
+ golang.org/x/sync v0.18.0 // indirect
75
+ golang.org/x/sys v0.38.0 // indirect
76
+ golang.org/x/text v0.31.0 // indirect
77
+ google.golang.org/protobuf v1.34.1 // indirect
78
+ gopkg.in/ini.v1 v1.67.0 // indirect
79
+ )
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,1522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ // kiro-api-key: []KiroKey
246
+ func (h *Handler) GetKiroKeys(c *gin.Context) {
247
+ c.JSON(200, gin.H{"kiro-api-key": h.cfg.KiroKey})
248
+ }
249
+ func (h *Handler) PutKiroKeys(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.KiroKey
256
+ if err = json.Unmarshal(data, &arr); err != nil {
257
+ var obj struct {
258
+ Items []config.KiroKey `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
+ normalizeKiroKey(&arr[i])
268
+ }
269
+ h.cfg.KiroKey = arr
270
+ // h.cfg.SanitizeKiroKeys() // Add sanitizer if needed
271
+ h.persist(c)
272
+ }
273
+ func (h *Handler) PatchKiroKey(c *gin.Context) {
274
+ type kiroKeyPatch struct {
275
+ RefreshToken *string `json:"refresh-token"`
276
+ ProfileARN *string `json:"profile-arn"`
277
+ Region *string `json:"region"`
278
+ Prefix *string `json:"prefix"`
279
+ ProxyURL *string `json:"proxy-url"`
280
+ CredentialsFile *string `json:"credentials-file"`
281
+ KiroCliDBFile *string `json:"kiro-cli-db-file"`
282
+ Models *[]config.KiroModel `json:"models"`
283
+ Headers *map[string]string `json:"headers"`
284
+ ExcludedModels *[]string `json:"excluded-models"`
285
+ }
286
+ var body struct {
287
+ Index *int `json:"index"`
288
+ Match *string `json:"match"`
289
+ Value *kiroKeyPatch `json:"value"`
290
+ }
291
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
292
+ c.JSON(400, gin.H{"error": "invalid body"})
293
+ return
294
+ }
295
+ targetIndex := -1
296
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.KiroKey) {
297
+ targetIndex = *body.Index
298
+ }
299
+ if targetIndex == -1 && body.Match != nil {
300
+ match := strings.TrimSpace(*body.Match)
301
+ for i := range h.cfg.KiroKey {
302
+ if h.cfg.KiroKey[i].RefreshToken == match {
303
+ targetIndex = i
304
+ break
305
+ }
306
+ }
307
+ }
308
+ if targetIndex == -1 {
309
+ c.JSON(404, gin.H{"error": "item not found"})
310
+ return
311
+ }
312
+
313
+ entry := h.cfg.KiroKey[targetIndex]
314
+ if body.Value.RefreshToken != nil {
315
+ entry.RefreshToken = strings.TrimSpace(*body.Value.RefreshToken)
316
+ }
317
+ if body.Value.ProfileARN != nil {
318
+ entry.ProfileARN = strings.TrimSpace(*body.Value.ProfileARN)
319
+ }
320
+ if body.Value.Region != nil {
321
+ entry.Region = strings.TrimSpace(*body.Value.Region)
322
+ }
323
+ if body.Value.Prefix != nil {
324
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
325
+ }
326
+ if body.Value.ProxyURL != nil {
327
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
328
+ }
329
+ if body.Value.CredentialsFile != nil {
330
+ entry.CredentialsFile = strings.TrimSpace(*body.Value.CredentialsFile)
331
+ }
332
+ if body.Value.KiroCliDBFile != nil {
333
+ entry.KiroCliDBFile = strings.TrimSpace(*body.Value.KiroCliDBFile)
334
+ }
335
+ if body.Value.Models != nil {
336
+ entry.Models = append([]config.KiroModel(nil), (*body.Value.Models)...)
337
+ }
338
+ if body.Value.Headers != nil {
339
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
340
+ }
341
+ if body.Value.ExcludedModels != nil {
342
+ entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
343
+ }
344
+ normalizeKiroKey(&entry)
345
+ h.cfg.KiroKey[targetIndex] = entry
346
+ h.persist(c)
347
+ }
348
+
349
+ func (h *Handler) DeleteKiroKey(c *gin.Context) {
350
+ if val := c.Query("refresh-token"); val != "" {
351
+ out := make([]config.KiroKey, 0, len(h.cfg.KiroKey))
352
+ for _, v := range h.cfg.KiroKey {
353
+ if v.RefreshToken != val {
354
+ out = append(out, v)
355
+ }
356
+ }
357
+ h.cfg.KiroKey = out
358
+ h.persist(c)
359
+ return
360
+ }
361
+ if idxStr := c.Query("index"); idxStr != "" {
362
+ var idx int
363
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
364
+ if err == nil && idx >= 0 && idx < len(h.cfg.KiroKey) {
365
+ h.cfg.KiroKey = append(h.cfg.KiroKey[:idx], h.cfg.KiroKey[idx+1:]...)
366
+ h.persist(c)
367
+ return
368
+ }
369
+ }
370
+ c.JSON(400, gin.H{"error": "missing refresh-token or index"})
371
+ }
372
+
373
+ // claude-api-key: []ClaudeKey
374
+ func (h *Handler) GetClaudeKeys(c *gin.Context) {
375
+ c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
376
+ }
377
+ func (h *Handler) PutClaudeKeys(c *gin.Context) {
378
+ data, err := c.GetRawData()
379
+ if err != nil {
380
+ c.JSON(400, gin.H{"error": "failed to read body"})
381
+ return
382
+ }
383
+ var arr []config.ClaudeKey
384
+ if err = json.Unmarshal(data, &arr); err != nil {
385
+ var obj struct {
386
+ Items []config.ClaudeKey `json:"items"`
387
+ }
388
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
389
+ c.JSON(400, gin.H{"error": "invalid body"})
390
+ return
391
+ }
392
+ arr = obj.Items
393
+ }
394
+ for i := range arr {
395
+ normalizeClaudeKey(&arr[i])
396
+ }
397
+ h.cfg.ClaudeKey = arr
398
+ h.cfg.SanitizeClaudeKeys()
399
+ h.persist(c)
400
+ }
401
+ func (h *Handler) PatchClaudeKey(c *gin.Context) {
402
+ type claudeKeyPatch struct {
403
+ APIKey *string `json:"api-key"`
404
+ Prefix *string `json:"prefix"`
405
+ BaseURL *string `json:"base-url"`
406
+ ProxyURL *string `json:"proxy-url"`
407
+ Models *[]config.ClaudeModel `json:"models"`
408
+ Headers *map[string]string `json:"headers"`
409
+ ExcludedModels *[]string `json:"excluded-models"`
410
+ }
411
+ var body struct {
412
+ Index *int `json:"index"`
413
+ Match *string `json:"match"`
414
+ Value *claudeKeyPatch `json:"value"`
415
+ }
416
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
417
+ c.JSON(400, gin.H{"error": "invalid body"})
418
+ return
419
+ }
420
+ targetIndex := -1
421
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
422
+ targetIndex = *body.Index
423
+ }
424
+ if targetIndex == -1 && body.Match != nil {
425
+ match := strings.TrimSpace(*body.Match)
426
+ for i := range h.cfg.ClaudeKey {
427
+ if h.cfg.ClaudeKey[i].APIKey == match {
428
+ targetIndex = i
429
+ break
430
+ }
431
+ }
432
+ }
433
+ if targetIndex == -1 {
434
+ c.JSON(404, gin.H{"error": "item not found"})
435
+ return
436
+ }
437
+
438
+ entry := h.cfg.ClaudeKey[targetIndex]
439
+ if body.Value.APIKey != nil {
440
+ entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
441
+ }
442
+ if body.Value.Prefix != nil {
443
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
444
+ }
445
+ if body.Value.BaseURL != nil {
446
+ entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
447
+ }
448
+ if body.Value.ProxyURL != nil {
449
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
450
+ }
451
+ if body.Value.Models != nil {
452
+ entry.Models = append([]config.ClaudeModel(nil), (*body.Value.Models)...)
453
+ }
454
+ if body.Value.Headers != nil {
455
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
456
+ }
457
+ if body.Value.ExcludedModels != nil {
458
+ entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
459
+ }
460
+ normalizeClaudeKey(&entry)
461
+ h.cfg.ClaudeKey[targetIndex] = entry
462
+ h.cfg.SanitizeClaudeKeys()
463
+ h.persist(c)
464
+ }
465
+
466
+ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
467
+ if val := c.Query("api-key"); val != "" {
468
+ out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
469
+ for _, v := range h.cfg.ClaudeKey {
470
+ if v.APIKey != val {
471
+ out = append(out, v)
472
+ }
473
+ }
474
+ h.cfg.ClaudeKey = out
475
+ h.cfg.SanitizeClaudeKeys()
476
+ h.persist(c)
477
+ return
478
+ }
479
+ if idxStr := c.Query("index"); idxStr != "" {
480
+ var idx int
481
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
482
+ if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
483
+ h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
484
+ h.cfg.SanitizeClaudeKeys()
485
+ h.persist(c)
486
+ return
487
+ }
488
+ }
489
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
490
+ }
491
+
492
+ // openai-compatibility: []OpenAICompatibility
493
+ func (h *Handler) GetOpenAICompat(c *gin.Context) {
494
+ c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
495
+ }
496
+ func (h *Handler) PutOpenAICompat(c *gin.Context) {
497
+ data, err := c.GetRawData()
498
+ if err != nil {
499
+ c.JSON(400, gin.H{"error": "failed to read body"})
500
+ return
501
+ }
502
+ var arr []config.OpenAICompatibility
503
+ if err = json.Unmarshal(data, &arr); err != nil {
504
+ var obj struct {
505
+ Items []config.OpenAICompatibility `json:"items"`
506
+ }
507
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
508
+ c.JSON(400, gin.H{"error": "invalid body"})
509
+ return
510
+ }
511
+ arr = obj.Items
512
+ }
513
+ filtered := make([]config.OpenAICompatibility, 0, len(arr))
514
+ for i := range arr {
515
+ normalizeOpenAICompatibilityEntry(&arr[i])
516
+ if strings.TrimSpace(arr[i].BaseURL) != "" {
517
+ filtered = append(filtered, arr[i])
518
+ }
519
+ }
520
+ h.cfg.OpenAICompatibility = filtered
521
+ h.cfg.SanitizeOpenAICompatibility()
522
+ h.persist(c)
523
+ }
524
+ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
525
+ type openAICompatPatch struct {
526
+ Name *string `json:"name"`
527
+ Prefix *string `json:"prefix"`
528
+ BaseURL *string `json:"base-url"`
529
+ APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
530
+ Models *[]config.OpenAICompatibilityModel `json:"models"`
531
+ Headers *map[string]string `json:"headers"`
532
+ }
533
+ var body struct {
534
+ Name *string `json:"name"`
535
+ Index *int `json:"index"`
536
+ Value *openAICompatPatch `json:"value"`
537
+ }
538
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
539
+ c.JSON(400, gin.H{"error": "invalid body"})
540
+ return
541
+ }
542
+ targetIndex := -1
543
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
544
+ targetIndex = *body.Index
545
+ }
546
+ if targetIndex == -1 && body.Name != nil {
547
+ match := strings.TrimSpace(*body.Name)
548
+ for i := range h.cfg.OpenAICompatibility {
549
+ if h.cfg.OpenAICompatibility[i].Name == match {
550
+ targetIndex = i
551
+ break
552
+ }
553
+ }
554
+ }
555
+ if targetIndex == -1 {
556
+ c.JSON(404, gin.H{"error": "item not found"})
557
+ return
558
+ }
559
+
560
+ entry := h.cfg.OpenAICompatibility[targetIndex]
561
+ if body.Value.Name != nil {
562
+ entry.Name = strings.TrimSpace(*body.Value.Name)
563
+ }
564
+ if body.Value.Prefix != nil {
565
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
566
+ }
567
+ if body.Value.BaseURL != nil {
568
+ trimmed := strings.TrimSpace(*body.Value.BaseURL)
569
+ if trimmed == "" {
570
+ h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
571
+ h.cfg.SanitizeOpenAICompatibility()
572
+ h.persist(c)
573
+ return
574
+ }
575
+ entry.BaseURL = trimmed
576
+ }
577
+ if body.Value.APIKeyEntries != nil {
578
+ entry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*body.Value.APIKeyEntries)...)
579
+ }
580
+ if body.Value.Models != nil {
581
+ entry.Models = append([]config.OpenAICompatibilityModel(nil), (*body.Value.Models)...)
582
+ }
583
+ if body.Value.Headers != nil {
584
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
585
+ }
586
+ normalizeOpenAICompatibilityEntry(&entry)
587
+ h.cfg.OpenAICompatibility[targetIndex] = entry
588
+ h.cfg.SanitizeOpenAICompatibility()
589
+ h.persist(c)
590
+ }
591
+
592
+ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
593
+ if name := c.Query("name"); name != "" {
594
+ out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
595
+ for _, v := range h.cfg.OpenAICompatibility {
596
+ if v.Name != name {
597
+ out = append(out, v)
598
+ }
599
+ }
600
+ h.cfg.OpenAICompatibility = out
601
+ h.cfg.SanitizeOpenAICompatibility()
602
+ h.persist(c)
603
+ return
604
+ }
605
+ if idxStr := c.Query("index"); idxStr != "" {
606
+ var idx int
607
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
608
+ if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
609
+ h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
610
+ h.cfg.SanitizeOpenAICompatibility()
611
+ h.persist(c)
612
+ return
613
+ }
614
+ }
615
+ c.JSON(400, gin.H{"error": "missing name or index"})
616
+ }
617
+
618
+ // vertex-api-key: []VertexCompatKey
619
+ func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
620
+ c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
621
+ }
622
+ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
623
+ data, err := c.GetRawData()
624
+ if err != nil {
625
+ c.JSON(400, gin.H{"error": "failed to read body"})
626
+ return
627
+ }
628
+ var arr []config.VertexCompatKey
629
+ if err = json.Unmarshal(data, &arr); err != nil {
630
+ var obj struct {
631
+ Items []config.VertexCompatKey `json:"items"`
632
+ }
633
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
634
+ c.JSON(400, gin.H{"error": "invalid body"})
635
+ return
636
+ }
637
+ arr = obj.Items
638
+ }
639
+ for i := range arr {
640
+ normalizeVertexCompatKey(&arr[i])
641
+ }
642
+ h.cfg.VertexCompatAPIKey = arr
643
+ h.cfg.SanitizeVertexCompatKeys()
644
+ h.persist(c)
645
+ }
646
+ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
647
+ type vertexCompatPatch struct {
648
+ APIKey *string `json:"api-key"`
649
+ Prefix *string `json:"prefix"`
650
+ BaseURL *string `json:"base-url"`
651
+ ProxyURL *string `json:"proxy-url"`
652
+ Headers *map[string]string `json:"headers"`
653
+ Models *[]config.VertexCompatModel `json:"models"`
654
+ }
655
+ var body struct {
656
+ Index *int `json:"index"`
657
+ Match *string `json:"match"`
658
+ Value *vertexCompatPatch `json:"value"`
659
+ }
660
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
661
+ c.JSON(400, gin.H{"error": "invalid body"})
662
+ return
663
+ }
664
+ targetIndex := -1
665
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
666
+ targetIndex = *body.Index
667
+ }
668
+ if targetIndex == -1 && body.Match != nil {
669
+ match := strings.TrimSpace(*body.Match)
670
+ if match != "" {
671
+ for i := range h.cfg.VertexCompatAPIKey {
672
+ if h.cfg.VertexCompatAPIKey[i].APIKey == match {
673
+ targetIndex = i
674
+ break
675
+ }
676
+ }
677
+ }
678
+ }
679
+ if targetIndex == -1 {
680
+ c.JSON(404, gin.H{"error": "item not found"})
681
+ return
682
+ }
683
+
684
+ entry := h.cfg.VertexCompatAPIKey[targetIndex]
685
+ if body.Value.APIKey != nil {
686
+ trimmed := strings.TrimSpace(*body.Value.APIKey)
687
+ if trimmed == "" {
688
+ h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
689
+ h.cfg.SanitizeVertexCompatKeys()
690
+ h.persist(c)
691
+ return
692
+ }
693
+ entry.APIKey = trimmed
694
+ }
695
+ if body.Value.Prefix != nil {
696
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
697
+ }
698
+ if body.Value.BaseURL != nil {
699
+ trimmed := strings.TrimSpace(*body.Value.BaseURL)
700
+ if trimmed == "" {
701
+ h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
702
+ h.cfg.SanitizeVertexCompatKeys()
703
+ h.persist(c)
704
+ return
705
+ }
706
+ entry.BaseURL = trimmed
707
+ }
708
+ if body.Value.ProxyURL != nil {
709
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
710
+ }
711
+ if body.Value.Headers != nil {
712
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
713
+ }
714
+ if body.Value.Models != nil {
715
+ entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
716
+ }
717
+ normalizeVertexCompatKey(&entry)
718
+ h.cfg.VertexCompatAPIKey[targetIndex] = entry
719
+ h.cfg.SanitizeVertexCompatKeys()
720
+ h.persist(c)
721
+ }
722
+
723
+ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
724
+ if val := strings.TrimSpace(c.Query("api-key")); val != "" {
725
+ out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
726
+ for _, v := range h.cfg.VertexCompatAPIKey {
727
+ if v.APIKey != val {
728
+ out = append(out, v)
729
+ }
730
+ }
731
+ h.cfg.VertexCompatAPIKey = out
732
+ h.cfg.SanitizeVertexCompatKeys()
733
+ h.persist(c)
734
+ return
735
+ }
736
+ if idxStr := c.Query("index"); idxStr != "" {
737
+ var idx int
738
+ _, errScan := fmt.Sscanf(idxStr, "%d", &idx)
739
+ if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
740
+ h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
741
+ h.cfg.SanitizeVertexCompatKeys()
742
+ h.persist(c)
743
+ return
744
+ }
745
+ }
746
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
747
+ }
748
+
749
+ // oauth-excluded-models: map[string][]string
750
+ func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
751
+ c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
752
+ }
753
+
754
+ func (h *Handler) PutOAuthExcludedModels(c *gin.Context) {
755
+ data, err := c.GetRawData()
756
+ if err != nil {
757
+ c.JSON(400, gin.H{"error": "failed to read body"})
758
+ return
759
+ }
760
+ var entries map[string][]string
761
+ if err = json.Unmarshal(data, &entries); err != nil {
762
+ var wrapper struct {
763
+ Items map[string][]string `json:"items"`
764
+ }
765
+ if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
766
+ c.JSON(400, gin.H{"error": "invalid body"})
767
+ return
768
+ }
769
+ entries = wrapper.Items
770
+ }
771
+ h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)
772
+ h.persist(c)
773
+ }
774
+
775
+ func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
776
+ var body struct {
777
+ Provider *string `json:"provider"`
778
+ Models []string `json:"models"`
779
+ }
780
+ if err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil {
781
+ c.JSON(400, gin.H{"error": "invalid body"})
782
+ return
783
+ }
784
+ provider := strings.ToLower(strings.TrimSpace(*body.Provider))
785
+ if provider == "" {
786
+ c.JSON(400, gin.H{"error": "invalid provider"})
787
+ return
788
+ }
789
+ normalized := config.NormalizeExcludedModels(body.Models)
790
+ if len(normalized) == 0 {
791
+ if h.cfg.OAuthExcludedModels == nil {
792
+ c.JSON(404, gin.H{"error": "provider not found"})
793
+ return
794
+ }
795
+ if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
796
+ c.JSON(404, gin.H{"error": "provider not found"})
797
+ return
798
+ }
799
+ delete(h.cfg.OAuthExcludedModels, provider)
800
+ if len(h.cfg.OAuthExcludedModels) == 0 {
801
+ h.cfg.OAuthExcludedModels = nil
802
+ }
803
+ h.persist(c)
804
+ return
805
+ }
806
+ if h.cfg.OAuthExcludedModels == nil {
807
+ h.cfg.OAuthExcludedModels = make(map[string][]string)
808
+ }
809
+ h.cfg.OAuthExcludedModels[provider] = normalized
810
+ h.persist(c)
811
+ }
812
+
813
+ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
814
+ provider := strings.ToLower(strings.TrimSpace(c.Query("provider")))
815
+ if provider == "" {
816
+ c.JSON(400, gin.H{"error": "missing provider"})
817
+ return
818
+ }
819
+ if h.cfg.OAuthExcludedModels == nil {
820
+ c.JSON(404, gin.H{"error": "provider not found"})
821
+ return
822
+ }
823
+ if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
824
+ c.JSON(404, gin.H{"error": "provider not found"})
825
+ return
826
+ }
827
+ delete(h.cfg.OAuthExcludedModels, provider)
828
+ if len(h.cfg.OAuthExcludedModels) == 0 {
829
+ h.cfg.OAuthExcludedModels = nil
830
+ }
831
+ h.persist(c)
832
+ }
833
+
834
+ // oauth-model-alias: map[string][]OAuthModelAlias
835
+ func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
836
+ c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
837
+ }
838
+
839
+ func (h *Handler) PutOAuthModelAlias(c *gin.Context) {
840
+ data, err := c.GetRawData()
841
+ if err != nil {
842
+ c.JSON(400, gin.H{"error": "failed to read body"})
843
+ return
844
+ }
845
+ var entries map[string][]config.OAuthModelAlias
846
+ if err = json.Unmarshal(data, &entries); err != nil {
847
+ var wrapper struct {
848
+ Items map[string][]config.OAuthModelAlias `json:"items"`
849
+ }
850
+ if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
851
+ c.JSON(400, gin.H{"error": "invalid body"})
852
+ return
853
+ }
854
+ entries = wrapper.Items
855
+ }
856
+ h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)
857
+ h.persist(c)
858
+ }
859
+
860
+ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
861
+ var body struct {
862
+ Provider *string `json:"provider"`
863
+ Channel *string `json:"channel"`
864
+ Aliases []config.OAuthModelAlias `json:"aliases"`
865
+ }
866
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
867
+ c.JSON(400, gin.H{"error": "invalid body"})
868
+ return
869
+ }
870
+ channelRaw := ""
871
+ if body.Channel != nil {
872
+ channelRaw = *body.Channel
873
+ } else if body.Provider != nil {
874
+ channelRaw = *body.Provider
875
+ }
876
+ channel := strings.ToLower(strings.TrimSpace(channelRaw))
877
+ if channel == "" {
878
+ c.JSON(400, gin.H{"error": "invalid channel"})
879
+ return
880
+ }
881
+
882
+ normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
883
+ normalized := normalizedMap[channel]
884
+ if len(normalized) == 0 {
885
+ if h.cfg.OAuthModelAlias == nil {
886
+ c.JSON(404, gin.H{"error": "channel not found"})
887
+ return
888
+ }
889
+ if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
890
+ c.JSON(404, gin.H{"error": "channel not found"})
891
+ return
892
+ }
893
+ delete(h.cfg.OAuthModelAlias, channel)
894
+ if len(h.cfg.OAuthModelAlias) == 0 {
895
+ h.cfg.OAuthModelAlias = nil
896
+ }
897
+ h.persist(c)
898
+ return
899
+ }
900
+ if h.cfg.OAuthModelAlias == nil {
901
+ h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
902
+ }
903
+ h.cfg.OAuthModelAlias[channel] = normalized
904
+ h.persist(c)
905
+ }
906
+
907
+ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
908
+ channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
909
+ if channel == "" {
910
+ channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
911
+ }
912
+ if channel == "" {
913
+ c.JSON(400, gin.H{"error": "missing channel"})
914
+ return
915
+ }
916
+ if h.cfg.OAuthModelAlias == nil {
917
+ c.JSON(404, gin.H{"error": "channel not found"})
918
+ return
919
+ }
920
+ if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
921
+ c.JSON(404, gin.H{"error": "channel not found"})
922
+ return
923
+ }
924
+ delete(h.cfg.OAuthModelAlias, channel)
925
+ if len(h.cfg.OAuthModelAlias) == 0 {
926
+ h.cfg.OAuthModelAlias = nil
927
+ }
928
+ h.persist(c)
929
+ }
930
+
931
+ // codex-api-key: []CodexKey
932
+ func (h *Handler) GetCodexKeys(c *gin.Context) {
933
+ c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
934
+ }
935
+ func (h *Handler) PutCodexKeys(c *gin.Context) {
936
+ data, err := c.GetRawData()
937
+ if err != nil {
938
+ c.JSON(400, gin.H{"error": "failed to read body"})
939
+ return
940
+ }
941
+ var arr []config.CodexKey
942
+ if err = json.Unmarshal(data, &arr); err != nil {
943
+ var obj struct {
944
+ Items []config.CodexKey `json:"items"`
945
+ }
946
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
947
+ c.JSON(400, gin.H{"error": "invalid body"})
948
+ return
949
+ }
950
+ arr = obj.Items
951
+ }
952
+ // Filter out codex entries with empty base-url (treat as removed)
953
+ filtered := make([]config.CodexKey, 0, len(arr))
954
+ for i := range arr {
955
+ entry := arr[i]
956
+ normalizeCodexKey(&entry)
957
+ if entry.BaseURL == "" {
958
+ continue
959
+ }
960
+ filtered = append(filtered, entry)
961
+ }
962
+ h.cfg.CodexKey = filtered
963
+ h.cfg.SanitizeCodexKeys()
964
+ h.persist(c)
965
+ }
966
+ func (h *Handler) PatchCodexKey(c *gin.Context) {
967
+ type codexKeyPatch struct {
968
+ APIKey *string `json:"api-key"`
969
+ Prefix *string `json:"prefix"`
970
+ BaseURL *string `json:"base-url"`
971
+ ProxyURL *string `json:"proxy-url"`
972
+ Models *[]config.CodexModel `json:"models"`
973
+ Headers *map[string]string `json:"headers"`
974
+ ExcludedModels *[]string `json:"excluded-models"`
975
+ }
976
+ var body struct {
977
+ Index *int `json:"index"`
978
+ Match *string `json:"match"`
979
+ Value *codexKeyPatch `json:"value"`
980
+ }
981
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
982
+ c.JSON(400, gin.H{"error": "invalid body"})
983
+ return
984
+ }
985
+ targetIndex := -1
986
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
987
+ targetIndex = *body.Index
988
+ }
989
+ if targetIndex == -1 && body.Match != nil {
990
+ match := strings.TrimSpace(*body.Match)
991
+ for i := range h.cfg.CodexKey {
992
+ if h.cfg.CodexKey[i].APIKey == match {
993
+ targetIndex = i
994
+ break
995
+ }
996
+ }
997
+ }
998
+ if targetIndex == -1 {
999
+ c.JSON(404, gin.H{"error": "item not found"})
1000
+ return
1001
+ }
1002
+
1003
+ entry := h.cfg.CodexKey[targetIndex]
1004
+ if body.Value.APIKey != nil {
1005
+ entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
1006
+ }
1007
+ if body.Value.Prefix != nil {
1008
+ entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
1009
+ }
1010
+ if body.Value.BaseURL != nil {
1011
+ trimmed := strings.TrimSpace(*body.Value.BaseURL)
1012
+ if trimmed == "" {
1013
+ h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
1014
+ h.cfg.SanitizeCodexKeys()
1015
+ h.persist(c)
1016
+ return
1017
+ }
1018
+ entry.BaseURL = trimmed
1019
+ }
1020
+ if body.Value.ProxyURL != nil {
1021
+ entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
1022
+ }
1023
+ if body.Value.Models != nil {
1024
+ entry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...)
1025
+ }
1026
+ if body.Value.Headers != nil {
1027
+ entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
1028
+ }
1029
+ if body.Value.ExcludedModels != nil {
1030
+ entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
1031
+ }
1032
+ normalizeCodexKey(&entry)
1033
+ h.cfg.CodexKey[targetIndex] = entry
1034
+ h.cfg.SanitizeCodexKeys()
1035
+ h.persist(c)
1036
+ }
1037
+
1038
+ func (h *Handler) DeleteCodexKey(c *gin.Context) {
1039
+ if val := c.Query("api-key"); val != "" {
1040
+ out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
1041
+ for _, v := range h.cfg.CodexKey {
1042
+ if v.APIKey != val {
1043
+ out = append(out, v)
1044
+ }
1045
+ }
1046
+ h.cfg.CodexKey = out
1047
+ h.cfg.SanitizeCodexKeys()
1048
+ h.persist(c)
1049
+ return
1050
+ }
1051
+ if idxStr := c.Query("index"); idxStr != "" {
1052
+ var idx int
1053
+ _, err := fmt.Sscanf(idxStr, "%d", &idx)
1054
+ if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
1055
+ h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
1056
+ h.cfg.SanitizeCodexKeys()
1057
+ h.persist(c)
1058
+ return
1059
+ }
1060
+ }
1061
+ c.JSON(400, gin.H{"error": "missing api-key or index"})
1062
+ }
1063
+
1064
+ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
1065
+ if entry == nil {
1066
+ return
1067
+ }
1068
+ // Trim base-url; empty base-url indicates provider should be removed by sanitization
1069
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
1070
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1071
+ existing := make(map[string]struct{}, len(entry.APIKeyEntries))
1072
+ for i := range entry.APIKeyEntries {
1073
+ trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
1074
+ entry.APIKeyEntries[i].APIKey = trimmed
1075
+ if trimmed != "" {
1076
+ existing[trimmed] = struct{}{}
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
1082
+ if len(entries) == 0 {
1083
+ return nil
1084
+ }
1085
+ out := make([]config.OpenAICompatibility, len(entries))
1086
+ for i := range entries {
1087
+ copyEntry := entries[i]
1088
+ if len(copyEntry.APIKeyEntries) > 0 {
1089
+ copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
1090
+ }
1091
+ normalizeOpenAICompatibilityEntry(&copyEntry)
1092
+ out[i] = copyEntry
1093
+ }
1094
+ return out
1095
+ }
1096
+
1097
+ func normalizeKiroKey(entry *config.KiroKey) {
1098
+ if entry == nil {
1099
+ return
1100
+ }
1101
+ entry.RefreshToken = strings.TrimSpace(entry.RefreshToken)
1102
+ entry.ProfileARN = strings.TrimSpace(entry.ProfileARN)
1103
+ entry.Region = strings.TrimSpace(entry.Region)
1104
+ entry.Prefix = strings.TrimSpace(entry.Prefix)
1105
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
1106
+ entry.CredentialsFile = strings.TrimSpace(entry.CredentialsFile)
1107
+ entry.KiroCliDBFile = strings.TrimSpace(entry.KiroCliDBFile)
1108
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1109
+ entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
1110
+ if len(entry.Models) == 0 {
1111
+ return
1112
+ }
1113
+ normalized := make([]config.KiroModel, 0, len(entry.Models))
1114
+ for i := range entry.Models {
1115
+ model := entry.Models[i]
1116
+ model.Name = strings.TrimSpace(model.Name)
1117
+ model.Alias = strings.TrimSpace(model.Alias)
1118
+ if model.Name == "" && model.Alias == "" {
1119
+ continue
1120
+ }
1121
+ normalized = append(normalized, model)
1122
+ }
1123
+ entry.Models = normalized
1124
+ }
1125
+
1126
+ func normalizeClaudeKey(entry *config.ClaudeKey) {
1127
+ if entry == nil {
1128
+ return
1129
+ }
1130
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
1131
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
1132
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
1133
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1134
+ entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
1135
+ if len(entry.Models) == 0 {
1136
+ return
1137
+ }
1138
+ normalized := make([]config.ClaudeModel, 0, len(entry.Models))
1139
+ for i := range entry.Models {
1140
+ model := entry.Models[i]
1141
+ model.Name = strings.TrimSpace(model.Name)
1142
+ model.Alias = strings.TrimSpace(model.Alias)
1143
+ if model.Name == "" && model.Alias == "" {
1144
+ continue
1145
+ }
1146
+ normalized = append(normalized, model)
1147
+ }
1148
+ entry.Models = normalized
1149
+ }
1150
+
1151
+ func normalizeCodexKey(entry *config.CodexKey) {
1152
+ if entry == nil {
1153
+ return
1154
+ }
1155
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
1156
+ entry.Prefix = strings.TrimSpace(entry.Prefix)
1157
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
1158
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
1159
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1160
+ entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
1161
+ if len(entry.Models) == 0 {
1162
+ return
1163
+ }
1164
+ normalized := make([]config.CodexModel, 0, len(entry.Models))
1165
+ for i := range entry.Models {
1166
+ model := entry.Models[i]
1167
+ model.Name = strings.TrimSpace(model.Name)
1168
+ model.Alias = strings.TrimSpace(model.Alias)
1169
+ if model.Name == "" && model.Alias == "" {
1170
+ continue
1171
+ }
1172
+ normalized = append(normalized, model)
1173
+ }
1174
+ entry.Models = normalized
1175
+ }
1176
+
1177
+ func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
1178
+ if entry == nil {
1179
+ return
1180
+ }
1181
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
1182
+ entry.Prefix = strings.TrimSpace(entry.Prefix)
1183
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
1184
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
1185
+ entry.Headers = config.NormalizeHeaders(entry.Headers)
1186
+ if len(entry.Models) == 0 {
1187
+ return
1188
+ }
1189
+ normalized := make([]config.VertexCompatModel, 0, len(entry.Models))
1190
+ for i := range entry.Models {
1191
+ model := entry.Models[i]
1192
+ model.Name = strings.TrimSpace(model.Name)
1193
+ model.Alias = strings.TrimSpace(model.Alias)
1194
+ if model.Name == "" || model.Alias == "" {
1195
+ continue
1196
+ }
1197
+ normalized = append(normalized, model)
1198
+ }
1199
+ entry.Models = normalized
1200
+ }
1201
+
1202
+ func sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias {
1203
+ if len(entries) == 0 {
1204
+ return nil
1205
+ }
1206
+ copied := make(map[string][]config.OAuthModelAlias, len(entries))
1207
+ for channel, aliases := range entries {
1208
+ if len(aliases) == 0 {
1209
+ continue
1210
+ }
1211
+ copied[channel] = append([]config.OAuthModelAlias(nil), aliases...)
1212
+ }
1213
+ if len(copied) == 0 {
1214
+ return nil
1215
+ }
1216
+ cfg := config.Config{OAuthModelAlias: copied}
1217
+ cfg.SanitizeOAuthModelAlias()
1218
+ if len(cfg.OAuthModelAlias) == 0 {
1219
+ return nil
1220
+ }
1221
+ return cfg.OAuthModelAlias
1222
+ }
1223
+
1224
+ // GetAmpCode returns the complete ampcode configuration.
1225
+ func (h *Handler) GetAmpCode(c *gin.Context) {
1226
+ if h == nil || h.cfg == nil {
1227
+ c.JSON(200, gin.H{"ampcode": config.AmpCode{}})
1228
+ return
1229
+ }
1230
+ c.JSON(200, gin.H{"ampcode": h.cfg.AmpCode})
1231
+ }
1232
+
1233
+ // GetAmpUpstreamURL returns the ampcode upstream URL.
1234
+ func (h *Handler) GetAmpUpstreamURL(c *gin.Context) {
1235
+ if h == nil || h.cfg == nil {
1236
+ c.JSON(200, gin.H{"upstream-url": ""})
1237
+ return
1238
+ }
1239
+ c.JSON(200, gin.H{"upstream-url": h.cfg.AmpCode.UpstreamURL})
1240
+ }
1241
+
1242
+ // PutAmpUpstreamURL updates the ampcode upstream URL.
1243
+ func (h *Handler) PutAmpUpstreamURL(c *gin.Context) {
1244
+ h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })
1245
+ }
1246
+
1247
+ // DeleteAmpUpstreamURL clears the ampcode upstream URL.
1248
+ func (h *Handler) DeleteAmpUpstreamURL(c *gin.Context) {
1249
+ h.cfg.AmpCode.UpstreamURL = ""
1250
+ h.persist(c)
1251
+ }
1252
+
1253
+ // GetAmpUpstreamAPIKey returns the ampcode upstream API key.
1254
+ func (h *Handler) GetAmpUpstreamAPIKey(c *gin.Context) {
1255
+ if h == nil || h.cfg == nil {
1256
+ c.JSON(200, gin.H{"upstream-api-key": ""})
1257
+ return
1258
+ }
1259
+ c.JSON(200, gin.H{"upstream-api-key": h.cfg.AmpCode.UpstreamAPIKey})
1260
+ }
1261
+
1262
+ // PutAmpUpstreamAPIKey updates the ampcode upstream API key.
1263
+ func (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {
1264
+ h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })
1265
+ }
1266
+
1267
+ // DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.
1268
+ func (h *Handler) DeleteAmpUpstreamAPIKey(c *gin.Context) {
1269
+ h.cfg.AmpCode.UpstreamAPIKey = ""
1270
+ h.persist(c)
1271
+ }
1272
+
1273
+ // GetAmpRestrictManagementToLocalhost returns the localhost restriction setting.
1274
+ func (h *Handler) GetAmpRestrictManagementToLocalhost(c *gin.Context) {
1275
+ if h == nil || h.cfg == nil {
1276
+ c.JSON(200, gin.H{"restrict-management-to-localhost": true})
1277
+ return
1278
+ }
1279
+ c.JSON(200, gin.H{"restrict-management-to-localhost": h.cfg.AmpCode.RestrictManagementToLocalhost})
1280
+ }
1281
+
1282
+ // PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.
1283
+ func (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {
1284
+ h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })
1285
+ }
1286
+
1287
+ // GetAmpModelMappings returns the ampcode model mappings.
1288
+ func (h *Handler) GetAmpModelMappings(c *gin.Context) {
1289
+ if h == nil || h.cfg == nil {
1290
+ c.JSON(200, gin.H{"model-mappings": []config.AmpModelMapping{}})
1291
+ return
1292
+ }
1293
+ c.JSON(200, gin.H{"model-mappings": h.cfg.AmpCode.ModelMappings})
1294
+ }
1295
+
1296
+ // PutAmpModelMappings replaces all ampcode model mappings.
1297
+ func (h *Handler) PutAmpModelMappings(c *gin.Context) {
1298
+ var body struct {
1299
+ Value []config.AmpModelMapping `json:"value"`
1300
+ }
1301
+ if err := c.ShouldBindJSON(&body); err != nil {
1302
+ c.JSON(400, gin.H{"error": "invalid body"})
1303
+ return
1304
+ }
1305
+ h.cfg.AmpCode.ModelMappings = body.Value
1306
+ h.persist(c)
1307
+ }
1308
+
1309
+ // PatchAmpModelMappings adds or updates model mappings.
1310
+ func (h *Handler) PatchAmpModelMappings(c *gin.Context) {
1311
+ var body struct {
1312
+ Value []config.AmpModelMapping `json:"value"`
1313
+ }
1314
+ if err := c.ShouldBindJSON(&body); err != nil {
1315
+ c.JSON(400, gin.H{"error": "invalid body"})
1316
+ return
1317
+ }
1318
+
1319
+ existing := make(map[string]int)
1320
+ for i, m := range h.cfg.AmpCode.ModelMappings {
1321
+ existing[strings.TrimSpace(m.From)] = i
1322
+ }
1323
+
1324
+ for _, newMapping := range body.Value {
1325
+ from := strings.TrimSpace(newMapping.From)
1326
+ if idx, ok := existing[from]; ok {
1327
+ h.cfg.AmpCode.ModelMappings[idx] = newMapping
1328
+ } else {
1329
+ h.cfg.AmpCode.ModelMappings = append(h.cfg.AmpCode.ModelMappings, newMapping)
1330
+ existing[from] = len(h.cfg.AmpCode.ModelMappings) - 1
1331
+ }
1332
+ }
1333
+ h.persist(c)
1334
+ }
1335
+
1336
+ // DeleteAmpModelMappings removes specified model mappings by "from" field.
1337
+ func (h *Handler) DeleteAmpModelMappings(c *gin.Context) {
1338
+ var body struct {
1339
+ Value []string `json:"value"`
1340
+ }
1341
+ if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
1342
+ h.cfg.AmpCode.ModelMappings = nil
1343
+ h.persist(c)
1344
+ return
1345
+ }
1346
+
1347
+ toRemove := make(map[string]bool)
1348
+ for _, from := range body.Value {
1349
+ toRemove[strings.TrimSpace(from)] = true
1350
+ }
1351
+
1352
+ newMappings := make([]config.AmpModelMapping, 0, len(h.cfg.AmpCode.ModelMappings))
1353
+ for _, m := range h.cfg.AmpCode.ModelMappings {
1354
+ if !toRemove[strings.TrimSpace(m.From)] {
1355
+ newMappings = append(newMappings, m)
1356
+ }
1357
+ }
1358
+ h.cfg.AmpCode.ModelMappings = newMappings
1359
+ h.persist(c)
1360
+ }
1361
+
1362
+ // GetAmpForceModelMappings returns whether model mappings are forced.
1363
+ func (h *Handler) GetAmpForceModelMappings(c *gin.Context) {
1364
+ if h == nil || h.cfg == nil {
1365
+ c.JSON(200, gin.H{"force-model-mappings": false})
1366
+ return
1367
+ }
1368
+ c.JSON(200, gin.H{"force-model-mappings": h.cfg.AmpCode.ForceModelMappings})
1369
+ }
1370
+
1371
+ // PutAmpForceModelMappings updates the force model mappings setting.
1372
+ func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
1373
+ h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
1374
+ }
1375
+
1376
+ // GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
1377
+ func (h *Handler) GetAmpUpstreamAPIKeys(c *gin.Context) {
1378
+ if h == nil || h.cfg == nil {
1379
+ c.JSON(200, gin.H{"upstream-api-keys": []config.AmpUpstreamAPIKeyEntry{}})
1380
+ return
1381
+ }
1382
+ c.JSON(200, gin.H{"upstream-api-keys": h.cfg.AmpCode.UpstreamAPIKeys})
1383
+ }
1384
+
1385
+ // PutAmpUpstreamAPIKeys replaces all ampcode upstream API keys mappings.
1386
+ func (h *Handler) PutAmpUpstreamAPIKeys(c *gin.Context) {
1387
+ var body struct {
1388
+ Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
1389
+ }
1390
+ if err := c.ShouldBindJSON(&body); err != nil {
1391
+ c.JSON(400, gin.H{"error": "invalid body"})
1392
+ return
1393
+ }
1394
+ // Normalize entries: trim whitespace, filter empty
1395
+ normalized := normalizeAmpUpstreamAPIKeyEntries(body.Value)
1396
+ h.cfg.AmpCode.UpstreamAPIKeys = normalized
1397
+ h.persist(c)
1398
+ }
1399
+
1400
+ // PatchAmpUpstreamAPIKeys adds or updates upstream API keys entries.
1401
+ // Matching is done by upstream-api-key value.
1402
+ func (h *Handler) PatchAmpUpstreamAPIKeys(c *gin.Context) {
1403
+ var body struct {
1404
+ Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
1405
+ }
1406
+ if err := c.ShouldBindJSON(&body); err != nil {
1407
+ c.JSON(400, gin.H{"error": "invalid body"})
1408
+ return
1409
+ }
1410
+
1411
+ existing := make(map[string]int)
1412
+ for i, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
1413
+ existing[strings.TrimSpace(entry.UpstreamAPIKey)] = i
1414
+ }
1415
+
1416
+ for _, newEntry := range body.Value {
1417
+ upstreamKey := strings.TrimSpace(newEntry.UpstreamAPIKey)
1418
+ if upstreamKey == "" {
1419
+ continue
1420
+ }
1421
+ normalizedEntry := config.AmpUpstreamAPIKeyEntry{
1422
+ UpstreamAPIKey: upstreamKey,
1423
+ APIKeys: normalizeAPIKeysList(newEntry.APIKeys),
1424
+ }
1425
+ if idx, ok := existing[upstreamKey]; ok {
1426
+ h.cfg.AmpCode.UpstreamAPIKeys[idx] = normalizedEntry
1427
+ } else {
1428
+ h.cfg.AmpCode.UpstreamAPIKeys = append(h.cfg.AmpCode.UpstreamAPIKeys, normalizedEntry)
1429
+ existing[upstreamKey] = len(h.cfg.AmpCode.UpstreamAPIKeys) - 1
1430
+ }
1431
+ }
1432
+ h.persist(c)
1433
+ }
1434
+
1435
+ // DeleteAmpUpstreamAPIKeys removes specified upstream API keys entries.
1436
+ // Body must be JSON: {"value": ["<upstream-api-key>", ...]}.
1437
+ // If "value" is an empty array, clears all entries.
1438
+ // If JSON is invalid or "value" is missing/null, returns 400 and does not persist any change.
1439
+ func (h *Handler) DeleteAmpUpstreamAPIKeys(c *gin.Context) {
1440
+ var body struct {
1441
+ Value []string `json:"value"`
1442
+ }
1443
+ if err := c.ShouldBindJSON(&body); err != nil {
1444
+ c.JSON(400, gin.H{"error": "invalid body"})
1445
+ return
1446
+ }
1447
+
1448
+ if body.Value == nil {
1449
+ c.JSON(400, gin.H{"error": "missing value"})
1450
+ return
1451
+ }
1452
+
1453
+ // Empty array means clear all
1454
+ if len(body.Value) == 0 {
1455
+ h.cfg.AmpCode.UpstreamAPIKeys = nil
1456
+ h.persist(c)
1457
+ return
1458
+ }
1459
+
1460
+ toRemove := make(map[string]bool)
1461
+ for _, key := range body.Value {
1462
+ trimmed := strings.TrimSpace(key)
1463
+ if trimmed == "" {
1464
+ continue
1465
+ }
1466
+ toRemove[trimmed] = true
1467
+ }
1468
+ if len(toRemove) == 0 {
1469
+ c.JSON(400, gin.H{"error": "empty value"})
1470
+ return
1471
+ }
1472
+
1473
+ newEntries := make([]config.AmpUpstreamAPIKeyEntry, 0, len(h.cfg.AmpCode.UpstreamAPIKeys))
1474
+ for _, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
1475
+ if !toRemove[strings.TrimSpace(entry.UpstreamAPIKey)] {
1476
+ newEntries = append(newEntries, entry)
1477
+ }
1478
+ }
1479
+ h.cfg.AmpCode.UpstreamAPIKeys = newEntries
1480
+ h.persist(c)
1481
+ }
1482
+
1483
+ // normalizeAmpUpstreamAPIKeyEntries normalizes a list of upstream API key entries.
1484
+ func normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry {
1485
+ if len(entries) == 0 {
1486
+ return nil
1487
+ }
1488
+ out := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries))
1489
+ for _, entry := range entries {
1490
+ upstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)
1491
+ if upstreamKey == "" {
1492
+ continue
1493
+ }
1494
+ apiKeys := normalizeAPIKeysList(entry.APIKeys)
1495
+ out = append(out, config.AmpUpstreamAPIKeyEntry{
1496
+ UpstreamAPIKey: upstreamKey,
1497
+ APIKeys: apiKeys,
1498
+ })
1499
+ }
1500
+ if len(out) == 0 {
1501
+ return nil
1502
+ }
1503
+ return out
1504
+ }
1505
+
1506
+ // normalizeAPIKeysList trims and filters empty strings from a list of API keys.
1507
+ func normalizeAPIKeysList(keys []string) []string {
1508
+ if len(keys) == 0 {
1509
+ return nil
1510
+ }
1511
+ out := make([]string, 0, len(keys))
1512
+ for _, k := range keys {
1513
+ trimmed := strings.TrimSpace(k)
1514
+ if trimmed != "" {
1515
+ out = append(out, trimmed)
1516
+ }
1517
+ }
1518
+ if len(out) == 0 {
1519
+ return nil
1520
+ }
1521
+ return out
1522
+ }
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
+ }