Commit ·
f94dd26
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +36 -0
- .env.example +34 -0
- .factory/settings.json +100 -0
- .github/FUNDING.yml +1 -0
- .github/ISSUE_TEMPLATE/bug_report.md +44 -0
- .github/workflows/docker-image.yml +46 -0
- .github/workflows/pr-path-guard.yml +28 -0
- .github/workflows/pr-test-build.yml +23 -0
- .github/workflows/release.yaml +38 -0
- .gitignore +50 -0
- .goreleaser.yml +39 -0
- Dockerfile +35 -0
- LICENSE +22 -0
- README.md +157 -0
- README_CN.md +164 -0
- assets/cubence.png +0 -0
- assets/packycode.png +0 -0
- auths/.gitkeep +0 -0
- cmd/server/main.go +482 -0
- config.example.yaml +308 -0
- docker-build.ps1 +53 -0
- docker-build.sh +180 -0
- docker-compose.yml +28 -0
- examples/custom-provider/main.go +225 -0
- examples/http-request/main.go +140 -0
- examples/translator/main.go +42 -0
- go.mod +76 -0
- go.sum +197 -0
- internal/access/config_access/provider.go +112 -0
- internal/access/reconcile.go +270 -0
- internal/api/handlers/management/api_tools.go +704 -0
- internal/api/handlers/management/api_tools_test.go +173 -0
- internal/api/handlers/management/auth_files.go +2191 -0
- internal/api/handlers/management/config_basic.go +309 -0
- internal/api/handlers/management/config_lists.go +1365 -0
- internal/api/handlers/management/handler.go +317 -0
- internal/api/handlers/management/logs.go +583 -0
- internal/api/handlers/management/model_definitions.go +33 -0
- internal/api/handlers/management/oauth_callback.go +100 -0
- internal/api/handlers/management/oauth_sessions.go +283 -0
- internal/api/handlers/management/quota.go +18 -0
- internal/api/handlers/management/usage.go +79 -0
- internal/api/handlers/management/vertex_import.go +156 -0
- internal/api/middleware/request_logging.go +122 -0
- internal/api/middleware/response_writer.go +382 -0
- internal/api/modules/amp/amp.go +428 -0
- internal/api/modules/amp/amp_test.go +352 -0
- internal/api/modules/amp/fallback_handlers.go +331 -0
- internal/api/modules/amp/fallback_handlers_test.go +73 -0
- internal/api/modules/amp/gemini_bridge.go +59 -0
.dockerignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git and GitHub folders
|
| 2 |
+
.git/*
|
| 3 |
+
.github/*
|
| 4 |
+
|
| 5 |
+
# Docker and CI/CD related files
|
| 6 |
+
docker-compose.yml
|
| 7 |
+
.dockerignore
|
| 8 |
+
.gitignore
|
| 9 |
+
.goreleaser.yml
|
| 10 |
+
Dockerfile
|
| 11 |
+
|
| 12 |
+
# Documentation and license
|
| 13 |
+
docs/*
|
| 14 |
+
README.md
|
| 15 |
+
README_CN.md
|
| 16 |
+
LICENSE
|
| 17 |
+
|
| 18 |
+
# Runtime data folders (should be mounted as volumes)
|
| 19 |
+
auths/*
|
| 20 |
+
logs/*
|
| 21 |
+
conv/*
|
| 22 |
+
config.yaml
|
| 23 |
+
|
| 24 |
+
# Development/editor
|
| 25 |
+
bin/*
|
| 26 |
+
.vscode/*
|
| 27 |
+
.claude/*
|
| 28 |
+
.codex/*
|
| 29 |
+
.gemini/*
|
| 30 |
+
.serena/*
|
| 31 |
+
.agent/*
|
| 32 |
+
.agents/*
|
| 33 |
+
.opencode/*
|
| 34 |
+
.bmad/*
|
| 35 |
+
_bmad/*
|
| 36 |
+
_bmad-output/*
|
.env.example
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example environment configuration for CLIProxyAPI.
|
| 2 |
+
# Copy this file to `.env` and uncomment the variables you need.
|
| 3 |
+
#
|
| 4 |
+
# NOTE: Environment variables are only required when using remote storage options.
|
| 5 |
+
# For local file-based storage (default), no environment variables need to be set.
|
| 6 |
+
|
| 7 |
+
# ------------------------------------------------------------------------------
|
| 8 |
+
# Management Web UI
|
| 9 |
+
# ------------------------------------------------------------------------------
|
| 10 |
+
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
|
| 11 |
+
|
| 12 |
+
# ------------------------------------------------------------------------------
|
| 13 |
+
# Postgres Token Store (optional)
|
| 14 |
+
# ------------------------------------------------------------------------------
|
| 15 |
+
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
|
| 16 |
+
# PGSTORE_SCHEMA=public
|
| 17 |
+
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
|
| 18 |
+
|
| 19 |
+
# ------------------------------------------------------------------------------
|
| 20 |
+
# Git-Backed Config Store (optional)
|
| 21 |
+
# ------------------------------------------------------------------------------
|
| 22 |
+
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
|
| 23 |
+
# GITSTORE_GIT_USERNAME=git-user
|
| 24 |
+
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
|
| 25 |
+
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
|
| 26 |
+
|
| 27 |
+
# ------------------------------------------------------------------------------
|
| 28 |
+
# Object Store Token Store (optional)
|
| 29 |
+
# ------------------------------------------------------------------------------
|
| 30 |
+
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
|
| 31 |
+
# OBJECTSTORE_BUCKET=cli-proxy-config
|
| 32 |
+
# OBJECTSTORE_ACCESS_KEY=your_access_key
|
| 33 |
+
# OBJECTSTORE_SECRET_KEY=your_secret_key
|
| 34 |
+
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore
|
.factory/settings.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": "z-ai/glm4.7",
|
| 3 |
+
"showThinkingInMainView": true,
|
| 4 |
+
"reasoningEffort": "medium",
|
| 5 |
+
"customModels": [
|
| 6 |
+
{
|
| 7 |
+
"model": "gpt-5.1-codex-max",
|
| 8 |
+
"displayName": "gpt-5.1-codex-max",
|
| 9 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 10 |
+
"apiKey": "shin",
|
| 11 |
+
"provider": "generic-chat-completion-api"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"model": "GPT-5.1",
|
| 15 |
+
"displayName": "GPT-5.1",
|
| 16 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 17 |
+
"apiKey": "shin",
|
| 18 |
+
"provider": "generic-chat-completion-api"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"model": "gpt-5.2-codex",
|
| 22 |
+
"displayName": "gpt-5.2-codex",
|
| 23 |
+
"baseUrl": "https://shinmen07.up.railway.app/v1",
|
| 24 |
+
"apiKey": "shin",
|
| 25 |
+
"provider": "generic-chat-completion-api"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"model": "gpt-5.2",
|
| 29 |
+
"displayName": "gpt-5.2",
|
| 30 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 31 |
+
"apiKey": "shin",
|
| 32 |
+
"provider": "generic-chat-completion-api"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"model": "gpt-5.1-codex",
|
| 36 |
+
"displayName": "gpt-5.1-codex",
|
| 37 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 38 |
+
"apiKey": "shin",
|
| 39 |
+
"provider": "generic-chat-completion-api"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"model": "gemini-claude-sonnet-4-5",
|
| 43 |
+
"displayName": "gemini-claude-sonnet-4-5",
|
| 44 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 45 |
+
"apiKey": "shin",
|
| 46 |
+
"provider": "generic-chat-completion-api"
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"model": "gemini-claude-opus-4-5-thinking",
|
| 50 |
+
"displayName": "gemini-claude-opus-4-5-thinking",
|
| 51 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 52 |
+
"apiKey": "shin",
|
| 53 |
+
"provider": "generic-chat-completion-api",
|
| 54 |
+
"supportsExtendedThinking": true
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"model": "gemini-3-pro-preview",
|
| 58 |
+
"displayName": "gemini-3-pro-preview",
|
| 59 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 60 |
+
"apiKey": "shin",
|
| 61 |
+
"provider": "generic-chat-completion-api",
|
| 62 |
+
"supportsExtendedThinking": true
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"model": "z-ai/glm4.7",
|
| 66 |
+
"displayName": "z-ai/glm4.7",
|
| 67 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 68 |
+
"apiKey": "shin",
|
| 69 |
+
"provider": "generic-chat-completion-api"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"model": "glm-4.7",
|
| 73 |
+
"displayName": "glm-4.7",
|
| 74 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 75 |
+
"apiKey": "shin",
|
| 76 |
+
"provider": "generic-chat-completion-api"
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"model": "minimaxai/minimax-m2.1",
|
| 80 |
+
"displayName": "minimaxai/minimax-m2.1",
|
| 81 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 82 |
+
"apiKey": "shin",
|
| 83 |
+
"provider": "generic-chat-completion-api"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"model": "claude-haiku-4.5",
|
| 87 |
+
"displayName": "claude-haiku-4.5",
|
| 88 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 89 |
+
"apiKey": "shin",
|
| 90 |
+
"provider": "generic-chat-completion-api"
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"model": "claude-opus-4.5",
|
| 94 |
+
"displayName": "claude-opus-4.5",
|
| 95 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 96 |
+
"apiKey": "shin",
|
| 97 |
+
"provider": "generic-chat-completion-api"
|
| 98 |
+
}
|
| 99 |
+
]
|
| 100 |
+
}
|
.github/FUNDING.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
github: [router-for-me]
|
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Is it a request payload issue?**
|
| 11 |
+
[ ] Yes, this is a request payload issue. I am using a client/cURL to send a request payload, but I received an unexpected error.
|
| 12 |
+
[ ] No, it's another issue.
|
| 13 |
+
|
| 14 |
+
**If it's a request payload issue, you MUST know**
|
| 15 |
+
Our team doesn't have any GODs or ORACLEs or MIND READERs. Please make sure to attach the request log or curl payload.
|
| 16 |
+
|
| 17 |
+
**Describe the bug**
|
| 18 |
+
A clear and concise description of what the bug is.
|
| 19 |
+
|
| 20 |
+
**CLI Type**
|
| 21 |
+
What type of CLI account do you use? (gemini-cli, gemini, codex, claude code or openai-compatibility)
|
| 22 |
+
|
| 23 |
+
**Model Name**
|
| 24 |
+
What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
|
| 25 |
+
|
| 26 |
+
**LLM Client**
|
| 27 |
+
What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
|
| 28 |
+
|
| 29 |
+
**Request Information**
|
| 30 |
+
The best way is to paste the cURL command of the HTTP request here.
|
| 31 |
+
Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
|
| 32 |
+
|
| 33 |
+
**Expected behavior**
|
| 34 |
+
A clear and concise description of what you expected to happen.
|
| 35 |
+
|
| 36 |
+
**Screenshots**
|
| 37 |
+
If applicable, add screenshots to help explain your problem.
|
| 38 |
+
|
| 39 |
+
**OS Type**
|
| 40 |
+
- OS: [e.g. macOS]
|
| 41 |
+
- Version [e.g. 15.6.0]
|
| 42 |
+
|
| 43 |
+
**Additional context**
|
| 44 |
+
Add any other context about the problem here.
|
.github/workflows/docker-image.yml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: docker-image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- v*
|
| 7 |
+
|
| 8 |
+
env:
|
| 9 |
+
APP_NAME: CLIProxyAPI
|
| 10 |
+
DOCKERHUB_REPO: eceasy/cli-proxy-api
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
docker:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- name: Checkout
|
| 17 |
+
uses: actions/checkout@v4
|
| 18 |
+
- name: Set up QEMU
|
| 19 |
+
uses: docker/setup-qemu-action@v3
|
| 20 |
+
- name: Set up Docker Buildx
|
| 21 |
+
uses: docker/setup-buildx-action@v3
|
| 22 |
+
- name: Login to DockerHub
|
| 23 |
+
uses: docker/login-action@v3
|
| 24 |
+
with:
|
| 25 |
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
| 26 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 27 |
+
- name: Generate Build Metadata
|
| 28 |
+
run: |
|
| 29 |
+
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
| 30 |
+
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
| 31 |
+
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
| 32 |
+
- name: Build and push
|
| 33 |
+
uses: docker/build-push-action@v6
|
| 34 |
+
with:
|
| 35 |
+
context: .
|
| 36 |
+
platforms: |
|
| 37 |
+
linux/amd64
|
| 38 |
+
linux/arm64
|
| 39 |
+
push: true
|
| 40 |
+
build-args: |
|
| 41 |
+
VERSION=${{ env.VERSION }}
|
| 42 |
+
COMMIT=${{ env.COMMIT }}
|
| 43 |
+
BUILD_DATE=${{ env.BUILD_DATE }}
|
| 44 |
+
tags: |
|
| 45 |
+
${{ env.DOCKERHUB_REPO }}:latest
|
| 46 |
+
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
|
.github/workflows/pr-path-guard.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: translator-path-guard
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
types:
|
| 6 |
+
- opened
|
| 7 |
+
- synchronize
|
| 8 |
+
- reopened
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
ensure-no-translator-changes:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
with:
|
| 16 |
+
fetch-depth: 0
|
| 17 |
+
- name: Detect internal/translator changes
|
| 18 |
+
id: changed-files
|
| 19 |
+
uses: tj-actions/changed-files@v45
|
| 20 |
+
with:
|
| 21 |
+
files: |
|
| 22 |
+
internal/translator/**
|
| 23 |
+
- name: Fail when restricted paths change
|
| 24 |
+
if: steps.changed-files.outputs.any_changed == 'true'
|
| 25 |
+
run: |
|
| 26 |
+
echo "Changes under internal/translator are not allowed in pull requests."
|
| 27 |
+
echo "You need to create an issue for our maintenance team to make the necessary changes."
|
| 28 |
+
exit 1
|
.github/workflows/pr-test-build.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: pr-test-build
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
|
| 6 |
+
permissions:
|
| 7 |
+
contents: read
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
build:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout
|
| 14 |
+
uses: actions/checkout@v4
|
| 15 |
+
- name: Set up Go
|
| 16 |
+
uses: actions/setup-go@v5
|
| 17 |
+
with:
|
| 18 |
+
go-version-file: go.mod
|
| 19 |
+
cache: true
|
| 20 |
+
- name: Build
|
| 21 |
+
run: |
|
| 22 |
+
go build -o test-output ./cmd/server
|
| 23 |
+
rm -f test-output
|
.github/workflows/release.yaml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: goreleaser
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
# run only against tags
|
| 6 |
+
tags:
|
| 7 |
+
- '*'
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: write
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
goreleaser:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- uses: actions/checkout@v4
|
| 17 |
+
with:
|
| 18 |
+
fetch-depth: 0
|
| 19 |
+
- run: git fetch --force --tags
|
| 20 |
+
- uses: actions/setup-go@v4
|
| 21 |
+
with:
|
| 22 |
+
go-version: '>=1.24.0'
|
| 23 |
+
cache: true
|
| 24 |
+
- name: Generate Build Metadata
|
| 25 |
+
run: |
|
| 26 |
+
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
| 27 |
+
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
| 28 |
+
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
| 29 |
+
- uses: goreleaser/goreleaser-action@v4
|
| 30 |
+
with:
|
| 31 |
+
distribution: goreleaser
|
| 32 |
+
version: latest
|
| 33 |
+
args: release --clean
|
| 34 |
+
env:
|
| 35 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 36 |
+
VERSION: ${{ env.VERSION }}
|
| 37 |
+
COMMIT: ${{ env.COMMIT }}
|
| 38 |
+
BUILD_DATE: ${{ env.BUILD_DATE }}
|
.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Binaries
|
| 2 |
+
cli-proxy-api
|
| 3 |
+
*.exe
|
| 4 |
+
|
| 5 |
+
# Configuration
|
| 6 |
+
config.yaml
|
| 7 |
+
.env
|
| 8 |
+
|
| 9 |
+
# Generated content
|
| 10 |
+
bin/*
|
| 11 |
+
logs/*
|
| 12 |
+
conv/*
|
| 13 |
+
temp/*
|
| 14 |
+
refs/*
|
| 15 |
+
|
| 16 |
+
# Storage backends
|
| 17 |
+
pgstore/*
|
| 18 |
+
gitstore/*
|
| 19 |
+
objectstore/*
|
| 20 |
+
|
| 21 |
+
# Static assets
|
| 22 |
+
static/*
|
| 23 |
+
|
| 24 |
+
# Authentication data
|
| 25 |
+
auths/*
|
| 26 |
+
!auths/.gitkeep
|
| 27 |
+
|
| 28 |
+
# Documentation
|
| 29 |
+
docs/*
|
| 30 |
+
AGENTS.md
|
| 31 |
+
CLAUDE.md
|
| 32 |
+
GEMINI.md
|
| 33 |
+
|
| 34 |
+
# Tooling metadata
|
| 35 |
+
.vscode/*
|
| 36 |
+
.codex/*
|
| 37 |
+
.claude/*
|
| 38 |
+
.gemini/*
|
| 39 |
+
.serena/*
|
| 40 |
+
.agent/*
|
| 41 |
+
.agents/*
|
| 42 |
+
.agents/*
|
| 43 |
+
.opencode/*
|
| 44 |
+
.bmad/*
|
| 45 |
+
_bmad/*
|
| 46 |
+
_bmad-output/*
|
| 47 |
+
|
| 48 |
+
# macOS
|
| 49 |
+
.DS_Store
|
| 50 |
+
._*
|
.goreleaser.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
builds:
|
| 2 |
+
- id: "cli-proxy-api"
|
| 3 |
+
env:
|
| 4 |
+
- CGO_ENABLED=0
|
| 5 |
+
goos:
|
| 6 |
+
- linux
|
| 7 |
+
- windows
|
| 8 |
+
- darwin
|
| 9 |
+
goarch:
|
| 10 |
+
- amd64
|
| 11 |
+
- arm64
|
| 12 |
+
main: ./cmd/server/
|
| 13 |
+
binary: cli-proxy-api
|
| 14 |
+
ldflags:
|
| 15 |
+
- -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
|
| 16 |
+
archives:
|
| 17 |
+
- id: "cli-proxy-api"
|
| 18 |
+
format: tar.gz
|
| 19 |
+
format_overrides:
|
| 20 |
+
- goos: windows
|
| 21 |
+
format: zip
|
| 22 |
+
files:
|
| 23 |
+
- LICENSE
|
| 24 |
+
- README.md
|
| 25 |
+
- README_CN.md
|
| 26 |
+
- config.example.yaml
|
| 27 |
+
|
| 28 |
+
checksum:
|
| 29 |
+
name_template: 'checksums.txt'
|
| 30 |
+
|
| 31 |
+
snapshot:
|
| 32 |
+
name_template: "{{ incpatch .Version }}-next"
|
| 33 |
+
|
| 34 |
+
changelog:
|
| 35 |
+
sort: asc
|
| 36 |
+
filters:
|
| 37 |
+
exclude:
|
| 38 |
+
- '^docs:'
|
| 39 |
+
- '^test:'
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM golang:1.24-alpine AS builder
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY go.mod go.sum ./
|
| 6 |
+
|
| 7 |
+
RUN go mod download
|
| 8 |
+
|
| 9 |
+
COPY . .
|
| 10 |
+
|
| 11 |
+
ARG VERSION=dev
|
| 12 |
+
ARG COMMIT=none
|
| 13 |
+
ARG BUILD_DATE=unknown
|
| 14 |
+
|
| 15 |
+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
|
| 16 |
+
|
| 17 |
+
FROM alpine:3.22.0
|
| 18 |
+
|
| 19 |
+
RUN apk add --no-cache tzdata
|
| 20 |
+
|
| 21 |
+
RUN mkdir /CLIProxyAPI
|
| 22 |
+
|
| 23 |
+
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
|
| 24 |
+
|
| 25 |
+
COPY config.example.yaml /CLIProxyAPI/config.example.yaml
|
| 26 |
+
|
| 27 |
+
WORKDIR /CLIProxyAPI
|
| 28 |
+
|
| 29 |
+
EXPOSE 8317
|
| 30 |
+
|
| 31 |
+
ENV TZ=Asia/Shanghai
|
| 32 |
+
|
| 33 |
+
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
|
| 34 |
+
|
| 35 |
+
CMD ["./CLIProxyAPI"]
|
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025-2005.9 Luis Pater
|
| 4 |
+
Copyright (c) 2025.9-present Router-For.ME
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLI Proxy API
|
| 2 |
+
|
| 3 |
+
English | [中文](README_CN.md)
|
| 4 |
+
|
| 5 |
+
A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
|
| 6 |
+
|
| 7 |
+
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
|
| 8 |
+
|
| 9 |
+
So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
|
| 10 |
+
|
| 11 |
+
## Sponsor
|
| 12 |
+
|
| 13 |
+
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
| 14 |
+
|
| 15 |
+
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
| 16 |
+
|
| 17 |
+
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.7 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
| 18 |
+
|
| 19 |
+
Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
<table>
|
| 24 |
+
<tbody>
|
| 25 |
+
<tr>
|
| 26 |
+
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
| 27 |
+
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
|
| 28 |
+
</tr>
|
| 29 |
+
<tr>
|
| 30 |
+
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
|
| 31 |
+
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
|
| 32 |
+
</tr>
|
| 33 |
+
</tbody>
|
| 34 |
+
</table>
|
| 35 |
+
|
| 36 |
+
## Overview
|
| 37 |
+
|
| 38 |
+
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
|
| 39 |
+
- OpenAI Codex support (GPT models) via OAuth login
|
| 40 |
+
- Claude Code support via OAuth login
|
| 41 |
+
- Qwen Code support via OAuth login
|
| 42 |
+
- iFlow support via OAuth login
|
| 43 |
+
- Amp CLI and IDE extensions support with provider routing
|
| 44 |
+
- Streaming and non-streaming responses
|
| 45 |
+
- Function calling/tools support
|
| 46 |
+
- Multimodal input support (text and images)
|
| 47 |
+
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
|
| 48 |
+
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
|
| 49 |
+
- Generative Language API Key support
|
| 50 |
+
- AI Studio Build multi-account load balancing
|
| 51 |
+
- Gemini CLI multi-account load balancing
|
| 52 |
+
- Claude Code multi-account load balancing
|
| 53 |
+
- Qwen Code multi-account load balancing
|
| 54 |
+
- iFlow multi-account load balancing
|
| 55 |
+
- OpenAI Codex multi-account load balancing
|
| 56 |
+
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
| 57 |
+
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
|
| 58 |
+
|
| 59 |
+
## Getting Started
|
| 60 |
+
|
| 61 |
+
CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
|
| 62 |
+
|
| 63 |
+
## Management API
|
| 64 |
+
|
| 65 |
+
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
|
| 66 |
+
|
| 67 |
+
## Amp CLI Support
|
| 68 |
+
|
| 69 |
+
CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
|
| 70 |
+
|
| 71 |
+
- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
|
| 72 |
+
- Management proxy for OAuth authentication and account features
|
| 73 |
+
- Smart model fallback with automatic routing
|
| 74 |
+
- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
|
| 75 |
+
- Security-first design with localhost-only management endpoints
|
| 76 |
+
|
| 77 |
+
**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
|
| 78 |
+
|
| 79 |
+
## SDK Docs
|
| 80 |
+
|
| 81 |
+
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
|
| 82 |
+
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
|
| 83 |
+
- Access: [docs/sdk-access.md](docs/sdk-access.md)
|
| 84 |
+
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
|
| 85 |
+
- Custom Provider Example: `examples/custom-provider`
|
| 86 |
+
|
| 87 |
+
## Contributing
|
| 88 |
+
|
| 89 |
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
| 90 |
+
|
| 91 |
+
1. Fork the repository
|
| 92 |
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
| 93 |
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
| 94 |
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
| 95 |
+
5. Open a Pull Request
|
| 96 |
+
|
| 97 |
+
## Who is with us?
|
| 98 |
+
|
| 99 |
+
Those projects are based on CLIProxyAPI:
|
| 100 |
+
|
| 101 |
+
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
| 102 |
+
|
| 103 |
+
Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
|
| 104 |
+
|
| 105 |
+
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
| 106 |
+
|
| 107 |
+
Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
|
| 108 |
+
|
| 109 |
+
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
| 110 |
+
|
| 111 |
+
CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
|
| 112 |
+
|
| 113 |
+
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
| 114 |
+
|
| 115 |
+
Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
|
| 116 |
+
|
| 117 |
+
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
| 118 |
+
|
| 119 |
+
Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
|
| 120 |
+
|
| 121 |
+
### [CodMate](https://github.com/loocor/CodMate)
|
| 122 |
+
|
| 123 |
+
Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
|
| 124 |
+
|
| 125 |
+
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
| 126 |
+
|
| 127 |
+
Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
|
| 128 |
+
|
| 129 |
+
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
|
| 130 |
+
|
| 131 |
+
VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.
|
| 132 |
+
|
| 133 |
+
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
|
| 134 |
+
|
| 135 |
+
Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas via CLIProxyAPI. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed.
|
| 136 |
+
|
| 137 |
+
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
|
| 138 |
+
|
| 139 |
+
A lightweight web admin panel for CLIProxyAPI with health checks, resource monitoring, real-time logs, auto-update, request statistics and pricing display. Supports one-click installation and systemd service.
|
| 140 |
+
|
| 141 |
+
> [!NOTE]
|
| 142 |
+
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
| 143 |
+
|
| 144 |
+
## More choices
|
| 145 |
+
|
| 146 |
+
Those projects are ports of CLIProxyAPI or inspired by it:
|
| 147 |
+
|
| 148 |
+
### [9Router](https://github.com/decolua/9router)
|
| 149 |
+
|
| 150 |
+
A Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.
|
| 151 |
+
|
| 152 |
+
> [!NOTE]
|
| 153 |
+
> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
|
| 154 |
+
|
| 155 |
+
## License
|
| 156 |
+
|
| 157 |
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
README_CN.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLI 代理 API
|
| 2 |
+
|
| 3 |
+
[English](README.md) | 中文
|
| 4 |
+
|
| 5 |
+
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
|
| 6 |
+
|
| 7 |
+
现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
|
| 8 |
+
|
| 9 |
+
您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
|
| 10 |
+
|
| 11 |
+
## 赞助商
|
| 12 |
+
|
| 13 |
+
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
| 14 |
+
|
| 15 |
+
本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
|
| 16 |
+
|
| 17 |
+
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7,为开发者提供顶尖的编码体验。
|
| 18 |
+
|
| 19 |
+
智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
<table>
|
| 24 |
+
<tbody>
|
| 25 |
+
<tr>
|
| 26 |
+
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
| 27 |
+
<td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
|
| 28 |
+
</tr>
|
| 29 |
+
<tr>
|
| 30 |
+
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
|
| 31 |
+
<td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
|
| 32 |
+
</tr>
|
| 33 |
+
</tbody>
|
| 34 |
+
</table>
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
## 功能特性
|
| 38 |
+
|
| 39 |
+
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
| 40 |
+
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
| 41 |
+
- 新增 Claude Code 支持(OAuth 登录)
|
| 42 |
+
- 新增 Qwen Code 支持(OAuth 登录)
|
| 43 |
+
- 新增 iFlow 支持(OAuth 登录)
|
| 44 |
+
- 支持流式与非流式响应
|
| 45 |
+
- 函数调用/工具支持
|
| 46 |
+
- 多模态输入(文本、图片)
|
| 47 |
+
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
| 48 |
+
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
| 49 |
+
- 支持 Gemini AIStudio API 密钥
|
| 50 |
+
- 支持 AI Studio Build 多账户轮询
|
| 51 |
+
- 支持 Gemini CLI 多账户轮询
|
| 52 |
+
- 支持 Claude Code 多账户轮询
|
| 53 |
+
- 支持 Qwen Code 多账户轮询
|
| 54 |
+
- 支持 iFlow 多账户轮询
|
| 55 |
+
- 支持 OpenAI Codex 多账户轮询
|
| 56 |
+
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
| 57 |
+
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
|
| 58 |
+
|
| 59 |
+
## 新手入门
|
| 60 |
+
|
| 61 |
+
CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
|
| 62 |
+
|
| 63 |
+
## 管理 API 文档
|
| 64 |
+
|
| 65 |
+
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
|
| 66 |
+
|
| 67 |
+
## Amp CLI 支持
|
| 68 |
+
|
| 69 |
+
CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
|
| 70 |
+
|
| 71 |
+
- 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
|
| 72 |
+
- 管理代理,处理 OAuth 认证和账号功能
|
| 73 |
+
- 智能模型回退与自动路由
|
| 74 |
+
- 以安全为先的设计,管理端点仅限 localhost
|
| 75 |
+
|
| 76 |
+
**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
|
| 77 |
+
|
| 78 |
+
## SDK 文档
|
| 79 |
+
|
| 80 |
+
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
|
| 81 |
+
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
|
| 82 |
+
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
|
| 83 |
+
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
|
| 84 |
+
- 自定义 Provider 示例:`examples/custom-provider`
|
| 85 |
+
|
| 86 |
+
## 贡献
|
| 87 |
+
|
| 88 |
+
欢迎贡献!请随时提交 Pull Request。
|
| 89 |
+
|
| 90 |
+
1. Fork 仓库
|
| 91 |
+
2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
|
| 92 |
+
3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
|
| 93 |
+
4. 推送到分支(`git push origin feature/amazing-feature`)
|
| 94 |
+
5. 打开 Pull Request
|
| 95 |
+
|
| 96 |
+
## 谁与我们在一起?
|
| 97 |
+
|
| 98 |
+
这些项目基于 CLIProxyAPI:
|
| 99 |
+
|
| 100 |
+
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
| 101 |
+
|
| 102 |
+
一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
|
| 103 |
+
|
| 104 |
+
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
| 105 |
+
|
| 106 |
+
一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
|
| 107 |
+
|
| 108 |
+
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
| 109 |
+
|
| 110 |
+
CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
|
| 111 |
+
|
| 112 |
+
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
| 113 |
+
|
| 114 |
+
基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。
|
| 115 |
+
|
| 116 |
+
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
| 117 |
+
|
| 118 |
+
原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
|
| 119 |
+
|
| 120 |
+
### [CodMate](https://github.com/loocor/CodMate)
|
| 121 |
+
|
| 122 |
+
原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
|
| 123 |
+
|
| 124 |
+
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
| 125 |
+
|
| 126 |
+
原生 Windows CLIProxyAPI 分支,集成 TUI、系统托盘及多服务商 OAuth 认证,专为 AI 编程工具打造,无需 API 密钥。
|
| 127 |
+
|
| 128 |
+
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
|
| 129 |
+
|
| 130 |
+
一款 VSCode 扩展,提供了在 VSCode 中快速切换 Claude Code 模型的功能,内置 CLIProxyAPI 作为其后端,支持后台自动启动和关闭。
|
| 131 |
+
|
| 132 |
+
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
|
| 133 |
+
|
| 134 |
+
Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI 监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。
|
| 135 |
+
|
| 136 |
+
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
|
| 137 |
+
|
| 138 |
+
面向 CLIProxyAPI 的 Web 管理面板,提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示,支持一键安装与 systemd 服务。
|
| 139 |
+
|
| 140 |
+
> [!NOTE]
|
| 141 |
+
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
| 142 |
+
|
| 143 |
+
## 更多选择
|
| 144 |
+
|
| 145 |
+
以下项目是 CLIProxyAPI 的移植版或受其启发:
|
| 146 |
+
|
| 147 |
+
### [9Router](https://github.com/decolua/9router)
|
| 148 |
+
|
| 149 |
+
基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
|
| 150 |
+
|
| 151 |
+
> [!NOTE]
|
| 152 |
+
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
|
| 153 |
+
|
| 154 |
+
## 许可证
|
| 155 |
+
|
| 156 |
+
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
| 157 |
+
|
| 158 |
+
## 写给所有中国网友的
|
| 159 |
+
|
| 160 |
+
QQ 群:188637136
|
| 161 |
+
|
| 162 |
+
或
|
| 163 |
+
|
| 164 |
+
Telegram 群:https://t.me/CLIProxyAPI
|
assets/cubence.png
ADDED
|
assets/packycode.png
ADDED
|
auths/.gitkeep
ADDED
|
File without changes
|
cmd/server/main.go
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package main provides the entry point for the CLI Proxy API server.
|
| 2 |
+
// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
|
| 3 |
+
// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
|
| 4 |
+
package main
|
| 5 |
+
|
| 6 |
+
import (
|
| 7 |
+
"context"
|
| 8 |
+
"errors"
|
| 9 |
+
"flag"
|
| 10 |
+
"fmt"
|
| 11 |
+
"io/fs"
|
| 12 |
+
"net/url"
|
| 13 |
+
"os"
|
| 14 |
+
"path/filepath"
|
| 15 |
+
"strings"
|
| 16 |
+
"time"
|
| 17 |
+
|
| 18 |
+
"github.com/joho/godotenv"
|
| 19 |
+
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
| 20 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
| 21 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
|
| 22 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 23 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
| 24 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
| 25 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
| 26 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
|
| 27 |
+
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
| 28 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
| 29 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 30 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 31 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 32 |
+
log "github.com/sirupsen/logrus"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
var (
|
| 36 |
+
Version = "dev"
|
| 37 |
+
Commit = "none"
|
| 38 |
+
BuildDate = "unknown"
|
| 39 |
+
DefaultConfigPath = ""
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
// init initializes the shared logger setup.
|
| 43 |
+
func init() {
|
| 44 |
+
logging.SetupBaseLogger()
|
| 45 |
+
buildinfo.Version = Version
|
| 46 |
+
buildinfo.Commit = Commit
|
| 47 |
+
buildinfo.BuildDate = BuildDate
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// main is the entry point of the application.
|
| 51 |
+
// It parses command-line flags, loads configuration, and starts the appropriate
|
| 52 |
+
// service based on the provided flags (login, codex-login, or server mode).
|
| 53 |
+
func main() {
|
| 54 |
+
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
| 55 |
+
|
| 56 |
+
// Command-line flags to control the application's behavior.
|
| 57 |
+
var login bool
|
| 58 |
+
var codexLogin bool
|
| 59 |
+
var claudeLogin bool
|
| 60 |
+
var qwenLogin bool
|
| 61 |
+
var iflowLogin bool
|
| 62 |
+
var iflowCookie bool
|
| 63 |
+
var noBrowser bool
|
| 64 |
+
var oauthCallbackPort int
|
| 65 |
+
var antigravityLogin bool
|
| 66 |
+
var projectID string
|
| 67 |
+
var vertexImport string
|
| 68 |
+
var configPath string
|
| 69 |
+
var password string
|
| 70 |
+
|
| 71 |
+
// Define command-line flags for different operation modes.
|
| 72 |
+
flag.BoolVar(&login, "login", false, "Login Google Account")
|
| 73 |
+
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
| 74 |
+
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
| 75 |
+
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
|
| 76 |
+
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
| 77 |
+
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
| 78 |
+
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
| 79 |
+
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
|
| 80 |
+
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
| 81 |
+
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
| 82 |
+
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
| 83 |
+
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
| 84 |
+
flag.StringVar(&password, "password", "", "")
|
| 85 |
+
|
| 86 |
+
flag.CommandLine.Usage = func() {
|
| 87 |
+
out := flag.CommandLine.Output()
|
| 88 |
+
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
|
| 89 |
+
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
| 90 |
+
if f.Name == "password" {
|
| 91 |
+
return
|
| 92 |
+
}
|
| 93 |
+
s := fmt.Sprintf(" -%s", f.Name)
|
| 94 |
+
name, unquoteUsage := flag.UnquoteUsage(f)
|
| 95 |
+
if name != "" {
|
| 96 |
+
s += " " + name
|
| 97 |
+
}
|
| 98 |
+
if len(s) <= 4 {
|
| 99 |
+
s += " "
|
| 100 |
+
} else {
|
| 101 |
+
s += "\n "
|
| 102 |
+
}
|
| 103 |
+
if unquoteUsage != "" {
|
| 104 |
+
s += unquoteUsage
|
| 105 |
+
}
|
| 106 |
+
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
|
| 107 |
+
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
| 108 |
+
}
|
| 109 |
+
_, _ = fmt.Fprint(out, s+"\n")
|
| 110 |
+
})
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Parse the command-line flags.
|
| 114 |
+
flag.Parse()
|
| 115 |
+
|
| 116 |
+
// Core application variables.
|
| 117 |
+
var err error
|
| 118 |
+
var cfg *config.Config
|
| 119 |
+
var isCloudDeploy bool
|
| 120 |
+
var (
|
| 121 |
+
usePostgresStore bool
|
| 122 |
+
pgStoreDSN string
|
| 123 |
+
pgStoreSchema string
|
| 124 |
+
pgStoreLocalPath string
|
| 125 |
+
pgStoreInst *store.PostgresStore
|
| 126 |
+
useGitStore bool
|
| 127 |
+
gitStoreRemoteURL string
|
| 128 |
+
gitStoreUser string
|
| 129 |
+
gitStorePassword string
|
| 130 |
+
gitStoreLocalPath string
|
| 131 |
+
gitStoreInst *store.GitTokenStore
|
| 132 |
+
gitStoreRoot string
|
| 133 |
+
useObjectStore bool
|
| 134 |
+
objectStoreEndpoint string
|
| 135 |
+
objectStoreAccess string
|
| 136 |
+
objectStoreSecret string
|
| 137 |
+
objectStoreBucket string
|
| 138 |
+
objectStoreLocalPath string
|
| 139 |
+
objectStoreInst *store.ObjectTokenStore
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
wd, err := os.Getwd()
|
| 143 |
+
if err != nil {
|
| 144 |
+
log.Errorf("failed to get working directory: %v", err)
|
| 145 |
+
return
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Load environment variables from .env if present.
|
| 149 |
+
if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
|
| 150 |
+
if !errors.Is(errLoad, os.ErrNotExist) {
|
| 151 |
+
log.WithError(errLoad).Warn("failed to load .env file")
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
lookupEnv := func(keys ...string) (string, bool) {
|
| 156 |
+
for _, key := range keys {
|
| 157 |
+
if value, ok := os.LookupEnv(key); ok {
|
| 158 |
+
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
| 159 |
+
return trimmed, true
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
return "", false
|
| 164 |
+
}
|
| 165 |
+
writableBase := util.WritablePath()
|
| 166 |
+
if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
|
| 167 |
+
usePostgresStore = true
|
| 168 |
+
pgStoreDSN = value
|
| 169 |
+
}
|
| 170 |
+
if usePostgresStore {
|
| 171 |
+
if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
|
| 172 |
+
pgStoreSchema = value
|
| 173 |
+
}
|
| 174 |
+
if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
|
| 175 |
+
pgStoreLocalPath = value
|
| 176 |
+
}
|
| 177 |
+
if pgStoreLocalPath == "" {
|
| 178 |
+
if writableBase != "" {
|
| 179 |
+
pgStoreLocalPath = writableBase
|
| 180 |
+
} else {
|
| 181 |
+
pgStoreLocalPath = wd
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
useGitStore = false
|
| 185 |
+
}
|
| 186 |
+
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
|
| 187 |
+
useGitStore = true
|
| 188 |
+
gitStoreRemoteURL = value
|
| 189 |
+
}
|
| 190 |
+
if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
|
| 191 |
+
gitStoreUser = value
|
| 192 |
+
}
|
| 193 |
+
if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
|
| 194 |
+
gitStorePassword = value
|
| 195 |
+
}
|
| 196 |
+
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
|
| 197 |
+
gitStoreLocalPath = value
|
| 198 |
+
}
|
| 199 |
+
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
|
| 200 |
+
useObjectStore = true
|
| 201 |
+
objectStoreEndpoint = value
|
| 202 |
+
}
|
| 203 |
+
if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
|
| 204 |
+
objectStoreAccess = value
|
| 205 |
+
}
|
| 206 |
+
if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
|
| 207 |
+
objectStoreSecret = value
|
| 208 |
+
}
|
| 209 |
+
if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
|
| 210 |
+
objectStoreBucket = value
|
| 211 |
+
}
|
| 212 |
+
if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
|
| 213 |
+
objectStoreLocalPath = value
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Check for cloud deploy mode only on first execution
|
| 217 |
+
// Read env var name in uppercase: DEPLOY
|
| 218 |
+
deployEnv := os.Getenv("DEPLOY")
|
| 219 |
+
if deployEnv == "cloud" {
|
| 220 |
+
isCloudDeploy = true
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// Determine and load the configuration file.
|
| 224 |
+
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
|
| 225 |
+
var configFilePath string
|
| 226 |
+
if usePostgresStore {
|
| 227 |
+
if pgStoreLocalPath == "" {
|
| 228 |
+
pgStoreLocalPath = wd
|
| 229 |
+
}
|
| 230 |
+
pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
|
| 231 |
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
| 232 |
+
pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
|
| 233 |
+
DSN: pgStoreDSN,
|
| 234 |
+
Schema: pgStoreSchema,
|
| 235 |
+
SpoolDir: pgStoreLocalPath,
|
| 236 |
+
})
|
| 237 |
+
cancel()
|
| 238 |
+
if err != nil {
|
| 239 |
+
log.Errorf("failed to initialize postgres token store: %v", err)
|
| 240 |
+
return
|
| 241 |
+
}
|
| 242 |
+
examplePath := filepath.Join(wd, "config.example.yaml")
|
| 243 |
+
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
| 244 |
+
if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
| 245 |
+
cancel()
|
| 246 |
+
log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
cancel()
|
| 250 |
+
configFilePath = pgStoreInst.ConfigPath()
|
| 251 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 252 |
+
if err == nil {
|
| 253 |
+
cfg.AuthDir = pgStoreInst.AuthDir()
|
| 254 |
+
log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
|
| 255 |
+
}
|
| 256 |
+
} else if useObjectStore {
|
| 257 |
+
if objectStoreLocalPath == "" {
|
| 258 |
+
if writableBase != "" {
|
| 259 |
+
objectStoreLocalPath = writableBase
|
| 260 |
+
} else {
|
| 261 |
+
objectStoreLocalPath = wd
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
|
| 265 |
+
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
|
| 266 |
+
useSSL := true
|
| 267 |
+
if strings.Contains(resolvedEndpoint, "://") {
|
| 268 |
+
parsed, errParse := url.Parse(resolvedEndpoint)
|
| 269 |
+
if errParse != nil {
|
| 270 |
+
log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
|
| 271 |
+
return
|
| 272 |
+
}
|
| 273 |
+
switch strings.ToLower(parsed.Scheme) {
|
| 274 |
+
case "http":
|
| 275 |
+
useSSL = false
|
| 276 |
+
case "https":
|
| 277 |
+
useSSL = true
|
| 278 |
+
default:
|
| 279 |
+
log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
|
| 280 |
+
return
|
| 281 |
+
}
|
| 282 |
+
if parsed.Host == "" {
|
| 283 |
+
log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
|
| 284 |
+
return
|
| 285 |
+
}
|
| 286 |
+
resolvedEndpoint = parsed.Host
|
| 287 |
+
if parsed.Path != "" && parsed.Path != "/" {
|
| 288 |
+
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
|
| 292 |
+
objCfg := store.ObjectStoreConfig{
|
| 293 |
+
Endpoint: resolvedEndpoint,
|
| 294 |
+
Bucket: objectStoreBucket,
|
| 295 |
+
AccessKey: objectStoreAccess,
|
| 296 |
+
SecretKey: objectStoreSecret,
|
| 297 |
+
LocalRoot: objectStoreRoot,
|
| 298 |
+
UseSSL: useSSL,
|
| 299 |
+
PathStyle: true,
|
| 300 |
+
}
|
| 301 |
+
objectStoreInst, err = store.NewObjectTokenStore(objCfg)
|
| 302 |
+
if err != nil {
|
| 303 |
+
log.Errorf("failed to initialize object token store: %v", err)
|
| 304 |
+
return
|
| 305 |
+
}
|
| 306 |
+
examplePath := filepath.Join(wd, "config.example.yaml")
|
| 307 |
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
| 308 |
+
if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
| 309 |
+
cancel()
|
| 310 |
+
log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
|
| 311 |
+
return
|
| 312 |
+
}
|
| 313 |
+
cancel()
|
| 314 |
+
configFilePath = objectStoreInst.ConfigPath()
|
| 315 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 316 |
+
if err == nil {
|
| 317 |
+
if cfg == nil {
|
| 318 |
+
cfg = &config.Config{}
|
| 319 |
+
}
|
| 320 |
+
cfg.AuthDir = objectStoreInst.AuthDir()
|
| 321 |
+
log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
|
| 322 |
+
}
|
| 323 |
+
} else if useGitStore {
|
| 324 |
+
if gitStoreLocalPath == "" {
|
| 325 |
+
if writableBase != "" {
|
| 326 |
+
gitStoreLocalPath = writableBase
|
| 327 |
+
} else {
|
| 328 |
+
gitStoreLocalPath = wd
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
|
| 332 |
+
authDir := filepath.Join(gitStoreRoot, "auths")
|
| 333 |
+
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
|
| 334 |
+
gitStoreInst.SetBaseDir(authDir)
|
| 335 |
+
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
|
| 336 |
+
log.Errorf("failed to prepare git token store: %v", errRepo)
|
| 337 |
+
return
|
| 338 |
+
}
|
| 339 |
+
configFilePath = gitStoreInst.ConfigPath()
|
| 340 |
+
if configFilePath == "" {
|
| 341 |
+
configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
|
| 342 |
+
}
|
| 343 |
+
if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
|
| 344 |
+
examplePath := filepath.Join(wd, "config.example.yaml")
|
| 345 |
+
if _, errExample := os.Stat(examplePath); errExample != nil {
|
| 346 |
+
log.Errorf("failed to find template config file: %v", errExample)
|
| 347 |
+
return
|
| 348 |
+
}
|
| 349 |
+
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
|
| 350 |
+
log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
|
| 351 |
+
return
|
| 352 |
+
}
|
| 353 |
+
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
|
| 354 |
+
log.Errorf("failed to commit initial git-backed config: %v", errCommit)
|
| 355 |
+
return
|
| 356 |
+
}
|
| 357 |
+
log.Infof("git-backed config initialized from template: %s", configFilePath)
|
| 358 |
+
} else if statErr != nil {
|
| 359 |
+
log.Errorf("failed to inspect git-backed config: %v", statErr)
|
| 360 |
+
return
|
| 361 |
+
}
|
| 362 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 363 |
+
if err == nil {
|
| 364 |
+
cfg.AuthDir = gitStoreInst.AuthDir()
|
| 365 |
+
log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
|
| 366 |
+
}
|
| 367 |
+
} else if configPath != "" {
|
| 368 |
+
configFilePath = configPath
|
| 369 |
+
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
|
| 370 |
+
} else {
|
| 371 |
+
wd, err = os.Getwd()
|
| 372 |
+
if err != nil {
|
| 373 |
+
log.Errorf("failed to get working directory: %v", err)
|
| 374 |
+
return
|
| 375 |
+
}
|
| 376 |
+
configFilePath = filepath.Join(wd, "config.yaml")
|
| 377 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 378 |
+
}
|
| 379 |
+
if err != nil {
|
| 380 |
+
log.Errorf("failed to load config: %v", err)
|
| 381 |
+
return
|
| 382 |
+
}
|
| 383 |
+
if cfg == nil {
|
| 384 |
+
cfg = &config.Config{}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// In cloud deploy mode, check if we have a valid configuration
|
| 388 |
+
var configFileExists bool
|
| 389 |
+
if isCloudDeploy {
|
| 390 |
+
if info, errStat := os.Stat(configFilePath); errStat != nil {
|
| 391 |
+
// Don't mislead: API server will not start until configuration is provided.
|
| 392 |
+
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
|
| 393 |
+
configFileExists = false
|
| 394 |
+
} else if info.IsDir() {
|
| 395 |
+
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
|
| 396 |
+
configFileExists = false
|
| 397 |
+
} else if cfg.Port == 0 {
|
| 398 |
+
// LoadConfigOptional returns empty config when file is empty or invalid.
|
| 399 |
+
// Config file exists but is empty or invalid; treat as missing config
|
| 400 |
+
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
|
| 401 |
+
configFileExists = false
|
| 402 |
+
} else {
|
| 403 |
+
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
| 404 |
+
configFileExists = true
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
| 408 |
+
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
| 409 |
+
|
| 410 |
+
if err = logging.ConfigureLogOutput(cfg); err != nil {
|
| 411 |
+
log.Errorf("failed to configure log output: %v", err)
|
| 412 |
+
return
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
| 416 |
+
|
| 417 |
+
// Set the log level based on the configuration.
|
| 418 |
+
util.SetLogLevel(cfg)
|
| 419 |
+
|
| 420 |
+
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
| 421 |
+
log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
|
| 422 |
+
return
|
| 423 |
+
} else {
|
| 424 |
+
cfg.AuthDir = resolvedAuthDir
|
| 425 |
+
}
|
| 426 |
+
managementasset.SetCurrentConfig(cfg)
|
| 427 |
+
|
| 428 |
+
// Create login options to be used in authentication flows.
|
| 429 |
+
options := &cmd.LoginOptions{
|
| 430 |
+
NoBrowser: noBrowser,
|
| 431 |
+
CallbackPort: oauthCallbackPort,
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// Register the shared token store once so all components use the same persistence backend.
|
| 435 |
+
if usePostgresStore {
|
| 436 |
+
sdkAuth.RegisterTokenStore(pgStoreInst)
|
| 437 |
+
} else if useObjectStore {
|
| 438 |
+
sdkAuth.RegisterTokenStore(objectStoreInst)
|
| 439 |
+
} else if useGitStore {
|
| 440 |
+
sdkAuth.RegisterTokenStore(gitStoreInst)
|
| 441 |
+
} else {
|
| 442 |
+
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// Register built-in access providers before constructing services.
|
| 446 |
+
configaccess.Register()
|
| 447 |
+
|
| 448 |
+
// Handle different command modes based on the provided flags.
|
| 449 |
+
|
| 450 |
+
if vertexImport != "" {
|
| 451 |
+
// Handle Vertex service account import
|
| 452 |
+
cmd.DoVertexImport(cfg, vertexImport)
|
| 453 |
+
} else if login {
|
| 454 |
+
// Handle Google/Gemini login
|
| 455 |
+
cmd.DoLogin(cfg, projectID, options)
|
| 456 |
+
} else if antigravityLogin {
|
| 457 |
+
// Handle Antigravity login
|
| 458 |
+
cmd.DoAntigravityLogin(cfg, options)
|
| 459 |
+
} else if codexLogin {
|
| 460 |
+
// Handle Codex login
|
| 461 |
+
cmd.DoCodexLogin(cfg, options)
|
| 462 |
+
} else if claudeLogin {
|
| 463 |
+
// Handle Claude login
|
| 464 |
+
cmd.DoClaudeLogin(cfg, options)
|
| 465 |
+
} else if qwenLogin {
|
| 466 |
+
cmd.DoQwenLogin(cfg, options)
|
| 467 |
+
} else if iflowLogin {
|
| 468 |
+
cmd.DoIFlowLogin(cfg, options)
|
| 469 |
+
} else if iflowCookie {
|
| 470 |
+
cmd.DoIFlowCookieAuth(cfg, options)
|
| 471 |
+
} else {
|
| 472 |
+
// In cloud deploy mode without config file, just wait for shutdown signals
|
| 473 |
+
if isCloudDeploy && !configFileExists {
|
| 474 |
+
// No config file available, just wait for shutdown
|
| 475 |
+
cmd.WaitForCloudDeploy()
|
| 476 |
+
return
|
| 477 |
+
}
|
| 478 |
+
// Start the main proxy service
|
| 479 |
+
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
| 480 |
+
cmd.StartService(cfg, configFilePath, password)
|
| 481 |
+
}
|
| 482 |
+
}
|
config.example.yaml
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Server host/interface to bind to. Default is empty ("") to bind all interfaces (IPv4 + IPv6).
|
| 2 |
+
# Use "127.0.0.1" or "localhost" to restrict access to local machine only.
|
| 3 |
+
host: ""
|
| 4 |
+
|
| 5 |
+
# Server port
|
| 6 |
+
port: 8317
|
| 7 |
+
|
| 8 |
+
# TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key.
|
| 9 |
+
tls:
|
| 10 |
+
enable: false
|
| 11 |
+
cert: ""
|
| 12 |
+
key: ""
|
| 13 |
+
|
| 14 |
+
# Management API settings
|
| 15 |
+
remote-management:
|
| 16 |
+
# Whether to allow remote (non-localhost) management access.
|
| 17 |
+
# When false, only localhost can access management endpoints (a key is still required).
|
| 18 |
+
allow-remote: false
|
| 19 |
+
|
| 20 |
+
# Management key. If a plaintext value is provided here, it will be hashed on startup.
|
| 21 |
+
# All management requests (even from localhost) require this key.
|
| 22 |
+
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
|
| 23 |
+
secret-key: ""
|
| 24 |
+
|
| 25 |
+
# Disable the bundled management control panel asset download and HTTP route when true.
|
| 26 |
+
disable-control-panel: false
|
| 27 |
+
|
| 28 |
+
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
|
| 29 |
+
panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
| 30 |
+
|
| 31 |
+
# Authentication directory (supports ~ for home directory)
|
| 32 |
+
auth-dir: "~/.cli-proxy-api"
|
| 33 |
+
|
| 34 |
+
# API keys for authentication
|
| 35 |
+
api-keys:
|
| 36 |
+
- "your-api-key-1"
|
| 37 |
+
- "your-api-key-2"
|
| 38 |
+
- "your-api-key-3"
|
| 39 |
+
|
| 40 |
+
# Enable debug logging
|
| 41 |
+
debug: false
|
| 42 |
+
|
| 43 |
+
# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
|
| 44 |
+
commercial-mode: false
|
| 45 |
+
|
| 46 |
+
# When true, write application logs to rotating files instead of stdout
|
| 47 |
+
logging-to-file: false
|
| 48 |
+
|
| 49 |
+
# Maximum total size (MB) of log files under the logs directory. When exceeded, the oldest log
|
| 50 |
+
# files are deleted until within the limit. Set to 0 to disable.
|
| 51 |
+
logs-max-total-size-mb: 0
|
| 52 |
+
|
| 53 |
+
# When false, disable in-memory usage statistics aggregation
|
| 54 |
+
usage-statistics-enabled: false
|
| 55 |
+
|
| 56 |
+
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
| 57 |
+
proxy-url: ""
|
| 58 |
+
|
| 59 |
+
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
|
| 60 |
+
force-model-prefix: false
|
| 61 |
+
|
| 62 |
+
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
|
| 63 |
+
request-retry: 3
|
| 64 |
+
|
| 65 |
+
# Maximum wait time in seconds for a cooled-down credential before triggering a retry.
|
| 66 |
+
max-retry-interval: 30
|
| 67 |
+
|
| 68 |
+
# Quota exceeded behavior
|
| 69 |
+
quota-exceeded:
|
| 70 |
+
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
|
| 71 |
+
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
|
| 72 |
+
|
| 73 |
+
# Routing strategy for selecting credentials when multiple match.
|
| 74 |
+
routing:
|
| 75 |
+
strategy: "round-robin" # round-robin (default), fill-first
|
| 76 |
+
|
| 77 |
+
# When true, enable authentication for the WebSocket API (/v1/ws).
|
| 78 |
+
ws-auth: false
|
| 79 |
+
|
| 80 |
+
# When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.
|
| 81 |
+
nonstream-keepalive-interval: 0
|
| 82 |
+
|
| 83 |
+
# Streaming behavior (SSE keep-alives + safe bootstrap retries).
|
| 84 |
+
# streaming:
|
| 85 |
+
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
|
| 86 |
+
# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
|
| 87 |
+
|
| 88 |
+
# When true, enable official Codex instructions injection for Codex API requests.
|
| 89 |
+
# When false (default), CodexInstructionsForModel returns immediately without modification.
|
| 90 |
+
codex-instructions-enabled: false
|
| 91 |
+
|
| 92 |
+
# Gemini API keys
|
| 93 |
+
# gemini-api-key:
|
| 94 |
+
# - api-key: "AIzaSy...01"
|
| 95 |
+
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
|
| 96 |
+
# base-url: "https://generativelanguage.googleapis.com"
|
| 97 |
+
# headers:
|
| 98 |
+
# X-Custom-Header: "custom-value"
|
| 99 |
+
# proxy-url: "socks5://proxy.example.com:1080"
|
| 100 |
+
# models:
|
| 101 |
+
# - name: "gemini-2.5-flash" # upstream model name
|
| 102 |
+
# alias: "gemini-flash" # client alias mapped to the upstream model
|
| 103 |
+
# excluded-models:
|
| 104 |
+
# - "gemini-2.5-pro" # exclude specific models from this provider (exact match)
|
| 105 |
+
# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)
|
| 106 |
+
# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
|
| 107 |
+
# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
|
| 108 |
+
# - api-key: "AIzaSy...02"
|
| 109 |
+
|
| 110 |
+
# Codex API keys
|
| 111 |
+
# codex-api-key:
|
| 112 |
+
# - api-key: "sk-atSM..."
|
| 113 |
+
# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
|
| 114 |
+
# base-url: "https://www.example.com" # use the custom codex API endpoint
|
| 115 |
+
# headers:
|
| 116 |
+
# X-Custom-Header: "custom-value"
|
| 117 |
+
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
| 118 |
+
# models:
|
| 119 |
+
# - name: "gpt-5-codex" # upstream model name
|
| 120 |
+
# alias: "codex-latest" # client alias mapped to the upstream model
|
| 121 |
+
# excluded-models:
|
| 122 |
+
# - "gpt-5.1" # exclude specific models (exact match)
|
| 123 |
+
# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)
|
| 124 |
+
# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
|
| 125 |
+
# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)
|
| 126 |
+
|
| 127 |
+
# Claude API keys
|
| 128 |
+
# claude-api-key:
|
| 129 |
+
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
| 130 |
+
# - api-key: "sk-atSM..."
|
| 131 |
+
# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
|
| 132 |
+
# base-url: "https://www.example.com" # use the custom claude API endpoint
|
| 133 |
+
# headers:
|
| 134 |
+
# X-Custom-Header: "custom-value"
|
| 135 |
+
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
| 136 |
+
# models:
|
| 137 |
+
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
| 138 |
+
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
| 139 |
+
# excluded-models:
|
| 140 |
+
# - "claude-opus-4-5-20251101" # exclude specific models (exact match)
|
| 141 |
+
# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)
|
| 142 |
+
# - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
|
| 143 |
+
# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
|
| 144 |
+
# cloak: # optional: request cloaking for non-Claude-Code clients
|
| 145 |
+
# mode: "auto" # "auto" (default): cloak only when client is not Claude Code
|
| 146 |
+
# # "always": always apply cloaking
|
| 147 |
+
# # "never": never apply cloaking
|
| 148 |
+
# strict-mode: false # false (default): prepend Claude Code prompt to user system messages
|
| 149 |
+
# # true: strip all user system messages, keep only Claude Code prompt
|
| 150 |
+
# sensitive-words: # optional: words to obfuscate with zero-width characters
|
| 151 |
+
# - "API"
|
| 152 |
+
# - "proxy"
|
| 153 |
+
|
| 154 |
+
# OpenAI compatibility providers
|
| 155 |
+
# openai-compatibility:
|
| 156 |
+
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
| 157 |
+
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
|
| 158 |
+
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
| 159 |
+
# headers:
|
| 160 |
+
# X-Custom-Header: "custom-value"
|
| 161 |
+
# api-key-entries:
|
| 162 |
+
# - api-key: "sk-or-v1-...b780"
|
| 163 |
+
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
| 164 |
+
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
| 165 |
+
# models: # The models supported by the provider.
|
| 166 |
+
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
| 167 |
+
# alias: "kimi-k2" # The alias used in the API.
|
| 168 |
+
|
| 169 |
+
# Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
|
| 170 |
+
# vertex-api-key:
|
| 171 |
+
# - api-key: "vk-123..." # x-goog-api-key header
|
| 172 |
+
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
|
| 173 |
+
# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
|
| 174 |
+
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
|
| 175 |
+
# headers:
|
| 176 |
+
# X-Custom-Header: "custom-value"
|
| 177 |
+
# models: # optional: map aliases to upstream model names
|
| 178 |
+
# - name: "gemini-2.5-flash" # upstream model name
|
| 179 |
+
# alias: "vertex-flash" # client-visible alias
|
| 180 |
+
# - name: "gemini-2.5-pro"
|
| 181 |
+
# alias: "vertex-pro"
|
| 182 |
+
|
| 183 |
+
# Amp Integration
|
| 184 |
+
# ampcode:
|
| 185 |
+
# # Configure upstream URL for Amp CLI OAuth and management features
|
| 186 |
+
# upstream-url: "https://ampcode.com"
|
| 187 |
+
# # Optional: Override API key for Amp upstream (otherwise uses env or file)
|
| 188 |
+
# upstream-api-key: ""
|
| 189 |
+
# # Per-client upstream API key mapping
|
| 190 |
+
# # Maps client API keys (from top-level api-keys) to different Amp upstream API keys.
|
| 191 |
+
# # Useful when different clients need to use different Amp accounts/quotas.
|
| 192 |
+
# # If a client key isn't mapped, falls back to upstream-api-key (default behavior).
|
| 193 |
+
# upstream-api-keys:
|
| 194 |
+
# - upstream-api-key: "amp_key_for_team_a" # Upstream key to use for these clients
|
| 195 |
+
# api-keys: # Client keys that use this upstream key
|
| 196 |
+
# - "your-api-key-1"
|
| 197 |
+
# - "your-api-key-2"
|
| 198 |
+
# - upstream-api-key: "amp_key_for_team_b"
|
| 199 |
+
# api-keys:
|
| 200 |
+
# - "your-api-key-3"
|
| 201 |
+
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false)
|
| 202 |
+
# restrict-management-to-localhost: false
|
| 203 |
+
# # Force model mappings to run before checking local API keys (default: false)
|
| 204 |
+
# force-model-mappings: false
|
| 205 |
+
# # Amp Model Mappings
|
| 206 |
+
# # Route unavailable Amp models to alternative models available in your local proxy.
|
| 207 |
+
# # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
|
| 208 |
+
# # but you have a similar model available (e.g., Claude Sonnet 4).
|
| 209 |
+
# model-mappings:
|
| 210 |
+
# - from: "claude-opus-4-5-20251101" # Model requested by Amp CLI
|
| 211 |
+
# to: "gemini-claude-opus-4-5-thinking" # Route to this available model instead
|
| 212 |
+
# - from: "claude-sonnet-4-5-20250929"
|
| 213 |
+
# to: "gemini-claude-sonnet-4-5-thinking"
|
| 214 |
+
# - from: "claude-haiku-4-5-20251001"
|
| 215 |
+
# to: "gemini-2.5-flash"
|
| 216 |
+
|
| 217 |
+
# Global OAuth model name aliases (per channel)
|
| 218 |
+
# These aliases rename model IDs for both model listing and request routing.
|
| 219 |
+
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
| 220 |
+
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
| 221 |
+
# You can repeat the same name with different aliases to expose multiple client model names.
|
| 222 |
+
oauth-model-alias:
|
| 223 |
+
antigravity:
|
| 224 |
+
- name: "rev19-uic3-1p"
|
| 225 |
+
alias: "gemini-2.5-computer-use-preview-10-2025"
|
| 226 |
+
- name: "gemini-3-pro-image"
|
| 227 |
+
alias: "gemini-3-pro-image-preview"
|
| 228 |
+
- name: "gemini-3-pro-high"
|
| 229 |
+
alias: "gemini-3-pro-preview"
|
| 230 |
+
- name: "gemini-3-flash"
|
| 231 |
+
alias: "gemini-3-flash-preview"
|
| 232 |
+
- name: "claude-sonnet-4-5"
|
| 233 |
+
alias: "gemini-claude-sonnet-4-5"
|
| 234 |
+
- name: "claude-sonnet-4-5-thinking"
|
| 235 |
+
alias: "gemini-claude-sonnet-4-5-thinking"
|
| 236 |
+
- name: "claude-opus-4-5-thinking"
|
| 237 |
+
alias: "gemini-claude-opus-4-5-thinking"
|
| 238 |
+
# gemini-cli:
|
| 239 |
+
# - name: "gemini-2.5-pro" # original model name under this channel
|
| 240 |
+
# alias: "g2.5p" # client-visible alias
|
| 241 |
+
# fork: true # when true, keep original and also add the alias as an extra model (default: false)
|
| 242 |
+
# vertex:
|
| 243 |
+
# - name: "gemini-2.5-pro"
|
| 244 |
+
# alias: "g2.5p"
|
| 245 |
+
# aistudio:
|
| 246 |
+
# - name: "gemini-2.5-pro"
|
| 247 |
+
# alias: "g2.5p"
|
| 248 |
+
# claude:
|
| 249 |
+
# - name: "claude-sonnet-4-5-20250929"
|
| 250 |
+
# alias: "cs4.5"
|
| 251 |
+
# codex:
|
| 252 |
+
# - name: "gpt-5"
|
| 253 |
+
# alias: "g5"
|
| 254 |
+
# qwen:
|
| 255 |
+
# - name: "qwen3-coder-plus"
|
| 256 |
+
# alias: "qwen-plus"
|
| 257 |
+
# iflow:
|
| 258 |
+
# - name: "glm-4.7"
|
| 259 |
+
# alias: "glm-god"
|
| 260 |
+
|
| 261 |
+
# OAuth provider excluded models
|
| 262 |
+
# oauth-excluded-models:
|
| 263 |
+
# gemini-cli:
|
| 264 |
+
# - "gemini-2.5-pro" # exclude specific models (exact match)
|
| 265 |
+
# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)
|
| 266 |
+
# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
|
| 267 |
+
# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
|
| 268 |
+
# vertex:
|
| 269 |
+
# - "gemini-3-pro-preview"
|
| 270 |
+
# aistudio:
|
| 271 |
+
# - "gemini-3-pro-preview"
|
| 272 |
+
# antigravity:
|
| 273 |
+
# - "gemini-3-pro-preview"
|
| 274 |
+
# claude:
|
| 275 |
+
# - "claude-3-5-haiku-20241022"
|
| 276 |
+
# codex:
|
| 277 |
+
# - "gpt-5-codex-mini"
|
| 278 |
+
# qwen:
|
| 279 |
+
# - "vision-model"
|
| 280 |
+
# iflow:
|
| 281 |
+
# - "tstars2.0"
|
| 282 |
+
|
| 283 |
+
# Optional payload configuration
|
| 284 |
+
# payload:
|
| 285 |
+
# default: # Default rules only set parameters when they are missing in the payload.
|
| 286 |
+
# - models:
|
| 287 |
+
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
|
| 288 |
+
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
| 289 |
+
# params: # JSON path (gjson/sjson syntax) -> value
|
| 290 |
+
# "generationConfig.thinkingConfig.thinkingBudget": 32768
|
| 291 |
+
# default-raw: # Default raw rules set parameters using raw JSON when missing (must be valid JSON).
|
| 292 |
+
# - models:
|
| 293 |
+
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
|
| 294 |
+
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
| 295 |
+
# params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
|
| 296 |
+
# "generationConfig.responseJsonSchema": "{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}"
|
| 297 |
+
# override: # Override rules always set parameters, overwriting any existing values.
|
| 298 |
+
# - models:
|
| 299 |
+
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
|
| 300 |
+
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
| 301 |
+
# params: # JSON path (gjson/sjson syntax) -> value
|
| 302 |
+
# "reasoning.effort": "high"
|
| 303 |
+
# override-raw: # Override raw rules always set parameters using raw JSON (must be valid JSON).
|
| 304 |
+
# - models:
|
| 305 |
+
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
|
| 306 |
+
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
| 307 |
+
# params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
|
| 308 |
+
# "response_format": "{\"type\":\"json_schema\",\"json_schema\":{\"name\":\"answer\",\"schema\":{\"type\":\"object\"}}}"
|
docker-build.ps1
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# build.ps1 - Windows PowerShell Build Script
|
| 2 |
+
#
|
| 3 |
+
# This script automates the process of building and running the Docker container
|
| 4 |
+
# with version information dynamically injected at build time.
|
| 5 |
+
|
| 6 |
+
# Stop script execution on any error
|
| 7 |
+
$ErrorActionPreference = "Stop"
|
| 8 |
+
|
| 9 |
+
# --- Step 1: Choose Environment ---
|
| 10 |
+
Write-Host "Please select an option:"
|
| 11 |
+
Write-Host "1) Run using Pre-built Image (Recommended)"
|
| 12 |
+
Write-Host "2) Build from Source and Run (For Developers)"
|
| 13 |
+
$choice = Read-Host -Prompt "Enter choice [1-2]"
|
| 14 |
+
|
| 15 |
+
# --- Step 2: Execute based on choice ---
|
| 16 |
+
switch ($choice) {
|
| 17 |
+
"1" {
|
| 18 |
+
Write-Host "--- Running with Pre-built Image ---"
|
| 19 |
+
docker compose up -d --remove-orphans --no-build
|
| 20 |
+
Write-Host "Services are starting from remote image."
|
| 21 |
+
Write-Host "Run 'docker compose logs -f' to see the logs."
|
| 22 |
+
}
|
| 23 |
+
"2" {
|
| 24 |
+
Write-Host "--- Building from Source and Running ---"
|
| 25 |
+
|
| 26 |
+
# Get Version Information
|
| 27 |
+
$VERSION = (git describe --tags --always --dirty)
|
| 28 |
+
$COMMIT = (git rev-parse --short HEAD)
|
| 29 |
+
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
| 30 |
+
|
| 31 |
+
Write-Host "Building with the following info:"
|
| 32 |
+
Write-Host " Version: $VERSION"
|
| 33 |
+
Write-Host " Commit: $COMMIT"
|
| 34 |
+
Write-Host " Build Date: $BUILD_DATE"
|
| 35 |
+
Write-Host "----------------------------------------"
|
| 36 |
+
|
| 37 |
+
# Build and start the services with a local-only image tag
|
| 38 |
+
$env:CLI_PROXY_IMAGE = "cli-proxy-api:local"
|
| 39 |
+
|
| 40 |
+
Write-Host "Building the Docker image..."
|
| 41 |
+
docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
|
| 42 |
+
|
| 43 |
+
Write-Host "Starting the services..."
|
| 44 |
+
docker compose up -d --remove-orphans --pull never
|
| 45 |
+
|
| 46 |
+
Write-Host "Build complete. Services are starting."
|
| 47 |
+
Write-Host "Run 'docker compose logs -f' to see the logs."
|
| 48 |
+
}
|
| 49 |
+
default {
|
| 50 |
+
Write-Host "Invalid choice. Please enter 1 or 2."
|
| 51 |
+
exit 1
|
| 52 |
+
}
|
| 53 |
+
}
|
docker-build.sh
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# build.sh - Linux/macOS Build Script
|
| 4 |
+
#
|
| 5 |
+
# This script automates the process of building and running the Docker container
|
| 6 |
+
# with version information dynamically injected at build time.
|
| 7 |
+
|
| 8 |
+
# Hidden feature: Preserve usage statistics across rebuilds
|
| 9 |
+
# Usage: ./docker-build.sh --with-usage
|
| 10 |
+
# First run prompts for management API key, saved to temp/stats/.api_secret
|
| 11 |
+
|
| 12 |
+
set -euo pipefail
|
| 13 |
+
|
| 14 |
+
STATS_DIR="temp/stats"
|
| 15 |
+
STATS_FILE="${STATS_DIR}/.usage_backup.json"
|
| 16 |
+
SECRET_FILE="${STATS_DIR}/.api_secret"
|
| 17 |
+
WITH_USAGE=false
|
| 18 |
+
|
| 19 |
+
get_port() {
|
| 20 |
+
if [[ -f "config.yaml" ]]; then
|
| 21 |
+
grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/'
|
| 22 |
+
else
|
| 23 |
+
echo "8317"
|
| 24 |
+
fi
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export_stats_api_secret() {
|
| 28 |
+
if [[ -f "${SECRET_FILE}" ]]; then
|
| 29 |
+
API_SECRET=$(cat "${SECRET_FILE}")
|
| 30 |
+
else
|
| 31 |
+
if [[ ! -d "${STATS_DIR}" ]]; then
|
| 32 |
+
mkdir -p "${STATS_DIR}"
|
| 33 |
+
fi
|
| 34 |
+
echo "First time using --with-usage. Management API key required."
|
| 35 |
+
read -r -p "Enter management key: " -s API_SECRET
|
| 36 |
+
echo
|
| 37 |
+
echo "${API_SECRET}" > "${SECRET_FILE}"
|
| 38 |
+
chmod 600 "${SECRET_FILE}"
|
| 39 |
+
fi
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
check_container_running() {
|
| 43 |
+
local port
|
| 44 |
+
port=$(get_port)
|
| 45 |
+
|
| 46 |
+
if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
|
| 47 |
+
echo "Error: cli-proxy-api service is not responding at localhost:${port}"
|
| 48 |
+
echo "Please start the container first or use without --with-usage flag."
|
| 49 |
+
exit 1
|
| 50 |
+
fi
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export_stats() {
|
| 54 |
+
local port
|
| 55 |
+
port=$(get_port)
|
| 56 |
+
|
| 57 |
+
if [[ ! -d "${STATS_DIR}" ]]; then
|
| 58 |
+
mkdir -p "${STATS_DIR}"
|
| 59 |
+
fi
|
| 60 |
+
check_container_running
|
| 61 |
+
echo "Exporting usage statistics..."
|
| 62 |
+
EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \
|
| 63 |
+
"http://localhost:${port}/v0/management/usage/export")
|
| 64 |
+
HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1)
|
| 65 |
+
RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d')
|
| 66 |
+
|
| 67 |
+
if [[ "${HTTP_CODE}" != "200" ]]; then
|
| 68 |
+
echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}"
|
| 69 |
+
exit 1
|
| 70 |
+
fi
|
| 71 |
+
|
| 72 |
+
echo "${RESPONSE_BODY}" > "${STATS_FILE}"
|
| 73 |
+
echo "Statistics exported to ${STATS_FILE}"
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
import_stats() {
|
| 77 |
+
local port
|
| 78 |
+
port=$(get_port)
|
| 79 |
+
|
| 80 |
+
echo "Importing usage statistics..."
|
| 81 |
+
IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
| 82 |
+
-H "X-Management-Key: ${API_SECRET}" \
|
| 83 |
+
-H "Content-Type: application/json" \
|
| 84 |
+
-d @"${STATS_FILE}" \
|
| 85 |
+
"http://localhost:${port}/v0/management/usage/import")
|
| 86 |
+
IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1)
|
| 87 |
+
IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d')
|
| 88 |
+
|
| 89 |
+
if [[ "${IMPORT_CODE}" == "200" ]]; then
|
| 90 |
+
echo "Statistics imported successfully"
|
| 91 |
+
else
|
| 92 |
+
echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}"
|
| 93 |
+
fi
|
| 94 |
+
|
| 95 |
+
rm -f "${STATS_FILE}"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
wait_for_service() {
|
| 99 |
+
local port
|
| 100 |
+
port=$(get_port)
|
| 101 |
+
|
| 102 |
+
echo "Waiting for service to be ready..."
|
| 103 |
+
for i in {1..30}; do
|
| 104 |
+
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
|
| 105 |
+
break
|
| 106 |
+
fi
|
| 107 |
+
sleep 1
|
| 108 |
+
done
|
| 109 |
+
sleep 2
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if [[ "${1:-}" == "--with-usage" ]]; then
|
| 113 |
+
WITH_USAGE=true
|
| 114 |
+
export_stats_api_secret
|
| 115 |
+
fi
|
| 116 |
+
|
| 117 |
+
# --- Step 1: Choose Environment ---
|
| 118 |
+
echo "Please select an option:"
|
| 119 |
+
echo "1) Run using Pre-built Image (Recommended)"
|
| 120 |
+
echo "2) Build from Source and Run (For Developers)"
|
| 121 |
+
read -r -p "Enter choice [1-2]: " choice
|
| 122 |
+
|
| 123 |
+
# --- Step 2: Execute based on choice ---
|
| 124 |
+
case "$choice" in
|
| 125 |
+
1)
|
| 126 |
+
echo "--- Running with Pre-built Image ---"
|
| 127 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 128 |
+
export_stats
|
| 129 |
+
fi
|
| 130 |
+
docker compose up -d --remove-orphans --no-build
|
| 131 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 132 |
+
wait_for_service
|
| 133 |
+
import_stats
|
| 134 |
+
fi
|
| 135 |
+
echo "Services are starting from remote image."
|
| 136 |
+
echo "Run 'docker compose logs -f' to see the logs."
|
| 137 |
+
;;
|
| 138 |
+
2)
|
| 139 |
+
echo "--- Building from Source and Running ---"
|
| 140 |
+
|
| 141 |
+
# Get Version Information
|
| 142 |
+
VERSION="$(git describe --tags --always --dirty)"
|
| 143 |
+
COMMIT="$(git rev-parse --short HEAD)"
|
| 144 |
+
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
| 145 |
+
|
| 146 |
+
echo "Building with the following info:"
|
| 147 |
+
echo " Version: ${VERSION}"
|
| 148 |
+
echo " Commit: ${COMMIT}"
|
| 149 |
+
echo " Build Date: ${BUILD_DATE}"
|
| 150 |
+
echo "----------------------------------------"
|
| 151 |
+
|
| 152 |
+
# Build and start the services with a local-only image tag
|
| 153 |
+
export CLI_PROXY_IMAGE="cli-proxy-api:local"
|
| 154 |
+
|
| 155 |
+
echo "Building the Docker image..."
|
| 156 |
+
docker compose build \
|
| 157 |
+
--build-arg VERSION="${VERSION}" \
|
| 158 |
+
--build-arg COMMIT="${COMMIT}" \
|
| 159 |
+
--build-arg BUILD_DATE="${BUILD_DATE}"
|
| 160 |
+
|
| 161 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 162 |
+
export_stats
|
| 163 |
+
fi
|
| 164 |
+
|
| 165 |
+
echo "Starting the services..."
|
| 166 |
+
docker compose up -d --remove-orphans --pull never
|
| 167 |
+
|
| 168 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 169 |
+
wait_for_service
|
| 170 |
+
import_stats
|
| 171 |
+
fi
|
| 172 |
+
|
| 173 |
+
echo "Build complete. Services are starting."
|
| 174 |
+
echo "Run 'docker compose logs -f' to see the logs."
|
| 175 |
+
;;
|
| 176 |
+
*)
|
| 177 |
+
echo "Invalid choice. Please enter 1 or 2."
|
| 178 |
+
exit 1
|
| 179 |
+
;;
|
| 180 |
+
esac
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
cli-proxy-api:
|
| 3 |
+
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
|
| 4 |
+
pull_policy: always
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
args:
|
| 9 |
+
VERSION: ${VERSION:-dev}
|
| 10 |
+
COMMIT: ${COMMIT:-none}
|
| 11 |
+
BUILD_DATE: ${BUILD_DATE:-unknown}
|
| 12 |
+
container_name: cli-proxy-api
|
| 13 |
+
# env_file:
|
| 14 |
+
# - .env
|
| 15 |
+
environment:
|
| 16 |
+
DEPLOY: ${DEPLOY:-}
|
| 17 |
+
ports:
|
| 18 |
+
- "8317:8317"
|
| 19 |
+
- "8085:8085"
|
| 20 |
+
- "1455:1455"
|
| 21 |
+
- "54545:54545"
|
| 22 |
+
- "51121:51121"
|
| 23 |
+
- "11451:11451"
|
| 24 |
+
volumes:
|
| 25 |
+
- ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml
|
| 26 |
+
- ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
|
| 27 |
+
- ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
|
| 28 |
+
restart: unless-stopped
|
examples/custom-provider/main.go
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package main demonstrates how to create a custom AI provider executor
|
| 2 |
+
// and integrate it with the CLI Proxy API server. This example shows how to:
|
| 3 |
+
// - Create a custom executor that implements the Executor interface
|
| 4 |
+
// - Register custom translators for request/response transformation
|
| 5 |
+
// - Integrate the custom provider with the SDK server
|
| 6 |
+
// - Register custom models in the model registry
|
| 7 |
+
//
|
| 8 |
+
// This example uses a simple echo service (httpbin.org) as the upstream API
|
| 9 |
+
// for demonstration purposes. In a real implementation, you would replace
|
| 10 |
+
// this with your actual AI service provider.
|
| 11 |
+
package main
|
| 12 |
+
|
| 13 |
+
import (
|
| 14 |
+
"bytes"
|
| 15 |
+
"context"
|
| 16 |
+
"errors"
|
| 17 |
+
"fmt"
|
| 18 |
+
"io"
|
| 19 |
+
"net/http"
|
| 20 |
+
"net/url"
|
| 21 |
+
"os"
|
| 22 |
+
"path/filepath"
|
| 23 |
+
"strings"
|
| 24 |
+
"time"
|
| 25 |
+
|
| 26 |
+
"github.com/gin-gonic/gin"
|
| 27 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
|
| 28 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 29 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
| 30 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 31 |
+
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
| 32 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 33 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
|
| 34 |
+
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
const (
|
| 38 |
+
// providerKey is the identifier for our custom provider.
|
| 39 |
+
providerKey = "myprov"
|
| 40 |
+
|
| 41 |
+
// fOpenAI represents the OpenAI chat format.
|
| 42 |
+
fOpenAI = sdktr.Format("openai.chat")
|
| 43 |
+
|
| 44 |
+
// fMyProv represents our custom provider's chat format.
|
| 45 |
+
fMyProv = sdktr.Format("myprov.chat")
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
// init registers trivial translators for demonstration purposes.
|
| 49 |
+
// In a real implementation, you would implement proper request/response
|
| 50 |
+
// transformation logic between OpenAI format and your provider's format.
|
| 51 |
+
func init() {
|
| 52 |
+
sdktr.Register(fOpenAI, fMyProv,
|
| 53 |
+
func(model string, raw []byte, stream bool) []byte { return raw },
|
| 54 |
+
sdktr.ResponseTransform{
|
| 55 |
+
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
| 56 |
+
return []string{string(raw)}
|
| 57 |
+
},
|
| 58 |
+
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
| 59 |
+
return string(raw)
|
| 60 |
+
},
|
| 61 |
+
},
|
| 62 |
+
)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// MyExecutor is a minimal provider implementation for demonstration purposes.
|
| 66 |
+
// It implements the Executor interface to handle requests to a custom AI provider.
|
| 67 |
+
type MyExecutor struct{}
|
| 68 |
+
|
| 69 |
+
// Identifier returns the unique identifier for this executor.
|
| 70 |
+
func (MyExecutor) Identifier() string { return providerKey }
|
| 71 |
+
|
| 72 |
+
// PrepareRequest optionally injects credentials to raw HTTP requests.
|
| 73 |
+
// This method is called before each request to allow the executor to modify
|
| 74 |
+
// the HTTP request with authentication headers or other necessary modifications.
|
| 75 |
+
//
|
| 76 |
+
// Parameters:
|
| 77 |
+
// - req: The HTTP request to prepare
|
| 78 |
+
// - a: The authentication information
|
| 79 |
+
//
|
| 80 |
+
// Returns:
|
| 81 |
+
// - error: An error if request preparation fails
|
| 82 |
+
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
| 83 |
+
if req == nil || a == nil {
|
| 84 |
+
return nil
|
| 85 |
+
}
|
| 86 |
+
if a.Attributes != nil {
|
| 87 |
+
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
|
| 88 |
+
req.Header.Set("Authorization", "Bearer "+ak)
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
return nil
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
func buildHTTPClient(a *coreauth.Auth) *http.Client {
|
| 95 |
+
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
|
| 96 |
+
return http.DefaultClient
|
| 97 |
+
}
|
| 98 |
+
u, err := url.Parse(a.ProxyURL)
|
| 99 |
+
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
| 100 |
+
return http.DefaultClient
|
| 101 |
+
}
|
| 102 |
+
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
func upstreamEndpoint(a *coreauth.Auth) string {
|
| 106 |
+
if a != nil && a.Attributes != nil {
|
| 107 |
+
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
|
| 108 |
+
return ep
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
// Demo echo endpoint; replace with your upstream.
|
| 112 |
+
return "https://httpbin.org/post"
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
| 116 |
+
client := buildHTTPClient(a)
|
| 117 |
+
endpoint := upstreamEndpoint(a)
|
| 118 |
+
|
| 119 |
+
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
|
| 120 |
+
if errNew != nil {
|
| 121 |
+
return clipexec.Response{}, errNew
|
| 122 |
+
}
|
| 123 |
+
httpReq.Header.Set("Content-Type", "application/json")
|
| 124 |
+
|
| 125 |
+
// Inject credentials via PrepareRequest hook.
|
| 126 |
+
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
| 127 |
+
return clipexec.Response{}, errPrep
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
resp, errDo := client.Do(httpReq)
|
| 131 |
+
if errDo != nil {
|
| 132 |
+
return clipexec.Response{}, errDo
|
| 133 |
+
}
|
| 134 |
+
defer func() {
|
| 135 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 136 |
+
fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose)
|
| 137 |
+
}
|
| 138 |
+
}()
|
| 139 |
+
body, _ := io.ReadAll(resp.Body)
|
| 140 |
+
return clipexec.Response{Payload: body}, nil
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
| 144 |
+
if req == nil {
|
| 145 |
+
return nil, fmt.Errorf("myprov executor: request is nil")
|
| 146 |
+
}
|
| 147 |
+
if ctx == nil {
|
| 148 |
+
ctx = req.Context()
|
| 149 |
+
}
|
| 150 |
+
httpReq := req.WithContext(ctx)
|
| 151 |
+
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
| 152 |
+
return nil, errPrep
|
| 153 |
+
}
|
| 154 |
+
client := buildHTTPClient(a)
|
| 155 |
+
return client.Do(httpReq)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
| 159 |
+
return clipexec.Response{}, errors.New("count tokens not implemented")
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
| 163 |
+
ch := make(chan clipexec.StreamChunk, 1)
|
| 164 |
+
go func() {
|
| 165 |
+
defer close(ch)
|
| 166 |
+
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
|
| 167 |
+
}()
|
| 168 |
+
return ch, nil
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
| 172 |
+
return a, nil
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
func main() {
|
| 176 |
+
cfg, err := config.LoadConfig("config.yaml")
|
| 177 |
+
if err != nil {
|
| 178 |
+
panic(err)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
tokenStore := sdkAuth.GetTokenStore()
|
| 182 |
+
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
| 183 |
+
dirSetter.SetBaseDir(cfg.AuthDir)
|
| 184 |
+
}
|
| 185 |
+
core := coreauth.NewManager(tokenStore, nil, nil)
|
| 186 |
+
core.RegisterExecutor(MyExecutor{})
|
| 187 |
+
|
| 188 |
+
hooks := cliproxy.Hooks{
|
| 189 |
+
OnAfterStart: func(s *cliproxy.Service) {
|
| 190 |
+
// Register demo models for the custom provider so they appear in /v1/models.
|
| 191 |
+
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
|
| 192 |
+
for _, a := range core.List() {
|
| 193 |
+
if strings.EqualFold(a.Provider, providerKey) {
|
| 194 |
+
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
svc, err := cliproxy.NewBuilder().
|
| 201 |
+
WithConfig(cfg).
|
| 202 |
+
WithConfigPath("config.yaml").
|
| 203 |
+
WithCoreAuthManager(core).
|
| 204 |
+
WithServerOptions(
|
| 205 |
+
// Optional: add a simple middleware + custom request logger
|
| 206 |
+
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
|
| 207 |
+
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
| 208 |
+
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
| 209 |
+
}),
|
| 210 |
+
).
|
| 211 |
+
WithHooks(hooks).
|
| 212 |
+
Build()
|
| 213 |
+
if err != nil {
|
| 214 |
+
panic(err)
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 218 |
+
defer cancel()
|
| 219 |
+
|
| 220 |
+
if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {
|
| 221 |
+
panic(errRun)
|
| 222 |
+
}
|
| 223 |
+
_ = os.Stderr // keep os import used (demo only)
|
| 224 |
+
_ = time.Second
|
| 225 |
+
}
|
examples/http-request/main.go
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest
|
| 2 |
+
// to execute arbitrary HTTP requests with provider credentials injected.
|
| 3 |
+
//
|
| 4 |
+
// This example registers a minimal custom executor that injects an Authorization
|
| 5 |
+
// header from auth.Attributes["api_key"], then performs two requests against
|
| 6 |
+
// httpbin.org to show the injected headers.
|
| 7 |
+
package main
|
| 8 |
+
|
| 9 |
+
import (
|
| 10 |
+
"bytes"
|
| 11 |
+
"context"
|
| 12 |
+
"errors"
|
| 13 |
+
"fmt"
|
| 14 |
+
"io"
|
| 15 |
+
"net/http"
|
| 16 |
+
"strings"
|
| 17 |
+
"time"
|
| 18 |
+
|
| 19 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 20 |
+
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
| 21 |
+
log "github.com/sirupsen/logrus"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
const providerKey = "echo"
|
| 25 |
+
|
| 26 |
+
// EchoExecutor is a minimal provider implementation for demonstration purposes.
|
| 27 |
+
type EchoExecutor struct{}
|
| 28 |
+
|
| 29 |
+
func (EchoExecutor) Identifier() string { return providerKey }
|
| 30 |
+
|
| 31 |
+
func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
|
| 32 |
+
if req == nil || auth == nil {
|
| 33 |
+
return nil
|
| 34 |
+
}
|
| 35 |
+
if auth.Attributes != nil {
|
| 36 |
+
if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" {
|
| 37 |
+
req.Header.Set("Authorization", "Bearer "+apiKey)
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
return nil
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
| 44 |
+
if req == nil {
|
| 45 |
+
return nil, fmt.Errorf("echo executor: request is nil")
|
| 46 |
+
}
|
| 47 |
+
if ctx == nil {
|
| 48 |
+
ctx = req.Context()
|
| 49 |
+
}
|
| 50 |
+
httpReq := req.WithContext(ctx)
|
| 51 |
+
if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {
|
| 52 |
+
return nil, errPrep
|
| 53 |
+
}
|
| 54 |
+
return http.DefaultClient.Do(httpReq)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
| 58 |
+
return clipexec.Response{}, errors.New("echo executor: Execute not implemented")
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
| 62 |
+
return nil, errors.New("echo executor: ExecuteStream not implemented")
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
|
| 66 |
+
return nil, errors.New("echo executor: Refresh not implemented")
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
| 70 |
+
return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented")
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func main() {
|
| 74 |
+
log.SetLevel(log.InfoLevel)
|
| 75 |
+
|
| 76 |
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
| 77 |
+
defer cancel()
|
| 78 |
+
|
| 79 |
+
core := coreauth.NewManager(nil, nil, nil)
|
| 80 |
+
core.RegisterExecutor(EchoExecutor{})
|
| 81 |
+
|
| 82 |
+
auth := &coreauth.Auth{
|
| 83 |
+
ID: "demo-echo",
|
| 84 |
+
Provider: providerKey,
|
| 85 |
+
Attributes: map[string]string{
|
| 86 |
+
"api_key": "demo-api-key",
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Example 1: Build a prepared request and execute it using your own http.Client.
|
| 91 |
+
reqPrepared, errReqPrepared := core.NewHttpRequest(
|
| 92 |
+
ctx,
|
| 93 |
+
auth,
|
| 94 |
+
http.MethodGet,
|
| 95 |
+
"https://httpbin.org/anything",
|
| 96 |
+
nil,
|
| 97 |
+
http.Header{"X-Example": []string{"prepared"}},
|
| 98 |
+
)
|
| 99 |
+
if errReqPrepared != nil {
|
| 100 |
+
panic(errReqPrepared)
|
| 101 |
+
}
|
| 102 |
+
respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)
|
| 103 |
+
if errDoPrepared != nil {
|
| 104 |
+
panic(errDoPrepared)
|
| 105 |
+
}
|
| 106 |
+
defer func() {
|
| 107 |
+
if errClose := respPrepared.Body.Close(); errClose != nil {
|
| 108 |
+
log.Errorf("close response body error: %v", errClose)
|
| 109 |
+
}
|
| 110 |
+
}()
|
| 111 |
+
bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)
|
| 112 |
+
if errReadPrepared != nil {
|
| 113 |
+
panic(errReadPrepared)
|
| 114 |
+
}
|
| 115 |
+
fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared)
|
| 116 |
+
|
| 117 |
+
// Example 2: Execute a raw request via core.HttpRequest (auto inject + do).
|
| 118 |
+
rawBody := []byte(`{"hello":"world"}`)
|
| 119 |
+
rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody))
|
| 120 |
+
if errRawReq != nil {
|
| 121 |
+
panic(errRawReq)
|
| 122 |
+
}
|
| 123 |
+
rawReq.Header.Set("Content-Type", "application/json")
|
| 124 |
+
rawReq.Header.Set("X-Example", "executed")
|
| 125 |
+
|
| 126 |
+
respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)
|
| 127 |
+
if errDoExec != nil {
|
| 128 |
+
panic(errDoExec)
|
| 129 |
+
}
|
| 130 |
+
defer func() {
|
| 131 |
+
if errClose := respExec.Body.Close(); errClose != nil {
|
| 132 |
+
log.Errorf("close response body error: %v", errClose)
|
| 133 |
+
}
|
| 134 |
+
}()
|
| 135 |
+
bodyExec, errReadExec := io.ReadAll(respExec.Body)
|
| 136 |
+
if errReadExec != nil {
|
| 137 |
+
panic(errReadExec)
|
| 138 |
+
}
|
| 139 |
+
fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec)
|
| 140 |
+
}
|
examples/translator/main.go
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
|
| 7 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
| 8 |
+
_ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func main() {
|
| 12 |
+
rawRequest := []byte(`{"messages":[{"content":[{"text":"Hello! Gemini","type":"text"}],"role":"user"}],"model":"gemini-2.5-pro","stream":false}`)
|
| 13 |
+
fmt.Println("Has gemini->openai response translator:", translator.HasResponseTransformerByFormatName(
|
| 14 |
+
translator.FormatGemini,
|
| 15 |
+
translator.FormatOpenAI,
|
| 16 |
+
))
|
| 17 |
+
|
| 18 |
+
translatedRequest := translator.TranslateRequestByFormatName(
|
| 19 |
+
translator.FormatOpenAI,
|
| 20 |
+
translator.FormatGemini,
|
| 21 |
+
"gemini-2.5-pro",
|
| 22 |
+
rawRequest,
|
| 23 |
+
false,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
fmt.Printf("Translated request to Gemini format:\n%s\n\n", translatedRequest)
|
| 27 |
+
|
| 28 |
+
claudeResponse := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"Okay, here's what's going through my mind. I need to schedule a meeting"},{"thoughtSignature":"","functionCall":{"name":"schedule_meeting","args":{"topic":"Q3 planning","attendees":["Bob","Alice"],"time":"10:00","date":"2025-03-27"}}}]},"finishReason":"STOP","avgLogprobs":-0.50018133435930523}],"usageMetadata":{"promptTokenCount":117,"candidatesTokenCount":28,"totalTokenCount":474,"trafficType":"PROVISIONED_THROUGHPUT","promptTokensDetails":[{"modality":"TEXT","tokenCount":117}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":28}],"thoughtsTokenCount":329},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T04:12:55.249090Z","responseId":"x7OeaIKaD6CU48APvNXDyA4"}`)
|
| 29 |
+
|
| 30 |
+
convertedResponse := translator.TranslateNonStreamByFormatName(
|
| 31 |
+
context.Background(),
|
| 32 |
+
translator.FormatGemini,
|
| 33 |
+
translator.FormatOpenAI,
|
| 34 |
+
"gemini-2.5-pro",
|
| 35 |
+
rawRequest,
|
| 36 |
+
translatedRequest,
|
| 37 |
+
claudeResponse,
|
| 38 |
+
nil,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
fmt.Printf("Converted response for OpenAI clients:\n%s\n", convertedResponse)
|
| 42 |
+
}
|
go.mod
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module github.com/router-for-me/CLIProxyAPI/v6
|
| 2 |
+
|
| 3 |
+
go 1.24.0
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/andybalholm/brotli v1.0.6
|
| 7 |
+
github.com/fsnotify/fsnotify v1.9.0
|
| 8 |
+
github.com/gin-gonic/gin v1.10.1
|
| 9 |
+
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
|
| 10 |
+
github.com/google/uuid v1.6.0
|
| 11 |
+
github.com/gorilla/websocket v1.5.3
|
| 12 |
+
github.com/jackc/pgx/v5 v5.7.6
|
| 13 |
+
github.com/joho/godotenv v1.5.1
|
| 14 |
+
github.com/klauspost/compress v1.17.4
|
| 15 |
+
github.com/minio/minio-go/v7 v7.0.66
|
| 16 |
+
github.com/sirupsen/logrus v1.9.3
|
| 17 |
+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
| 18 |
+
github.com/tidwall/gjson v1.18.0
|
| 19 |
+
github.com/tidwall/sjson v1.2.5
|
| 20 |
+
github.com/tiktoken-go/tokenizer v0.7.0
|
| 21 |
+
golang.org/x/crypto v0.45.0
|
| 22 |
+
golang.org/x/net v0.47.0
|
| 23 |
+
golang.org/x/oauth2 v0.30.0
|
| 24 |
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
| 25 |
+
gopkg.in/yaml.v3 v3.0.1
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
require (
|
| 29 |
+
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
| 30 |
+
github.com/Microsoft/go-winio v0.6.2 // indirect
|
| 31 |
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
| 32 |
+
github.com/bytedance/sonic v1.11.6 // indirect
|
| 33 |
+
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
| 34 |
+
github.com/cloudflare/circl v1.6.1 // indirect
|
| 35 |
+
github.com/cloudwego/base64x v0.1.4 // indirect
|
| 36 |
+
github.com/cloudwego/iasm v0.2.0 // indirect
|
| 37 |
+
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
| 38 |
+
github.com/dlclark/regexp2 v1.11.5 // indirect
|
| 39 |
+
github.com/dustin/go-humanize v1.0.1 // indirect
|
| 40 |
+
github.com/emirpasic/gods v1.18.1 // indirect
|
| 41 |
+
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
| 42 |
+
github.com/gin-contrib/sse v0.1.0 // indirect
|
| 43 |
+
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
| 44 |
+
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
|
| 45 |
+
github.com/go-playground/locales v0.14.1 // indirect
|
| 46 |
+
github.com/go-playground/universal-translator v0.18.1 // indirect
|
| 47 |
+
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
| 48 |
+
github.com/goccy/go-json v0.10.2 // indirect
|
| 49 |
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
| 50 |
+
github.com/jackc/pgpassfile v1.0.0 // indirect
|
| 51 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
| 52 |
+
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
| 53 |
+
github.com/json-iterator/go v1.1.12 // indirect
|
| 54 |
+
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
| 55 |
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
| 56 |
+
github.com/leodido/go-urn v1.4.0 // indirect
|
| 57 |
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
| 58 |
+
github.com/minio/md5-simd v1.1.2 // indirect
|
| 59 |
+
github.com/minio/sha256-simd v1.0.1 // indirect
|
| 60 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
| 61 |
+
github.com/modern-go/reflect2 v1.0.2 // indirect
|
| 62 |
+
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
| 63 |
+
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
| 64 |
+
github.com/rs/xid v1.5.0 // indirect
|
| 65 |
+
github.com/sergi/go-diff v1.4.0 // indirect
|
| 66 |
+
github.com/tidwall/match v1.1.1 // indirect
|
| 67 |
+
github.com/tidwall/pretty v1.2.0 // indirect
|
| 68 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
| 69 |
+
github.com/ugorji/go/codec v1.2.12 // indirect
|
| 70 |
+
golang.org/x/arch v0.8.0 // indirect
|
| 71 |
+
golang.org/x/sync v0.18.0 // indirect
|
| 72 |
+
golang.org/x/sys v0.38.0 // indirect
|
| 73 |
+
golang.org/x/text v0.31.0 // indirect
|
| 74 |
+
google.golang.org/protobuf v1.34.1 // indirect
|
| 75 |
+
gopkg.in/ini.v1 v1.67.0 // indirect
|
| 76 |
+
)
|
go.sum
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
| 2 |
+
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
| 3 |
+
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
| 4 |
+
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
| 5 |
+
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
| 6 |
+
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
| 7 |
+
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
| 8 |
+
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
| 9 |
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
| 10 |
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
| 11 |
+
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
| 12 |
+
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
| 13 |
+
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
| 14 |
+
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
| 15 |
+
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
| 16 |
+
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
| 17 |
+
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
| 18 |
+
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
| 19 |
+
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
| 20 |
+
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
| 21 |
+
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
| 22 |
+
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
| 23 |
+
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
| 24 |
+
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
| 25 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 26 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
| 27 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 28 |
+
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
| 29 |
+
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
| 30 |
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
| 31 |
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
| 32 |
+
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
| 33 |
+
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
| 34 |
+
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
| 35 |
+
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
| 36 |
+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
| 37 |
+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
| 38 |
+
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
| 39 |
+
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
| 40 |
+
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
| 41 |
+
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
| 42 |
+
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
| 43 |
+
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
| 44 |
+
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
| 45 |
+
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
| 46 |
+
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
| 47 |
+
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
| 48 |
+
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
|
| 49 |
+
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
|
| 50 |
+
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
|
| 51 |
+
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
|
| 52 |
+
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=
|
| 53 |
+
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=
|
| 54 |
+
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
| 55 |
+
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
| 56 |
+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
| 57 |
+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
| 58 |
+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
| 59 |
+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
| 60 |
+
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
| 61 |
+
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
| 62 |
+
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
| 63 |
+
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
| 64 |
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
| 65 |
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
| 66 |
+
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
| 67 |
+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
| 68 |
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
| 69 |
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
| 70 |
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
| 71 |
+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
| 72 |
+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
| 73 |
+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
| 74 |
+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
| 75 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
| 76 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
| 77 |
+
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
| 78 |
+
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
| 79 |
+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
| 80 |
+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
| 81 |
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
| 82 |
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
| 83 |
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
| 84 |
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
| 85 |
+
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
| 86 |
+
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
| 87 |
+
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
| 88 |
+
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
| 89 |
+
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
| 90 |
+
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
| 91 |
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
| 92 |
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
| 93 |
+
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
| 94 |
+
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
| 95 |
+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
| 96 |
+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
| 97 |
+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
| 98 |
+
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
| 99 |
+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
| 100 |
+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
| 101 |
+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
| 102 |
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
| 103 |
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
| 104 |
+
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
| 105 |
+
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
| 106 |
+
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
|
| 107 |
+
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
| 108 |
+
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
| 109 |
+
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
| 110 |
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 111 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
| 112 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 113 |
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
| 114 |
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
| 115 |
+
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
| 116 |
+
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
| 117 |
+
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
| 118 |
+
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
| 119 |
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
| 120 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
| 121 |
+
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
| 122 |
+
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
| 123 |
+
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
| 124 |
+
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
| 125 |
+
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
| 126 |
+
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
| 127 |
+
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
| 128 |
+
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
| 129 |
+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
| 130 |
+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
| 131 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
| 132 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
| 133 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
| 134 |
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
| 135 |
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
| 136 |
+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
| 137 |
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 138 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 139 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
| 140 |
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
| 141 |
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
| 142 |
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
| 143 |
+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
| 144 |
+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
| 145 |
+
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
| 146 |
+
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
| 147 |
+
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
| 148 |
+
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
| 149 |
+
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
| 150 |
+
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
| 151 |
+
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
| 152 |
+
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
| 153 |
+
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
| 154 |
+
github.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw=
|
| 155 |
+
github.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
|
| 156 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
| 157 |
+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
| 158 |
+
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
| 159 |
+
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
| 160 |
+
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
| 161 |
+
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
| 162 |
+
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
| 163 |
+
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
| 164 |
+
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
| 165 |
+
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
| 166 |
+
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
| 167 |
+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
| 168 |
+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
| 169 |
+
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
| 170 |
+
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
| 171 |
+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 172 |
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 173 |
+
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
| 174 |
+
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
| 175 |
+
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
| 176 |
+
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
| 177 |
+
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
| 178 |
+
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
| 179 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
| 180 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 181 |
+
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
| 182 |
+
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
| 183 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 184 |
+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 185 |
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
| 186 |
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
| 187 |
+
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
| 188 |
+
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
| 189 |
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
| 190 |
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
| 191 |
+
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
| 192 |
+
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
| 193 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 194 |
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
| 195 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 196 |
+
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
| 197 |
+
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
internal/access/config_access/provider.go
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package configaccess
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strings"
|
| 7 |
+
"sync"
|
| 8 |
+
|
| 9 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 10 |
+
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
var registerOnce sync.Once
|
| 14 |
+
|
| 15 |
+
// Register ensures the config-access provider is available to the access manager.
|
| 16 |
+
func Register() {
|
| 17 |
+
registerOnce.Do(func() {
|
| 18 |
+
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
|
| 19 |
+
})
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type provider struct {
|
| 23 |
+
name string
|
| 24 |
+
keys map[string]struct{}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
|
| 28 |
+
name := cfg.Name
|
| 29 |
+
if name == "" {
|
| 30 |
+
name = sdkconfig.DefaultAccessProviderName
|
| 31 |
+
}
|
| 32 |
+
keys := make(map[string]struct{}, len(cfg.APIKeys))
|
| 33 |
+
for _, key := range cfg.APIKeys {
|
| 34 |
+
if key == "" {
|
| 35 |
+
continue
|
| 36 |
+
}
|
| 37 |
+
keys[key] = struct{}{}
|
| 38 |
+
}
|
| 39 |
+
return &provider{name: name, keys: keys}, nil
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func (p *provider) Identifier() string {
|
| 43 |
+
if p == nil || p.name == "" {
|
| 44 |
+
return sdkconfig.DefaultAccessProviderName
|
| 45 |
+
}
|
| 46 |
+
return p.name
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
| 50 |
+
if p == nil {
|
| 51 |
+
return nil, sdkaccess.ErrNotHandled
|
| 52 |
+
}
|
| 53 |
+
if len(p.keys) == 0 {
|
| 54 |
+
return nil, sdkaccess.ErrNotHandled
|
| 55 |
+
}
|
| 56 |
+
authHeader := r.Header.Get("Authorization")
|
| 57 |
+
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
|
| 58 |
+
authHeaderAnthropic := r.Header.Get("X-Api-Key")
|
| 59 |
+
queryKey := ""
|
| 60 |
+
queryAuthToken := ""
|
| 61 |
+
if r.URL != nil {
|
| 62 |
+
queryKey = r.URL.Query().Get("key")
|
| 63 |
+
queryAuthToken = r.URL.Query().Get("auth_token")
|
| 64 |
+
}
|
| 65 |
+
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
|
| 66 |
+
return nil, sdkaccess.ErrNoCredentials
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
apiKey := extractBearerToken(authHeader)
|
| 70 |
+
|
| 71 |
+
candidates := []struct {
|
| 72 |
+
value string
|
| 73 |
+
source string
|
| 74 |
+
}{
|
| 75 |
+
{apiKey, "authorization"},
|
| 76 |
+
{authHeaderGoogle, "x-goog-api-key"},
|
| 77 |
+
{authHeaderAnthropic, "x-api-key"},
|
| 78 |
+
{queryKey, "query-key"},
|
| 79 |
+
{queryAuthToken, "query-auth-token"},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
for _, candidate := range candidates {
|
| 83 |
+
if candidate.value == "" {
|
| 84 |
+
continue
|
| 85 |
+
}
|
| 86 |
+
if _, ok := p.keys[candidate.value]; ok {
|
| 87 |
+
return &sdkaccess.Result{
|
| 88 |
+
Provider: p.Identifier(),
|
| 89 |
+
Principal: candidate.value,
|
| 90 |
+
Metadata: map[string]string{
|
| 91 |
+
"source": candidate.source,
|
| 92 |
+
},
|
| 93 |
+
}, nil
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return nil, sdkaccess.ErrInvalidCredential
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
func extractBearerToken(header string) string {
|
| 101 |
+
if header == "" {
|
| 102 |
+
return ""
|
| 103 |
+
}
|
| 104 |
+
parts := strings.SplitN(header, " ", 2)
|
| 105 |
+
if len(parts) != 2 {
|
| 106 |
+
return header
|
| 107 |
+
}
|
| 108 |
+
if strings.ToLower(parts[0]) != "bearer" {
|
| 109 |
+
return header
|
| 110 |
+
}
|
| 111 |
+
return strings.TrimSpace(parts[1])
|
| 112 |
+
}
|
internal/access/reconcile.go
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package access
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"reflect"
|
| 6 |
+
"sort"
|
| 7 |
+
"strings"
|
| 8 |
+
|
| 9 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 10 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 11 |
+
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 12 |
+
log "github.com/sirupsen/logrus"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// ReconcileProviders builds the desired provider list by reusing existing providers when possible
|
| 16 |
+
// and creating or removing providers only when their configuration changed. It returns the final
|
| 17 |
+
// ordered provider slice along with the identifiers of providers that were added, updated, or
|
| 18 |
+
// removed compared to the previous configuration.
|
| 19 |
+
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
|
| 20 |
+
if newCfg == nil {
|
| 21 |
+
return nil, nil, nil, nil, nil
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
existingMap := make(map[string]sdkaccess.Provider, len(existing))
|
| 25 |
+
for _, provider := range existing {
|
| 26 |
+
if provider == nil {
|
| 27 |
+
continue
|
| 28 |
+
}
|
| 29 |
+
existingMap[provider.Identifier()] = provider
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
oldCfgMap := accessProviderMap(oldCfg)
|
| 33 |
+
newEntries := collectProviderEntries(newCfg)
|
| 34 |
+
|
| 35 |
+
result = make([]sdkaccess.Provider, 0, len(newEntries))
|
| 36 |
+
finalIDs := make(map[string]struct{}, len(newEntries))
|
| 37 |
+
|
| 38 |
+
isInlineProvider := func(id string) bool {
|
| 39 |
+
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
|
| 40 |
+
}
|
| 41 |
+
appendChange := func(list *[]string, id string) {
|
| 42 |
+
if isInlineProvider(id) {
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
*list = append(*list, id)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
for _, providerCfg := range newEntries {
|
| 49 |
+
key := providerIdentifier(providerCfg)
|
| 50 |
+
if key == "" {
|
| 51 |
+
continue
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
|
| 55 |
+
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
| 56 |
+
isAliased := oldCfgProvider == providerCfg
|
| 57 |
+
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
| 58 |
+
if existingProvider, okExisting := existingMap[key]; okExisting {
|
| 59 |
+
result = append(result, existingProvider)
|
| 60 |
+
finalIDs[key] = struct{}{}
|
| 61 |
+
continue
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
| 67 |
+
if buildErr != nil {
|
| 68 |
+
return nil, nil, nil, nil, buildErr
|
| 69 |
+
}
|
| 70 |
+
if _, ok := oldCfgMap[key]; ok {
|
| 71 |
+
if _, existed := existingMap[key]; existed {
|
| 72 |
+
appendChange(&updated, key)
|
| 73 |
+
} else {
|
| 74 |
+
appendChange(&added, key)
|
| 75 |
+
}
|
| 76 |
+
} else {
|
| 77 |
+
appendChange(&added, key)
|
| 78 |
+
}
|
| 79 |
+
result = append(result, provider)
|
| 80 |
+
finalIDs[key] = struct{}{}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if len(result) == 0 {
|
| 84 |
+
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
|
| 85 |
+
key := providerIdentifier(inline)
|
| 86 |
+
if key != "" {
|
| 87 |
+
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
| 88 |
+
if providerConfigEqual(oldCfgProvider, inline) {
|
| 89 |
+
if existingProvider, okExisting := existingMap[key]; okExisting {
|
| 90 |
+
result = append(result, existingProvider)
|
| 91 |
+
finalIDs[key] = struct{}{}
|
| 92 |
+
goto inlineDone
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
|
| 97 |
+
if buildErr != nil {
|
| 98 |
+
return nil, nil, nil, nil, buildErr
|
| 99 |
+
}
|
| 100 |
+
if _, existed := existingMap[key]; existed {
|
| 101 |
+
appendChange(&updated, key)
|
| 102 |
+
} else if _, hadOld := oldCfgMap[key]; hadOld {
|
| 103 |
+
appendChange(&updated, key)
|
| 104 |
+
} else {
|
| 105 |
+
appendChange(&added, key)
|
| 106 |
+
}
|
| 107 |
+
result = append(result, provider)
|
| 108 |
+
finalIDs[key] = struct{}{}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
inlineDone:
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
removedSet := make(map[string]struct{})
|
| 115 |
+
for id := range existingMap {
|
| 116 |
+
if _, ok := finalIDs[id]; !ok {
|
| 117 |
+
if isInlineProvider(id) {
|
| 118 |
+
continue
|
| 119 |
+
}
|
| 120 |
+
removedSet[id] = struct{}{}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
removed = make([]string, 0, len(removedSet))
|
| 125 |
+
for id := range removedSet {
|
| 126 |
+
removed = append(removed, id)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
sort.Strings(added)
|
| 130 |
+
sort.Strings(updated)
|
| 131 |
+
sort.Strings(removed)
|
| 132 |
+
|
| 133 |
+
return result, added, updated, removed, nil
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// ApplyAccessProviders reconciles the configured access providers against the
|
| 137 |
+
// currently registered providers and updates the manager. It logs a concise
|
| 138 |
+
// summary of the detected changes and returns whether any provider changed.
|
| 139 |
+
func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {
|
| 140 |
+
if manager == nil || newCfg == nil {
|
| 141 |
+
return false, nil
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
existing := manager.Providers()
|
| 145 |
+
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
|
| 146 |
+
if err != nil {
|
| 147 |
+
log.Errorf("failed to reconcile request auth providers: %v", err)
|
| 148 |
+
return false, fmt.Errorf("reconciling access providers: %w", err)
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
manager.SetProviders(providers)
|
| 152 |
+
|
| 153 |
+
if len(added)+len(updated)+len(removed) > 0 {
|
| 154 |
+
log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
|
| 155 |
+
log.Debugf("auth providers changes details - added=%v updated=%v removed=%v", added, updated, removed)
|
| 156 |
+
return true, nil
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
log.Debug("auth providers unchanged after config update")
|
| 160 |
+
return false, nil
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
|
| 164 |
+
result := make(map[string]*sdkConfig.AccessProvider)
|
| 165 |
+
if cfg == nil {
|
| 166 |
+
return result
|
| 167 |
+
}
|
| 168 |
+
for i := range cfg.Access.Providers {
|
| 169 |
+
providerCfg := &cfg.Access.Providers[i]
|
| 170 |
+
if providerCfg.Type == "" {
|
| 171 |
+
continue
|
| 172 |
+
}
|
| 173 |
+
key := providerIdentifier(providerCfg)
|
| 174 |
+
if key == "" {
|
| 175 |
+
continue
|
| 176 |
+
}
|
| 177 |
+
result[key] = providerCfg
|
| 178 |
+
}
|
| 179 |
+
if len(result) == 0 && len(cfg.APIKeys) > 0 {
|
| 180 |
+
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
|
| 181 |
+
if key := providerIdentifier(provider); key != "" {
|
| 182 |
+
result[key] = provider
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
return result
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
|
| 190 |
+
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
|
| 191 |
+
for i := range cfg.Access.Providers {
|
| 192 |
+
providerCfg := &cfg.Access.Providers[i]
|
| 193 |
+
if providerCfg.Type == "" {
|
| 194 |
+
continue
|
| 195 |
+
}
|
| 196 |
+
if key := providerIdentifier(providerCfg); key != "" {
|
| 197 |
+
entries = append(entries, providerCfg)
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
|
| 201 |
+
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
|
| 202 |
+
entries = append(entries, inline)
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
return entries
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
|
| 209 |
+
if provider == nil {
|
| 210 |
+
return ""
|
| 211 |
+
}
|
| 212 |
+
if name := strings.TrimSpace(provider.Name); name != "" {
|
| 213 |
+
return name
|
| 214 |
+
}
|
| 215 |
+
typ := strings.TrimSpace(provider.Type)
|
| 216 |
+
if typ == "" {
|
| 217 |
+
return ""
|
| 218 |
+
}
|
| 219 |
+
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
|
| 220 |
+
return sdkConfig.DefaultAccessProviderName
|
| 221 |
+
}
|
| 222 |
+
return typ
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
|
| 226 |
+
if a == nil || b == nil {
|
| 227 |
+
return a == nil && b == nil
|
| 228 |
+
}
|
| 229 |
+
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
|
| 230 |
+
return false
|
| 231 |
+
}
|
| 232 |
+
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
|
| 233 |
+
return false
|
| 234 |
+
}
|
| 235 |
+
if !stringSetEqual(a.APIKeys, b.APIKeys) {
|
| 236 |
+
return false
|
| 237 |
+
}
|
| 238 |
+
if len(a.Config) != len(b.Config) {
|
| 239 |
+
return false
|
| 240 |
+
}
|
| 241 |
+
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
|
| 242 |
+
return false
|
| 243 |
+
}
|
| 244 |
+
return true
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
func stringSetEqual(a, b []string) bool {
|
| 248 |
+
if len(a) != len(b) {
|
| 249 |
+
return false
|
| 250 |
+
}
|
| 251 |
+
if len(a) == 0 {
|
| 252 |
+
return true
|
| 253 |
+
}
|
| 254 |
+
seen := make(map[string]int, len(a))
|
| 255 |
+
for _, val := range a {
|
| 256 |
+
seen[val]++
|
| 257 |
+
}
|
| 258 |
+
for _, val := range b {
|
| 259 |
+
count := seen[val]
|
| 260 |
+
if count == 0 {
|
| 261 |
+
return false
|
| 262 |
+
}
|
| 263 |
+
if count == 1 {
|
| 264 |
+
delete(seen, val)
|
| 265 |
+
} else {
|
| 266 |
+
seen[val] = count - 1
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
return len(seen) == 0
|
| 270 |
+
}
|
internal/api/handlers/management/api_tools.go
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"fmt"
|
| 7 |
+
"io"
|
| 8 |
+
"net"
|
| 9 |
+
"net/http"
|
| 10 |
+
"net/url"
|
| 11 |
+
"strings"
|
| 12 |
+
"time"
|
| 13 |
+
|
| 14 |
+
"github.com/gin-gonic/gin"
|
| 15 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
| 16 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 17 |
+
log "github.com/sirupsen/logrus"
|
| 18 |
+
"golang.org/x/net/proxy"
|
| 19 |
+
"golang.org/x/oauth2"
|
| 20 |
+
"golang.org/x/oauth2/google"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
const defaultAPICallTimeout = 60 * time.Second
|
| 24 |
+
|
| 25 |
+
const (
|
| 26 |
+
geminiOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
| 27 |
+
geminiOAuthClientSecret = "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"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
var geminiOAuthScopes = []string{
|
| 31 |
+
"https://www.googleapis.com/auth/cloud-platform",
|
| 32 |
+
"https://www.googleapis.com/auth/userinfo.email",
|
| 33 |
+
"https://www.googleapis.com/auth/userinfo.profile",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const (
|
| 37 |
+
antigravityOAuthClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
| 38 |
+
antigravityOAuthClientSecret = "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"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
var antigravityOAuthTokenURL = "https://oauth2.googleapis.com/token"
|
| 42 |
+
|
| 43 |
+
type apiCallRequest struct {
|
| 44 |
+
AuthIndexSnake *string `json:"auth_index"`
|
| 45 |
+
AuthIndexCamel *string `json:"authIndex"`
|
| 46 |
+
AuthIndexPascal *string `json:"AuthIndex"`
|
| 47 |
+
Method string `json:"method"`
|
| 48 |
+
URL string `json:"url"`
|
| 49 |
+
Header map[string]string `json:"header"`
|
| 50 |
+
Data string `json:"data"`
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
type apiCallResponse struct {
|
| 54 |
+
StatusCode int `json:"status_code"`
|
| 55 |
+
Header map[string][]string `json:"header"`
|
| 56 |
+
Body string `json:"body"`
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// APICall makes a generic HTTP request on behalf of the management API caller.
|
| 60 |
+
// It is protected by the management middleware.
|
| 61 |
+
//
|
| 62 |
+
// Endpoint:
|
| 63 |
+
//
|
| 64 |
+
// POST /v0/management/api-call
|
| 65 |
+
//
|
| 66 |
+
// Authentication:
|
| 67 |
+
//
|
| 68 |
+
// Same as other management APIs (requires a management key and remote-management rules).
|
| 69 |
+
// You can provide the key via:
|
| 70 |
+
// - Authorization: Bearer <key>
|
| 71 |
+
// - X-Management-Key: <key>
|
| 72 |
+
//
|
| 73 |
+
// Request JSON:
|
| 74 |
+
// - auth_index / authIndex / AuthIndex (optional):
|
| 75 |
+
// The credential "auth_index" from GET /v0/management/auth-files (or other endpoints returning it).
|
| 76 |
+
// If omitted or not found, credential-specific proxy/token substitution is skipped.
|
| 77 |
+
// - method (required): HTTP method, e.g. GET, POST, PUT, PATCH, DELETE.
|
| 78 |
+
// - url (required): Absolute URL including scheme and host, e.g. "https://api.example.com/v1/ping".
|
| 79 |
+
// - header (optional): Request headers map.
|
| 80 |
+
// Supports magic variable "$TOKEN$" which is replaced using the selected credential:
|
| 81 |
+
// 1) metadata.access_token
|
| 82 |
+
// 2) attributes.api_key
|
| 83 |
+
// 3) metadata.token / metadata.id_token / metadata.cookie
|
| 84 |
+
// Example: {"Authorization":"Bearer $TOKEN$"}.
|
| 85 |
+
// Note: if you need to override the HTTP Host header, set header["Host"].
|
| 86 |
+
// - data (optional): Raw request body as string (useful for POST/PUT/PATCH).
|
| 87 |
+
//
|
| 88 |
+
// Proxy selection (highest priority first):
|
| 89 |
+
// 1. Selected credential proxy_url
|
| 90 |
+
// 2. Global config proxy-url
|
| 91 |
+
// 3. Direct connect (environment proxies are not used)
|
| 92 |
+
//
|
| 93 |
+
// Response JSON (returned with HTTP 200 when the APICall itself succeeds):
|
| 94 |
+
// - status_code: Upstream HTTP status code.
|
| 95 |
+
// - header: Upstream response headers.
|
| 96 |
+
// - body: Upstream response body as string.
|
| 97 |
+
//
|
| 98 |
+
// Example:
|
| 99 |
+
//
|
| 100 |
+
// curl -sS -X POST "http://127.0.0.1:8317/v0/management/api-call" \
|
| 101 |
+
// -H "Authorization: Bearer <MANAGEMENT_KEY>" \
|
| 102 |
+
// -H "Content-Type: application/json" \
|
| 103 |
+
// -d '{"auth_index":"<AUTH_INDEX>","method":"GET","url":"https://api.example.com/v1/ping","header":{"Authorization":"Bearer $TOKEN$"}}'
|
| 104 |
+
//
|
| 105 |
+
// curl -sS -X POST "http://127.0.0.1:8317/v0/management/api-call" \
|
| 106 |
+
// -H "Authorization: Bearer 831227" \
|
| 107 |
+
// -H "Content-Type: application/json" \
|
| 108 |
+
// -d '{"auth_index":"<AUTH_INDEX>","method":"POST","url":"https://api.example.com/v1/fetchAvailableModels","header":{"Authorization":"Bearer $TOKEN$","Content-Type":"application/json","User-Agent":"cliproxyapi"},"data":"{}"}'
|
| 109 |
+
func (h *Handler) APICall(c *gin.Context) {
|
| 110 |
+
var body apiCallRequest
|
| 111 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
| 112 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 113 |
+
return
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
method := strings.ToUpper(strings.TrimSpace(body.Method))
|
| 117 |
+
if method == "" {
|
| 118 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing method"})
|
| 119 |
+
return
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
urlStr := strings.TrimSpace(body.URL)
|
| 123 |
+
if urlStr == "" {
|
| 124 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
|
| 125 |
+
return
|
| 126 |
+
}
|
| 127 |
+
parsedURL, errParseURL := url.Parse(urlStr)
|
| 128 |
+
if errParseURL != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
| 129 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
| 130 |
+
return
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
authIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel, body.AuthIndexPascal)
|
| 134 |
+
auth := h.authByIndex(authIndex)
|
| 135 |
+
|
| 136 |
+
reqHeaders := body.Header
|
| 137 |
+
if reqHeaders == nil {
|
| 138 |
+
reqHeaders = map[string]string{}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
var hostOverride string
|
| 142 |
+
var token string
|
| 143 |
+
var tokenResolved bool
|
| 144 |
+
var tokenErr error
|
| 145 |
+
for key, value := range reqHeaders {
|
| 146 |
+
if !strings.Contains(value, "$TOKEN$") {
|
| 147 |
+
continue
|
| 148 |
+
}
|
| 149 |
+
if !tokenResolved {
|
| 150 |
+
token, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth)
|
| 151 |
+
tokenResolved = true
|
| 152 |
+
}
|
| 153 |
+
if auth != nil && token == "" {
|
| 154 |
+
if tokenErr != nil {
|
| 155 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "auth token refresh failed"})
|
| 156 |
+
return
|
| 157 |
+
}
|
| 158 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "auth token not found"})
|
| 159 |
+
return
|
| 160 |
+
}
|
| 161 |
+
if token == "" {
|
| 162 |
+
continue
|
| 163 |
+
}
|
| 164 |
+
reqHeaders[key] = strings.ReplaceAll(value, "$TOKEN$", token)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
var requestBody io.Reader
|
| 168 |
+
if body.Data != "" {
|
| 169 |
+
requestBody = strings.NewReader(body.Data)
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), method, urlStr, requestBody)
|
| 173 |
+
if errNewRequest != nil {
|
| 174 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to build request"})
|
| 175 |
+
return
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
for key, value := range reqHeaders {
|
| 179 |
+
if strings.EqualFold(key, "host") {
|
| 180 |
+
hostOverride = strings.TrimSpace(value)
|
| 181 |
+
continue
|
| 182 |
+
}
|
| 183 |
+
req.Header.Set(key, value)
|
| 184 |
+
}
|
| 185 |
+
if hostOverride != "" {
|
| 186 |
+
req.Host = hostOverride
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
httpClient := &http.Client{
|
| 190 |
+
Timeout: defaultAPICallTimeout,
|
| 191 |
+
}
|
| 192 |
+
httpClient.Transport = h.apiCallTransport(auth)
|
| 193 |
+
|
| 194 |
+
resp, errDo := httpClient.Do(req)
|
| 195 |
+
if errDo != nil {
|
| 196 |
+
log.WithError(errDo).Debug("management APICall request failed")
|
| 197 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
|
| 198 |
+
return
|
| 199 |
+
}
|
| 200 |
+
defer func() {
|
| 201 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 202 |
+
log.Errorf("response body close error: %v", errClose)
|
| 203 |
+
}
|
| 204 |
+
}()
|
| 205 |
+
|
| 206 |
+
respBody, errReadAll := io.ReadAll(resp.Body)
|
| 207 |
+
if errReadAll != nil {
|
| 208 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
|
| 209 |
+
return
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
c.JSON(http.StatusOK, apiCallResponse{
|
| 213 |
+
StatusCode: resp.StatusCode,
|
| 214 |
+
Header: resp.Header,
|
| 215 |
+
Body: string(respBody),
|
| 216 |
+
})
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
func firstNonEmptyString(values ...*string) string {
|
| 220 |
+
for _, v := range values {
|
| 221 |
+
if v == nil {
|
| 222 |
+
continue
|
| 223 |
+
}
|
| 224 |
+
if out := strings.TrimSpace(*v); out != "" {
|
| 225 |
+
return out
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
return ""
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
func tokenValueForAuth(auth *coreauth.Auth) string {
|
| 232 |
+
if auth == nil {
|
| 233 |
+
return ""
|
| 234 |
+
}
|
| 235 |
+
if v := tokenValueFromMetadata(auth.Metadata); v != "" {
|
| 236 |
+
return v
|
| 237 |
+
}
|
| 238 |
+
if auth.Attributes != nil {
|
| 239 |
+
if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
|
| 240 |
+
return v
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {
|
| 244 |
+
if v := tokenValueFromMetadata(shared.MetadataSnapshot()); v != "" {
|
| 245 |
+
return v
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
return ""
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
func (h *Handler) resolveTokenForAuth(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 252 |
+
if auth == nil {
|
| 253 |
+
return "", nil
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
| 257 |
+
if provider == "gemini-cli" {
|
| 258 |
+
token, errToken := h.refreshGeminiOAuthAccessToken(ctx, auth)
|
| 259 |
+
return token, errToken
|
| 260 |
+
}
|
| 261 |
+
if provider == "antigravity" {
|
| 262 |
+
token, errToken := h.refreshAntigravityOAuthAccessToken(ctx, auth)
|
| 263 |
+
return token, errToken
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
return tokenValueForAuth(auth), nil
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
func (h *Handler) refreshGeminiOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 270 |
+
if ctx == nil {
|
| 271 |
+
ctx = context.Background()
|
| 272 |
+
}
|
| 273 |
+
if auth == nil {
|
| 274 |
+
return "", nil
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
metadata, updater := geminiOAuthMetadata(auth)
|
| 278 |
+
if len(metadata) == 0 {
|
| 279 |
+
return "", fmt.Errorf("gemini oauth metadata missing")
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
base := make(map[string]any)
|
| 283 |
+
if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil {
|
| 284 |
+
base = cloneMap(tokenRaw)
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
var token oauth2.Token
|
| 288 |
+
if len(base) > 0 {
|
| 289 |
+
if raw, errMarshal := json.Marshal(base); errMarshal == nil {
|
| 290 |
+
_ = json.Unmarshal(raw, &token)
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
if token.AccessToken == "" {
|
| 295 |
+
token.AccessToken = stringValue(metadata, "access_token")
|
| 296 |
+
}
|
| 297 |
+
if token.RefreshToken == "" {
|
| 298 |
+
token.RefreshToken = stringValue(metadata, "refresh_token")
|
| 299 |
+
}
|
| 300 |
+
if token.TokenType == "" {
|
| 301 |
+
token.TokenType = stringValue(metadata, "token_type")
|
| 302 |
+
}
|
| 303 |
+
if token.Expiry.IsZero() {
|
| 304 |
+
if expiry := stringValue(metadata, "expiry"); expiry != "" {
|
| 305 |
+
if ts, errParseTime := time.Parse(time.RFC3339, expiry); errParseTime == nil {
|
| 306 |
+
token.Expiry = ts
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
conf := &oauth2.Config{
|
| 312 |
+
ClientID: geminiOAuthClientID,
|
| 313 |
+
ClientSecret: geminiOAuthClientSecret,
|
| 314 |
+
Scopes: geminiOAuthScopes,
|
| 315 |
+
Endpoint: google.Endpoint,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
ctxToken := ctx
|
| 319 |
+
httpClient := &http.Client{
|
| 320 |
+
Timeout: defaultAPICallTimeout,
|
| 321 |
+
Transport: h.apiCallTransport(auth),
|
| 322 |
+
}
|
| 323 |
+
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)
|
| 324 |
+
|
| 325 |
+
src := conf.TokenSource(ctxToken, &token)
|
| 326 |
+
currentToken, errToken := src.Token()
|
| 327 |
+
if errToken != nil {
|
| 328 |
+
return "", errToken
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
merged := buildOAuthTokenMap(base, currentToken)
|
| 332 |
+
fields := buildOAuthTokenFields(currentToken, merged)
|
| 333 |
+
if updater != nil {
|
| 334 |
+
updater(fields)
|
| 335 |
+
}
|
| 336 |
+
return strings.TrimSpace(currentToken.AccessToken), nil
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
func (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 340 |
+
if ctx == nil {
|
| 341 |
+
ctx = context.Background()
|
| 342 |
+
}
|
| 343 |
+
if auth == nil {
|
| 344 |
+
return "", nil
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
metadata := auth.Metadata
|
| 348 |
+
if len(metadata) == 0 {
|
| 349 |
+
return "", fmt.Errorf("antigravity oauth metadata missing")
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
current := strings.TrimSpace(tokenValueFromMetadata(metadata))
|
| 353 |
+
if current != "" && !antigravityTokenNeedsRefresh(metadata) {
|
| 354 |
+
return current, nil
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
refreshToken := stringValue(metadata, "refresh_token")
|
| 358 |
+
if refreshToken == "" {
|
| 359 |
+
return "", fmt.Errorf("antigravity refresh token missing")
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
tokenURL := strings.TrimSpace(antigravityOAuthTokenURL)
|
| 363 |
+
if tokenURL == "" {
|
| 364 |
+
tokenURL = "https://oauth2.googleapis.com/token"
|
| 365 |
+
}
|
| 366 |
+
form := url.Values{}
|
| 367 |
+
form.Set("client_id", antigravityOAuthClientID)
|
| 368 |
+
form.Set("client_secret", antigravityOAuthClientSecret)
|
| 369 |
+
form.Set("grant_type", "refresh_token")
|
| 370 |
+
form.Set("refresh_token", refreshToken)
|
| 371 |
+
|
| 372 |
+
req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
| 373 |
+
if errReq != nil {
|
| 374 |
+
return "", errReq
|
| 375 |
+
}
|
| 376 |
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
| 377 |
+
|
| 378 |
+
httpClient := &http.Client{
|
| 379 |
+
Timeout: defaultAPICallTimeout,
|
| 380 |
+
Transport: h.apiCallTransport(auth),
|
| 381 |
+
}
|
| 382 |
+
resp, errDo := httpClient.Do(req)
|
| 383 |
+
if errDo != nil {
|
| 384 |
+
return "", errDo
|
| 385 |
+
}
|
| 386 |
+
defer func() {
|
| 387 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 388 |
+
log.Errorf("response body close error: %v", errClose)
|
| 389 |
+
}
|
| 390 |
+
}()
|
| 391 |
+
|
| 392 |
+
bodyBytes, errRead := io.ReadAll(resp.Body)
|
| 393 |
+
if errRead != nil {
|
| 394 |
+
return "", errRead
|
| 395 |
+
}
|
| 396 |
+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
| 397 |
+
return "", fmt.Errorf("antigravity oauth token refresh failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
var tokenResp struct {
|
| 401 |
+
AccessToken string `json:"access_token"`
|
| 402 |
+
RefreshToken string `json:"refresh_token"`
|
| 403 |
+
ExpiresIn int64 `json:"expires_in"`
|
| 404 |
+
TokenType string `json:"token_type"`
|
| 405 |
+
}
|
| 406 |
+
if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {
|
| 407 |
+
return "", errUnmarshal
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
if strings.TrimSpace(tokenResp.AccessToken) == "" {
|
| 411 |
+
return "", fmt.Errorf("antigravity oauth token refresh returned empty access_token")
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
if auth.Metadata == nil {
|
| 415 |
+
auth.Metadata = make(map[string]any)
|
| 416 |
+
}
|
| 417 |
+
now := time.Now()
|
| 418 |
+
auth.Metadata["access_token"] = strings.TrimSpace(tokenResp.AccessToken)
|
| 419 |
+
if strings.TrimSpace(tokenResp.RefreshToken) != "" {
|
| 420 |
+
auth.Metadata["refresh_token"] = strings.TrimSpace(tokenResp.RefreshToken)
|
| 421 |
+
}
|
| 422 |
+
if tokenResp.ExpiresIn > 0 {
|
| 423 |
+
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
|
| 424 |
+
auth.Metadata["timestamp"] = now.UnixMilli()
|
| 425 |
+
auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
|
| 426 |
+
}
|
| 427 |
+
auth.Metadata["type"] = "antigravity"
|
| 428 |
+
|
| 429 |
+
if h != nil && h.authManager != nil {
|
| 430 |
+
auth.LastRefreshedAt = now
|
| 431 |
+
auth.UpdatedAt = now
|
| 432 |
+
_, _ = h.authManager.Update(ctx, auth)
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
return strings.TrimSpace(tokenResp.AccessToken), nil
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
func antigravityTokenNeedsRefresh(metadata map[string]any) bool {
|
| 439 |
+
// Refresh a bit early to avoid requests racing token expiry.
|
| 440 |
+
const skew = 30 * time.Second
|
| 441 |
+
|
| 442 |
+
if metadata == nil {
|
| 443 |
+
return true
|
| 444 |
+
}
|
| 445 |
+
if expStr, ok := metadata["expired"].(string); ok {
|
| 446 |
+
if ts, errParse := time.Parse(time.RFC3339, strings.TrimSpace(expStr)); errParse == nil {
|
| 447 |
+
return !ts.After(time.Now().Add(skew))
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
expiresIn := int64Value(metadata["expires_in"])
|
| 451 |
+
timestampMs := int64Value(metadata["timestamp"])
|
| 452 |
+
if expiresIn > 0 && timestampMs > 0 {
|
| 453 |
+
exp := time.UnixMilli(timestampMs).Add(time.Duration(expiresIn) * time.Second)
|
| 454 |
+
return !exp.After(time.Now().Add(skew))
|
| 455 |
+
}
|
| 456 |
+
return true
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
func int64Value(raw any) int64 {
|
| 460 |
+
switch typed := raw.(type) {
|
| 461 |
+
case int:
|
| 462 |
+
return int64(typed)
|
| 463 |
+
case int32:
|
| 464 |
+
return int64(typed)
|
| 465 |
+
case int64:
|
| 466 |
+
return typed
|
| 467 |
+
case uint:
|
| 468 |
+
return int64(typed)
|
| 469 |
+
case uint32:
|
| 470 |
+
return int64(typed)
|
| 471 |
+
case uint64:
|
| 472 |
+
if typed > uint64(^uint64(0)>>1) {
|
| 473 |
+
return 0
|
| 474 |
+
}
|
| 475 |
+
return int64(typed)
|
| 476 |
+
case float32:
|
| 477 |
+
return int64(typed)
|
| 478 |
+
case float64:
|
| 479 |
+
return int64(typed)
|
| 480 |
+
case json.Number:
|
| 481 |
+
if i, errParse := typed.Int64(); errParse == nil {
|
| 482 |
+
return i
|
| 483 |
+
}
|
| 484 |
+
case string:
|
| 485 |
+
if s := strings.TrimSpace(typed); s != "" {
|
| 486 |
+
if i, errParse := json.Number(s).Int64(); errParse == nil {
|
| 487 |
+
return i
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
return 0
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
func geminiOAuthMetadata(auth *coreauth.Auth) (map[string]any, func(map[string]any)) {
|
| 495 |
+
if auth == nil {
|
| 496 |
+
return nil, nil
|
| 497 |
+
}
|
| 498 |
+
if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {
|
| 499 |
+
snapshot := shared.MetadataSnapshot()
|
| 500 |
+
return snapshot, func(fields map[string]any) { shared.MergeMetadata(fields) }
|
| 501 |
+
}
|
| 502 |
+
return auth.Metadata, func(fields map[string]any) {
|
| 503 |
+
if auth.Metadata == nil {
|
| 504 |
+
auth.Metadata = make(map[string]any)
|
| 505 |
+
}
|
| 506 |
+
for k, v := range fields {
|
| 507 |
+
auth.Metadata[k] = v
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
func stringValue(metadata map[string]any, key string) string {
|
| 513 |
+
if len(metadata) == 0 || key == "" {
|
| 514 |
+
return ""
|
| 515 |
+
}
|
| 516 |
+
if v, ok := metadata[key].(string); ok {
|
| 517 |
+
return strings.TrimSpace(v)
|
| 518 |
+
}
|
| 519 |
+
return ""
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
func cloneMap(in map[string]any) map[string]any {
|
| 523 |
+
if len(in) == 0 {
|
| 524 |
+
return nil
|
| 525 |
+
}
|
| 526 |
+
out := make(map[string]any, len(in))
|
| 527 |
+
for k, v := range in {
|
| 528 |
+
out[k] = v
|
| 529 |
+
}
|
| 530 |
+
return out
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
func buildOAuthTokenMap(base map[string]any, tok *oauth2.Token) map[string]any {
|
| 534 |
+
merged := cloneMap(base)
|
| 535 |
+
if merged == nil {
|
| 536 |
+
merged = make(map[string]any)
|
| 537 |
+
}
|
| 538 |
+
if tok == nil {
|
| 539 |
+
return merged
|
| 540 |
+
}
|
| 541 |
+
if raw, errMarshal := json.Marshal(tok); errMarshal == nil {
|
| 542 |
+
var tokenMap map[string]any
|
| 543 |
+
if errUnmarshal := json.Unmarshal(raw, &tokenMap); errUnmarshal == nil {
|
| 544 |
+
for k, v := range tokenMap {
|
| 545 |
+
merged[k] = v
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
return merged
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
func buildOAuthTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any {
|
| 553 |
+
fields := make(map[string]any, 5)
|
| 554 |
+
if tok != nil && tok.AccessToken != "" {
|
| 555 |
+
fields["access_token"] = tok.AccessToken
|
| 556 |
+
}
|
| 557 |
+
if tok != nil && tok.TokenType != "" {
|
| 558 |
+
fields["token_type"] = tok.TokenType
|
| 559 |
+
}
|
| 560 |
+
if tok != nil && tok.RefreshToken != "" {
|
| 561 |
+
fields["refresh_token"] = tok.RefreshToken
|
| 562 |
+
}
|
| 563 |
+
if tok != nil && !tok.Expiry.IsZero() {
|
| 564 |
+
fields["expiry"] = tok.Expiry.Format(time.RFC3339)
|
| 565 |
+
}
|
| 566 |
+
if len(merged) > 0 {
|
| 567 |
+
fields["token"] = cloneMap(merged)
|
| 568 |
+
}
|
| 569 |
+
return fields
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
func tokenValueFromMetadata(metadata map[string]any) string {
|
| 573 |
+
if len(metadata) == 0 {
|
| 574 |
+
return ""
|
| 575 |
+
}
|
| 576 |
+
if v, ok := metadata["accessToken"].(string); ok && strings.TrimSpace(v) != "" {
|
| 577 |
+
return strings.TrimSpace(v)
|
| 578 |
+
}
|
| 579 |
+
if v, ok := metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 580 |
+
return strings.TrimSpace(v)
|
| 581 |
+
}
|
| 582 |
+
if tokenRaw, ok := metadata["token"]; ok && tokenRaw != nil {
|
| 583 |
+
switch typed := tokenRaw.(type) {
|
| 584 |
+
case string:
|
| 585 |
+
if v := strings.TrimSpace(typed); v != "" {
|
| 586 |
+
return v
|
| 587 |
+
}
|
| 588 |
+
case map[string]any:
|
| 589 |
+
if v, ok := typed["access_token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 590 |
+
return strings.TrimSpace(v)
|
| 591 |
+
}
|
| 592 |
+
if v, ok := typed["accessToken"].(string); ok && strings.TrimSpace(v) != "" {
|
| 593 |
+
return strings.TrimSpace(v)
|
| 594 |
+
}
|
| 595 |
+
case map[string]string:
|
| 596 |
+
if v := strings.TrimSpace(typed["access_token"]); v != "" {
|
| 597 |
+
return v
|
| 598 |
+
}
|
| 599 |
+
if v := strings.TrimSpace(typed["accessToken"]); v != "" {
|
| 600 |
+
return v
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
}
|
| 604 |
+
if v, ok := metadata["token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 605 |
+
return strings.TrimSpace(v)
|
| 606 |
+
}
|
| 607 |
+
if v, ok := metadata["id_token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 608 |
+
return strings.TrimSpace(v)
|
| 609 |
+
}
|
| 610 |
+
if v, ok := metadata["cookie"].(string); ok && strings.TrimSpace(v) != "" {
|
| 611 |
+
return strings.TrimSpace(v)
|
| 612 |
+
}
|
| 613 |
+
return ""
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
func (h *Handler) authByIndex(authIndex string) *coreauth.Auth {
|
| 617 |
+
authIndex = strings.TrimSpace(authIndex)
|
| 618 |
+
if authIndex == "" || h == nil || h.authManager == nil {
|
| 619 |
+
return nil
|
| 620 |
+
}
|
| 621 |
+
auths := h.authManager.List()
|
| 622 |
+
for _, auth := range auths {
|
| 623 |
+
if auth == nil {
|
| 624 |
+
continue
|
| 625 |
+
}
|
| 626 |
+
auth.EnsureIndex()
|
| 627 |
+
if auth.Index == authIndex {
|
| 628 |
+
return auth
|
| 629 |
+
}
|
| 630 |
+
}
|
| 631 |
+
return nil
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {
|
| 635 |
+
var proxyCandidates []string
|
| 636 |
+
if auth != nil {
|
| 637 |
+
if proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != "" {
|
| 638 |
+
proxyCandidates = append(proxyCandidates, proxyStr)
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
if h != nil && h.cfg != nil {
|
| 642 |
+
if proxyStr := strings.TrimSpace(h.cfg.ProxyURL); proxyStr != "" {
|
| 643 |
+
proxyCandidates = append(proxyCandidates, proxyStr)
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
for _, proxyStr := range proxyCandidates {
|
| 648 |
+
if transport := buildProxyTransport(proxyStr); transport != nil {
|
| 649 |
+
return transport
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
transport, ok := http.DefaultTransport.(*http.Transport)
|
| 654 |
+
if !ok || transport == nil {
|
| 655 |
+
return &http.Transport{Proxy: nil}
|
| 656 |
+
}
|
| 657 |
+
clone := transport.Clone()
|
| 658 |
+
clone.Proxy = nil
|
| 659 |
+
return clone
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
func buildProxyTransport(proxyStr string) *http.Transport {
|
| 663 |
+
proxyStr = strings.TrimSpace(proxyStr)
|
| 664 |
+
if proxyStr == "" {
|
| 665 |
+
return nil
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
proxyURL, errParse := url.Parse(proxyStr)
|
| 669 |
+
if errParse != nil {
|
| 670 |
+
log.WithError(errParse).Debug("parse proxy URL failed")
|
| 671 |
+
return nil
|
| 672 |
+
}
|
| 673 |
+
if proxyURL.Scheme == "" || proxyURL.Host == "" {
|
| 674 |
+
log.Debug("proxy URL missing scheme/host")
|
| 675 |
+
return nil
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
if proxyURL.Scheme == "socks5" {
|
| 679 |
+
var proxyAuth *proxy.Auth
|
| 680 |
+
if proxyURL.User != nil {
|
| 681 |
+
username := proxyURL.User.Username()
|
| 682 |
+
password, _ := proxyURL.User.Password()
|
| 683 |
+
proxyAuth = &proxy.Auth{User: username, Password: password}
|
| 684 |
+
}
|
| 685 |
+
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
|
| 686 |
+
if errSOCKS5 != nil {
|
| 687 |
+
log.WithError(errSOCKS5).Debug("create SOCKS5 dialer failed")
|
| 688 |
+
return nil
|
| 689 |
+
}
|
| 690 |
+
return &http.Transport{
|
| 691 |
+
Proxy: nil,
|
| 692 |
+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
| 693 |
+
return dialer.Dial(network, addr)
|
| 694 |
+
},
|
| 695 |
+
}
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
| 699 |
+
return &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
|
| 703 |
+
return nil
|
| 704 |
+
}
|
internal/api/handlers/management/api_tools_test.go
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"io"
|
| 7 |
+
"net/http"
|
| 8 |
+
"net/http/httptest"
|
| 9 |
+
"net/url"
|
| 10 |
+
"strings"
|
| 11 |
+
"sync"
|
| 12 |
+
"testing"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
type memoryAuthStore struct {
|
| 19 |
+
mu sync.Mutex
|
| 20 |
+
items map[string]*coreauth.Auth
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (s *memoryAuthStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
|
| 24 |
+
_ = ctx
|
| 25 |
+
s.mu.Lock()
|
| 26 |
+
defer s.mu.Unlock()
|
| 27 |
+
out := make([]*coreauth.Auth, 0, len(s.items))
|
| 28 |
+
for _, a := range s.items {
|
| 29 |
+
out = append(out, a.Clone())
|
| 30 |
+
}
|
| 31 |
+
return out, nil
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func (s *memoryAuthStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 35 |
+
_ = ctx
|
| 36 |
+
if auth == nil {
|
| 37 |
+
return "", nil
|
| 38 |
+
}
|
| 39 |
+
s.mu.Lock()
|
| 40 |
+
if s.items == nil {
|
| 41 |
+
s.items = make(map[string]*coreauth.Auth)
|
| 42 |
+
}
|
| 43 |
+
s.items[auth.ID] = auth.Clone()
|
| 44 |
+
s.mu.Unlock()
|
| 45 |
+
return auth.ID, nil
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func (s *memoryAuthStore) Delete(ctx context.Context, id string) error {
|
| 49 |
+
_ = ctx
|
| 50 |
+
s.mu.Lock()
|
| 51 |
+
delete(s.items, id)
|
| 52 |
+
s.mu.Unlock()
|
| 53 |
+
return nil
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestResolveTokenForAuth_Antigravity_RefreshesExpiredToken(t *testing.T) {
|
| 57 |
+
var callCount int
|
| 58 |
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 59 |
+
callCount++
|
| 60 |
+
if r.Method != http.MethodPost {
|
| 61 |
+
t.Fatalf("expected POST, got %s", r.Method)
|
| 62 |
+
}
|
| 63 |
+
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
| 64 |
+
t.Fatalf("unexpected content-type: %s", ct)
|
| 65 |
+
}
|
| 66 |
+
bodyBytes, _ := io.ReadAll(r.Body)
|
| 67 |
+
_ = r.Body.Close()
|
| 68 |
+
values, err := url.ParseQuery(string(bodyBytes))
|
| 69 |
+
if err != nil {
|
| 70 |
+
t.Fatalf("parse form: %v", err)
|
| 71 |
+
}
|
| 72 |
+
if values.Get("grant_type") != "refresh_token" {
|
| 73 |
+
t.Fatalf("unexpected grant_type: %s", values.Get("grant_type"))
|
| 74 |
+
}
|
| 75 |
+
if values.Get("refresh_token") != "rt" {
|
| 76 |
+
t.Fatalf("unexpected refresh_token: %s", values.Get("refresh_token"))
|
| 77 |
+
}
|
| 78 |
+
if values.Get("client_id") != antigravityOAuthClientID {
|
| 79 |
+
t.Fatalf("unexpected client_id: %s", values.Get("client_id"))
|
| 80 |
+
}
|
| 81 |
+
if values.Get("client_secret") != antigravityOAuthClientSecret {
|
| 82 |
+
t.Fatalf("unexpected client_secret")
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
w.Header().Set("Content-Type", "application/json")
|
| 86 |
+
_ = json.NewEncoder(w).Encode(map[string]any{
|
| 87 |
+
"access_token": "new-token",
|
| 88 |
+
"refresh_token": "rt2",
|
| 89 |
+
"expires_in": int64(3600),
|
| 90 |
+
"token_type": "Bearer",
|
| 91 |
+
})
|
| 92 |
+
}))
|
| 93 |
+
t.Cleanup(srv.Close)
|
| 94 |
+
|
| 95 |
+
originalURL := antigravityOAuthTokenURL
|
| 96 |
+
antigravityOAuthTokenURL = srv.URL
|
| 97 |
+
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
| 98 |
+
|
| 99 |
+
store := &memoryAuthStore{}
|
| 100 |
+
manager := coreauth.NewManager(store, nil, nil)
|
| 101 |
+
|
| 102 |
+
auth := &coreauth.Auth{
|
| 103 |
+
ID: "antigravity-test.json",
|
| 104 |
+
FileName: "antigravity-test.json",
|
| 105 |
+
Provider: "antigravity",
|
| 106 |
+
Metadata: map[string]any{
|
| 107 |
+
"type": "antigravity",
|
| 108 |
+
"access_token": "old-token",
|
| 109 |
+
"refresh_token": "rt",
|
| 110 |
+
"expires_in": int64(3600),
|
| 111 |
+
"timestamp": time.Now().Add(-2 * time.Hour).UnixMilli(),
|
| 112 |
+
"expired": time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
|
| 113 |
+
},
|
| 114 |
+
}
|
| 115 |
+
if _, err := manager.Register(context.Background(), auth); err != nil {
|
| 116 |
+
t.Fatalf("register auth: %v", err)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
h := &Handler{authManager: manager}
|
| 120 |
+
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
| 121 |
+
if err != nil {
|
| 122 |
+
t.Fatalf("resolveTokenForAuth: %v", err)
|
| 123 |
+
}
|
| 124 |
+
if token != "new-token" {
|
| 125 |
+
t.Fatalf("expected refreshed token, got %q", token)
|
| 126 |
+
}
|
| 127 |
+
if callCount != 1 {
|
| 128 |
+
t.Fatalf("expected 1 refresh call, got %d", callCount)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
updated, ok := manager.GetByID(auth.ID)
|
| 132 |
+
if !ok || updated == nil {
|
| 133 |
+
t.Fatalf("expected auth in manager after update")
|
| 134 |
+
}
|
| 135 |
+
if got := tokenValueFromMetadata(updated.Metadata); got != "new-token" {
|
| 136 |
+
t.Fatalf("expected manager metadata updated, got %q", got)
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
func TestResolveTokenForAuth_Antigravity_SkipsRefreshWhenTokenValid(t *testing.T) {
|
| 141 |
+
var callCount int
|
| 142 |
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 143 |
+
callCount++
|
| 144 |
+
w.WriteHeader(http.StatusInternalServerError)
|
| 145 |
+
}))
|
| 146 |
+
t.Cleanup(srv.Close)
|
| 147 |
+
|
| 148 |
+
originalURL := antigravityOAuthTokenURL
|
| 149 |
+
antigravityOAuthTokenURL = srv.URL
|
| 150 |
+
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
| 151 |
+
|
| 152 |
+
auth := &coreauth.Auth{
|
| 153 |
+
ID: "antigravity-valid.json",
|
| 154 |
+
FileName: "antigravity-valid.json",
|
| 155 |
+
Provider: "antigravity",
|
| 156 |
+
Metadata: map[string]any{
|
| 157 |
+
"type": "antigravity",
|
| 158 |
+
"access_token": "ok-token",
|
| 159 |
+
"expired": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
|
| 160 |
+
},
|
| 161 |
+
}
|
| 162 |
+
h := &Handler{}
|
| 163 |
+
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
| 164 |
+
if err != nil {
|
| 165 |
+
t.Fatalf("resolveTokenForAuth: %v", err)
|
| 166 |
+
}
|
| 167 |
+
if token != "ok-token" {
|
| 168 |
+
t.Fatalf("expected existing token, got %q", token)
|
| 169 |
+
}
|
| 170 |
+
if callCount != 0 {
|
| 171 |
+
t.Fatalf("expected no refresh calls, got %d", callCount)
|
| 172 |
+
}
|
| 173 |
+
}
|
internal/api/handlers/management/auth_files.go
ADDED
|
@@ -0,0 +1,2191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"crypto/sha256"
|
| 7 |
+
"encoding/hex"
|
| 8 |
+
"encoding/json"
|
| 9 |
+
"errors"
|
| 10 |
+
"fmt"
|
| 11 |
+
"io"
|
| 12 |
+
"net"
|
| 13 |
+
"net/http"
|
| 14 |
+
"os"
|
| 15 |
+
"path/filepath"
|
| 16 |
+
"sort"
|
| 17 |
+
"strconv"
|
| 18 |
+
"strings"
|
| 19 |
+
"sync"
|
| 20 |
+
"time"
|
| 21 |
+
|
| 22 |
+
"github.com/gin-gonic/gin"
|
| 23 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
|
| 24 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
| 25 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
| 26 |
+
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
| 27 |
+
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
| 28 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
| 29 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
| 30 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
| 31 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
| 32 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 33 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 34 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 35 |
+
log "github.com/sirupsen/logrus"
|
| 36 |
+
"github.com/tidwall/gjson"
|
| 37 |
+
"golang.org/x/oauth2"
|
| 38 |
+
"golang.org/x/oauth2/google"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
|
| 42 |
+
|
| 43 |
+
const (
|
| 44 |
+
anthropicCallbackPort = 54545
|
| 45 |
+
geminiCallbackPort = 8085
|
| 46 |
+
codexCallbackPort = 1455
|
| 47 |
+
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
|
| 48 |
+
geminiCLIVersion = "v1internal"
|
| 49 |
+
geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
|
| 50 |
+
geminiCLIApiClient = "gl-node/22.17.0"
|
| 51 |
+
geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
type callbackForwarder struct {
|
| 55 |
+
provider string
|
| 56 |
+
server *http.Server
|
| 57 |
+
done chan struct{}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
var (
|
| 61 |
+
callbackForwardersMu sync.Mutex
|
| 62 |
+
callbackForwarders = make(map[int]*callbackForwarder)
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
|
| 66 |
+
if len(meta) == 0 {
|
| 67 |
+
return time.Time{}, false
|
| 68 |
+
}
|
| 69 |
+
for _, key := range lastRefreshKeys {
|
| 70 |
+
if val, ok := meta[key]; ok {
|
| 71 |
+
if ts, ok1 := parseLastRefreshValue(val); ok1 {
|
| 72 |
+
return ts, true
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
return time.Time{}, false
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
func parseLastRefreshValue(v any) (time.Time, bool) {
|
| 80 |
+
switch val := v.(type) {
|
| 81 |
+
case string:
|
| 82 |
+
s := strings.TrimSpace(val)
|
| 83 |
+
if s == "" {
|
| 84 |
+
return time.Time{}, false
|
| 85 |
+
}
|
| 86 |
+
layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
|
| 87 |
+
for _, layout := range layouts {
|
| 88 |
+
if ts, err := time.Parse(layout, s); err == nil {
|
| 89 |
+
return ts.UTC(), true
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
|
| 93 |
+
return time.Unix(unix, 0).UTC(), true
|
| 94 |
+
}
|
| 95 |
+
case float64:
|
| 96 |
+
if val <= 0 {
|
| 97 |
+
return time.Time{}, false
|
| 98 |
+
}
|
| 99 |
+
return time.Unix(int64(val), 0).UTC(), true
|
| 100 |
+
case int64:
|
| 101 |
+
if val <= 0 {
|
| 102 |
+
return time.Time{}, false
|
| 103 |
+
}
|
| 104 |
+
return time.Unix(val, 0).UTC(), true
|
| 105 |
+
case int:
|
| 106 |
+
if val <= 0 {
|
| 107 |
+
return time.Time{}, false
|
| 108 |
+
}
|
| 109 |
+
return time.Unix(int64(val), 0).UTC(), true
|
| 110 |
+
case json.Number:
|
| 111 |
+
if i, err := val.Int64(); err == nil && i > 0 {
|
| 112 |
+
return time.Unix(i, 0).UTC(), true
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
return time.Time{}, false
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func isWebUIRequest(c *gin.Context) bool {
|
| 119 |
+
raw := strings.TrimSpace(c.Query("is_webui"))
|
| 120 |
+
if raw == "" {
|
| 121 |
+
return false
|
| 122 |
+
}
|
| 123 |
+
switch strings.ToLower(raw) {
|
| 124 |
+
case "1", "true", "yes", "on":
|
| 125 |
+
return true
|
| 126 |
+
default:
|
| 127 |
+
return false
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) {
|
| 132 |
+
callbackForwardersMu.Lock()
|
| 133 |
+
prev := callbackForwarders[port]
|
| 134 |
+
if prev != nil {
|
| 135 |
+
delete(callbackForwarders, port)
|
| 136 |
+
}
|
| 137 |
+
callbackForwardersMu.Unlock()
|
| 138 |
+
|
| 139 |
+
if prev != nil {
|
| 140 |
+
stopForwarderInstance(port, prev)
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
| 144 |
+
ln, err := net.Listen("tcp", addr)
|
| 145 |
+
if err != nil {
|
| 146 |
+
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 150 |
+
target := targetBase
|
| 151 |
+
if raw := r.URL.RawQuery; raw != "" {
|
| 152 |
+
if strings.Contains(target, "?") {
|
| 153 |
+
target = target + "&" + raw
|
| 154 |
+
} else {
|
| 155 |
+
target = target + "?" + raw
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
w.Header().Set("Cache-Control", "no-store")
|
| 159 |
+
http.Redirect(w, r, target, http.StatusFound)
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
srv := &http.Server{
|
| 163 |
+
Handler: handler,
|
| 164 |
+
ReadHeaderTimeout: 5 * time.Second,
|
| 165 |
+
WriteTimeout: 5 * time.Second,
|
| 166 |
+
}
|
| 167 |
+
done := make(chan struct{})
|
| 168 |
+
|
| 169 |
+
go func() {
|
| 170 |
+
if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
|
| 171 |
+
log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider)
|
| 172 |
+
}
|
| 173 |
+
close(done)
|
| 174 |
+
}()
|
| 175 |
+
|
| 176 |
+
forwarder := &callbackForwarder{
|
| 177 |
+
provider: provider,
|
| 178 |
+
server: srv,
|
| 179 |
+
done: done,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
callbackForwardersMu.Lock()
|
| 183 |
+
callbackForwarders[port] = forwarder
|
| 184 |
+
callbackForwardersMu.Unlock()
|
| 185 |
+
|
| 186 |
+
log.Infof("callback forwarder for %s listening on %s", provider, addr)
|
| 187 |
+
|
| 188 |
+
return forwarder, nil
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
func stopCallbackForwarder(port int) {
|
| 192 |
+
callbackForwardersMu.Lock()
|
| 193 |
+
forwarder := callbackForwarders[port]
|
| 194 |
+
if forwarder != nil {
|
| 195 |
+
delete(callbackForwarders, port)
|
| 196 |
+
}
|
| 197 |
+
callbackForwardersMu.Unlock()
|
| 198 |
+
|
| 199 |
+
stopForwarderInstance(port, forwarder)
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
|
| 203 |
+
if forwarder == nil {
|
| 204 |
+
return
|
| 205 |
+
}
|
| 206 |
+
callbackForwardersMu.Lock()
|
| 207 |
+
if current := callbackForwarders[port]; current == forwarder {
|
| 208 |
+
delete(callbackForwarders, port)
|
| 209 |
+
}
|
| 210 |
+
callbackForwardersMu.Unlock()
|
| 211 |
+
|
| 212 |
+
stopForwarderInstance(port, forwarder)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
func stopForwarderInstance(port int, forwarder *callbackForwarder) {
|
| 216 |
+
if forwarder == nil || forwarder.server == nil {
|
| 217 |
+
return
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
| 221 |
+
defer cancel()
|
| 222 |
+
|
| 223 |
+
if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
| 224 |
+
log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
select {
|
| 228 |
+
case <-forwarder.done:
|
| 229 |
+
case <-time.After(2 * time.Second):
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
log.Infof("callback forwarder on port %d stopped", port)
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
func (h *Handler) managementCallbackURL(path string) (string, error) {
|
| 236 |
+
if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
|
| 237 |
+
return "", fmt.Errorf("server port is not configured")
|
| 238 |
+
}
|
| 239 |
+
if !strings.HasPrefix(path, "/") {
|
| 240 |
+
path = "/" + path
|
| 241 |
+
}
|
| 242 |
+
scheme := "http"
|
| 243 |
+
if h.cfg.TLS.Enable {
|
| 244 |
+
scheme = "https"
|
| 245 |
+
}
|
| 246 |
+
return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
func (h *Handler) ListAuthFiles(c *gin.Context) {
|
| 250 |
+
if h == nil {
|
| 251 |
+
c.JSON(500, gin.H{"error": "handler not initialized"})
|
| 252 |
+
return
|
| 253 |
+
}
|
| 254 |
+
if h.authManager == nil {
|
| 255 |
+
h.listAuthFilesFromDisk(c)
|
| 256 |
+
return
|
| 257 |
+
}
|
| 258 |
+
auths := h.authManager.List()
|
| 259 |
+
files := make([]gin.H, 0, len(auths))
|
| 260 |
+
for _, auth := range auths {
|
| 261 |
+
if entry := h.buildAuthFileEntry(auth); entry != nil {
|
| 262 |
+
files = append(files, entry)
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
sort.Slice(files, func(i, j int) bool {
|
| 266 |
+
nameI, _ := files[i]["name"].(string)
|
| 267 |
+
nameJ, _ := files[j]["name"].(string)
|
| 268 |
+
return strings.ToLower(nameI) < strings.ToLower(nameJ)
|
| 269 |
+
})
|
| 270 |
+
c.JSON(200, gin.H{"files": files})
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// GetAuthFileModels returns the models supported by a specific auth file
|
| 274 |
+
func (h *Handler) GetAuthFileModels(c *gin.Context) {
|
| 275 |
+
name := c.Query("name")
|
| 276 |
+
if name == "" {
|
| 277 |
+
c.JSON(400, gin.H{"error": "name is required"})
|
| 278 |
+
return
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Try to find auth ID via authManager
|
| 282 |
+
var authID string
|
| 283 |
+
if h.authManager != nil {
|
| 284 |
+
auths := h.authManager.List()
|
| 285 |
+
for _, auth := range auths {
|
| 286 |
+
if auth.FileName == name || auth.ID == name {
|
| 287 |
+
authID = auth.ID
|
| 288 |
+
break
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
if authID == "" {
|
| 294 |
+
authID = name // fallback to filename as ID
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// Get models from registry
|
| 298 |
+
reg := registry.GetGlobalRegistry()
|
| 299 |
+
models := reg.GetModelsForClient(authID)
|
| 300 |
+
|
| 301 |
+
result := make([]gin.H, 0, len(models))
|
| 302 |
+
for _, m := range models {
|
| 303 |
+
entry := gin.H{
|
| 304 |
+
"id": m.ID,
|
| 305 |
+
}
|
| 306 |
+
if m.DisplayName != "" {
|
| 307 |
+
entry["display_name"] = m.DisplayName
|
| 308 |
+
}
|
| 309 |
+
if m.Type != "" {
|
| 310 |
+
entry["type"] = m.Type
|
| 311 |
+
}
|
| 312 |
+
if m.OwnedBy != "" {
|
| 313 |
+
entry["owned_by"] = m.OwnedBy
|
| 314 |
+
}
|
| 315 |
+
result = append(result, entry)
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
c.JSON(200, gin.H{"models": result})
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// List auth files from disk when the auth manager is unavailable.
|
| 322 |
+
func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
|
| 323 |
+
entries, err := os.ReadDir(h.cfg.AuthDir)
|
| 324 |
+
if err != nil {
|
| 325 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
| 326 |
+
return
|
| 327 |
+
}
|
| 328 |
+
files := make([]gin.H, 0)
|
| 329 |
+
for _, e := range entries {
|
| 330 |
+
if e.IsDir() {
|
| 331 |
+
continue
|
| 332 |
+
}
|
| 333 |
+
name := e.Name()
|
| 334 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 335 |
+
continue
|
| 336 |
+
}
|
| 337 |
+
if info, errInfo := e.Info(); errInfo == nil {
|
| 338 |
+
fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
|
| 339 |
+
|
| 340 |
+
// Read file to get type field
|
| 341 |
+
full := filepath.Join(h.cfg.AuthDir, name)
|
| 342 |
+
if data, errRead := os.ReadFile(full); errRead == nil {
|
| 343 |
+
typeValue := gjson.GetBytes(data, "type").String()
|
| 344 |
+
emailValue := gjson.GetBytes(data, "email").String()
|
| 345 |
+
fileData["type"] = typeValue
|
| 346 |
+
fileData["email"] = emailValue
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
files = append(files, fileData)
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
c.JSON(200, gin.H{"files": files})
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
|
| 356 |
+
if auth == nil {
|
| 357 |
+
return nil
|
| 358 |
+
}
|
| 359 |
+
auth.EnsureIndex()
|
| 360 |
+
runtimeOnly := isRuntimeOnlyAuth(auth)
|
| 361 |
+
if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
|
| 362 |
+
return nil
|
| 363 |
+
}
|
| 364 |
+
path := strings.TrimSpace(authAttribute(auth, "path"))
|
| 365 |
+
if path == "" && !runtimeOnly {
|
| 366 |
+
return nil
|
| 367 |
+
}
|
| 368 |
+
name := strings.TrimSpace(auth.FileName)
|
| 369 |
+
if name == "" {
|
| 370 |
+
name = auth.ID
|
| 371 |
+
}
|
| 372 |
+
entry := gin.H{
|
| 373 |
+
"id": auth.ID,
|
| 374 |
+
"auth_index": auth.Index,
|
| 375 |
+
"name": name,
|
| 376 |
+
"type": strings.TrimSpace(auth.Provider),
|
| 377 |
+
"provider": strings.TrimSpace(auth.Provider),
|
| 378 |
+
"label": auth.Label,
|
| 379 |
+
"status": auth.Status,
|
| 380 |
+
"status_message": auth.StatusMessage,
|
| 381 |
+
"disabled": auth.Disabled,
|
| 382 |
+
"unavailable": auth.Unavailable,
|
| 383 |
+
"runtime_only": runtimeOnly,
|
| 384 |
+
"source": "memory",
|
| 385 |
+
"size": int64(0),
|
| 386 |
+
}
|
| 387 |
+
if email := authEmail(auth); email != "" {
|
| 388 |
+
entry["email"] = email
|
| 389 |
+
}
|
| 390 |
+
if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
|
| 391 |
+
if accountType != "" {
|
| 392 |
+
entry["account_type"] = accountType
|
| 393 |
+
}
|
| 394 |
+
if account != "" {
|
| 395 |
+
entry["account"] = account
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
if !auth.CreatedAt.IsZero() {
|
| 399 |
+
entry["created_at"] = auth.CreatedAt
|
| 400 |
+
}
|
| 401 |
+
if !auth.UpdatedAt.IsZero() {
|
| 402 |
+
entry["modtime"] = auth.UpdatedAt
|
| 403 |
+
entry["updated_at"] = auth.UpdatedAt
|
| 404 |
+
}
|
| 405 |
+
if !auth.LastRefreshedAt.IsZero() {
|
| 406 |
+
entry["last_refresh"] = auth.LastRefreshedAt
|
| 407 |
+
}
|
| 408 |
+
if path != "" {
|
| 409 |
+
entry["path"] = path
|
| 410 |
+
entry["source"] = "file"
|
| 411 |
+
if info, err := os.Stat(path); err == nil {
|
| 412 |
+
entry["size"] = info.Size()
|
| 413 |
+
entry["modtime"] = info.ModTime()
|
| 414 |
+
} else if os.IsNotExist(err) {
|
| 415 |
+
// Hide credentials removed from disk but still lingering in memory.
|
| 416 |
+
if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
|
| 417 |
+
return nil
|
| 418 |
+
}
|
| 419 |
+
entry["source"] = "memory"
|
| 420 |
+
} else {
|
| 421 |
+
log.WithError(err).Warnf("failed to stat auth file %s", path)
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
if claims := extractCodexIDTokenClaims(auth); claims != nil {
|
| 425 |
+
entry["id_token"] = claims
|
| 426 |
+
}
|
| 427 |
+
return entry
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
|
| 431 |
+
if auth == nil || auth.Metadata == nil {
|
| 432 |
+
return nil
|
| 433 |
+
}
|
| 434 |
+
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
|
| 435 |
+
return nil
|
| 436 |
+
}
|
| 437 |
+
idTokenRaw, ok := auth.Metadata["id_token"].(string)
|
| 438 |
+
if !ok {
|
| 439 |
+
return nil
|
| 440 |
+
}
|
| 441 |
+
idToken := strings.TrimSpace(idTokenRaw)
|
| 442 |
+
if idToken == "" {
|
| 443 |
+
return nil
|
| 444 |
+
}
|
| 445 |
+
claims, err := codex.ParseJWTToken(idToken)
|
| 446 |
+
if err != nil || claims == nil {
|
| 447 |
+
return nil
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
result := gin.H{}
|
| 451 |
+
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
|
| 452 |
+
result["chatgpt_account_id"] = v
|
| 453 |
+
}
|
| 454 |
+
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
|
| 455 |
+
result["plan_type"] = v
|
| 456 |
+
}
|
| 457 |
+
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
|
| 458 |
+
result["chatgpt_subscription_active_start"] = v
|
| 459 |
+
}
|
| 460 |
+
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
|
| 461 |
+
result["chatgpt_subscription_active_until"] = v
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
if len(result) == 0 {
|
| 465 |
+
return nil
|
| 466 |
+
}
|
| 467 |
+
return result
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
func authEmail(auth *coreauth.Auth) string {
|
| 471 |
+
if auth == nil {
|
| 472 |
+
return ""
|
| 473 |
+
}
|
| 474 |
+
if auth.Metadata != nil {
|
| 475 |
+
if v, ok := auth.Metadata["email"].(string); ok {
|
| 476 |
+
return strings.TrimSpace(v)
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
if auth.Attributes != nil {
|
| 480 |
+
if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
|
| 481 |
+
return v
|
| 482 |
+
}
|
| 483 |
+
if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
|
| 484 |
+
return v
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
return ""
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
func authAttribute(auth *coreauth.Auth, key string) string {
|
| 491 |
+
if auth == nil || len(auth.Attributes) == 0 {
|
| 492 |
+
return ""
|
| 493 |
+
}
|
| 494 |
+
return auth.Attributes[key]
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
|
| 498 |
+
if auth == nil || len(auth.Attributes) == 0 {
|
| 499 |
+
return false
|
| 500 |
+
}
|
| 501 |
+
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// Download single auth file by name
|
| 505 |
+
func (h *Handler) DownloadAuthFile(c *gin.Context) {
|
| 506 |
+
name := c.Query("name")
|
| 507 |
+
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
| 508 |
+
c.JSON(400, gin.H{"error": "invalid name"})
|
| 509 |
+
return
|
| 510 |
+
}
|
| 511 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 512 |
+
c.JSON(400, gin.H{"error": "name must end with .json"})
|
| 513 |
+
return
|
| 514 |
+
}
|
| 515 |
+
full := filepath.Join(h.cfg.AuthDir, name)
|
| 516 |
+
data, err := os.ReadFile(full)
|
| 517 |
+
if err != nil {
|
| 518 |
+
if os.IsNotExist(err) {
|
| 519 |
+
c.JSON(404, gin.H{"error": "file not found"})
|
| 520 |
+
} else {
|
| 521 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
| 522 |
+
}
|
| 523 |
+
return
|
| 524 |
+
}
|
| 525 |
+
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
|
| 526 |
+
c.Data(200, "application/json", data)
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// Upload auth file: multipart or raw JSON with ?name=
|
| 530 |
+
func (h *Handler) UploadAuthFile(c *gin.Context) {
|
| 531 |
+
if h.authManager == nil {
|
| 532 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
| 533 |
+
return
|
| 534 |
+
}
|
| 535 |
+
ctx := c.Request.Context()
|
| 536 |
+
if file, err := c.FormFile("file"); err == nil && file != nil {
|
| 537 |
+
name := filepath.Base(file.Filename)
|
| 538 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 539 |
+
c.JSON(400, gin.H{"error": "file must be .json"})
|
| 540 |
+
return
|
| 541 |
+
}
|
| 542 |
+
dst := filepath.Join(h.cfg.AuthDir, name)
|
| 543 |
+
if !filepath.IsAbs(dst) {
|
| 544 |
+
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
| 545 |
+
dst = abs
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
|
| 549 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
|
| 550 |
+
return
|
| 551 |
+
}
|
| 552 |
+
data, errRead := os.ReadFile(dst)
|
| 553 |
+
if errRead != nil {
|
| 554 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
|
| 555 |
+
return
|
| 556 |
+
}
|
| 557 |
+
if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
|
| 558 |
+
c.JSON(500, gin.H{"error": errReg.Error()})
|
| 559 |
+
return
|
| 560 |
+
}
|
| 561 |
+
c.JSON(200, gin.H{"status": "ok"})
|
| 562 |
+
return
|
| 563 |
+
}
|
| 564 |
+
name := c.Query("name")
|
| 565 |
+
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
| 566 |
+
c.JSON(400, gin.H{"error": "invalid name"})
|
| 567 |
+
return
|
| 568 |
+
}
|
| 569 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 570 |
+
c.JSON(400, gin.H{"error": "name must end with .json"})
|
| 571 |
+
return
|
| 572 |
+
}
|
| 573 |
+
data, err := io.ReadAll(c.Request.Body)
|
| 574 |
+
if err != nil {
|
| 575 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 576 |
+
return
|
| 577 |
+
}
|
| 578 |
+
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
| 579 |
+
if !filepath.IsAbs(dst) {
|
| 580 |
+
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
| 581 |
+
dst = abs
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
|
| 585 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
|
| 586 |
+
return
|
| 587 |
+
}
|
| 588 |
+
if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
|
| 589 |
+
c.JSON(500, gin.H{"error": err.Error()})
|
| 590 |
+
return
|
| 591 |
+
}
|
| 592 |
+
c.JSON(200, gin.H{"status": "ok"})
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
// Delete auth files: single by name or all
|
| 596 |
+
func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
| 597 |
+
if h.authManager == nil {
|
| 598 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
| 599 |
+
return
|
| 600 |
+
}
|
| 601 |
+
ctx := c.Request.Context()
|
| 602 |
+
if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
|
| 603 |
+
entries, err := os.ReadDir(h.cfg.AuthDir)
|
| 604 |
+
if err != nil {
|
| 605 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
| 606 |
+
return
|
| 607 |
+
}
|
| 608 |
+
deleted := 0
|
| 609 |
+
for _, e := range entries {
|
| 610 |
+
if e.IsDir() {
|
| 611 |
+
continue
|
| 612 |
+
}
|
| 613 |
+
name := e.Name()
|
| 614 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 615 |
+
continue
|
| 616 |
+
}
|
| 617 |
+
full := filepath.Join(h.cfg.AuthDir, name)
|
| 618 |
+
if !filepath.IsAbs(full) {
|
| 619 |
+
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
| 620 |
+
full = abs
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
if err = os.Remove(full); err == nil {
|
| 624 |
+
if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
|
| 625 |
+
c.JSON(500, gin.H{"error": errDel.Error()})
|
| 626 |
+
return
|
| 627 |
+
}
|
| 628 |
+
deleted++
|
| 629 |
+
h.disableAuth(ctx, full)
|
| 630 |
+
}
|
| 631 |
+
}
|
| 632 |
+
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
|
| 633 |
+
return
|
| 634 |
+
}
|
| 635 |
+
name := c.Query("name")
|
| 636 |
+
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
| 637 |
+
c.JSON(400, gin.H{"error": "invalid name"})
|
| 638 |
+
return
|
| 639 |
+
}
|
| 640 |
+
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
| 641 |
+
if !filepath.IsAbs(full) {
|
| 642 |
+
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
| 643 |
+
full = abs
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
if err := os.Remove(full); err != nil {
|
| 647 |
+
if os.IsNotExist(err) {
|
| 648 |
+
c.JSON(404, gin.H{"error": "file not found"})
|
| 649 |
+
} else {
|
| 650 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
|
| 651 |
+
}
|
| 652 |
+
return
|
| 653 |
+
}
|
| 654 |
+
if err := h.deleteTokenRecord(ctx, full); err != nil {
|
| 655 |
+
c.JSON(500, gin.H{"error": err.Error()})
|
| 656 |
+
return
|
| 657 |
+
}
|
| 658 |
+
h.disableAuth(ctx, full)
|
| 659 |
+
c.JSON(200, gin.H{"status": "ok"})
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
func (h *Handler) authIDForPath(path string) string {
|
| 663 |
+
path = strings.TrimSpace(path)
|
| 664 |
+
if path == "" {
|
| 665 |
+
return ""
|
| 666 |
+
}
|
| 667 |
+
if h == nil || h.cfg == nil {
|
| 668 |
+
return path
|
| 669 |
+
}
|
| 670 |
+
authDir := strings.TrimSpace(h.cfg.AuthDir)
|
| 671 |
+
if authDir == "" {
|
| 672 |
+
return path
|
| 673 |
+
}
|
| 674 |
+
if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" {
|
| 675 |
+
return rel
|
| 676 |
+
}
|
| 677 |
+
return path
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
|
| 681 |
+
if h.authManager == nil {
|
| 682 |
+
return nil
|
| 683 |
+
}
|
| 684 |
+
if path == "" {
|
| 685 |
+
return fmt.Errorf("auth path is empty")
|
| 686 |
+
}
|
| 687 |
+
if data == nil {
|
| 688 |
+
var err error
|
| 689 |
+
data, err = os.ReadFile(path)
|
| 690 |
+
if err != nil {
|
| 691 |
+
return fmt.Errorf("failed to read auth file: %w", err)
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
metadata := make(map[string]any)
|
| 695 |
+
if err := json.Unmarshal(data, &metadata); err != nil {
|
| 696 |
+
return fmt.Errorf("invalid auth file: %w", err)
|
| 697 |
+
}
|
| 698 |
+
provider, _ := metadata["type"].(string)
|
| 699 |
+
if provider == "" {
|
| 700 |
+
provider = "unknown"
|
| 701 |
+
}
|
| 702 |
+
label := provider
|
| 703 |
+
if email, ok := metadata["email"].(string); ok && email != "" {
|
| 704 |
+
label = email
|
| 705 |
+
}
|
| 706 |
+
lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
|
| 707 |
+
|
| 708 |
+
authID := h.authIDForPath(path)
|
| 709 |
+
if authID == "" {
|
| 710 |
+
authID = path
|
| 711 |
+
}
|
| 712 |
+
attr := map[string]string{
|
| 713 |
+
"path": path,
|
| 714 |
+
"source": path,
|
| 715 |
+
}
|
| 716 |
+
auth := &coreauth.Auth{
|
| 717 |
+
ID: authID,
|
| 718 |
+
Provider: provider,
|
| 719 |
+
FileName: filepath.Base(path),
|
| 720 |
+
Label: label,
|
| 721 |
+
Status: coreauth.StatusActive,
|
| 722 |
+
Attributes: attr,
|
| 723 |
+
Metadata: metadata,
|
| 724 |
+
CreatedAt: time.Now(),
|
| 725 |
+
UpdatedAt: time.Now(),
|
| 726 |
+
}
|
| 727 |
+
if hasLastRefresh {
|
| 728 |
+
auth.LastRefreshedAt = lastRefresh
|
| 729 |
+
}
|
| 730 |
+
if existing, ok := h.authManager.GetByID(authID); ok {
|
| 731 |
+
auth.CreatedAt = existing.CreatedAt
|
| 732 |
+
if !hasLastRefresh {
|
| 733 |
+
auth.LastRefreshedAt = existing.LastRefreshedAt
|
| 734 |
+
}
|
| 735 |
+
auth.NextRefreshAfter = existing.NextRefreshAfter
|
| 736 |
+
auth.Runtime = existing.Runtime
|
| 737 |
+
_, err := h.authManager.Update(ctx, auth)
|
| 738 |
+
return err
|
| 739 |
+
}
|
| 740 |
+
_, err := h.authManager.Register(ctx, auth)
|
| 741 |
+
return err
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
// PatchAuthFileStatus toggles the disabled state of an auth file
|
| 745 |
+
func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
|
| 746 |
+
if h.authManager == nil {
|
| 747 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
| 748 |
+
return
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
var req struct {
|
| 752 |
+
Name string `json:"name"`
|
| 753 |
+
Disabled *bool `json:"disabled"`
|
| 754 |
+
}
|
| 755 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 756 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
| 757 |
+
return
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
name := strings.TrimSpace(req.Name)
|
| 761 |
+
if name == "" {
|
| 762 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
| 763 |
+
return
|
| 764 |
+
}
|
| 765 |
+
if req.Disabled == nil {
|
| 766 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
|
| 767 |
+
return
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
ctx := c.Request.Context()
|
| 771 |
+
|
| 772 |
+
// Find auth by name or ID
|
| 773 |
+
var targetAuth *coreauth.Auth
|
| 774 |
+
if auth, ok := h.authManager.GetByID(name); ok {
|
| 775 |
+
targetAuth = auth
|
| 776 |
+
} else {
|
| 777 |
+
auths := h.authManager.List()
|
| 778 |
+
for _, auth := range auths {
|
| 779 |
+
if auth.FileName == name {
|
| 780 |
+
targetAuth = auth
|
| 781 |
+
break
|
| 782 |
+
}
|
| 783 |
+
}
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
if targetAuth == nil {
|
| 787 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
| 788 |
+
return
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
// Update disabled state
|
| 792 |
+
targetAuth.Disabled = *req.Disabled
|
| 793 |
+
if *req.Disabled {
|
| 794 |
+
targetAuth.Status = coreauth.StatusDisabled
|
| 795 |
+
targetAuth.StatusMessage = "disabled via management API"
|
| 796 |
+
} else {
|
| 797 |
+
targetAuth.Status = coreauth.StatusActive
|
| 798 |
+
targetAuth.StatusMessage = ""
|
| 799 |
+
}
|
| 800 |
+
targetAuth.UpdatedAt = time.Now()
|
| 801 |
+
|
| 802 |
+
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
| 803 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
| 804 |
+
return
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
| 811 |
+
if h == nil || h.authManager == nil {
|
| 812 |
+
return
|
| 813 |
+
}
|
| 814 |
+
authID := h.authIDForPath(id)
|
| 815 |
+
if authID == "" {
|
| 816 |
+
authID = strings.TrimSpace(id)
|
| 817 |
+
}
|
| 818 |
+
if authID == "" {
|
| 819 |
+
return
|
| 820 |
+
}
|
| 821 |
+
if auth, ok := h.authManager.GetByID(authID); ok {
|
| 822 |
+
auth.Disabled = true
|
| 823 |
+
auth.Status = coreauth.StatusDisabled
|
| 824 |
+
auth.StatusMessage = "removed via management API"
|
| 825 |
+
auth.UpdatedAt = time.Now()
|
| 826 |
+
_, _ = h.authManager.Update(ctx, auth)
|
| 827 |
+
}
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
|
| 831 |
+
if strings.TrimSpace(path) == "" {
|
| 832 |
+
return fmt.Errorf("auth path is empty")
|
| 833 |
+
}
|
| 834 |
+
store := h.tokenStoreWithBaseDir()
|
| 835 |
+
if store == nil {
|
| 836 |
+
return fmt.Errorf("token store unavailable")
|
| 837 |
+
}
|
| 838 |
+
return store.Delete(ctx, path)
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
func (h *Handler) tokenStoreWithBaseDir() coreauth.Store {
|
| 842 |
+
if h == nil {
|
| 843 |
+
return nil
|
| 844 |
+
}
|
| 845 |
+
store := h.tokenStore
|
| 846 |
+
if store == nil {
|
| 847 |
+
store = sdkAuth.GetTokenStore()
|
| 848 |
+
h.tokenStore = store
|
| 849 |
+
}
|
| 850 |
+
if h.cfg != nil {
|
| 851 |
+
if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
|
| 852 |
+
dirSetter.SetBaseDir(h.cfg.AuthDir)
|
| 853 |
+
}
|
| 854 |
+
}
|
| 855 |
+
return store
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
|
| 859 |
+
if record == nil {
|
| 860 |
+
return "", fmt.Errorf("token record is nil")
|
| 861 |
+
}
|
| 862 |
+
store := h.tokenStoreWithBaseDir()
|
| 863 |
+
if store == nil {
|
| 864 |
+
return "", fmt.Errorf("token store unavailable")
|
| 865 |
+
}
|
| 866 |
+
return store.Save(ctx, record)
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
| 870 |
+
ctx := context.Background()
|
| 871 |
+
|
| 872 |
+
fmt.Println("Initializing Claude authentication...")
|
| 873 |
+
|
| 874 |
+
// Generate PKCE codes
|
| 875 |
+
pkceCodes, err := claude.GeneratePKCECodes()
|
| 876 |
+
if err != nil {
|
| 877 |
+
log.Errorf("Failed to generate PKCE codes: %v", err)
|
| 878 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
|
| 879 |
+
return
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
// Generate random state parameter
|
| 883 |
+
state, err := misc.GenerateRandomState()
|
| 884 |
+
if err != nil {
|
| 885 |
+
log.Errorf("Failed to generate state parameter: %v", err)
|
| 886 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
|
| 887 |
+
return
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
// Initialize Claude auth service
|
| 891 |
+
anthropicAuth := claude.NewClaudeAuth(h.cfg)
|
| 892 |
+
|
| 893 |
+
// Generate authorization URL (then override redirect_uri to reuse server port)
|
| 894 |
+
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
|
| 895 |
+
if err != nil {
|
| 896 |
+
log.Errorf("Failed to generate authorization URL: %v", err)
|
| 897 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
| 898 |
+
return
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
RegisterOAuthSession(state, "anthropic")
|
| 902 |
+
|
| 903 |
+
isWebUI := isWebUIRequest(c)
|
| 904 |
+
var forwarder *callbackForwarder
|
| 905 |
+
if isWebUI {
|
| 906 |
+
targetURL, errTarget := h.managementCallbackURL("/anthropic/callback")
|
| 907 |
+
if errTarget != nil {
|
| 908 |
+
log.WithError(errTarget).Error("failed to compute anthropic callback target")
|
| 909 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 910 |
+
return
|
| 911 |
+
}
|
| 912 |
+
var errStart error
|
| 913 |
+
if forwarder, errStart = startCallbackForwarder(anthropicCallbackPort, "anthropic", targetURL); errStart != nil {
|
| 914 |
+
log.WithError(errStart).Error("failed to start anthropic callback forwarder")
|
| 915 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 916 |
+
return
|
| 917 |
+
}
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
go func() {
|
| 921 |
+
if isWebUI {
|
| 922 |
+
defer stopCallbackForwarderInstance(anthropicCallbackPort, forwarder)
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
// Helper: wait for callback file
|
| 926 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
|
| 927 |
+
waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
|
| 928 |
+
deadline := time.Now().Add(timeout)
|
| 929 |
+
for {
|
| 930 |
+
if !IsOAuthSessionPending(state, "anthropic") {
|
| 931 |
+
return nil, errOAuthSessionNotPending
|
| 932 |
+
}
|
| 933 |
+
if time.Now().After(deadline) {
|
| 934 |
+
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
|
| 935 |
+
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
| 936 |
+
}
|
| 937 |
+
data, errRead := os.ReadFile(path)
|
| 938 |
+
if errRead == nil {
|
| 939 |
+
var m map[string]string
|
| 940 |
+
_ = json.Unmarshal(data, &m)
|
| 941 |
+
_ = os.Remove(path)
|
| 942 |
+
return m, nil
|
| 943 |
+
}
|
| 944 |
+
time.Sleep(500 * time.Millisecond)
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
fmt.Println("Waiting for authentication callback...")
|
| 949 |
+
// Wait up to 5 minutes
|
| 950 |
+
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
|
| 951 |
+
if errWait != nil {
|
| 952 |
+
if errors.Is(errWait, errOAuthSessionNotPending) {
|
| 953 |
+
return
|
| 954 |
+
}
|
| 955 |
+
authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
|
| 956 |
+
log.Error(claude.GetUserFriendlyMessage(authErr))
|
| 957 |
+
return
|
| 958 |
+
}
|
| 959 |
+
if errStr := resultMap["error"]; errStr != "" {
|
| 960 |
+
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
|
| 961 |
+
log.Error(claude.GetUserFriendlyMessage(oauthErr))
|
| 962 |
+
SetOAuthSessionError(state, "Bad request")
|
| 963 |
+
return
|
| 964 |
+
}
|
| 965 |
+
if resultMap["state"] != state {
|
| 966 |
+
authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
|
| 967 |
+
log.Error(claude.GetUserFriendlyMessage(authErr))
|
| 968 |
+
SetOAuthSessionError(state, "State code error")
|
| 969 |
+
return
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
// Parse code (Claude may append state after '#')
|
| 973 |
+
rawCode := resultMap["code"]
|
| 974 |
+
code := strings.Split(rawCode, "#")[0]
|
| 975 |
+
|
| 976 |
+
// Exchange code for tokens using internal auth service
|
| 977 |
+
bundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes)
|
| 978 |
+
if errExchange != nil {
|
| 979 |
+
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange)
|
| 980 |
+
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
| 981 |
+
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
|
| 982 |
+
return
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
// Create token storage
|
| 986 |
+
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
|
| 987 |
+
record := &coreauth.Auth{
|
| 988 |
+
ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
| 989 |
+
Provider: "claude",
|
| 990 |
+
FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
| 991 |
+
Storage: tokenStorage,
|
| 992 |
+
Metadata: map[string]any{"email": tokenStorage.Email},
|
| 993 |
+
}
|
| 994 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 995 |
+
if errSave != nil {
|
| 996 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 997 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 998 |
+
return
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1002 |
+
if bundle.APIKey != "" {
|
| 1003 |
+
fmt.Println("API key obtained and saved")
|
| 1004 |
+
}
|
| 1005 |
+
fmt.Println("You can now use Claude services through this CLI")
|
| 1006 |
+
CompleteOAuthSession(state)
|
| 1007 |
+
CompleteOAuthSessionsByProvider("anthropic")
|
| 1008 |
+
}()
|
| 1009 |
+
|
| 1010 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
| 1014 |
+
ctx := context.Background()
|
| 1015 |
+
proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
|
| 1016 |
+
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)
|
| 1017 |
+
|
| 1018 |
+
// Optional project ID from query
|
| 1019 |
+
projectID := c.Query("project_id")
|
| 1020 |
+
|
| 1021 |
+
fmt.Println("Initializing Google authentication...")
|
| 1022 |
+
|
| 1023 |
+
// OAuth2 configuration using exported constants from internal/auth/gemini
|
| 1024 |
+
conf := &oauth2.Config{
|
| 1025 |
+
ClientID: geminiAuth.ClientID,
|
| 1026 |
+
ClientSecret: geminiAuth.ClientSecret,
|
| 1027 |
+
RedirectURL: fmt.Sprintf("http://localhost:%d/oauth2callback", geminiAuth.DefaultCallbackPort),
|
| 1028 |
+
Scopes: geminiAuth.Scopes,
|
| 1029 |
+
Endpoint: google.Endpoint,
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
// Build authorization URL and return it immediately
|
| 1033 |
+
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
| 1034 |
+
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
| 1035 |
+
|
| 1036 |
+
RegisterOAuthSession(state, "gemini")
|
| 1037 |
+
|
| 1038 |
+
isWebUI := isWebUIRequest(c)
|
| 1039 |
+
var forwarder *callbackForwarder
|
| 1040 |
+
if isWebUI {
|
| 1041 |
+
targetURL, errTarget := h.managementCallbackURL("/google/callback")
|
| 1042 |
+
if errTarget != nil {
|
| 1043 |
+
log.WithError(errTarget).Error("failed to compute gemini callback target")
|
| 1044 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 1045 |
+
return
|
| 1046 |
+
}
|
| 1047 |
+
var errStart error
|
| 1048 |
+
if forwarder, errStart = startCallbackForwarder(geminiCallbackPort, "gemini", targetURL); errStart != nil {
|
| 1049 |
+
log.WithError(errStart).Error("failed to start gemini callback forwarder")
|
| 1050 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 1051 |
+
return
|
| 1052 |
+
}
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
go func() {
|
| 1056 |
+
if isWebUI {
|
| 1057 |
+
defer stopCallbackForwarderInstance(geminiCallbackPort, forwarder)
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
// Wait for callback file written by server route
|
| 1061 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
|
| 1062 |
+
fmt.Println("Waiting for authentication callback...")
|
| 1063 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1064 |
+
var authCode string
|
| 1065 |
+
for {
|
| 1066 |
+
if !IsOAuthSessionPending(state, "gemini") {
|
| 1067 |
+
return
|
| 1068 |
+
}
|
| 1069 |
+
if time.Now().After(deadline) {
|
| 1070 |
+
log.Error("oauth flow timed out")
|
| 1071 |
+
SetOAuthSessionError(state, "OAuth flow timed out")
|
| 1072 |
+
return
|
| 1073 |
+
}
|
| 1074 |
+
if data, errR := os.ReadFile(waitFile); errR == nil {
|
| 1075 |
+
var m map[string]string
|
| 1076 |
+
_ = json.Unmarshal(data, &m)
|
| 1077 |
+
_ = os.Remove(waitFile)
|
| 1078 |
+
if errStr := m["error"]; errStr != "" {
|
| 1079 |
+
log.Errorf("Authentication failed: %s", errStr)
|
| 1080 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1081 |
+
return
|
| 1082 |
+
}
|
| 1083 |
+
authCode = m["code"]
|
| 1084 |
+
if authCode == "" {
|
| 1085 |
+
log.Errorf("Authentication failed: code not found")
|
| 1086 |
+
SetOAuthSessionError(state, "Authentication failed: code not found")
|
| 1087 |
+
return
|
| 1088 |
+
}
|
| 1089 |
+
break
|
| 1090 |
+
}
|
| 1091 |
+
time.Sleep(500 * time.Millisecond)
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
// Exchange authorization code for token
|
| 1095 |
+
token, err := conf.Exchange(ctx, authCode)
|
| 1096 |
+
if err != nil {
|
| 1097 |
+
log.Errorf("Failed to exchange token: %v", err)
|
| 1098 |
+
SetOAuthSessionError(state, "Failed to exchange token")
|
| 1099 |
+
return
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
requestedProjectID := strings.TrimSpace(projectID)
|
| 1103 |
+
|
| 1104 |
+
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
| 1105 |
+
authHTTPClient := conf.Client(ctx, token)
|
| 1106 |
+
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
| 1107 |
+
if errNewRequest != nil {
|
| 1108 |
+
log.Errorf("Could not get user info: %v", errNewRequest)
|
| 1109 |
+
SetOAuthSessionError(state, "Could not get user info")
|
| 1110 |
+
return
|
| 1111 |
+
}
|
| 1112 |
+
req.Header.Set("Content-Type", "application/json")
|
| 1113 |
+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
| 1114 |
+
|
| 1115 |
+
resp, errDo := authHTTPClient.Do(req)
|
| 1116 |
+
if errDo != nil {
|
| 1117 |
+
log.Errorf("Failed to execute request: %v", errDo)
|
| 1118 |
+
SetOAuthSessionError(state, "Failed to execute request")
|
| 1119 |
+
return
|
| 1120 |
+
}
|
| 1121 |
+
defer func() {
|
| 1122 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 1123 |
+
log.Printf("warn: failed to close response body: %v", errClose)
|
| 1124 |
+
}
|
| 1125 |
+
}()
|
| 1126 |
+
|
| 1127 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 1128 |
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
| 1129 |
+
log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
| 1130 |
+
SetOAuthSessionError(state, fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode))
|
| 1131 |
+
return
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
email := gjson.GetBytes(bodyBytes, "email").String()
|
| 1135 |
+
if email != "" {
|
| 1136 |
+
fmt.Printf("Authenticated user email: %s\n", email)
|
| 1137 |
+
} else {
|
| 1138 |
+
fmt.Println("Failed to get user email from token")
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
// Marshal/unmarshal oauth2.Token to generic map and enrich fields
|
| 1142 |
+
var ifToken map[string]any
|
| 1143 |
+
jsonData, _ := json.Marshal(token)
|
| 1144 |
+
if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
|
| 1145 |
+
log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
|
| 1146 |
+
SetOAuthSessionError(state, "Failed to unmarshal token")
|
| 1147 |
+
return
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
|
| 1151 |
+
ifToken["client_id"] = geminiAuth.ClientID
|
| 1152 |
+
ifToken["client_secret"] = geminiAuth.ClientSecret
|
| 1153 |
+
ifToken["scopes"] = geminiAuth.Scopes
|
| 1154 |
+
ifToken["universe_domain"] = "googleapis.com"
|
| 1155 |
+
|
| 1156 |
+
ts := geminiAuth.GeminiTokenStorage{
|
| 1157 |
+
Token: ifToken,
|
| 1158 |
+
ProjectID: requestedProjectID,
|
| 1159 |
+
Email: email,
|
| 1160 |
+
Auto: requestedProjectID == "",
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
|
| 1164 |
+
gemAuth := geminiAuth.NewGeminiAuth()
|
| 1165 |
+
gemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, &geminiAuth.WebLoginOptions{
|
| 1166 |
+
NoBrowser: true,
|
| 1167 |
+
})
|
| 1168 |
+
if errGetClient != nil {
|
| 1169 |
+
log.Errorf("failed to get authenticated client: %v", errGetClient)
|
| 1170 |
+
SetOAuthSessionError(state, "Failed to get authenticated client")
|
| 1171 |
+
return
|
| 1172 |
+
}
|
| 1173 |
+
fmt.Println("Authentication successful.")
|
| 1174 |
+
|
| 1175 |
+
if strings.EqualFold(requestedProjectID, "ALL") {
|
| 1176 |
+
ts.Auto = false
|
| 1177 |
+
projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts)
|
| 1178 |
+
if errAll != nil {
|
| 1179 |
+
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errAll)
|
| 1180 |
+
SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
|
| 1181 |
+
return
|
| 1182 |
+
}
|
| 1183 |
+
if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil {
|
| 1184 |
+
log.Errorf("Failed to verify Cloud AI API status: %v", errVerify)
|
| 1185 |
+
SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
|
| 1186 |
+
return
|
| 1187 |
+
}
|
| 1188 |
+
ts.ProjectID = strings.Join(projects, ",")
|
| 1189 |
+
ts.Checked = true
|
| 1190 |
+
} else {
|
| 1191 |
+
if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
|
| 1192 |
+
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
|
| 1193 |
+
SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
|
| 1194 |
+
return
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
if strings.TrimSpace(ts.ProjectID) == "" {
|
| 1198 |
+
log.Error("Onboarding did not return a project ID")
|
| 1199 |
+
SetOAuthSessionError(state, "Failed to resolve project ID")
|
| 1200 |
+
return
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
|
| 1204 |
+
if errCheck != nil {
|
| 1205 |
+
log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
|
| 1206 |
+
SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
|
| 1207 |
+
return
|
| 1208 |
+
}
|
| 1209 |
+
ts.Checked = isChecked
|
| 1210 |
+
if !isChecked {
|
| 1211 |
+
log.Error("Cloud AI API is not enabled for the selected project")
|
| 1212 |
+
SetOAuthSessionError(state, "Cloud AI API not enabled")
|
| 1213 |
+
return
|
| 1214 |
+
}
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
recordMetadata := map[string]any{
|
| 1218 |
+
"email": ts.Email,
|
| 1219 |
+
"project_id": ts.ProjectID,
|
| 1220 |
+
"auto": ts.Auto,
|
| 1221 |
+
"checked": ts.Checked,
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
fileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true)
|
| 1225 |
+
record := &coreauth.Auth{
|
| 1226 |
+
ID: fileName,
|
| 1227 |
+
Provider: "gemini",
|
| 1228 |
+
FileName: fileName,
|
| 1229 |
+
Storage: &ts,
|
| 1230 |
+
Metadata: recordMetadata,
|
| 1231 |
+
}
|
| 1232 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1233 |
+
if errSave != nil {
|
| 1234 |
+
log.Errorf("Failed to save token to file: %v", errSave)
|
| 1235 |
+
SetOAuthSessionError(state, "Failed to save token to file")
|
| 1236 |
+
return
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
CompleteOAuthSession(state)
|
| 1240 |
+
CompleteOAuthSessionsByProvider("gemini")
|
| 1241 |
+
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
|
| 1242 |
+
}()
|
| 1243 |
+
|
| 1244 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
func (h *Handler) RequestCodexToken(c *gin.Context) {
|
| 1248 |
+
ctx := context.Background()
|
| 1249 |
+
|
| 1250 |
+
fmt.Println("Initializing Codex authentication...")
|
| 1251 |
+
|
| 1252 |
+
// Generate PKCE codes
|
| 1253 |
+
pkceCodes, err := codex.GeneratePKCECodes()
|
| 1254 |
+
if err != nil {
|
| 1255 |
+
log.Errorf("Failed to generate PKCE codes: %v", err)
|
| 1256 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
|
| 1257 |
+
return
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
// Generate random state parameter
|
| 1261 |
+
state, err := misc.GenerateRandomState()
|
| 1262 |
+
if err != nil {
|
| 1263 |
+
log.Errorf("Failed to generate state parameter: %v", err)
|
| 1264 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
|
| 1265 |
+
return
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
// Initialize Codex auth service
|
| 1269 |
+
openaiAuth := codex.NewCodexAuth(h.cfg)
|
| 1270 |
+
|
| 1271 |
+
// Generate authorization URL
|
| 1272 |
+
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
|
| 1273 |
+
if err != nil {
|
| 1274 |
+
log.Errorf("Failed to generate authorization URL: %v", err)
|
| 1275 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
| 1276 |
+
return
|
| 1277 |
+
}
|
| 1278 |
+
|
| 1279 |
+
RegisterOAuthSession(state, "codex")
|
| 1280 |
+
|
| 1281 |
+
isWebUI := isWebUIRequest(c)
|
| 1282 |
+
var forwarder *callbackForwarder
|
| 1283 |
+
if isWebUI {
|
| 1284 |
+
targetURL, errTarget := h.managementCallbackURL("/codex/callback")
|
| 1285 |
+
if errTarget != nil {
|
| 1286 |
+
log.WithError(errTarget).Error("failed to compute codex callback target")
|
| 1287 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 1288 |
+
return
|
| 1289 |
+
}
|
| 1290 |
+
var errStart error
|
| 1291 |
+
if forwarder, errStart = startCallbackForwarder(codexCallbackPort, "codex", targetURL); errStart != nil {
|
| 1292 |
+
log.WithError(errStart).Error("failed to start codex callback forwarder")
|
| 1293 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 1294 |
+
return
|
| 1295 |
+
}
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
go func() {
|
| 1299 |
+
if isWebUI {
|
| 1300 |
+
defer stopCallbackForwarderInstance(codexCallbackPort, forwarder)
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
// Wait for callback file
|
| 1304 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
|
| 1305 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1306 |
+
var code string
|
| 1307 |
+
for {
|
| 1308 |
+
if !IsOAuthSessionPending(state, "codex") {
|
| 1309 |
+
return
|
| 1310 |
+
}
|
| 1311 |
+
if time.Now().After(deadline) {
|
| 1312 |
+
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
|
| 1313 |
+
log.Error(codex.GetUserFriendlyMessage(authErr))
|
| 1314 |
+
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
|
| 1315 |
+
return
|
| 1316 |
+
}
|
| 1317 |
+
if data, errR := os.ReadFile(waitFile); errR == nil {
|
| 1318 |
+
var m map[string]string
|
| 1319 |
+
_ = json.Unmarshal(data, &m)
|
| 1320 |
+
_ = os.Remove(waitFile)
|
| 1321 |
+
if errStr := m["error"]; errStr != "" {
|
| 1322 |
+
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
|
| 1323 |
+
log.Error(codex.GetUserFriendlyMessage(oauthErr))
|
| 1324 |
+
SetOAuthSessionError(state, "Bad Request")
|
| 1325 |
+
return
|
| 1326 |
+
}
|
| 1327 |
+
if m["state"] != state {
|
| 1328 |
+
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
|
| 1329 |
+
SetOAuthSessionError(state, "State code error")
|
| 1330 |
+
log.Error(codex.GetUserFriendlyMessage(authErr))
|
| 1331 |
+
return
|
| 1332 |
+
}
|
| 1333 |
+
code = m["code"]
|
| 1334 |
+
break
|
| 1335 |
+
}
|
| 1336 |
+
time.Sleep(500 * time.Millisecond)
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
log.Debug("Authorization code received, exchanging for tokens...")
|
| 1340 |
+
// Exchange code for tokens using internal auth service
|
| 1341 |
+
bundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes)
|
| 1342 |
+
if errExchange != nil {
|
| 1343 |
+
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange)
|
| 1344 |
+
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
|
| 1345 |
+
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
| 1346 |
+
return
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
// Extract additional info for filename generation
|
| 1350 |
+
claims, _ := codex.ParseJWTToken(bundle.TokenData.IDToken)
|
| 1351 |
+
planType := ""
|
| 1352 |
+
hashAccountID := ""
|
| 1353 |
+
if claims != nil {
|
| 1354 |
+
planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
|
| 1355 |
+
if accountID := claims.GetAccountID(); accountID != "" {
|
| 1356 |
+
digest := sha256.Sum256([]byte(accountID))
|
| 1357 |
+
hashAccountID = hex.EncodeToString(digest[:])[:8]
|
| 1358 |
+
}
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
// Create token storage and persist
|
| 1362 |
+
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
|
| 1363 |
+
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
|
| 1364 |
+
record := &coreauth.Auth{
|
| 1365 |
+
ID: fileName,
|
| 1366 |
+
Provider: "codex",
|
| 1367 |
+
FileName: fileName,
|
| 1368 |
+
Storage: tokenStorage,
|
| 1369 |
+
Metadata: map[string]any{
|
| 1370 |
+
"email": tokenStorage.Email,
|
| 1371 |
+
"account_id": tokenStorage.AccountID,
|
| 1372 |
+
},
|
| 1373 |
+
}
|
| 1374 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1375 |
+
if errSave != nil {
|
| 1376 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 1377 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 1378 |
+
return
|
| 1379 |
+
}
|
| 1380 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1381 |
+
if bundle.APIKey != "" {
|
| 1382 |
+
fmt.Println("API key obtained and saved")
|
| 1383 |
+
}
|
| 1384 |
+
fmt.Println("You can now use Codex services through this CLI")
|
| 1385 |
+
CompleteOAuthSession(state)
|
| 1386 |
+
CompleteOAuthSessionsByProvider("codex")
|
| 1387 |
+
}()
|
| 1388 |
+
|
| 1389 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
func (h *Handler) RequestAntigravityToken(c *gin.Context) {
|
| 1393 |
+
ctx := context.Background()
|
| 1394 |
+
|
| 1395 |
+
fmt.Println("Initializing Antigravity authentication...")
|
| 1396 |
+
|
| 1397 |
+
authSvc := antigravity.NewAntigravityAuth(h.cfg, nil)
|
| 1398 |
+
|
| 1399 |
+
state, errState := misc.GenerateRandomState()
|
| 1400 |
+
if errState != nil {
|
| 1401 |
+
log.Errorf("Failed to generate state parameter: %v", errState)
|
| 1402 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
|
| 1403 |
+
return
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", antigravity.CallbackPort)
|
| 1407 |
+
authURL := authSvc.BuildAuthURL(state, redirectURI)
|
| 1408 |
+
|
| 1409 |
+
RegisterOAuthSession(state, "antigravity")
|
| 1410 |
+
|
| 1411 |
+
isWebUI := isWebUIRequest(c)
|
| 1412 |
+
var forwarder *callbackForwarder
|
| 1413 |
+
if isWebUI {
|
| 1414 |
+
targetURL, errTarget := h.managementCallbackURL("/antigravity/callback")
|
| 1415 |
+
if errTarget != nil {
|
| 1416 |
+
log.WithError(errTarget).Error("failed to compute antigravity callback target")
|
| 1417 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 1418 |
+
return
|
| 1419 |
+
}
|
| 1420 |
+
var errStart error
|
| 1421 |
+
if forwarder, errStart = startCallbackForwarder(antigravity.CallbackPort, "antigravity", targetURL); errStart != nil {
|
| 1422 |
+
log.WithError(errStart).Error("failed to start antigravity callback forwarder")
|
| 1423 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 1424 |
+
return
|
| 1425 |
+
}
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
go func() {
|
| 1429 |
+
if isWebUI {
|
| 1430 |
+
defer stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder)
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state))
|
| 1434 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1435 |
+
var authCode string
|
| 1436 |
+
for {
|
| 1437 |
+
if !IsOAuthSessionPending(state, "antigravity") {
|
| 1438 |
+
return
|
| 1439 |
+
}
|
| 1440 |
+
if time.Now().After(deadline) {
|
| 1441 |
+
log.Error("oauth flow timed out")
|
| 1442 |
+
SetOAuthSessionError(state, "OAuth flow timed out")
|
| 1443 |
+
return
|
| 1444 |
+
}
|
| 1445 |
+
if data, errReadFile := os.ReadFile(waitFile); errReadFile == nil {
|
| 1446 |
+
var payload map[string]string
|
| 1447 |
+
_ = json.Unmarshal(data, &payload)
|
| 1448 |
+
_ = os.Remove(waitFile)
|
| 1449 |
+
if errStr := strings.TrimSpace(payload["error"]); errStr != "" {
|
| 1450 |
+
log.Errorf("Authentication failed: %s", errStr)
|
| 1451 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1452 |
+
return
|
| 1453 |
+
}
|
| 1454 |
+
if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state {
|
| 1455 |
+
log.Errorf("Authentication failed: state mismatch")
|
| 1456 |
+
SetOAuthSessionError(state, "Authentication failed: state mismatch")
|
| 1457 |
+
return
|
| 1458 |
+
}
|
| 1459 |
+
authCode = strings.TrimSpace(payload["code"])
|
| 1460 |
+
if authCode == "" {
|
| 1461 |
+
log.Error("Authentication failed: code not found")
|
| 1462 |
+
SetOAuthSessionError(state, "Authentication failed: code not found")
|
| 1463 |
+
return
|
| 1464 |
+
}
|
| 1465 |
+
break
|
| 1466 |
+
}
|
| 1467 |
+
time.Sleep(500 * time.Millisecond)
|
| 1468 |
+
}
|
| 1469 |
+
|
| 1470 |
+
tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI)
|
| 1471 |
+
if errToken != nil {
|
| 1472 |
+
log.Errorf("Failed to exchange token: %v", errToken)
|
| 1473 |
+
SetOAuthSessionError(state, "Failed to exchange token")
|
| 1474 |
+
return
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
accessToken := strings.TrimSpace(tokenResp.AccessToken)
|
| 1478 |
+
if accessToken == "" {
|
| 1479 |
+
log.Error("antigravity: token exchange returned empty access token")
|
| 1480 |
+
SetOAuthSessionError(state, "Failed to exchange token")
|
| 1481 |
+
return
|
| 1482 |
+
}
|
| 1483 |
+
|
| 1484 |
+
email, errInfo := authSvc.FetchUserInfo(ctx, accessToken)
|
| 1485 |
+
if errInfo != nil {
|
| 1486 |
+
log.Errorf("Failed to fetch user info: %v", errInfo)
|
| 1487 |
+
SetOAuthSessionError(state, "Failed to fetch user info")
|
| 1488 |
+
return
|
| 1489 |
+
}
|
| 1490 |
+
email = strings.TrimSpace(email)
|
| 1491 |
+
if email == "" {
|
| 1492 |
+
log.Error("antigravity: user info returned empty email")
|
| 1493 |
+
SetOAuthSessionError(state, "Failed to fetch user info")
|
| 1494 |
+
return
|
| 1495 |
+
}
|
| 1496 |
+
|
| 1497 |
+
projectID := ""
|
| 1498 |
+
if accessToken != "" {
|
| 1499 |
+
fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)
|
| 1500 |
+
if errProject != nil {
|
| 1501 |
+
log.Warnf("antigravity: failed to fetch project ID: %v", errProject)
|
| 1502 |
+
} else {
|
| 1503 |
+
projectID = fetchedProjectID
|
| 1504 |
+
log.Infof("antigravity: obtained project ID %s", projectID)
|
| 1505 |
+
}
|
| 1506 |
+
}
|
| 1507 |
+
|
| 1508 |
+
now := time.Now()
|
| 1509 |
+
metadata := map[string]any{
|
| 1510 |
+
"type": "antigravity",
|
| 1511 |
+
"access_token": tokenResp.AccessToken,
|
| 1512 |
+
"refresh_token": tokenResp.RefreshToken,
|
| 1513 |
+
"expires_in": tokenResp.ExpiresIn,
|
| 1514 |
+
"timestamp": now.UnixMilli(),
|
| 1515 |
+
"expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
| 1516 |
+
}
|
| 1517 |
+
if email != "" {
|
| 1518 |
+
metadata["email"] = email
|
| 1519 |
+
}
|
| 1520 |
+
if projectID != "" {
|
| 1521 |
+
metadata["project_id"] = projectID
|
| 1522 |
+
}
|
| 1523 |
+
|
| 1524 |
+
fileName := antigravity.CredentialFileName(email)
|
| 1525 |
+
label := strings.TrimSpace(email)
|
| 1526 |
+
if label == "" {
|
| 1527 |
+
label = "antigravity"
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
record := &coreauth.Auth{
|
| 1531 |
+
ID: fileName,
|
| 1532 |
+
Provider: "antigravity",
|
| 1533 |
+
FileName: fileName,
|
| 1534 |
+
Label: label,
|
| 1535 |
+
Metadata: metadata,
|
| 1536 |
+
}
|
| 1537 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1538 |
+
if errSave != nil {
|
| 1539 |
+
log.Errorf("Failed to save token to file: %v", errSave)
|
| 1540 |
+
SetOAuthSessionError(state, "Failed to save token to file")
|
| 1541 |
+
return
|
| 1542 |
+
}
|
| 1543 |
+
|
| 1544 |
+
CompleteOAuthSession(state)
|
| 1545 |
+
CompleteOAuthSessionsByProvider("antigravity")
|
| 1546 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1547 |
+
if projectID != "" {
|
| 1548 |
+
fmt.Printf("Using GCP project: %s\n", projectID)
|
| 1549 |
+
}
|
| 1550 |
+
fmt.Println("You can now use Antigravity services through this CLI")
|
| 1551 |
+
}()
|
| 1552 |
+
|
| 1553 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1554 |
+
}
|
| 1555 |
+
|
| 1556 |
+
func (h *Handler) RequestQwenToken(c *gin.Context) {
|
| 1557 |
+
ctx := context.Background()
|
| 1558 |
+
|
| 1559 |
+
fmt.Println("Initializing Qwen authentication...")
|
| 1560 |
+
|
| 1561 |
+
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
| 1562 |
+
// Initialize Qwen auth service
|
| 1563 |
+
qwenAuth := qwen.NewQwenAuth(h.cfg)
|
| 1564 |
+
|
| 1565 |
+
// Generate authorization URL
|
| 1566 |
+
deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
|
| 1567 |
+
if err != nil {
|
| 1568 |
+
log.Errorf("Failed to generate authorization URL: %v", err)
|
| 1569 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
| 1570 |
+
return
|
| 1571 |
+
}
|
| 1572 |
+
authURL := deviceFlow.VerificationURIComplete
|
| 1573 |
+
|
| 1574 |
+
RegisterOAuthSession(state, "qwen")
|
| 1575 |
+
|
| 1576 |
+
go func() {
|
| 1577 |
+
fmt.Println("Waiting for authentication...")
|
| 1578 |
+
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
| 1579 |
+
if errPollForToken != nil {
|
| 1580 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1581 |
+
fmt.Printf("Authentication failed: %v\n", errPollForToken)
|
| 1582 |
+
return
|
| 1583 |
+
}
|
| 1584 |
+
|
| 1585 |
+
// Create token storage
|
| 1586 |
+
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
|
| 1587 |
+
|
| 1588 |
+
tokenStorage.Email = fmt.Sprintf("%d", time.Now().UnixMilli())
|
| 1589 |
+
record := &coreauth.Auth{
|
| 1590 |
+
ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
| 1591 |
+
Provider: "qwen",
|
| 1592 |
+
FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
| 1593 |
+
Storage: tokenStorage,
|
| 1594 |
+
Metadata: map[string]any{"email": tokenStorage.Email},
|
| 1595 |
+
}
|
| 1596 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1597 |
+
if errSave != nil {
|
| 1598 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 1599 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 1600 |
+
return
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1604 |
+
fmt.Println("You can now use Qwen services through this CLI")
|
| 1605 |
+
CompleteOAuthSession(state)
|
| 1606 |
+
}()
|
| 1607 |
+
|
| 1608 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
| 1612 |
+
ctx := context.Background()
|
| 1613 |
+
|
| 1614 |
+
fmt.Println("Initializing iFlow authentication...")
|
| 1615 |
+
|
| 1616 |
+
state := fmt.Sprintf("ifl-%d", time.Now().UnixNano())
|
| 1617 |
+
authSvc := iflowauth.NewIFlowAuth(h.cfg)
|
| 1618 |
+
authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort)
|
| 1619 |
+
|
| 1620 |
+
RegisterOAuthSession(state, "iflow")
|
| 1621 |
+
|
| 1622 |
+
isWebUI := isWebUIRequest(c)
|
| 1623 |
+
var forwarder *callbackForwarder
|
| 1624 |
+
if isWebUI {
|
| 1625 |
+
targetURL, errTarget := h.managementCallbackURL("/iflow/callback")
|
| 1626 |
+
if errTarget != nil {
|
| 1627 |
+
log.WithError(errTarget).Error("failed to compute iflow callback target")
|
| 1628 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "callback server unavailable"})
|
| 1629 |
+
return
|
| 1630 |
+
}
|
| 1631 |
+
var errStart error
|
| 1632 |
+
if forwarder, errStart = startCallbackForwarder(iflowauth.CallbackPort, "iflow", targetURL); errStart != nil {
|
| 1633 |
+
log.WithError(errStart).Error("failed to start iflow callback forwarder")
|
| 1634 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start callback server"})
|
| 1635 |
+
return
|
| 1636 |
+
}
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
go func() {
|
| 1640 |
+
if isWebUI {
|
| 1641 |
+
defer stopCallbackForwarderInstance(iflowauth.CallbackPort, forwarder)
|
| 1642 |
+
}
|
| 1643 |
+
fmt.Println("Waiting for authentication...")
|
| 1644 |
+
|
| 1645 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state))
|
| 1646 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1647 |
+
var resultMap map[string]string
|
| 1648 |
+
for {
|
| 1649 |
+
if !IsOAuthSessionPending(state, "iflow") {
|
| 1650 |
+
return
|
| 1651 |
+
}
|
| 1652 |
+
if time.Now().After(deadline) {
|
| 1653 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1654 |
+
fmt.Println("Authentication failed: timeout waiting for callback")
|
| 1655 |
+
return
|
| 1656 |
+
}
|
| 1657 |
+
if data, errR := os.ReadFile(waitFile); errR == nil {
|
| 1658 |
+
_ = os.Remove(waitFile)
|
| 1659 |
+
_ = json.Unmarshal(data, &resultMap)
|
| 1660 |
+
break
|
| 1661 |
+
}
|
| 1662 |
+
time.Sleep(500 * time.Millisecond)
|
| 1663 |
+
}
|
| 1664 |
+
|
| 1665 |
+
if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" {
|
| 1666 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1667 |
+
fmt.Printf("Authentication failed: %s\n", errStr)
|
| 1668 |
+
return
|
| 1669 |
+
}
|
| 1670 |
+
if resultState := strings.TrimSpace(resultMap["state"]); resultState != state {
|
| 1671 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1672 |
+
fmt.Println("Authentication failed: state mismatch")
|
| 1673 |
+
return
|
| 1674 |
+
}
|
| 1675 |
+
|
| 1676 |
+
code := strings.TrimSpace(resultMap["code"])
|
| 1677 |
+
if code == "" {
|
| 1678 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1679 |
+
fmt.Println("Authentication failed: code missing")
|
| 1680 |
+
return
|
| 1681 |
+
}
|
| 1682 |
+
|
| 1683 |
+
tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)
|
| 1684 |
+
if errExchange != nil {
|
| 1685 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1686 |
+
fmt.Printf("Authentication failed: %v\n", errExchange)
|
| 1687 |
+
return
|
| 1688 |
+
}
|
| 1689 |
+
|
| 1690 |
+
tokenStorage := authSvc.CreateTokenStorage(tokenData)
|
| 1691 |
+
identifier := strings.TrimSpace(tokenStorage.Email)
|
| 1692 |
+
if identifier == "" {
|
| 1693 |
+
identifier = fmt.Sprintf("%d", time.Now().UnixMilli())
|
| 1694 |
+
tokenStorage.Email = identifier
|
| 1695 |
+
}
|
| 1696 |
+
record := &coreauth.Auth{
|
| 1697 |
+
ID: fmt.Sprintf("iflow-%s.json", identifier),
|
| 1698 |
+
Provider: "iflow",
|
| 1699 |
+
FileName: fmt.Sprintf("iflow-%s.json", identifier),
|
| 1700 |
+
Storage: tokenStorage,
|
| 1701 |
+
Metadata: map[string]any{"email": identifier, "api_key": tokenStorage.APIKey},
|
| 1702 |
+
Attributes: map[string]string{"api_key": tokenStorage.APIKey},
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1706 |
+
if errSave != nil {
|
| 1707 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 1708 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 1709 |
+
return
|
| 1710 |
+
}
|
| 1711 |
+
|
| 1712 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1713 |
+
if tokenStorage.APIKey != "" {
|
| 1714 |
+
fmt.Println("API key obtained and saved")
|
| 1715 |
+
}
|
| 1716 |
+
fmt.Println("You can now use iFlow services through this CLI")
|
| 1717 |
+
CompleteOAuthSession(state)
|
| 1718 |
+
CompleteOAuthSessionsByProvider("iflow")
|
| 1719 |
+
}()
|
| 1720 |
+
|
| 1721 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1722 |
+
}
|
| 1723 |
+
|
| 1724 |
+
func (h *Handler) RequestIFlowCookieToken(c *gin.Context) {
|
| 1725 |
+
ctx := context.Background()
|
| 1726 |
+
|
| 1727 |
+
var payload struct {
|
| 1728 |
+
Cookie string `json:"cookie"`
|
| 1729 |
+
}
|
| 1730 |
+
if err := c.ShouldBindJSON(&payload); err != nil {
|
| 1731 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"})
|
| 1732 |
+
return
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
cookieValue := strings.TrimSpace(payload.Cookie)
|
| 1736 |
+
|
| 1737 |
+
if cookieValue == "" {
|
| 1738 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"})
|
| 1739 |
+
return
|
| 1740 |
+
}
|
| 1741 |
+
|
| 1742 |
+
cookieValue, errNormalize := iflowauth.NormalizeCookie(cookieValue)
|
| 1743 |
+
if errNormalize != nil {
|
| 1744 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errNormalize.Error()})
|
| 1745 |
+
return
|
| 1746 |
+
}
|
| 1747 |
+
|
| 1748 |
+
// Check for duplicate BXAuth before authentication
|
| 1749 |
+
bxAuth := iflowauth.ExtractBXAuth(cookieValue)
|
| 1750 |
+
if existingFile, err := iflowauth.CheckDuplicateBXAuth(h.cfg.AuthDir, bxAuth); err != nil {
|
| 1751 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to check duplicate"})
|
| 1752 |
+
return
|
| 1753 |
+
} else if existingFile != "" {
|
| 1754 |
+
existingFileName := filepath.Base(existingFile)
|
| 1755 |
+
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "duplicate BXAuth found", "existing_file": existingFileName})
|
| 1756 |
+
return
|
| 1757 |
+
}
|
| 1758 |
+
|
| 1759 |
+
authSvc := iflowauth.NewIFlowAuth(h.cfg)
|
| 1760 |
+
tokenData, errAuth := authSvc.AuthenticateWithCookie(ctx, cookieValue)
|
| 1761 |
+
if errAuth != nil {
|
| 1762 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errAuth.Error()})
|
| 1763 |
+
return
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
tokenData.Cookie = cookieValue
|
| 1767 |
+
|
| 1768 |
+
tokenStorage := authSvc.CreateCookieTokenStorage(tokenData)
|
| 1769 |
+
email := strings.TrimSpace(tokenStorage.Email)
|
| 1770 |
+
if email == "" {
|
| 1771 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to extract email from token"})
|
| 1772 |
+
return
|
| 1773 |
+
}
|
| 1774 |
+
|
| 1775 |
+
fileName := iflowauth.SanitizeIFlowFileName(email)
|
| 1776 |
+
if fileName == "" {
|
| 1777 |
+
fileName = fmt.Sprintf("iflow-%d", time.Now().UnixMilli())
|
| 1778 |
+
} else {
|
| 1779 |
+
fileName = fmt.Sprintf("iflow-%s", fileName)
|
| 1780 |
+
}
|
| 1781 |
+
|
| 1782 |
+
tokenStorage.Email = email
|
| 1783 |
+
timestamp := time.Now().Unix()
|
| 1784 |
+
|
| 1785 |
+
record := &coreauth.Auth{
|
| 1786 |
+
ID: fmt.Sprintf("%s-%d.json", fileName, timestamp),
|
| 1787 |
+
Provider: "iflow",
|
| 1788 |
+
FileName: fmt.Sprintf("%s-%d.json", fileName, timestamp),
|
| 1789 |
+
Storage: tokenStorage,
|
| 1790 |
+
Metadata: map[string]any{
|
| 1791 |
+
"email": email,
|
| 1792 |
+
"api_key": tokenStorage.APIKey,
|
| 1793 |
+
"expired": tokenStorage.Expire,
|
| 1794 |
+
"cookie": tokenStorage.Cookie,
|
| 1795 |
+
"type": tokenStorage.Type,
|
| 1796 |
+
"last_refresh": tokenStorage.LastRefresh,
|
| 1797 |
+
},
|
| 1798 |
+
Attributes: map[string]string{
|
| 1799 |
+
"api_key": tokenStorage.APIKey,
|
| 1800 |
+
},
|
| 1801 |
+
}
|
| 1802 |
+
|
| 1803 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1804 |
+
if errSave != nil {
|
| 1805 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to save authentication tokens"})
|
| 1806 |
+
return
|
| 1807 |
+
}
|
| 1808 |
+
|
| 1809 |
+
fmt.Printf("iFlow cookie authentication successful. Token saved to %s\n", savedPath)
|
| 1810 |
+
c.JSON(http.StatusOK, gin.H{
|
| 1811 |
+
"status": "ok",
|
| 1812 |
+
"saved_path": savedPath,
|
| 1813 |
+
"email": email,
|
| 1814 |
+
"expired": tokenStorage.Expire,
|
| 1815 |
+
"type": tokenStorage.Type,
|
| 1816 |
+
})
|
| 1817 |
+
}
|
| 1818 |
+
|
| 1819 |
+
type projectSelectionRequiredError struct{}
|
| 1820 |
+
|
| 1821 |
+
func (e *projectSelectionRequiredError) Error() string {
|
| 1822 |
+
return "gemini cli: project selection required"
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
+
func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
| 1826 |
+
if storage == nil {
|
| 1827 |
+
return fmt.Errorf("gemini storage is nil")
|
| 1828 |
+
}
|
| 1829 |
+
|
| 1830 |
+
trimmedRequest := strings.TrimSpace(requestedProject)
|
| 1831 |
+
if trimmedRequest == "" {
|
| 1832 |
+
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
| 1833 |
+
if errProjects != nil {
|
| 1834 |
+
return fmt.Errorf("fetch project list: %w", errProjects)
|
| 1835 |
+
}
|
| 1836 |
+
if len(projects) == 0 {
|
| 1837 |
+
return fmt.Errorf("no Google Cloud projects available for this account")
|
| 1838 |
+
}
|
| 1839 |
+
trimmedRequest = strings.TrimSpace(projects[0].ProjectID)
|
| 1840 |
+
if trimmedRequest == "" {
|
| 1841 |
+
return fmt.Errorf("resolved project id is empty")
|
| 1842 |
+
}
|
| 1843 |
+
storage.Auto = true
|
| 1844 |
+
} else {
|
| 1845 |
+
storage.Auto = false
|
| 1846 |
+
}
|
| 1847 |
+
|
| 1848 |
+
if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {
|
| 1849 |
+
return err
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
if strings.TrimSpace(storage.ProjectID) == "" {
|
| 1853 |
+
storage.ProjectID = trimmedRequest
|
| 1854 |
+
}
|
| 1855 |
+
|
| 1856 |
+
return nil
|
| 1857 |
+
}
|
| 1858 |
+
|
| 1859 |
+
func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) {
|
| 1860 |
+
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
| 1861 |
+
if errProjects != nil {
|
| 1862 |
+
return nil, fmt.Errorf("fetch project list: %w", errProjects)
|
| 1863 |
+
}
|
| 1864 |
+
if len(projects) == 0 {
|
| 1865 |
+
return nil, fmt.Errorf("no Google Cloud projects available for this account")
|
| 1866 |
+
}
|
| 1867 |
+
activated := make([]string, 0, len(projects))
|
| 1868 |
+
seen := make(map[string]struct{}, len(projects))
|
| 1869 |
+
for _, project := range projects {
|
| 1870 |
+
candidate := strings.TrimSpace(project.ProjectID)
|
| 1871 |
+
if candidate == "" {
|
| 1872 |
+
continue
|
| 1873 |
+
}
|
| 1874 |
+
if _, dup := seen[candidate]; dup {
|
| 1875 |
+
continue
|
| 1876 |
+
}
|
| 1877 |
+
if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil {
|
| 1878 |
+
return nil, fmt.Errorf("onboard project %s: %w", candidate, err)
|
| 1879 |
+
}
|
| 1880 |
+
finalID := strings.TrimSpace(storage.ProjectID)
|
| 1881 |
+
if finalID == "" {
|
| 1882 |
+
finalID = candidate
|
| 1883 |
+
}
|
| 1884 |
+
activated = append(activated, finalID)
|
| 1885 |
+
seen[candidate] = struct{}{}
|
| 1886 |
+
}
|
| 1887 |
+
if len(activated) == 0 {
|
| 1888 |
+
return nil, fmt.Errorf("no Google Cloud projects available for this account")
|
| 1889 |
+
}
|
| 1890 |
+
return activated, nil
|
| 1891 |
+
}
|
| 1892 |
+
|
| 1893 |
+
func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error {
|
| 1894 |
+
for _, pid := range projectIDs {
|
| 1895 |
+
trimmed := strings.TrimSpace(pid)
|
| 1896 |
+
if trimmed == "" {
|
| 1897 |
+
continue
|
| 1898 |
+
}
|
| 1899 |
+
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed)
|
| 1900 |
+
if errCheck != nil {
|
| 1901 |
+
return fmt.Errorf("project %s: %w", trimmed, errCheck)
|
| 1902 |
+
}
|
| 1903 |
+
if !isChecked {
|
| 1904 |
+
return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed)
|
| 1905 |
+
}
|
| 1906 |
+
}
|
| 1907 |
+
return nil
|
| 1908 |
+
}
|
| 1909 |
+
|
| 1910 |
+
func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
| 1911 |
+
metadata := map[string]string{
|
| 1912 |
+
"ideType": "IDE_UNSPECIFIED",
|
| 1913 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 1914 |
+
"pluginType": "GEMINI",
|
| 1915 |
+
}
|
| 1916 |
+
|
| 1917 |
+
trimmedRequest := strings.TrimSpace(requestedProject)
|
| 1918 |
+
explicitProject := trimmedRequest != ""
|
| 1919 |
+
|
| 1920 |
+
loadReqBody := map[string]any{
|
| 1921 |
+
"metadata": metadata,
|
| 1922 |
+
}
|
| 1923 |
+
if explicitProject {
|
| 1924 |
+
loadReqBody["cloudaicompanionProject"] = trimmedRequest
|
| 1925 |
+
}
|
| 1926 |
+
|
| 1927 |
+
var loadResp map[string]any
|
| 1928 |
+
if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
|
| 1929 |
+
return fmt.Errorf("load code assist: %w", errLoad)
|
| 1930 |
+
}
|
| 1931 |
+
|
| 1932 |
+
tierID := "legacy-tier"
|
| 1933 |
+
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
| 1934 |
+
for _, rawTier := range tiers {
|
| 1935 |
+
tier, okTier := rawTier.(map[string]any)
|
| 1936 |
+
if !okTier {
|
| 1937 |
+
continue
|
| 1938 |
+
}
|
| 1939 |
+
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
|
| 1940 |
+
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
|
| 1941 |
+
tierID = strings.TrimSpace(id)
|
| 1942 |
+
break
|
| 1943 |
+
}
|
| 1944 |
+
}
|
| 1945 |
+
}
|
| 1946 |
+
}
|
| 1947 |
+
|
| 1948 |
+
projectID := trimmedRequest
|
| 1949 |
+
if projectID == "" {
|
| 1950 |
+
if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
|
| 1951 |
+
projectID = strings.TrimSpace(id)
|
| 1952 |
+
}
|
| 1953 |
+
if projectID == "" {
|
| 1954 |
+
if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
|
| 1955 |
+
if id, okID := projectMap["id"].(string); okID {
|
| 1956 |
+
projectID = strings.TrimSpace(id)
|
| 1957 |
+
}
|
| 1958 |
+
}
|
| 1959 |
+
}
|
| 1960 |
+
}
|
| 1961 |
+
if projectID == "" {
|
| 1962 |
+
return &projectSelectionRequiredError{}
|
| 1963 |
+
}
|
| 1964 |
+
|
| 1965 |
+
onboardReqBody := map[string]any{
|
| 1966 |
+
"tierId": tierID,
|
| 1967 |
+
"metadata": metadata,
|
| 1968 |
+
"cloudaicompanionProject": projectID,
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
storage.ProjectID = projectID
|
| 1972 |
+
|
| 1973 |
+
for {
|
| 1974 |
+
var onboardResp map[string]any
|
| 1975 |
+
if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
|
| 1976 |
+
return fmt.Errorf("onboard user: %w", errOnboard)
|
| 1977 |
+
}
|
| 1978 |
+
|
| 1979 |
+
if done, okDone := onboardResp["done"].(bool); okDone && done {
|
| 1980 |
+
responseProjectID := ""
|
| 1981 |
+
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
|
| 1982 |
+
switch projectValue := resp["cloudaicompanionProject"].(type) {
|
| 1983 |
+
case map[string]any:
|
| 1984 |
+
if id, okID := projectValue["id"].(string); okID {
|
| 1985 |
+
responseProjectID = strings.TrimSpace(id)
|
| 1986 |
+
}
|
| 1987 |
+
case string:
|
| 1988 |
+
responseProjectID = strings.TrimSpace(projectValue)
|
| 1989 |
+
}
|
| 1990 |
+
}
|
| 1991 |
+
|
| 1992 |
+
finalProjectID := projectID
|
| 1993 |
+
if responseProjectID != "" {
|
| 1994 |
+
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
|
| 1995 |
+
// Check if this is a free user (gen-lang-client projects or free/legacy tier)
|
| 1996 |
+
isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
|
| 1997 |
+
strings.EqualFold(tierID, "FREE") ||
|
| 1998 |
+
strings.EqualFold(tierID, "LEGACY")
|
| 1999 |
+
|
| 2000 |
+
if isFreeUser {
|
| 2001 |
+
// For free users, use backend project ID for preview model access
|
| 2002 |
+
log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
|
| 2003 |
+
log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
|
| 2004 |
+
finalProjectID = responseProjectID
|
| 2005 |
+
} else {
|
| 2006 |
+
// Pro users: keep requested project ID (original behavior)
|
| 2007 |
+
log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
|
| 2008 |
+
}
|
| 2009 |
+
} else {
|
| 2010 |
+
finalProjectID = responseProjectID
|
| 2011 |
+
}
|
| 2012 |
+
}
|
| 2013 |
+
|
| 2014 |
+
storage.ProjectID = strings.TrimSpace(finalProjectID)
|
| 2015 |
+
if storage.ProjectID == "" {
|
| 2016 |
+
storage.ProjectID = strings.TrimSpace(projectID)
|
| 2017 |
+
}
|
| 2018 |
+
if storage.ProjectID == "" {
|
| 2019 |
+
return fmt.Errorf("onboard user completed without project id")
|
| 2020 |
+
}
|
| 2021 |
+
log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
|
| 2022 |
+
return nil
|
| 2023 |
+
}
|
| 2024 |
+
|
| 2025 |
+
log.Println("Onboarding in progress, waiting 5 seconds...")
|
| 2026 |
+
time.Sleep(5 * time.Second)
|
| 2027 |
+
}
|
| 2028 |
+
}
|
| 2029 |
+
|
| 2030 |
+
func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
|
| 2031 |
+
endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
|
| 2032 |
+
if strings.HasPrefix(endpoint, "operations/") {
|
| 2033 |
+
endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
|
| 2034 |
+
}
|
| 2035 |
+
|
| 2036 |
+
var reader io.Reader
|
| 2037 |
+
if body != nil {
|
| 2038 |
+
rawBody, errMarshal := json.Marshal(body)
|
| 2039 |
+
if errMarshal != nil {
|
| 2040 |
+
return fmt.Errorf("marshal request body: %w", errMarshal)
|
| 2041 |
+
}
|
| 2042 |
+
reader = bytes.NewReader(rawBody)
|
| 2043 |
+
}
|
| 2044 |
+
|
| 2045 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)
|
| 2046 |
+
if errRequest != nil {
|
| 2047 |
+
return fmt.Errorf("create request: %w", errRequest)
|
| 2048 |
+
}
|
| 2049 |
+
req.Header.Set("Content-Type", "application/json")
|
| 2050 |
+
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
| 2051 |
+
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
|
| 2052 |
+
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
|
| 2053 |
+
|
| 2054 |
+
resp, errDo := httpClient.Do(req)
|
| 2055 |
+
if errDo != nil {
|
| 2056 |
+
return fmt.Errorf("execute request: %w", errDo)
|
| 2057 |
+
}
|
| 2058 |
+
defer func() {
|
| 2059 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 2060 |
+
log.Errorf("response body close error: %v", errClose)
|
| 2061 |
+
}
|
| 2062 |
+
}()
|
| 2063 |
+
|
| 2064 |
+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
| 2065 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2066 |
+
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
| 2067 |
+
}
|
| 2068 |
+
|
| 2069 |
+
if result == nil {
|
| 2070 |
+
_, _ = io.Copy(io.Discard, resp.Body)
|
| 2071 |
+
return nil
|
| 2072 |
+
}
|
| 2073 |
+
|
| 2074 |
+
if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
|
| 2075 |
+
return fmt.Errorf("decode response body: %w", errDecode)
|
| 2076 |
+
}
|
| 2077 |
+
|
| 2078 |
+
return nil
|
| 2079 |
+
}
|
| 2080 |
+
|
| 2081 |
+
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
| 2082 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
| 2083 |
+
if errRequest != nil {
|
| 2084 |
+
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
| 2085 |
+
}
|
| 2086 |
+
|
| 2087 |
+
resp, errDo := httpClient.Do(req)
|
| 2088 |
+
if errDo != nil {
|
| 2089 |
+
return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
|
| 2090 |
+
}
|
| 2091 |
+
defer func() {
|
| 2092 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 2093 |
+
log.Errorf("response body close error: %v", errClose)
|
| 2094 |
+
}
|
| 2095 |
+
}()
|
| 2096 |
+
|
| 2097 |
+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
| 2098 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2099 |
+
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
| 2100 |
+
}
|
| 2101 |
+
|
| 2102 |
+
var projects interfaces.GCPProject
|
| 2103 |
+
if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
|
| 2104 |
+
return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
|
| 2105 |
+
}
|
| 2106 |
+
|
| 2107 |
+
return projects.Projects, nil
|
| 2108 |
+
}
|
| 2109 |
+
|
| 2110 |
+
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
|
| 2111 |
+
serviceUsageURL := "https://serviceusage.googleapis.com"
|
| 2112 |
+
requiredServices := []string{
|
| 2113 |
+
"cloudaicompanion.googleapis.com",
|
| 2114 |
+
}
|
| 2115 |
+
for _, service := range requiredServices {
|
| 2116 |
+
checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
| 2117 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
|
| 2118 |
+
if errRequest != nil {
|
| 2119 |
+
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 2120 |
+
}
|
| 2121 |
+
req.Header.Set("Content-Type", "application/json")
|
| 2122 |
+
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
| 2123 |
+
resp, errDo := httpClient.Do(req)
|
| 2124 |
+
if errDo != nil {
|
| 2125 |
+
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
| 2126 |
+
}
|
| 2127 |
+
|
| 2128 |
+
if resp.StatusCode == http.StatusOK {
|
| 2129 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2130 |
+
if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
|
| 2131 |
+
_ = resp.Body.Close()
|
| 2132 |
+
continue
|
| 2133 |
+
}
|
| 2134 |
+
}
|
| 2135 |
+
_ = resp.Body.Close()
|
| 2136 |
+
|
| 2137 |
+
enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
|
| 2138 |
+
req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}"))
|
| 2139 |
+
if errRequest != nil {
|
| 2140 |
+
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 2141 |
+
}
|
| 2142 |
+
req.Header.Set("Content-Type", "application/json")
|
| 2143 |
+
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
| 2144 |
+
resp, errDo = httpClient.Do(req)
|
| 2145 |
+
if errDo != nil {
|
| 2146 |
+
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
| 2147 |
+
}
|
| 2148 |
+
|
| 2149 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2150 |
+
errMessage := string(bodyBytes)
|
| 2151 |
+
errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
|
| 2152 |
+
if errMessageResult.Exists() {
|
| 2153 |
+
errMessage = errMessageResult.String()
|
| 2154 |
+
}
|
| 2155 |
+
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
| 2156 |
+
_ = resp.Body.Close()
|
| 2157 |
+
continue
|
| 2158 |
+
} else if resp.StatusCode == http.StatusBadRequest {
|
| 2159 |
+
_ = resp.Body.Close()
|
| 2160 |
+
if strings.Contains(strings.ToLower(errMessage), "already enabled") {
|
| 2161 |
+
continue
|
| 2162 |
+
}
|
| 2163 |
+
}
|
| 2164 |
+
_ = resp.Body.Close()
|
| 2165 |
+
return false, fmt.Errorf("project activation required: %s", errMessage)
|
| 2166 |
+
}
|
| 2167 |
+
return true, nil
|
| 2168 |
+
}
|
| 2169 |
+
|
| 2170 |
+
func (h *Handler) GetAuthStatus(c *gin.Context) {
|
| 2171 |
+
state := strings.TrimSpace(c.Query("state"))
|
| 2172 |
+
if state == "" {
|
| 2173 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 2174 |
+
return
|
| 2175 |
+
}
|
| 2176 |
+
if err := ValidateOAuthState(state); err != nil {
|
| 2177 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
|
| 2178 |
+
return
|
| 2179 |
+
}
|
| 2180 |
+
|
| 2181 |
+
_, status, ok := GetOAuthSession(state)
|
| 2182 |
+
if !ok {
|
| 2183 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 2184 |
+
return
|
| 2185 |
+
}
|
| 2186 |
+
if status != "" {
|
| 2187 |
+
c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
|
| 2188 |
+
return
|
| 2189 |
+
}
|
| 2190 |
+
c.JSON(http.StatusOK, gin.H{"status": "wait"})
|
| 2191 |
+
}
|
internal/api/handlers/management/config_basic.go
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"io"
|
| 7 |
+
"net/http"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"strings"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 15 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 16 |
+
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 17 |
+
log "github.com/sirupsen/logrus"
|
| 18 |
+
"gopkg.in/yaml.v3"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
const (
|
| 22 |
+
latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
|
| 23 |
+
latestReleaseUserAgent = "CLIProxyAPI"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
func (h *Handler) GetConfig(c *gin.Context) {
|
| 27 |
+
if h == nil || h.cfg == nil {
|
| 28 |
+
c.JSON(200, gin.H{})
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
cfgCopy := *h.cfg
|
| 32 |
+
c.JSON(200, &cfgCopy)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
type releaseInfo struct {
|
| 36 |
+
TagName string `json:"tag_name"`
|
| 37 |
+
Name string `json:"name"`
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// GetLatestVersion returns the latest release version from GitHub without downloading assets.
|
| 41 |
+
func (h *Handler) GetLatestVersion(c *gin.Context) {
|
| 42 |
+
client := &http.Client{Timeout: 10 * time.Second}
|
| 43 |
+
proxyURL := ""
|
| 44 |
+
if h != nil && h.cfg != nil {
|
| 45 |
+
proxyURL = strings.TrimSpace(h.cfg.ProxyURL)
|
| 46 |
+
}
|
| 47 |
+
if proxyURL != "" {
|
| 48 |
+
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL}
|
| 49 |
+
util.SetProxy(sdkCfg, client)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
|
| 53 |
+
if err != nil {
|
| 54 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
req.Header.Set("Accept", "application/vnd.github+json")
|
| 58 |
+
req.Header.Set("User-Agent", latestReleaseUserAgent)
|
| 59 |
+
|
| 60 |
+
resp, err := client.Do(req)
|
| 61 |
+
if err != nil {
|
| 62 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "request_failed", "message": err.Error()})
|
| 63 |
+
return
|
| 64 |
+
}
|
| 65 |
+
defer func() {
|
| 66 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 67 |
+
log.WithError(errClose).Debug("failed to close latest version response body")
|
| 68 |
+
}
|
| 69 |
+
}()
|
| 70 |
+
|
| 71 |
+
if resp.StatusCode != http.StatusOK {
|
| 72 |
+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
| 73 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected_status", "message": fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))})
|
| 74 |
+
return
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
var info releaseInfo
|
| 78 |
+
if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {
|
| 79 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "decode_failed", "message": errDecode.Error()})
|
| 80 |
+
return
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
version := strings.TrimSpace(info.TagName)
|
| 84 |
+
if version == "" {
|
| 85 |
+
version = strings.TrimSpace(info.Name)
|
| 86 |
+
}
|
| 87 |
+
if version == "" {
|
| 88 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid_response", "message": "missing release version"})
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
c.JSON(http.StatusOK, gin.H{"latest-version": version})
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
func WriteConfig(path string, data []byte) error {
|
| 96 |
+
data = config.NormalizeCommentIndentation(data)
|
| 97 |
+
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
| 98 |
+
if err != nil {
|
| 99 |
+
return err
|
| 100 |
+
}
|
| 101 |
+
if _, errWrite := f.Write(data); errWrite != nil {
|
| 102 |
+
_ = f.Close()
|
| 103 |
+
return errWrite
|
| 104 |
+
}
|
| 105 |
+
if errSync := f.Sync(); errSync != nil {
|
| 106 |
+
_ = f.Close()
|
| 107 |
+
return errSync
|
| 108 |
+
}
|
| 109 |
+
return f.Close()
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
func (h *Handler) PutConfigYAML(c *gin.Context) {
|
| 113 |
+
body, err := io.ReadAll(c.Request.Body)
|
| 114 |
+
if err != nil {
|
| 115 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": "cannot read request body"})
|
| 116 |
+
return
|
| 117 |
+
}
|
| 118 |
+
var cfg config.Config
|
| 119 |
+
if err = yaml.Unmarshal(body, &cfg); err != nil {
|
| 120 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()})
|
| 121 |
+
return
|
| 122 |
+
}
|
| 123 |
+
// Validate config using LoadConfigOptional with optional=false to enforce parsing
|
| 124 |
+
tmpDir := filepath.Dir(h.configFilePath)
|
| 125 |
+
tmpFile, err := os.CreateTemp(tmpDir, "config-validate-*.yaml")
|
| 126 |
+
if err != nil {
|
| 127 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()})
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
tempFile := tmpFile.Name()
|
| 131 |
+
if _, errWrite := tmpFile.Write(body); errWrite != nil {
|
| 132 |
+
_ = tmpFile.Close()
|
| 133 |
+
_ = os.Remove(tempFile)
|
| 134 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errWrite.Error()})
|
| 135 |
+
return
|
| 136 |
+
}
|
| 137 |
+
if errClose := tmpFile.Close(); errClose != nil {
|
| 138 |
+
_ = os.Remove(tempFile)
|
| 139 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errClose.Error()})
|
| 140 |
+
return
|
| 141 |
+
}
|
| 142 |
+
defer func() {
|
| 143 |
+
_ = os.Remove(tempFile)
|
| 144 |
+
}()
|
| 145 |
+
_, err = config.LoadConfigOptional(tempFile, false)
|
| 146 |
+
if err != nil {
|
| 147 |
+
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()})
|
| 148 |
+
return
|
| 149 |
+
}
|
| 150 |
+
h.mu.Lock()
|
| 151 |
+
defer h.mu.Unlock()
|
| 152 |
+
if WriteConfig(h.configFilePath, body) != nil {
|
| 153 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": "failed to write config"})
|
| 154 |
+
return
|
| 155 |
+
}
|
| 156 |
+
// Reload into handler to keep memory in sync
|
| 157 |
+
newCfg, err := config.LoadConfig(h.configFilePath)
|
| 158 |
+
if err != nil {
|
| 159 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "reload_failed", "message": err.Error()})
|
| 160 |
+
return
|
| 161 |
+
}
|
| 162 |
+
h.cfg = newCfg
|
| 163 |
+
c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// GetConfigYAML returns the raw config.yaml file bytes without re-encoding.
|
| 167 |
+
// It preserves comments and original formatting/styles.
|
| 168 |
+
func (h *Handler) GetConfigYAML(c *gin.Context) {
|
| 169 |
+
data, err := os.ReadFile(h.configFilePath)
|
| 170 |
+
if err != nil {
|
| 171 |
+
if os.IsNotExist(err) {
|
| 172 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "not_found", "message": "config file not found"})
|
| 173 |
+
return
|
| 174 |
+
}
|
| 175 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()})
|
| 176 |
+
return
|
| 177 |
+
}
|
| 178 |
+
c.Header("Content-Type", "application/yaml; charset=utf-8")
|
| 179 |
+
c.Header("Cache-Control", "no-store")
|
| 180 |
+
c.Header("X-Content-Type-Options", "nosniff")
|
| 181 |
+
// Write raw bytes as-is
|
| 182 |
+
_, _ = c.Writer.Write(data)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Debug
|
| 186 |
+
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
|
| 187 |
+
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
|
| 188 |
+
|
| 189 |
+
// UsageStatisticsEnabled
|
| 190 |
+
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
|
| 191 |
+
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
|
| 192 |
+
}
|
| 193 |
+
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
|
| 194 |
+
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// UsageStatisticsEnabled
|
| 198 |
+
func (h *Handler) GetLoggingToFile(c *gin.Context) {
|
| 199 |
+
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
|
| 200 |
+
}
|
| 201 |
+
func (h *Handler) PutLoggingToFile(c *gin.Context) {
|
| 202 |
+
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// LogsMaxTotalSizeMB
|
| 206 |
+
func (h *Handler) GetLogsMaxTotalSizeMB(c *gin.Context) {
|
| 207 |
+
c.JSON(200, gin.H{"logs-max-total-size-mb": h.cfg.LogsMaxTotalSizeMB})
|
| 208 |
+
}
|
| 209 |
+
func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {
|
| 210 |
+
var body struct {
|
| 211 |
+
Value *int `json:"value"`
|
| 212 |
+
}
|
| 213 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
|
| 214 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 215 |
+
return
|
| 216 |
+
}
|
| 217 |
+
value := *body.Value
|
| 218 |
+
if value < 0 {
|
| 219 |
+
value = 0
|
| 220 |
+
}
|
| 221 |
+
h.cfg.LogsMaxTotalSizeMB = value
|
| 222 |
+
h.persist(c)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Request log
|
| 226 |
+
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
|
| 227 |
+
func (h *Handler) PutRequestLog(c *gin.Context) {
|
| 228 |
+
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Websocket auth
|
| 232 |
+
func (h *Handler) GetWebsocketAuth(c *gin.Context) {
|
| 233 |
+
c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
|
| 234 |
+
}
|
| 235 |
+
func (h *Handler) PutWebsocketAuth(c *gin.Context) {
|
| 236 |
+
h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// Request retry
|
| 240 |
+
func (h *Handler) GetRequestRetry(c *gin.Context) {
|
| 241 |
+
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
|
| 242 |
+
}
|
| 243 |
+
func (h *Handler) PutRequestRetry(c *gin.Context) {
|
| 244 |
+
h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Max retry interval
|
| 248 |
+
func (h *Handler) GetMaxRetryInterval(c *gin.Context) {
|
| 249 |
+
c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval})
|
| 250 |
+
}
|
| 251 |
+
func (h *Handler) PutMaxRetryInterval(c *gin.Context) {
|
| 252 |
+
h.updateIntField(c, func(v int) { h.cfg.MaxRetryInterval = v })
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// ForceModelPrefix
|
| 256 |
+
func (h *Handler) GetForceModelPrefix(c *gin.Context) {
|
| 257 |
+
c.JSON(200, gin.H{"force-model-prefix": h.cfg.ForceModelPrefix})
|
| 258 |
+
}
|
| 259 |
+
func (h *Handler) PutForceModelPrefix(c *gin.Context) {
|
| 260 |
+
h.updateBoolField(c, func(v bool) { h.cfg.ForceModelPrefix = v })
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
func normalizeRoutingStrategy(strategy string) (string, bool) {
|
| 264 |
+
normalized := strings.ToLower(strings.TrimSpace(strategy))
|
| 265 |
+
switch normalized {
|
| 266 |
+
case "", "round-robin", "roundrobin", "rr":
|
| 267 |
+
return "round-robin", true
|
| 268 |
+
case "fill-first", "fillfirst", "ff":
|
| 269 |
+
return "fill-first", true
|
| 270 |
+
default:
|
| 271 |
+
return "", false
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// RoutingStrategy
|
| 276 |
+
func (h *Handler) GetRoutingStrategy(c *gin.Context) {
|
| 277 |
+
strategy, ok := normalizeRoutingStrategy(h.cfg.Routing.Strategy)
|
| 278 |
+
if !ok {
|
| 279 |
+
c.JSON(200, gin.H{"strategy": strings.TrimSpace(h.cfg.Routing.Strategy)})
|
| 280 |
+
return
|
| 281 |
+
}
|
| 282 |
+
c.JSON(200, gin.H{"strategy": strategy})
|
| 283 |
+
}
|
| 284 |
+
func (h *Handler) PutRoutingStrategy(c *gin.Context) {
|
| 285 |
+
var body struct {
|
| 286 |
+
Value *string `json:"value"`
|
| 287 |
+
}
|
| 288 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
|
| 289 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 290 |
+
return
|
| 291 |
+
}
|
| 292 |
+
normalized, ok := normalizeRoutingStrategy(*body.Value)
|
| 293 |
+
if !ok {
|
| 294 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid strategy"})
|
| 295 |
+
return
|
| 296 |
+
}
|
| 297 |
+
h.cfg.Routing.Strategy = normalized
|
| 298 |
+
h.persist(c)
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Proxy URL
|
| 302 |
+
func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
|
| 303 |
+
func (h *Handler) PutProxyURL(c *gin.Context) {
|
| 304 |
+
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
|
| 305 |
+
}
|
| 306 |
+
func (h *Handler) DeleteProxyURL(c *gin.Context) {
|
| 307 |
+
h.cfg.ProxyURL = ""
|
| 308 |
+
h.persist(c)
|
| 309 |
+
}
|
internal/api/handlers/management/config_lists.go
ADDED
|
@@ -0,0 +1,1365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"strings"
|
| 7 |
+
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
// Generic helpers for list[string]
|
| 13 |
+
func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
|
| 14 |
+
data, err := c.GetRawData()
|
| 15 |
+
if err != nil {
|
| 16 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 17 |
+
return
|
| 18 |
+
}
|
| 19 |
+
var arr []string
|
| 20 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 21 |
+
var obj struct {
|
| 22 |
+
Items []string `json:"items"`
|
| 23 |
+
}
|
| 24 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 25 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 26 |
+
return
|
| 27 |
+
}
|
| 28 |
+
arr = obj.Items
|
| 29 |
+
}
|
| 30 |
+
set(arr)
|
| 31 |
+
if after != nil {
|
| 32 |
+
after()
|
| 33 |
+
}
|
| 34 |
+
h.persist(c)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
|
| 38 |
+
var body struct {
|
| 39 |
+
Old *string `json:"old"`
|
| 40 |
+
New *string `json:"new"`
|
| 41 |
+
Index *int `json:"index"`
|
| 42 |
+
Value *string `json:"value"`
|
| 43 |
+
}
|
| 44 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 45 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
|
| 49 |
+
(*target)[*body.Index] = *body.Value
|
| 50 |
+
if after != nil {
|
| 51 |
+
after()
|
| 52 |
+
}
|
| 53 |
+
h.persist(c)
|
| 54 |
+
return
|
| 55 |
+
}
|
| 56 |
+
if body.Old != nil && body.New != nil {
|
| 57 |
+
for i := range *target {
|
| 58 |
+
if (*target)[i] == *body.Old {
|
| 59 |
+
(*target)[i] = *body.New
|
| 60 |
+
if after != nil {
|
| 61 |
+
after()
|
| 62 |
+
}
|
| 63 |
+
h.persist(c)
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
*target = append(*target, *body.New)
|
| 68 |
+
if after != nil {
|
| 69 |
+
after()
|
| 70 |
+
}
|
| 71 |
+
h.persist(c)
|
| 72 |
+
return
|
| 73 |
+
}
|
| 74 |
+
c.JSON(400, gin.H{"error": "missing fields"})
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
|
| 78 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 79 |
+
var idx int
|
| 80 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 81 |
+
if err == nil && idx >= 0 && idx < len(*target) {
|
| 82 |
+
*target = append((*target)[:idx], (*target)[idx+1:]...)
|
| 83 |
+
if after != nil {
|
| 84 |
+
after()
|
| 85 |
+
}
|
| 86 |
+
h.persist(c)
|
| 87 |
+
return
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
if val := strings.TrimSpace(c.Query("value")); val != "" {
|
| 91 |
+
out := make([]string, 0, len(*target))
|
| 92 |
+
for _, v := range *target {
|
| 93 |
+
if strings.TrimSpace(v) != val {
|
| 94 |
+
out = append(out, v)
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
*target = out
|
| 98 |
+
if after != nil {
|
| 99 |
+
after()
|
| 100 |
+
}
|
| 101 |
+
h.persist(c)
|
| 102 |
+
return
|
| 103 |
+
}
|
| 104 |
+
c.JSON(400, gin.H{"error": "missing index or value"})
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// api-keys
|
| 108 |
+
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
| 109 |
+
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
| 110 |
+
h.putStringList(c, func(v []string) {
|
| 111 |
+
h.cfg.APIKeys = append([]string(nil), v...)
|
| 112 |
+
h.cfg.Access.Providers = nil
|
| 113 |
+
}, nil)
|
| 114 |
+
}
|
| 115 |
+
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
| 116 |
+
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
| 117 |
+
}
|
| 118 |
+
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
| 119 |
+
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// gemini-api-key: []GeminiKey
|
| 123 |
+
func (h *Handler) GetGeminiKeys(c *gin.Context) {
|
| 124 |
+
c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
|
| 125 |
+
}
|
| 126 |
+
func (h *Handler) PutGeminiKeys(c *gin.Context) {
|
| 127 |
+
data, err := c.GetRawData()
|
| 128 |
+
if err != nil {
|
| 129 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 130 |
+
return
|
| 131 |
+
}
|
| 132 |
+
var arr []config.GeminiKey
|
| 133 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 134 |
+
var obj struct {
|
| 135 |
+
Items []config.GeminiKey `json:"items"`
|
| 136 |
+
}
|
| 137 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 138 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 139 |
+
return
|
| 140 |
+
}
|
| 141 |
+
arr = obj.Items
|
| 142 |
+
}
|
| 143 |
+
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
|
| 144 |
+
h.cfg.SanitizeGeminiKeys()
|
| 145 |
+
h.persist(c)
|
| 146 |
+
}
|
| 147 |
+
func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
| 148 |
+
type geminiKeyPatch struct {
|
| 149 |
+
APIKey *string `json:"api-key"`
|
| 150 |
+
Prefix *string `json:"prefix"`
|
| 151 |
+
BaseURL *string `json:"base-url"`
|
| 152 |
+
ProxyURL *string `json:"proxy-url"`
|
| 153 |
+
Headers *map[string]string `json:"headers"`
|
| 154 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 155 |
+
}
|
| 156 |
+
var body struct {
|
| 157 |
+
Index *int `json:"index"`
|
| 158 |
+
Match *string `json:"match"`
|
| 159 |
+
Value *geminiKeyPatch `json:"value"`
|
| 160 |
+
}
|
| 161 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 162 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 163 |
+
return
|
| 164 |
+
}
|
| 165 |
+
targetIndex := -1
|
| 166 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
| 167 |
+
targetIndex = *body.Index
|
| 168 |
+
}
|
| 169 |
+
if targetIndex == -1 && body.Match != nil {
|
| 170 |
+
match := strings.TrimSpace(*body.Match)
|
| 171 |
+
if match != "" {
|
| 172 |
+
for i := range h.cfg.GeminiKey {
|
| 173 |
+
if h.cfg.GeminiKey[i].APIKey == match {
|
| 174 |
+
targetIndex = i
|
| 175 |
+
break
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
if targetIndex == -1 {
|
| 181 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 182 |
+
return
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
entry := h.cfg.GeminiKey[targetIndex]
|
| 186 |
+
if body.Value.APIKey != nil {
|
| 187 |
+
trimmed := strings.TrimSpace(*body.Value.APIKey)
|
| 188 |
+
if trimmed == "" {
|
| 189 |
+
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
|
| 190 |
+
h.cfg.SanitizeGeminiKeys()
|
| 191 |
+
h.persist(c)
|
| 192 |
+
return
|
| 193 |
+
}
|
| 194 |
+
entry.APIKey = trimmed
|
| 195 |
+
}
|
| 196 |
+
if body.Value.Prefix != nil {
|
| 197 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 198 |
+
}
|
| 199 |
+
if body.Value.BaseURL != nil {
|
| 200 |
+
entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
|
| 201 |
+
}
|
| 202 |
+
if body.Value.ProxyURL != nil {
|
| 203 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 204 |
+
}
|
| 205 |
+
if body.Value.Headers != nil {
|
| 206 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 207 |
+
}
|
| 208 |
+
if body.Value.ExcludedModels != nil {
|
| 209 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 210 |
+
}
|
| 211 |
+
h.cfg.GeminiKey[targetIndex] = entry
|
| 212 |
+
h.cfg.SanitizeGeminiKeys()
|
| 213 |
+
h.persist(c)
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
| 217 |
+
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
| 218 |
+
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
| 219 |
+
for _, v := range h.cfg.GeminiKey {
|
| 220 |
+
if v.APIKey != val {
|
| 221 |
+
out = append(out, v)
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
if len(out) != len(h.cfg.GeminiKey) {
|
| 225 |
+
h.cfg.GeminiKey = out
|
| 226 |
+
h.cfg.SanitizeGeminiKeys()
|
| 227 |
+
h.persist(c)
|
| 228 |
+
} else {
|
| 229 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 230 |
+
}
|
| 231 |
+
return
|
| 232 |
+
}
|
| 233 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 234 |
+
var idx int
|
| 235 |
+
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
|
| 236 |
+
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
|
| 237 |
+
h.cfg.SanitizeGeminiKeys()
|
| 238 |
+
h.persist(c)
|
| 239 |
+
return
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// claude-api-key: []ClaudeKey
|
| 246 |
+
func (h *Handler) GetClaudeKeys(c *gin.Context) {
|
| 247 |
+
c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
|
| 248 |
+
}
|
| 249 |
+
func (h *Handler) PutClaudeKeys(c *gin.Context) {
|
| 250 |
+
data, err := c.GetRawData()
|
| 251 |
+
if err != nil {
|
| 252 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 253 |
+
return
|
| 254 |
+
}
|
| 255 |
+
var arr []config.ClaudeKey
|
| 256 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 257 |
+
var obj struct {
|
| 258 |
+
Items []config.ClaudeKey `json:"items"`
|
| 259 |
+
}
|
| 260 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 261 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 262 |
+
return
|
| 263 |
+
}
|
| 264 |
+
arr = obj.Items
|
| 265 |
+
}
|
| 266 |
+
for i := range arr {
|
| 267 |
+
normalizeClaudeKey(&arr[i])
|
| 268 |
+
}
|
| 269 |
+
h.cfg.ClaudeKey = arr
|
| 270 |
+
h.cfg.SanitizeClaudeKeys()
|
| 271 |
+
h.persist(c)
|
| 272 |
+
}
|
| 273 |
+
func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
| 274 |
+
type claudeKeyPatch struct {
|
| 275 |
+
APIKey *string `json:"api-key"`
|
| 276 |
+
Prefix *string `json:"prefix"`
|
| 277 |
+
BaseURL *string `json:"base-url"`
|
| 278 |
+
ProxyURL *string `json:"proxy-url"`
|
| 279 |
+
Models *[]config.ClaudeModel `json:"models"`
|
| 280 |
+
Headers *map[string]string `json:"headers"`
|
| 281 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 282 |
+
}
|
| 283 |
+
var body struct {
|
| 284 |
+
Index *int `json:"index"`
|
| 285 |
+
Match *string `json:"match"`
|
| 286 |
+
Value *claudeKeyPatch `json:"value"`
|
| 287 |
+
}
|
| 288 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 289 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 290 |
+
return
|
| 291 |
+
}
|
| 292 |
+
targetIndex := -1
|
| 293 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
|
| 294 |
+
targetIndex = *body.Index
|
| 295 |
+
}
|
| 296 |
+
if targetIndex == -1 && body.Match != nil {
|
| 297 |
+
match := strings.TrimSpace(*body.Match)
|
| 298 |
+
for i := range h.cfg.ClaudeKey {
|
| 299 |
+
if h.cfg.ClaudeKey[i].APIKey == match {
|
| 300 |
+
targetIndex = i
|
| 301 |
+
break
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
if targetIndex == -1 {
|
| 306 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 307 |
+
return
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
entry := h.cfg.ClaudeKey[targetIndex]
|
| 311 |
+
if body.Value.APIKey != nil {
|
| 312 |
+
entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
|
| 313 |
+
}
|
| 314 |
+
if body.Value.Prefix != nil {
|
| 315 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 316 |
+
}
|
| 317 |
+
if body.Value.BaseURL != nil {
|
| 318 |
+
entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
|
| 319 |
+
}
|
| 320 |
+
if body.Value.ProxyURL != nil {
|
| 321 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 322 |
+
}
|
| 323 |
+
if body.Value.Models != nil {
|
| 324 |
+
entry.Models = append([]config.ClaudeModel(nil), (*body.Value.Models)...)
|
| 325 |
+
}
|
| 326 |
+
if body.Value.Headers != nil {
|
| 327 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 328 |
+
}
|
| 329 |
+
if body.Value.ExcludedModels != nil {
|
| 330 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 331 |
+
}
|
| 332 |
+
normalizeClaudeKey(&entry)
|
| 333 |
+
h.cfg.ClaudeKey[targetIndex] = entry
|
| 334 |
+
h.cfg.SanitizeClaudeKeys()
|
| 335 |
+
h.persist(c)
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
| 339 |
+
if val := c.Query("api-key"); val != "" {
|
| 340 |
+
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
| 341 |
+
for _, v := range h.cfg.ClaudeKey {
|
| 342 |
+
if v.APIKey != val {
|
| 343 |
+
out = append(out, v)
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
h.cfg.ClaudeKey = out
|
| 347 |
+
h.cfg.SanitizeClaudeKeys()
|
| 348 |
+
h.persist(c)
|
| 349 |
+
return
|
| 350 |
+
}
|
| 351 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 352 |
+
var idx int
|
| 353 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 354 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
|
| 355 |
+
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
|
| 356 |
+
h.cfg.SanitizeClaudeKeys()
|
| 357 |
+
h.persist(c)
|
| 358 |
+
return
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// openai-compatibility: []OpenAICompatibility
|
| 365 |
+
func (h *Handler) GetOpenAICompat(c *gin.Context) {
|
| 366 |
+
c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
|
| 367 |
+
}
|
| 368 |
+
func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
| 369 |
+
data, err := c.GetRawData()
|
| 370 |
+
if err != nil {
|
| 371 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 372 |
+
return
|
| 373 |
+
}
|
| 374 |
+
var arr []config.OpenAICompatibility
|
| 375 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 376 |
+
var obj struct {
|
| 377 |
+
Items []config.OpenAICompatibility `json:"items"`
|
| 378 |
+
}
|
| 379 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 380 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 381 |
+
return
|
| 382 |
+
}
|
| 383 |
+
arr = obj.Items
|
| 384 |
+
}
|
| 385 |
+
filtered := make([]config.OpenAICompatibility, 0, len(arr))
|
| 386 |
+
for i := range arr {
|
| 387 |
+
normalizeOpenAICompatibilityEntry(&arr[i])
|
| 388 |
+
if strings.TrimSpace(arr[i].BaseURL) != "" {
|
| 389 |
+
filtered = append(filtered, arr[i])
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
h.cfg.OpenAICompatibility = filtered
|
| 393 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 394 |
+
h.persist(c)
|
| 395 |
+
}
|
| 396 |
+
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
| 397 |
+
type openAICompatPatch struct {
|
| 398 |
+
Name *string `json:"name"`
|
| 399 |
+
Prefix *string `json:"prefix"`
|
| 400 |
+
BaseURL *string `json:"base-url"`
|
| 401 |
+
APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
|
| 402 |
+
Models *[]config.OpenAICompatibilityModel `json:"models"`
|
| 403 |
+
Headers *map[string]string `json:"headers"`
|
| 404 |
+
}
|
| 405 |
+
var body struct {
|
| 406 |
+
Name *string `json:"name"`
|
| 407 |
+
Index *int `json:"index"`
|
| 408 |
+
Value *openAICompatPatch `json:"value"`
|
| 409 |
+
}
|
| 410 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 411 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 412 |
+
return
|
| 413 |
+
}
|
| 414 |
+
targetIndex := -1
|
| 415 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
| 416 |
+
targetIndex = *body.Index
|
| 417 |
+
}
|
| 418 |
+
if targetIndex == -1 && body.Name != nil {
|
| 419 |
+
match := strings.TrimSpace(*body.Name)
|
| 420 |
+
for i := range h.cfg.OpenAICompatibility {
|
| 421 |
+
if h.cfg.OpenAICompatibility[i].Name == match {
|
| 422 |
+
targetIndex = i
|
| 423 |
+
break
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
if targetIndex == -1 {
|
| 428 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 429 |
+
return
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
entry := h.cfg.OpenAICompatibility[targetIndex]
|
| 433 |
+
if body.Value.Name != nil {
|
| 434 |
+
entry.Name = strings.TrimSpace(*body.Value.Name)
|
| 435 |
+
}
|
| 436 |
+
if body.Value.Prefix != nil {
|
| 437 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 438 |
+
}
|
| 439 |
+
if body.Value.BaseURL != nil {
|
| 440 |
+
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
| 441 |
+
if trimmed == "" {
|
| 442 |
+
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
|
| 443 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 444 |
+
h.persist(c)
|
| 445 |
+
return
|
| 446 |
+
}
|
| 447 |
+
entry.BaseURL = trimmed
|
| 448 |
+
}
|
| 449 |
+
if body.Value.APIKeyEntries != nil {
|
| 450 |
+
entry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*body.Value.APIKeyEntries)...)
|
| 451 |
+
}
|
| 452 |
+
if body.Value.Models != nil {
|
| 453 |
+
entry.Models = append([]config.OpenAICompatibilityModel(nil), (*body.Value.Models)...)
|
| 454 |
+
}
|
| 455 |
+
if body.Value.Headers != nil {
|
| 456 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 457 |
+
}
|
| 458 |
+
normalizeOpenAICompatibilityEntry(&entry)
|
| 459 |
+
h.cfg.OpenAICompatibility[targetIndex] = entry
|
| 460 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 461 |
+
h.persist(c)
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
| 465 |
+
if name := c.Query("name"); name != "" {
|
| 466 |
+
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
| 467 |
+
for _, v := range h.cfg.OpenAICompatibility {
|
| 468 |
+
if v.Name != name {
|
| 469 |
+
out = append(out, v)
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
h.cfg.OpenAICompatibility = out
|
| 473 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 474 |
+
h.persist(c)
|
| 475 |
+
return
|
| 476 |
+
}
|
| 477 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 478 |
+
var idx int
|
| 479 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 480 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
|
| 481 |
+
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
|
| 482 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 483 |
+
h.persist(c)
|
| 484 |
+
return
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
c.JSON(400, gin.H{"error": "missing name or index"})
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
// vertex-api-key: []VertexCompatKey
|
| 491 |
+
func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
|
| 492 |
+
c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
|
| 493 |
+
}
|
| 494 |
+
func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
|
| 495 |
+
data, err := c.GetRawData()
|
| 496 |
+
if err != nil {
|
| 497 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 498 |
+
return
|
| 499 |
+
}
|
| 500 |
+
var arr []config.VertexCompatKey
|
| 501 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 502 |
+
var obj struct {
|
| 503 |
+
Items []config.VertexCompatKey `json:"items"`
|
| 504 |
+
}
|
| 505 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 506 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 507 |
+
return
|
| 508 |
+
}
|
| 509 |
+
arr = obj.Items
|
| 510 |
+
}
|
| 511 |
+
for i := range arr {
|
| 512 |
+
normalizeVertexCompatKey(&arr[i])
|
| 513 |
+
}
|
| 514 |
+
h.cfg.VertexCompatAPIKey = arr
|
| 515 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 516 |
+
h.persist(c)
|
| 517 |
+
}
|
| 518 |
+
func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
| 519 |
+
type vertexCompatPatch struct {
|
| 520 |
+
APIKey *string `json:"api-key"`
|
| 521 |
+
Prefix *string `json:"prefix"`
|
| 522 |
+
BaseURL *string `json:"base-url"`
|
| 523 |
+
ProxyURL *string `json:"proxy-url"`
|
| 524 |
+
Headers *map[string]string `json:"headers"`
|
| 525 |
+
Models *[]config.VertexCompatModel `json:"models"`
|
| 526 |
+
}
|
| 527 |
+
var body struct {
|
| 528 |
+
Index *int `json:"index"`
|
| 529 |
+
Match *string `json:"match"`
|
| 530 |
+
Value *vertexCompatPatch `json:"value"`
|
| 531 |
+
}
|
| 532 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
|
| 533 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 534 |
+
return
|
| 535 |
+
}
|
| 536 |
+
targetIndex := -1
|
| 537 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
|
| 538 |
+
targetIndex = *body.Index
|
| 539 |
+
}
|
| 540 |
+
if targetIndex == -1 && body.Match != nil {
|
| 541 |
+
match := strings.TrimSpace(*body.Match)
|
| 542 |
+
if match != "" {
|
| 543 |
+
for i := range h.cfg.VertexCompatAPIKey {
|
| 544 |
+
if h.cfg.VertexCompatAPIKey[i].APIKey == match {
|
| 545 |
+
targetIndex = i
|
| 546 |
+
break
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
if targetIndex == -1 {
|
| 552 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 553 |
+
return
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
entry := h.cfg.VertexCompatAPIKey[targetIndex]
|
| 557 |
+
if body.Value.APIKey != nil {
|
| 558 |
+
trimmed := strings.TrimSpace(*body.Value.APIKey)
|
| 559 |
+
if trimmed == "" {
|
| 560 |
+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
|
| 561 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 562 |
+
h.persist(c)
|
| 563 |
+
return
|
| 564 |
+
}
|
| 565 |
+
entry.APIKey = trimmed
|
| 566 |
+
}
|
| 567 |
+
if body.Value.Prefix != nil {
|
| 568 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 569 |
+
}
|
| 570 |
+
if body.Value.BaseURL != nil {
|
| 571 |
+
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
| 572 |
+
if trimmed == "" {
|
| 573 |
+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
|
| 574 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 575 |
+
h.persist(c)
|
| 576 |
+
return
|
| 577 |
+
}
|
| 578 |
+
entry.BaseURL = trimmed
|
| 579 |
+
}
|
| 580 |
+
if body.Value.ProxyURL != nil {
|
| 581 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 582 |
+
}
|
| 583 |
+
if body.Value.Headers != nil {
|
| 584 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 585 |
+
}
|
| 586 |
+
if body.Value.Models != nil {
|
| 587 |
+
entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
|
| 588 |
+
}
|
| 589 |
+
normalizeVertexCompatKey(&entry)
|
| 590 |
+
h.cfg.VertexCompatAPIKey[targetIndex] = entry
|
| 591 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 592 |
+
h.persist(c)
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
|
| 596 |
+
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
| 597 |
+
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
|
| 598 |
+
for _, v := range h.cfg.VertexCompatAPIKey {
|
| 599 |
+
if v.APIKey != val {
|
| 600 |
+
out = append(out, v)
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
h.cfg.VertexCompatAPIKey = out
|
| 604 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 605 |
+
h.persist(c)
|
| 606 |
+
return
|
| 607 |
+
}
|
| 608 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 609 |
+
var idx int
|
| 610 |
+
_, errScan := fmt.Sscanf(idxStr, "%d", &idx)
|
| 611 |
+
if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
|
| 612 |
+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
|
| 613 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 614 |
+
h.persist(c)
|
| 615 |
+
return
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
// oauth-excluded-models: map[string][]string
|
| 622 |
+
func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
|
| 623 |
+
c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
func (h *Handler) PutOAuthExcludedModels(c *gin.Context) {
|
| 627 |
+
data, err := c.GetRawData()
|
| 628 |
+
if err != nil {
|
| 629 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 630 |
+
return
|
| 631 |
+
}
|
| 632 |
+
var entries map[string][]string
|
| 633 |
+
if err = json.Unmarshal(data, &entries); err != nil {
|
| 634 |
+
var wrapper struct {
|
| 635 |
+
Items map[string][]string `json:"items"`
|
| 636 |
+
}
|
| 637 |
+
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
| 638 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 639 |
+
return
|
| 640 |
+
}
|
| 641 |
+
entries = wrapper.Items
|
| 642 |
+
}
|
| 643 |
+
h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)
|
| 644 |
+
h.persist(c)
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
|
| 648 |
+
var body struct {
|
| 649 |
+
Provider *string `json:"provider"`
|
| 650 |
+
Models []string `json:"models"`
|
| 651 |
+
}
|
| 652 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil {
|
| 653 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 654 |
+
return
|
| 655 |
+
}
|
| 656 |
+
provider := strings.ToLower(strings.TrimSpace(*body.Provider))
|
| 657 |
+
if provider == "" {
|
| 658 |
+
c.JSON(400, gin.H{"error": "invalid provider"})
|
| 659 |
+
return
|
| 660 |
+
}
|
| 661 |
+
normalized := config.NormalizeExcludedModels(body.Models)
|
| 662 |
+
if len(normalized) == 0 {
|
| 663 |
+
if h.cfg.OAuthExcludedModels == nil {
|
| 664 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 665 |
+
return
|
| 666 |
+
}
|
| 667 |
+
if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
|
| 668 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 669 |
+
return
|
| 670 |
+
}
|
| 671 |
+
delete(h.cfg.OAuthExcludedModels, provider)
|
| 672 |
+
if len(h.cfg.OAuthExcludedModels) == 0 {
|
| 673 |
+
h.cfg.OAuthExcludedModels = nil
|
| 674 |
+
}
|
| 675 |
+
h.persist(c)
|
| 676 |
+
return
|
| 677 |
+
}
|
| 678 |
+
if h.cfg.OAuthExcludedModels == nil {
|
| 679 |
+
h.cfg.OAuthExcludedModels = make(map[string][]string)
|
| 680 |
+
}
|
| 681 |
+
h.cfg.OAuthExcludedModels[provider] = normalized
|
| 682 |
+
h.persist(c)
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
|
| 686 |
+
provider := strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
| 687 |
+
if provider == "" {
|
| 688 |
+
c.JSON(400, gin.H{"error": "missing provider"})
|
| 689 |
+
return
|
| 690 |
+
}
|
| 691 |
+
if h.cfg.OAuthExcludedModels == nil {
|
| 692 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 693 |
+
return
|
| 694 |
+
}
|
| 695 |
+
if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
|
| 696 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 697 |
+
return
|
| 698 |
+
}
|
| 699 |
+
delete(h.cfg.OAuthExcludedModels, provider)
|
| 700 |
+
if len(h.cfg.OAuthExcludedModels) == 0 {
|
| 701 |
+
h.cfg.OAuthExcludedModels = nil
|
| 702 |
+
}
|
| 703 |
+
h.persist(c)
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
// oauth-model-alias: map[string][]OAuthModelAlias
|
| 707 |
+
func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
|
| 708 |
+
c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
func (h *Handler) PutOAuthModelAlias(c *gin.Context) {
|
| 712 |
+
data, err := c.GetRawData()
|
| 713 |
+
if err != nil {
|
| 714 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 715 |
+
return
|
| 716 |
+
}
|
| 717 |
+
var entries map[string][]config.OAuthModelAlias
|
| 718 |
+
if err = json.Unmarshal(data, &entries); err != nil {
|
| 719 |
+
var wrapper struct {
|
| 720 |
+
Items map[string][]config.OAuthModelAlias `json:"items"`
|
| 721 |
+
}
|
| 722 |
+
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
| 723 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 724 |
+
return
|
| 725 |
+
}
|
| 726 |
+
entries = wrapper.Items
|
| 727 |
+
}
|
| 728 |
+
h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)
|
| 729 |
+
h.persist(c)
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
|
| 733 |
+
var body struct {
|
| 734 |
+
Provider *string `json:"provider"`
|
| 735 |
+
Channel *string `json:"channel"`
|
| 736 |
+
Aliases []config.OAuthModelAlias `json:"aliases"`
|
| 737 |
+
}
|
| 738 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
| 739 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 740 |
+
return
|
| 741 |
+
}
|
| 742 |
+
channelRaw := ""
|
| 743 |
+
if body.Channel != nil {
|
| 744 |
+
channelRaw = *body.Channel
|
| 745 |
+
} else if body.Provider != nil {
|
| 746 |
+
channelRaw = *body.Provider
|
| 747 |
+
}
|
| 748 |
+
channel := strings.ToLower(strings.TrimSpace(channelRaw))
|
| 749 |
+
if channel == "" {
|
| 750 |
+
c.JSON(400, gin.H{"error": "invalid channel"})
|
| 751 |
+
return
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
|
| 755 |
+
normalized := normalizedMap[channel]
|
| 756 |
+
if len(normalized) == 0 {
|
| 757 |
+
if h.cfg.OAuthModelAlias == nil {
|
| 758 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 759 |
+
return
|
| 760 |
+
}
|
| 761 |
+
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
|
| 762 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 763 |
+
return
|
| 764 |
+
}
|
| 765 |
+
delete(h.cfg.OAuthModelAlias, channel)
|
| 766 |
+
if len(h.cfg.OAuthModelAlias) == 0 {
|
| 767 |
+
h.cfg.OAuthModelAlias = nil
|
| 768 |
+
}
|
| 769 |
+
h.persist(c)
|
| 770 |
+
return
|
| 771 |
+
}
|
| 772 |
+
if h.cfg.OAuthModelAlias == nil {
|
| 773 |
+
h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
|
| 774 |
+
}
|
| 775 |
+
h.cfg.OAuthModelAlias[channel] = normalized
|
| 776 |
+
h.persist(c)
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
|
| 780 |
+
channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
|
| 781 |
+
if channel == "" {
|
| 782 |
+
channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
| 783 |
+
}
|
| 784 |
+
if channel == "" {
|
| 785 |
+
c.JSON(400, gin.H{"error": "missing channel"})
|
| 786 |
+
return
|
| 787 |
+
}
|
| 788 |
+
if h.cfg.OAuthModelAlias == nil {
|
| 789 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 790 |
+
return
|
| 791 |
+
}
|
| 792 |
+
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
|
| 793 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 794 |
+
return
|
| 795 |
+
}
|
| 796 |
+
delete(h.cfg.OAuthModelAlias, channel)
|
| 797 |
+
if len(h.cfg.OAuthModelAlias) == 0 {
|
| 798 |
+
h.cfg.OAuthModelAlias = nil
|
| 799 |
+
}
|
| 800 |
+
h.persist(c)
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
// codex-api-key: []CodexKey
|
| 804 |
+
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
| 805 |
+
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
| 806 |
+
}
|
| 807 |
+
func (h *Handler) PutCodexKeys(c *gin.Context) {
|
| 808 |
+
data, err := c.GetRawData()
|
| 809 |
+
if err != nil {
|
| 810 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 811 |
+
return
|
| 812 |
+
}
|
| 813 |
+
var arr []config.CodexKey
|
| 814 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 815 |
+
var obj struct {
|
| 816 |
+
Items []config.CodexKey `json:"items"`
|
| 817 |
+
}
|
| 818 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 819 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 820 |
+
return
|
| 821 |
+
}
|
| 822 |
+
arr = obj.Items
|
| 823 |
+
}
|
| 824 |
+
// Filter out codex entries with empty base-url (treat as removed)
|
| 825 |
+
filtered := make([]config.CodexKey, 0, len(arr))
|
| 826 |
+
for i := range arr {
|
| 827 |
+
entry := arr[i]
|
| 828 |
+
normalizeCodexKey(&entry)
|
| 829 |
+
if entry.BaseURL == "" {
|
| 830 |
+
continue
|
| 831 |
+
}
|
| 832 |
+
filtered = append(filtered, entry)
|
| 833 |
+
}
|
| 834 |
+
h.cfg.CodexKey = filtered
|
| 835 |
+
h.cfg.SanitizeCodexKeys()
|
| 836 |
+
h.persist(c)
|
| 837 |
+
}
|
| 838 |
+
func (h *Handler) PatchCodexKey(c *gin.Context) {
|
| 839 |
+
type codexKeyPatch struct {
|
| 840 |
+
APIKey *string `json:"api-key"`
|
| 841 |
+
Prefix *string `json:"prefix"`
|
| 842 |
+
BaseURL *string `json:"base-url"`
|
| 843 |
+
ProxyURL *string `json:"proxy-url"`
|
| 844 |
+
Models *[]config.CodexModel `json:"models"`
|
| 845 |
+
Headers *map[string]string `json:"headers"`
|
| 846 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 847 |
+
}
|
| 848 |
+
var body struct {
|
| 849 |
+
Index *int `json:"index"`
|
| 850 |
+
Match *string `json:"match"`
|
| 851 |
+
Value *codexKeyPatch `json:"value"`
|
| 852 |
+
}
|
| 853 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 854 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 855 |
+
return
|
| 856 |
+
}
|
| 857 |
+
targetIndex := -1
|
| 858 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
| 859 |
+
targetIndex = *body.Index
|
| 860 |
+
}
|
| 861 |
+
if targetIndex == -1 && body.Match != nil {
|
| 862 |
+
match := strings.TrimSpace(*body.Match)
|
| 863 |
+
for i := range h.cfg.CodexKey {
|
| 864 |
+
if h.cfg.CodexKey[i].APIKey == match {
|
| 865 |
+
targetIndex = i
|
| 866 |
+
break
|
| 867 |
+
}
|
| 868 |
+
}
|
| 869 |
+
}
|
| 870 |
+
if targetIndex == -1 {
|
| 871 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 872 |
+
return
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
entry := h.cfg.CodexKey[targetIndex]
|
| 876 |
+
if body.Value.APIKey != nil {
|
| 877 |
+
entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
|
| 878 |
+
}
|
| 879 |
+
if body.Value.Prefix != nil {
|
| 880 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 881 |
+
}
|
| 882 |
+
if body.Value.BaseURL != nil {
|
| 883 |
+
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
| 884 |
+
if trimmed == "" {
|
| 885 |
+
h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
|
| 886 |
+
h.cfg.SanitizeCodexKeys()
|
| 887 |
+
h.persist(c)
|
| 888 |
+
return
|
| 889 |
+
}
|
| 890 |
+
entry.BaseURL = trimmed
|
| 891 |
+
}
|
| 892 |
+
if body.Value.ProxyURL != nil {
|
| 893 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 894 |
+
}
|
| 895 |
+
if body.Value.Models != nil {
|
| 896 |
+
entry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...)
|
| 897 |
+
}
|
| 898 |
+
if body.Value.Headers != nil {
|
| 899 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 900 |
+
}
|
| 901 |
+
if body.Value.ExcludedModels != nil {
|
| 902 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 903 |
+
}
|
| 904 |
+
normalizeCodexKey(&entry)
|
| 905 |
+
h.cfg.CodexKey[targetIndex] = entry
|
| 906 |
+
h.cfg.SanitizeCodexKeys()
|
| 907 |
+
h.persist(c)
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
| 911 |
+
if val := c.Query("api-key"); val != "" {
|
| 912 |
+
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
| 913 |
+
for _, v := range h.cfg.CodexKey {
|
| 914 |
+
if v.APIKey != val {
|
| 915 |
+
out = append(out, v)
|
| 916 |
+
}
|
| 917 |
+
}
|
| 918 |
+
h.cfg.CodexKey = out
|
| 919 |
+
h.cfg.SanitizeCodexKeys()
|
| 920 |
+
h.persist(c)
|
| 921 |
+
return
|
| 922 |
+
}
|
| 923 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 924 |
+
var idx int
|
| 925 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 926 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
|
| 927 |
+
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
|
| 928 |
+
h.cfg.SanitizeCodexKeys()
|
| 929 |
+
h.persist(c)
|
| 930 |
+
return
|
| 931 |
+
}
|
| 932 |
+
}
|
| 933 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
|
| 937 |
+
if entry == nil {
|
| 938 |
+
return
|
| 939 |
+
}
|
| 940 |
+
// Trim base-url; empty base-url indicates provider should be removed by sanitization
|
| 941 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 942 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 943 |
+
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
|
| 944 |
+
for i := range entry.APIKeyEntries {
|
| 945 |
+
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
|
| 946 |
+
entry.APIKeyEntries[i].APIKey = trimmed
|
| 947 |
+
if trimmed != "" {
|
| 948 |
+
existing[trimmed] = struct{}{}
|
| 949 |
+
}
|
| 950 |
+
}
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
|
| 954 |
+
if len(entries) == 0 {
|
| 955 |
+
return nil
|
| 956 |
+
}
|
| 957 |
+
out := make([]config.OpenAICompatibility, len(entries))
|
| 958 |
+
for i := range entries {
|
| 959 |
+
copyEntry := entries[i]
|
| 960 |
+
if len(copyEntry.APIKeyEntries) > 0 {
|
| 961 |
+
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
|
| 962 |
+
}
|
| 963 |
+
normalizeOpenAICompatibilityEntry(©Entry)
|
| 964 |
+
out[i] = copyEntry
|
| 965 |
+
}
|
| 966 |
+
return out
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
func normalizeClaudeKey(entry *config.ClaudeKey) {
|
| 970 |
+
if entry == nil {
|
| 971 |
+
return
|
| 972 |
+
}
|
| 973 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 974 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 975 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 976 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 977 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
| 978 |
+
if len(entry.Models) == 0 {
|
| 979 |
+
return
|
| 980 |
+
}
|
| 981 |
+
normalized := make([]config.ClaudeModel, 0, len(entry.Models))
|
| 982 |
+
for i := range entry.Models {
|
| 983 |
+
model := entry.Models[i]
|
| 984 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 985 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 986 |
+
if model.Name == "" && model.Alias == "" {
|
| 987 |
+
continue
|
| 988 |
+
}
|
| 989 |
+
normalized = append(normalized, model)
|
| 990 |
+
}
|
| 991 |
+
entry.Models = normalized
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
func normalizeCodexKey(entry *config.CodexKey) {
|
| 995 |
+
if entry == nil {
|
| 996 |
+
return
|
| 997 |
+
}
|
| 998 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 999 |
+
entry.Prefix = strings.TrimSpace(entry.Prefix)
|
| 1000 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 1001 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 1002 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1003 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
| 1004 |
+
if len(entry.Models) == 0 {
|
| 1005 |
+
return
|
| 1006 |
+
}
|
| 1007 |
+
normalized := make([]config.CodexModel, 0, len(entry.Models))
|
| 1008 |
+
for i := range entry.Models {
|
| 1009 |
+
model := entry.Models[i]
|
| 1010 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 1011 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 1012 |
+
if model.Name == "" && model.Alias == "" {
|
| 1013 |
+
continue
|
| 1014 |
+
}
|
| 1015 |
+
normalized = append(normalized, model)
|
| 1016 |
+
}
|
| 1017 |
+
entry.Models = normalized
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
|
| 1021 |
+
if entry == nil {
|
| 1022 |
+
return
|
| 1023 |
+
}
|
| 1024 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 1025 |
+
entry.Prefix = strings.TrimSpace(entry.Prefix)
|
| 1026 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 1027 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 1028 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1029 |
+
if len(entry.Models) == 0 {
|
| 1030 |
+
return
|
| 1031 |
+
}
|
| 1032 |
+
normalized := make([]config.VertexCompatModel, 0, len(entry.Models))
|
| 1033 |
+
for i := range entry.Models {
|
| 1034 |
+
model := entry.Models[i]
|
| 1035 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 1036 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 1037 |
+
if model.Name == "" || model.Alias == "" {
|
| 1038 |
+
continue
|
| 1039 |
+
}
|
| 1040 |
+
normalized = append(normalized, model)
|
| 1041 |
+
}
|
| 1042 |
+
entry.Models = normalized
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
func sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias {
|
| 1046 |
+
if len(entries) == 0 {
|
| 1047 |
+
return nil
|
| 1048 |
+
}
|
| 1049 |
+
copied := make(map[string][]config.OAuthModelAlias, len(entries))
|
| 1050 |
+
for channel, aliases := range entries {
|
| 1051 |
+
if len(aliases) == 0 {
|
| 1052 |
+
continue
|
| 1053 |
+
}
|
| 1054 |
+
copied[channel] = append([]config.OAuthModelAlias(nil), aliases...)
|
| 1055 |
+
}
|
| 1056 |
+
if len(copied) == 0 {
|
| 1057 |
+
return nil
|
| 1058 |
+
}
|
| 1059 |
+
cfg := config.Config{OAuthModelAlias: copied}
|
| 1060 |
+
cfg.SanitizeOAuthModelAlias()
|
| 1061 |
+
if len(cfg.OAuthModelAlias) == 0 {
|
| 1062 |
+
return nil
|
| 1063 |
+
}
|
| 1064 |
+
return cfg.OAuthModelAlias
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
// GetAmpCode returns the complete ampcode configuration.
|
| 1068 |
+
func (h *Handler) GetAmpCode(c *gin.Context) {
|
| 1069 |
+
if h == nil || h.cfg == nil {
|
| 1070 |
+
c.JSON(200, gin.H{"ampcode": config.AmpCode{}})
|
| 1071 |
+
return
|
| 1072 |
+
}
|
| 1073 |
+
c.JSON(200, gin.H{"ampcode": h.cfg.AmpCode})
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
// GetAmpUpstreamURL returns the ampcode upstream URL.
|
| 1077 |
+
func (h *Handler) GetAmpUpstreamURL(c *gin.Context) {
|
| 1078 |
+
if h == nil || h.cfg == nil {
|
| 1079 |
+
c.JSON(200, gin.H{"upstream-url": ""})
|
| 1080 |
+
return
|
| 1081 |
+
}
|
| 1082 |
+
c.JSON(200, gin.H{"upstream-url": h.cfg.AmpCode.UpstreamURL})
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
// PutAmpUpstreamURL updates the ampcode upstream URL.
|
| 1086 |
+
func (h *Handler) PutAmpUpstreamURL(c *gin.Context) {
|
| 1087 |
+
h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
// DeleteAmpUpstreamURL clears the ampcode upstream URL.
|
| 1091 |
+
func (h *Handler) DeleteAmpUpstreamURL(c *gin.Context) {
|
| 1092 |
+
h.cfg.AmpCode.UpstreamURL = ""
|
| 1093 |
+
h.persist(c)
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
// GetAmpUpstreamAPIKey returns the ampcode upstream API key.
|
| 1097 |
+
func (h *Handler) GetAmpUpstreamAPIKey(c *gin.Context) {
|
| 1098 |
+
if h == nil || h.cfg == nil {
|
| 1099 |
+
c.JSON(200, gin.H{"upstream-api-key": ""})
|
| 1100 |
+
return
|
| 1101 |
+
}
|
| 1102 |
+
c.JSON(200, gin.H{"upstream-api-key": h.cfg.AmpCode.UpstreamAPIKey})
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
// PutAmpUpstreamAPIKey updates the ampcode upstream API key.
|
| 1106 |
+
func (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {
|
| 1107 |
+
h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
// DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.
|
| 1111 |
+
func (h *Handler) DeleteAmpUpstreamAPIKey(c *gin.Context) {
|
| 1112 |
+
h.cfg.AmpCode.UpstreamAPIKey = ""
|
| 1113 |
+
h.persist(c)
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
// GetAmpRestrictManagementToLocalhost returns the localhost restriction setting.
|
| 1117 |
+
func (h *Handler) GetAmpRestrictManagementToLocalhost(c *gin.Context) {
|
| 1118 |
+
if h == nil || h.cfg == nil {
|
| 1119 |
+
c.JSON(200, gin.H{"restrict-management-to-localhost": true})
|
| 1120 |
+
return
|
| 1121 |
+
}
|
| 1122 |
+
c.JSON(200, gin.H{"restrict-management-to-localhost": h.cfg.AmpCode.RestrictManagementToLocalhost})
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
// PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.
|
| 1126 |
+
func (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {
|
| 1127 |
+
h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
// GetAmpModelMappings returns the ampcode model mappings.
|
| 1131 |
+
func (h *Handler) GetAmpModelMappings(c *gin.Context) {
|
| 1132 |
+
if h == nil || h.cfg == nil {
|
| 1133 |
+
c.JSON(200, gin.H{"model-mappings": []config.AmpModelMapping{}})
|
| 1134 |
+
return
|
| 1135 |
+
}
|
| 1136 |
+
c.JSON(200, gin.H{"model-mappings": h.cfg.AmpCode.ModelMappings})
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
// PutAmpModelMappings replaces all ampcode model mappings.
|
| 1140 |
+
func (h *Handler) PutAmpModelMappings(c *gin.Context) {
|
| 1141 |
+
var body struct {
|
| 1142 |
+
Value []config.AmpModelMapping `json:"value"`
|
| 1143 |
+
}
|
| 1144 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1145 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1146 |
+
return
|
| 1147 |
+
}
|
| 1148 |
+
h.cfg.AmpCode.ModelMappings = body.Value
|
| 1149 |
+
h.persist(c)
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
// PatchAmpModelMappings adds or updates model mappings.
|
| 1153 |
+
func (h *Handler) PatchAmpModelMappings(c *gin.Context) {
|
| 1154 |
+
var body struct {
|
| 1155 |
+
Value []config.AmpModelMapping `json:"value"`
|
| 1156 |
+
}
|
| 1157 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1158 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1159 |
+
return
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
existing := make(map[string]int)
|
| 1163 |
+
for i, m := range h.cfg.AmpCode.ModelMappings {
|
| 1164 |
+
existing[strings.TrimSpace(m.From)] = i
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
for _, newMapping := range body.Value {
|
| 1168 |
+
from := strings.TrimSpace(newMapping.From)
|
| 1169 |
+
if idx, ok := existing[from]; ok {
|
| 1170 |
+
h.cfg.AmpCode.ModelMappings[idx] = newMapping
|
| 1171 |
+
} else {
|
| 1172 |
+
h.cfg.AmpCode.ModelMappings = append(h.cfg.AmpCode.ModelMappings, newMapping)
|
| 1173 |
+
existing[from] = len(h.cfg.AmpCode.ModelMappings) - 1
|
| 1174 |
+
}
|
| 1175 |
+
}
|
| 1176 |
+
h.persist(c)
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
// DeleteAmpModelMappings removes specified model mappings by "from" field.
|
| 1180 |
+
func (h *Handler) DeleteAmpModelMappings(c *gin.Context) {
|
| 1181 |
+
var body struct {
|
| 1182 |
+
Value []string `json:"value"`
|
| 1183 |
+
}
|
| 1184 |
+
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
| 1185 |
+
h.cfg.AmpCode.ModelMappings = nil
|
| 1186 |
+
h.persist(c)
|
| 1187 |
+
return
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
toRemove := make(map[string]bool)
|
| 1191 |
+
for _, from := range body.Value {
|
| 1192 |
+
toRemove[strings.TrimSpace(from)] = true
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
newMappings := make([]config.AmpModelMapping, 0, len(h.cfg.AmpCode.ModelMappings))
|
| 1196 |
+
for _, m := range h.cfg.AmpCode.ModelMappings {
|
| 1197 |
+
if !toRemove[strings.TrimSpace(m.From)] {
|
| 1198 |
+
newMappings = append(newMappings, m)
|
| 1199 |
+
}
|
| 1200 |
+
}
|
| 1201 |
+
h.cfg.AmpCode.ModelMappings = newMappings
|
| 1202 |
+
h.persist(c)
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
+
// GetAmpForceModelMappings returns whether model mappings are forced.
|
| 1206 |
+
func (h *Handler) GetAmpForceModelMappings(c *gin.Context) {
|
| 1207 |
+
if h == nil || h.cfg == nil {
|
| 1208 |
+
c.JSON(200, gin.H{"force-model-mappings": false})
|
| 1209 |
+
return
|
| 1210 |
+
}
|
| 1211 |
+
c.JSON(200, gin.H{"force-model-mappings": h.cfg.AmpCode.ForceModelMappings})
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
// PutAmpForceModelMappings updates the force model mappings setting.
|
| 1215 |
+
func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
|
| 1216 |
+
h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
|
| 1217 |
+
}
|
| 1218 |
+
|
| 1219 |
+
// GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
|
| 1220 |
+
func (h *Handler) GetAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1221 |
+
if h == nil || h.cfg == nil {
|
| 1222 |
+
c.JSON(200, gin.H{"upstream-api-keys": []config.AmpUpstreamAPIKeyEntry{}})
|
| 1223 |
+
return
|
| 1224 |
+
}
|
| 1225 |
+
c.JSON(200, gin.H{"upstream-api-keys": h.cfg.AmpCode.UpstreamAPIKeys})
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
// PutAmpUpstreamAPIKeys replaces all ampcode upstream API keys mappings.
|
| 1229 |
+
func (h *Handler) PutAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1230 |
+
var body struct {
|
| 1231 |
+
Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
|
| 1232 |
+
}
|
| 1233 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1234 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1235 |
+
return
|
| 1236 |
+
}
|
| 1237 |
+
// Normalize entries: trim whitespace, filter empty
|
| 1238 |
+
normalized := normalizeAmpUpstreamAPIKeyEntries(body.Value)
|
| 1239 |
+
h.cfg.AmpCode.UpstreamAPIKeys = normalized
|
| 1240 |
+
h.persist(c)
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
// PatchAmpUpstreamAPIKeys adds or updates upstream API keys entries.
|
| 1244 |
+
// Matching is done by upstream-api-key value.
|
| 1245 |
+
func (h *Handler) PatchAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1246 |
+
var body struct {
|
| 1247 |
+
Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
|
| 1248 |
+
}
|
| 1249 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1250 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1251 |
+
return
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
existing := make(map[string]int)
|
| 1255 |
+
for i, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
|
| 1256 |
+
existing[strings.TrimSpace(entry.UpstreamAPIKey)] = i
|
| 1257 |
+
}
|
| 1258 |
+
|
| 1259 |
+
for _, newEntry := range body.Value {
|
| 1260 |
+
upstreamKey := strings.TrimSpace(newEntry.UpstreamAPIKey)
|
| 1261 |
+
if upstreamKey == "" {
|
| 1262 |
+
continue
|
| 1263 |
+
}
|
| 1264 |
+
normalizedEntry := config.AmpUpstreamAPIKeyEntry{
|
| 1265 |
+
UpstreamAPIKey: upstreamKey,
|
| 1266 |
+
APIKeys: normalizeAPIKeysList(newEntry.APIKeys),
|
| 1267 |
+
}
|
| 1268 |
+
if idx, ok := existing[upstreamKey]; ok {
|
| 1269 |
+
h.cfg.AmpCode.UpstreamAPIKeys[idx] = normalizedEntry
|
| 1270 |
+
} else {
|
| 1271 |
+
h.cfg.AmpCode.UpstreamAPIKeys = append(h.cfg.AmpCode.UpstreamAPIKeys, normalizedEntry)
|
| 1272 |
+
existing[upstreamKey] = len(h.cfg.AmpCode.UpstreamAPIKeys) - 1
|
| 1273 |
+
}
|
| 1274 |
+
}
|
| 1275 |
+
h.persist(c)
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
// DeleteAmpUpstreamAPIKeys removes specified upstream API keys entries.
|
| 1279 |
+
// Body must be JSON: {"value": ["<upstream-api-key>", ...]}.
|
| 1280 |
+
// If "value" is an empty array, clears all entries.
|
| 1281 |
+
// If JSON is invalid or "value" is missing/null, returns 400 and does not persist any change.
|
| 1282 |
+
func (h *Handler) DeleteAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1283 |
+
var body struct {
|
| 1284 |
+
Value []string `json:"value"`
|
| 1285 |
+
}
|
| 1286 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1287 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1288 |
+
return
|
| 1289 |
+
}
|
| 1290 |
+
|
| 1291 |
+
if body.Value == nil {
|
| 1292 |
+
c.JSON(400, gin.H{"error": "missing value"})
|
| 1293 |
+
return
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
// Empty array means clear all
|
| 1297 |
+
if len(body.Value) == 0 {
|
| 1298 |
+
h.cfg.AmpCode.UpstreamAPIKeys = nil
|
| 1299 |
+
h.persist(c)
|
| 1300 |
+
return
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
toRemove := make(map[string]bool)
|
| 1304 |
+
for _, key := range body.Value {
|
| 1305 |
+
trimmed := strings.TrimSpace(key)
|
| 1306 |
+
if trimmed == "" {
|
| 1307 |
+
continue
|
| 1308 |
+
}
|
| 1309 |
+
toRemove[trimmed] = true
|
| 1310 |
+
}
|
| 1311 |
+
if len(toRemove) == 0 {
|
| 1312 |
+
c.JSON(400, gin.H{"error": "empty value"})
|
| 1313 |
+
return
|
| 1314 |
+
}
|
| 1315 |
+
|
| 1316 |
+
newEntries := make([]config.AmpUpstreamAPIKeyEntry, 0, len(h.cfg.AmpCode.UpstreamAPIKeys))
|
| 1317 |
+
for _, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
|
| 1318 |
+
if !toRemove[strings.TrimSpace(entry.UpstreamAPIKey)] {
|
| 1319 |
+
newEntries = append(newEntries, entry)
|
| 1320 |
+
}
|
| 1321 |
+
}
|
| 1322 |
+
h.cfg.AmpCode.UpstreamAPIKeys = newEntries
|
| 1323 |
+
h.persist(c)
|
| 1324 |
+
}
|
| 1325 |
+
|
| 1326 |
+
// normalizeAmpUpstreamAPIKeyEntries normalizes a list of upstream API key entries.
|
| 1327 |
+
func normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry {
|
| 1328 |
+
if len(entries) == 0 {
|
| 1329 |
+
return nil
|
| 1330 |
+
}
|
| 1331 |
+
out := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries))
|
| 1332 |
+
for _, entry := range entries {
|
| 1333 |
+
upstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)
|
| 1334 |
+
if upstreamKey == "" {
|
| 1335 |
+
continue
|
| 1336 |
+
}
|
| 1337 |
+
apiKeys := normalizeAPIKeysList(entry.APIKeys)
|
| 1338 |
+
out = append(out, config.AmpUpstreamAPIKeyEntry{
|
| 1339 |
+
UpstreamAPIKey: upstreamKey,
|
| 1340 |
+
APIKeys: apiKeys,
|
| 1341 |
+
})
|
| 1342 |
+
}
|
| 1343 |
+
if len(out) == 0 {
|
| 1344 |
+
return nil
|
| 1345 |
+
}
|
| 1346 |
+
return out
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
// normalizeAPIKeysList trims and filters empty strings from a list of API keys.
|
| 1350 |
+
func normalizeAPIKeysList(keys []string) []string {
|
| 1351 |
+
if len(keys) == 0 {
|
| 1352 |
+
return nil
|
| 1353 |
+
}
|
| 1354 |
+
out := make([]string, 0, len(keys))
|
| 1355 |
+
for _, k := range keys {
|
| 1356 |
+
trimmed := strings.TrimSpace(k)
|
| 1357 |
+
if trimmed != "" {
|
| 1358 |
+
out = append(out, trimmed)
|
| 1359 |
+
}
|
| 1360 |
+
}
|
| 1361 |
+
if len(out) == 0 {
|
| 1362 |
+
return nil
|
| 1363 |
+
}
|
| 1364 |
+
return out
|
| 1365 |
+
}
|
internal/api/handlers/management/handler.go
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package management provides the management API handlers and middleware
|
| 2 |
+
// for configuring the server and managing auth files.
|
| 3 |
+
package management
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"crypto/subtle"
|
| 7 |
+
"fmt"
|
| 8 |
+
"net/http"
|
| 9 |
+
"os"
|
| 10 |
+
"path/filepath"
|
| 11 |
+
"strings"
|
| 12 |
+
"sync"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"github.com/gin-gonic/gin"
|
| 16 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
| 17 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 18 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
| 19 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 20 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 21 |
+
"golang.org/x/crypto/bcrypt"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
type attemptInfo struct {
|
| 25 |
+
count int
|
| 26 |
+
blockedUntil time.Time
|
| 27 |
+
lastActivity time.Time // track last activity for cleanup
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// attemptCleanupInterval controls how often stale IP entries are purged
|
| 31 |
+
const attemptCleanupInterval = 1 * time.Hour
|
| 32 |
+
|
| 33 |
+
// attemptMaxIdleTime controls how long an IP can be idle before cleanup
|
| 34 |
+
const attemptMaxIdleTime = 2 * time.Hour
|
| 35 |
+
|
| 36 |
+
// Handler aggregates config reference, persistence path and helpers.
|
| 37 |
+
type Handler struct {
|
| 38 |
+
cfg *config.Config
|
| 39 |
+
configFilePath string
|
| 40 |
+
mu sync.Mutex
|
| 41 |
+
attemptsMu sync.Mutex
|
| 42 |
+
failedAttempts map[string]*attemptInfo // keyed by client IP
|
| 43 |
+
authManager *coreauth.Manager
|
| 44 |
+
usageStats *usage.RequestStatistics
|
| 45 |
+
tokenStore coreauth.Store
|
| 46 |
+
localPassword string
|
| 47 |
+
allowRemoteOverride bool
|
| 48 |
+
envSecret string
|
| 49 |
+
logDir string
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// NewHandler creates a new management handler instance.
|
| 53 |
+
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
|
| 54 |
+
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
|
| 55 |
+
envSecret = strings.TrimSpace(envSecret)
|
| 56 |
+
|
| 57 |
+
h := &Handler{
|
| 58 |
+
cfg: cfg,
|
| 59 |
+
configFilePath: configFilePath,
|
| 60 |
+
failedAttempts: make(map[string]*attemptInfo),
|
| 61 |
+
authManager: manager,
|
| 62 |
+
usageStats: usage.GetRequestStatistics(),
|
| 63 |
+
tokenStore: sdkAuth.GetTokenStore(),
|
| 64 |
+
allowRemoteOverride: envSecret != "",
|
| 65 |
+
envSecret: envSecret,
|
| 66 |
+
}
|
| 67 |
+
h.startAttemptCleanup()
|
| 68 |
+
return h
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// startAttemptCleanup launches a background goroutine that periodically
|
| 72 |
+
// removes stale IP entries from failedAttempts to prevent memory leaks.
|
| 73 |
+
func (h *Handler) startAttemptCleanup() {
|
| 74 |
+
go func() {
|
| 75 |
+
ticker := time.NewTicker(attemptCleanupInterval)
|
| 76 |
+
defer ticker.Stop()
|
| 77 |
+
for range ticker.C {
|
| 78 |
+
h.purgeStaleAttempts()
|
| 79 |
+
}
|
| 80 |
+
}()
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// purgeStaleAttempts removes IP entries that have been idle beyond attemptMaxIdleTime
|
| 84 |
+
// and whose ban (if any) has expired.
|
| 85 |
+
func (h *Handler) purgeStaleAttempts() {
|
| 86 |
+
now := time.Now()
|
| 87 |
+
h.attemptsMu.Lock()
|
| 88 |
+
defer h.attemptsMu.Unlock()
|
| 89 |
+
for ip, ai := range h.failedAttempts {
|
| 90 |
+
// Skip if still banned
|
| 91 |
+
if !ai.blockedUntil.IsZero() && now.Before(ai.blockedUntil) {
|
| 92 |
+
continue
|
| 93 |
+
}
|
| 94 |
+
// Remove if idle too long
|
| 95 |
+
if now.Sub(ai.lastActivity) > attemptMaxIdleTime {
|
| 96 |
+
delete(h.failedAttempts, ip)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// NewHandler creates a new management handler instance.
|
| 102 |
+
func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler {
|
| 103 |
+
return NewHandler(cfg, "", manager)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// SetConfig updates the in-memory config reference when the server hot-reloads.
|
| 107 |
+
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
|
| 108 |
+
|
| 109 |
+
// SetAuthManager updates the auth manager reference used by management endpoints.
|
| 110 |
+
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
|
| 111 |
+
|
| 112 |
+
// SetUsageStatistics allows replacing the usage statistics reference.
|
| 113 |
+
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
|
| 114 |
+
|
| 115 |
+
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
| 116 |
+
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
| 117 |
+
|
| 118 |
+
// SetLogDirectory updates the directory where main.log should be looked up.
|
| 119 |
+
func (h *Handler) SetLogDirectory(dir string) {
|
| 120 |
+
if dir == "" {
|
| 121 |
+
return
|
| 122 |
+
}
|
| 123 |
+
if !filepath.IsAbs(dir) {
|
| 124 |
+
if abs, err := filepath.Abs(dir); err == nil {
|
| 125 |
+
dir = abs
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
h.logDir = dir
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// Middleware enforces access control for management endpoints.
|
| 132 |
+
// All requests (local and remote) require a valid management key.
|
| 133 |
+
// Additionally, remote access requires allow-remote-management=true.
|
| 134 |
+
func (h *Handler) Middleware() gin.HandlerFunc {
|
| 135 |
+
const maxFailures = 5
|
| 136 |
+
const banDuration = 30 * time.Minute
|
| 137 |
+
|
| 138 |
+
return func(c *gin.Context) {
|
| 139 |
+
c.Header("X-CPA-VERSION", buildinfo.Version)
|
| 140 |
+
c.Header("X-CPA-COMMIT", buildinfo.Commit)
|
| 141 |
+
c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate)
|
| 142 |
+
|
| 143 |
+
clientIP := c.ClientIP()
|
| 144 |
+
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
|
| 145 |
+
cfg := h.cfg
|
| 146 |
+
var (
|
| 147 |
+
allowRemote bool
|
| 148 |
+
secretHash string
|
| 149 |
+
)
|
| 150 |
+
if cfg != nil {
|
| 151 |
+
allowRemote = cfg.RemoteManagement.AllowRemote
|
| 152 |
+
secretHash = cfg.RemoteManagement.SecretKey
|
| 153 |
+
}
|
| 154 |
+
if h.allowRemoteOverride {
|
| 155 |
+
allowRemote = true
|
| 156 |
+
}
|
| 157 |
+
envSecret := h.envSecret
|
| 158 |
+
|
| 159 |
+
fail := func() {}
|
| 160 |
+
if !localClient {
|
| 161 |
+
h.attemptsMu.Lock()
|
| 162 |
+
ai := h.failedAttempts[clientIP]
|
| 163 |
+
if ai != nil {
|
| 164 |
+
if !ai.blockedUntil.IsZero() {
|
| 165 |
+
if time.Now().Before(ai.blockedUntil) {
|
| 166 |
+
remaining := time.Until(ai.blockedUntil).Round(time.Second)
|
| 167 |
+
h.attemptsMu.Unlock()
|
| 168 |
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
|
| 169 |
+
return
|
| 170 |
+
}
|
| 171 |
+
// Ban expired, reset state
|
| 172 |
+
ai.blockedUntil = time.Time{}
|
| 173 |
+
ai.count = 0
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
h.attemptsMu.Unlock()
|
| 177 |
+
|
| 178 |
+
if !allowRemote {
|
| 179 |
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
|
| 180 |
+
return
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
fail = func() {
|
| 184 |
+
h.attemptsMu.Lock()
|
| 185 |
+
aip := h.failedAttempts[clientIP]
|
| 186 |
+
if aip == nil {
|
| 187 |
+
aip = &attemptInfo{}
|
| 188 |
+
h.failedAttempts[clientIP] = aip
|
| 189 |
+
}
|
| 190 |
+
aip.count++
|
| 191 |
+
aip.lastActivity = time.Now()
|
| 192 |
+
if aip.count >= maxFailures {
|
| 193 |
+
aip.blockedUntil = time.Now().Add(banDuration)
|
| 194 |
+
aip.count = 0
|
| 195 |
+
}
|
| 196 |
+
h.attemptsMu.Unlock()
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
if secretHash == "" && envSecret == "" {
|
| 200 |
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
|
| 201 |
+
return
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Accept either Authorization: Bearer <key> or X-Management-Key
|
| 205 |
+
var provided string
|
| 206 |
+
if ah := c.GetHeader("Authorization"); ah != "" {
|
| 207 |
+
parts := strings.SplitN(ah, " ", 2)
|
| 208 |
+
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
| 209 |
+
provided = parts[1]
|
| 210 |
+
} else {
|
| 211 |
+
provided = ah
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
if provided == "" {
|
| 215 |
+
provided = c.GetHeader("X-Management-Key")
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
if provided == "" {
|
| 219 |
+
if !localClient {
|
| 220 |
+
fail()
|
| 221 |
+
}
|
| 222 |
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
| 223 |
+
return
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if localClient {
|
| 227 |
+
if lp := h.localPassword; lp != "" {
|
| 228 |
+
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
|
| 229 |
+
c.Next()
|
| 230 |
+
return
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
|
| 236 |
+
if !localClient {
|
| 237 |
+
h.attemptsMu.Lock()
|
| 238 |
+
if ai := h.failedAttempts[clientIP]; ai != nil {
|
| 239 |
+
ai.count = 0
|
| 240 |
+
ai.blockedUntil = time.Time{}
|
| 241 |
+
}
|
| 242 |
+
h.attemptsMu.Unlock()
|
| 243 |
+
}
|
| 244 |
+
c.Next()
|
| 245 |
+
return
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
|
| 249 |
+
if !localClient {
|
| 250 |
+
fail()
|
| 251 |
+
}
|
| 252 |
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
|
| 253 |
+
return
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
if !localClient {
|
| 257 |
+
h.attemptsMu.Lock()
|
| 258 |
+
if ai := h.failedAttempts[clientIP]; ai != nil {
|
| 259 |
+
ai.count = 0
|
| 260 |
+
ai.blockedUntil = time.Time{}
|
| 261 |
+
}
|
| 262 |
+
h.attemptsMu.Unlock()
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
c.Next()
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// persist saves the current in-memory config to disk.
|
| 270 |
+
func (h *Handler) persist(c *gin.Context) bool {
|
| 271 |
+
h.mu.Lock()
|
| 272 |
+
defer h.mu.Unlock()
|
| 273 |
+
// Preserve comments when writing
|
| 274 |
+
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
|
| 275 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
|
| 276 |
+
return false
|
| 277 |
+
}
|
| 278 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 279 |
+
return true
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// Helper methods for simple types
|
| 283 |
+
func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
|
| 284 |
+
var body struct {
|
| 285 |
+
Value *bool `json:"value"`
|
| 286 |
+
}
|
| 287 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 288 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 289 |
+
return
|
| 290 |
+
}
|
| 291 |
+
set(*body.Value)
|
| 292 |
+
h.persist(c)
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
|
| 296 |
+
var body struct {
|
| 297 |
+
Value *int `json:"value"`
|
| 298 |
+
}
|
| 299 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 300 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 301 |
+
return
|
| 302 |
+
}
|
| 303 |
+
set(*body.Value)
|
| 304 |
+
h.persist(c)
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
|
| 308 |
+
var body struct {
|
| 309 |
+
Value *string `json:"value"`
|
| 310 |
+
}
|
| 311 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 312 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 313 |
+
return
|
| 314 |
+
}
|
| 315 |
+
set(*body.Value)
|
| 316 |
+
h.persist(c)
|
| 317 |
+
}
|
internal/api/handlers/management/logs.go
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bufio"
|
| 5 |
+
"fmt"
|
| 6 |
+
"math"
|
| 7 |
+
"net/http"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"sort"
|
| 11 |
+
"strconv"
|
| 12 |
+
"strings"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"github.com/gin-gonic/gin"
|
| 16 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
const (
|
| 20 |
+
defaultLogFileName = "main.log"
|
| 21 |
+
logScannerInitialBuffer = 64 * 1024
|
| 22 |
+
logScannerMaxBuffer = 8 * 1024 * 1024
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
// GetLogs returns log lines with optional incremental loading.
|
| 26 |
+
func (h *Handler) GetLogs(c *gin.Context) {
|
| 27 |
+
if h == nil {
|
| 28 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
if h.cfg == nil {
|
| 32 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 33 |
+
return
|
| 34 |
+
}
|
| 35 |
+
if !h.cfg.LoggingToFile {
|
| 36 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
logDir := h.logDirectory()
|
| 41 |
+
if strings.TrimSpace(logDir) == "" {
|
| 42 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
files, err := h.collectLogFiles(logDir)
|
| 47 |
+
if err != nil {
|
| 48 |
+
if os.IsNotExist(err) {
|
| 49 |
+
cutoff := parseCutoff(c.Query("after"))
|
| 50 |
+
c.JSON(http.StatusOK, gin.H{
|
| 51 |
+
"lines": []string{},
|
| 52 |
+
"line-count": 0,
|
| 53 |
+
"latest-timestamp": cutoff,
|
| 54 |
+
})
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)})
|
| 58 |
+
return
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
limit, errLimit := parseLimit(c.Query("limit"))
|
| 62 |
+
if errLimit != nil {
|
| 63 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid limit: %v", errLimit)})
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
cutoff := parseCutoff(c.Query("after"))
|
| 68 |
+
acc := newLogAccumulator(cutoff, limit)
|
| 69 |
+
for i := range files {
|
| 70 |
+
if errProcess := acc.consumeFile(files[i]); errProcess != nil {
|
| 71 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)})
|
| 72 |
+
return
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
lines, total, latest := acc.result()
|
| 77 |
+
if latest == 0 || latest < cutoff {
|
| 78 |
+
latest = cutoff
|
| 79 |
+
}
|
| 80 |
+
c.JSON(http.StatusOK, gin.H{
|
| 81 |
+
"lines": lines,
|
| 82 |
+
"line-count": total,
|
| 83 |
+
"latest-timestamp": latest,
|
| 84 |
+
})
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// DeleteLogs removes all rotated log files and truncates the active log.
|
| 88 |
+
func (h *Handler) DeleteLogs(c *gin.Context) {
|
| 89 |
+
if h == nil {
|
| 90 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 91 |
+
return
|
| 92 |
+
}
|
| 93 |
+
if h.cfg == nil {
|
| 94 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 95 |
+
return
|
| 96 |
+
}
|
| 97 |
+
if !h.cfg.LoggingToFile {
|
| 98 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
| 99 |
+
return
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
dir := h.logDirectory()
|
| 103 |
+
if strings.TrimSpace(dir) == "" {
|
| 104 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 105 |
+
return
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
entries, err := os.ReadDir(dir)
|
| 109 |
+
if err != nil {
|
| 110 |
+
if os.IsNotExist(err) {
|
| 111 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
|
| 112 |
+
return
|
| 113 |
+
}
|
| 114 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
|
| 115 |
+
return
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
removed := 0
|
| 119 |
+
for _, entry := range entries {
|
| 120 |
+
if entry.IsDir() {
|
| 121 |
+
continue
|
| 122 |
+
}
|
| 123 |
+
name := entry.Name()
|
| 124 |
+
fullPath := filepath.Join(dir, name)
|
| 125 |
+
if name == defaultLogFileName {
|
| 126 |
+
if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {
|
| 127 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)})
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
continue
|
| 131 |
+
}
|
| 132 |
+
if isRotatedLogFile(name) {
|
| 133 |
+
if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {
|
| 134 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)})
|
| 135 |
+
return
|
| 136 |
+
}
|
| 137 |
+
removed++
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
c.JSON(http.StatusOK, gin.H{
|
| 142 |
+
"success": true,
|
| 143 |
+
"message": "Logs cleared successfully",
|
| 144 |
+
"removed": removed,
|
| 145 |
+
})
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// GetRequestErrorLogs lists error request log files when RequestLog is disabled.
|
| 149 |
+
// It returns an empty list when RequestLog is enabled.
|
| 150 |
+
func (h *Handler) GetRequestErrorLogs(c *gin.Context) {
|
| 151 |
+
if h == nil {
|
| 152 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 153 |
+
return
|
| 154 |
+
}
|
| 155 |
+
if h.cfg == nil {
|
| 156 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 157 |
+
return
|
| 158 |
+
}
|
| 159 |
+
if h.cfg.RequestLog {
|
| 160 |
+
c.JSON(http.StatusOK, gin.H{"files": []any{}})
|
| 161 |
+
return
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
dir := h.logDirectory()
|
| 165 |
+
if strings.TrimSpace(dir) == "" {
|
| 166 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 167 |
+
return
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
entries, err := os.ReadDir(dir)
|
| 171 |
+
if err != nil {
|
| 172 |
+
if os.IsNotExist(err) {
|
| 173 |
+
c.JSON(http.StatusOK, gin.H{"files": []any{}})
|
| 174 |
+
return
|
| 175 |
+
}
|
| 176 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list request error logs: %v", err)})
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
type errorLog struct {
|
| 181 |
+
Name string `json:"name"`
|
| 182 |
+
Size int64 `json:"size"`
|
| 183 |
+
Modified int64 `json:"modified"`
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
files := make([]errorLog, 0, len(entries))
|
| 187 |
+
for _, entry := range entries {
|
| 188 |
+
if entry.IsDir() {
|
| 189 |
+
continue
|
| 190 |
+
}
|
| 191 |
+
name := entry.Name()
|
| 192 |
+
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
|
| 193 |
+
continue
|
| 194 |
+
}
|
| 195 |
+
info, errInfo := entry.Info()
|
| 196 |
+
if errInfo != nil {
|
| 197 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log info for %s: %v", name, errInfo)})
|
| 198 |
+
return
|
| 199 |
+
}
|
| 200 |
+
files = append(files, errorLog{
|
| 201 |
+
Name: name,
|
| 202 |
+
Size: info.Size(),
|
| 203 |
+
Modified: info.ModTime().Unix(),
|
| 204 |
+
})
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
|
| 208 |
+
|
| 209 |
+
c.JSON(http.StatusOK, gin.H{"files": files})
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// GetRequestLogByID finds and downloads a request log file by its request ID.
|
| 213 |
+
// The ID is matched against the suffix of log file names (format: *-{requestID}.log).
|
| 214 |
+
func (h *Handler) GetRequestLogByID(c *gin.Context) {
|
| 215 |
+
if h == nil {
|
| 216 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 217 |
+
return
|
| 218 |
+
}
|
| 219 |
+
if h.cfg == nil {
|
| 220 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 221 |
+
return
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
dir := h.logDirectory()
|
| 225 |
+
if strings.TrimSpace(dir) == "" {
|
| 226 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 227 |
+
return
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
requestID := strings.TrimSpace(c.Param("id"))
|
| 231 |
+
if requestID == "" {
|
| 232 |
+
requestID = strings.TrimSpace(c.Query("id"))
|
| 233 |
+
}
|
| 234 |
+
if requestID == "" {
|
| 235 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing request ID"})
|
| 236 |
+
return
|
| 237 |
+
}
|
| 238 |
+
if strings.ContainsAny(requestID, "/\\") {
|
| 239 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request ID"})
|
| 240 |
+
return
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
entries, err := os.ReadDir(dir)
|
| 244 |
+
if err != nil {
|
| 245 |
+
if os.IsNotExist(err) {
|
| 246 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
|
| 250 |
+
return
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
suffix := "-" + requestID + ".log"
|
| 254 |
+
var matchedFile string
|
| 255 |
+
for _, entry := range entries {
|
| 256 |
+
if entry.IsDir() {
|
| 257 |
+
continue
|
| 258 |
+
}
|
| 259 |
+
name := entry.Name()
|
| 260 |
+
if strings.HasSuffix(name, suffix) {
|
| 261 |
+
matchedFile = name
|
| 262 |
+
break
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
if matchedFile == "" {
|
| 267 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found for the given request ID"})
|
| 268 |
+
return
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
dirAbs, errAbs := filepath.Abs(dir)
|
| 272 |
+
if errAbs != nil {
|
| 273 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
|
| 274 |
+
return
|
| 275 |
+
}
|
| 276 |
+
fullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile))
|
| 277 |
+
prefix := dirAbs + string(os.PathSeparator)
|
| 278 |
+
if !strings.HasPrefix(fullPath, prefix) {
|
| 279 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
|
| 280 |
+
return
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
info, errStat := os.Stat(fullPath)
|
| 284 |
+
if errStat != nil {
|
| 285 |
+
if os.IsNotExist(errStat) {
|
| 286 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
|
| 287 |
+
return
|
| 288 |
+
}
|
| 289 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
|
| 290 |
+
return
|
| 291 |
+
}
|
| 292 |
+
if info.IsDir() {
|
| 293 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
|
| 294 |
+
return
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
c.FileAttachment(fullPath, matchedFile)
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// DownloadRequestErrorLog downloads a specific error request log file by name.
|
| 301 |
+
func (h *Handler) DownloadRequestErrorLog(c *gin.Context) {
|
| 302 |
+
if h == nil {
|
| 303 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 304 |
+
return
|
| 305 |
+
}
|
| 306 |
+
if h.cfg == nil {
|
| 307 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 308 |
+
return
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
dir := h.logDirectory()
|
| 312 |
+
if strings.TrimSpace(dir) == "" {
|
| 313 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 314 |
+
return
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
name := strings.TrimSpace(c.Param("name"))
|
| 318 |
+
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
| 319 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file name"})
|
| 320 |
+
return
|
| 321 |
+
}
|
| 322 |
+
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
|
| 323 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
|
| 324 |
+
return
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
dirAbs, errAbs := filepath.Abs(dir)
|
| 328 |
+
if errAbs != nil {
|
| 329 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
|
| 330 |
+
return
|
| 331 |
+
}
|
| 332 |
+
fullPath := filepath.Clean(filepath.Join(dirAbs, name))
|
| 333 |
+
prefix := dirAbs + string(os.PathSeparator)
|
| 334 |
+
if !strings.HasPrefix(fullPath, prefix) {
|
| 335 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
|
| 336 |
+
return
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
info, errStat := os.Stat(fullPath)
|
| 340 |
+
if errStat != nil {
|
| 341 |
+
if os.IsNotExist(errStat) {
|
| 342 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
|
| 343 |
+
return
|
| 344 |
+
}
|
| 345 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
|
| 346 |
+
return
|
| 347 |
+
}
|
| 348 |
+
if info.IsDir() {
|
| 349 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
|
| 350 |
+
return
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
c.FileAttachment(fullPath, name)
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
func (h *Handler) logDirectory() string {
|
| 357 |
+
if h == nil {
|
| 358 |
+
return ""
|
| 359 |
+
}
|
| 360 |
+
if h.logDir != "" {
|
| 361 |
+
return h.logDir
|
| 362 |
+
}
|
| 363 |
+
return logging.ResolveLogDirectory(h.cfg)
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
func (h *Handler) collectLogFiles(dir string) ([]string, error) {
|
| 367 |
+
entries, err := os.ReadDir(dir)
|
| 368 |
+
if err != nil {
|
| 369 |
+
return nil, err
|
| 370 |
+
}
|
| 371 |
+
type candidate struct {
|
| 372 |
+
path string
|
| 373 |
+
order int64
|
| 374 |
+
}
|
| 375 |
+
cands := make([]candidate, 0, len(entries))
|
| 376 |
+
for _, entry := range entries {
|
| 377 |
+
if entry.IsDir() {
|
| 378 |
+
continue
|
| 379 |
+
}
|
| 380 |
+
name := entry.Name()
|
| 381 |
+
if name == defaultLogFileName {
|
| 382 |
+
cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})
|
| 383 |
+
continue
|
| 384 |
+
}
|
| 385 |
+
if order, ok := rotationOrder(name); ok {
|
| 386 |
+
cands = append(cands, candidate{path: filepath.Join(dir, name), order: order})
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
if len(cands) == 0 {
|
| 390 |
+
return []string{}, nil
|
| 391 |
+
}
|
| 392 |
+
sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })
|
| 393 |
+
paths := make([]string, 0, len(cands))
|
| 394 |
+
for i := len(cands) - 1; i >= 0; i-- {
|
| 395 |
+
paths = append(paths, cands[i].path)
|
| 396 |
+
}
|
| 397 |
+
return paths, nil
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
type logAccumulator struct {
|
| 401 |
+
cutoff int64
|
| 402 |
+
limit int
|
| 403 |
+
lines []string
|
| 404 |
+
total int
|
| 405 |
+
latest int64
|
| 406 |
+
include bool
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
func newLogAccumulator(cutoff int64, limit int) *logAccumulator {
|
| 410 |
+
capacity := 256
|
| 411 |
+
if limit > 0 && limit < capacity {
|
| 412 |
+
capacity = limit
|
| 413 |
+
}
|
| 414 |
+
return &logAccumulator{
|
| 415 |
+
cutoff: cutoff,
|
| 416 |
+
limit: limit,
|
| 417 |
+
lines: make([]string, 0, capacity),
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
func (acc *logAccumulator) consumeFile(path string) error {
|
| 422 |
+
file, err := os.Open(path)
|
| 423 |
+
if err != nil {
|
| 424 |
+
if os.IsNotExist(err) {
|
| 425 |
+
return nil
|
| 426 |
+
}
|
| 427 |
+
return err
|
| 428 |
+
}
|
| 429 |
+
defer func() {
|
| 430 |
+
_ = file.Close()
|
| 431 |
+
}()
|
| 432 |
+
|
| 433 |
+
scanner := bufio.NewScanner(file)
|
| 434 |
+
buf := make([]byte, 0, logScannerInitialBuffer)
|
| 435 |
+
scanner.Buffer(buf, logScannerMaxBuffer)
|
| 436 |
+
for scanner.Scan() {
|
| 437 |
+
acc.addLine(scanner.Text())
|
| 438 |
+
}
|
| 439 |
+
if errScan := scanner.Err(); errScan != nil {
|
| 440 |
+
return errScan
|
| 441 |
+
}
|
| 442 |
+
return nil
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
func (acc *logAccumulator) addLine(raw string) {
|
| 446 |
+
line := strings.TrimRight(raw, "\r")
|
| 447 |
+
acc.total++
|
| 448 |
+
ts := parseTimestamp(line)
|
| 449 |
+
if ts > acc.latest {
|
| 450 |
+
acc.latest = ts
|
| 451 |
+
}
|
| 452 |
+
if ts > 0 {
|
| 453 |
+
acc.include = acc.cutoff == 0 || ts > acc.cutoff
|
| 454 |
+
if acc.cutoff == 0 || acc.include {
|
| 455 |
+
acc.append(line)
|
| 456 |
+
}
|
| 457 |
+
return
|
| 458 |
+
}
|
| 459 |
+
if acc.cutoff == 0 || acc.include {
|
| 460 |
+
acc.append(line)
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
func (acc *logAccumulator) append(line string) {
|
| 465 |
+
acc.lines = append(acc.lines, line)
|
| 466 |
+
if acc.limit > 0 && len(acc.lines) > acc.limit {
|
| 467 |
+
acc.lines = acc.lines[len(acc.lines)-acc.limit:]
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
func (acc *logAccumulator) result() ([]string, int, int64) {
|
| 472 |
+
if acc.lines == nil {
|
| 473 |
+
acc.lines = []string{}
|
| 474 |
+
}
|
| 475 |
+
return acc.lines, acc.total, acc.latest
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
func parseCutoff(raw string) int64 {
|
| 479 |
+
value := strings.TrimSpace(raw)
|
| 480 |
+
if value == "" {
|
| 481 |
+
return 0
|
| 482 |
+
}
|
| 483 |
+
ts, err := strconv.ParseInt(value, 10, 64)
|
| 484 |
+
if err != nil || ts <= 0 {
|
| 485 |
+
return 0
|
| 486 |
+
}
|
| 487 |
+
return ts
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
func parseLimit(raw string) (int, error) {
|
| 491 |
+
value := strings.TrimSpace(raw)
|
| 492 |
+
if value == "" {
|
| 493 |
+
return 0, nil
|
| 494 |
+
}
|
| 495 |
+
limit, err := strconv.Atoi(value)
|
| 496 |
+
if err != nil {
|
| 497 |
+
return 0, fmt.Errorf("must be a positive integer")
|
| 498 |
+
}
|
| 499 |
+
if limit <= 0 {
|
| 500 |
+
return 0, fmt.Errorf("must be greater than zero")
|
| 501 |
+
}
|
| 502 |
+
return limit, nil
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
func parseTimestamp(line string) int64 {
|
| 506 |
+
if strings.HasPrefix(line, "[") {
|
| 507 |
+
line = line[1:]
|
| 508 |
+
}
|
| 509 |
+
if len(line) < 19 {
|
| 510 |
+
return 0
|
| 511 |
+
}
|
| 512 |
+
candidate := line[:19]
|
| 513 |
+
t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local)
|
| 514 |
+
if err != nil {
|
| 515 |
+
return 0
|
| 516 |
+
}
|
| 517 |
+
return t.Unix()
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
func isRotatedLogFile(name string) bool {
|
| 521 |
+
if _, ok := rotationOrder(name); ok {
|
| 522 |
+
return true
|
| 523 |
+
}
|
| 524 |
+
return false
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
func rotationOrder(name string) (int64, bool) {
|
| 528 |
+
if order, ok := numericRotationOrder(name); ok {
|
| 529 |
+
return order, true
|
| 530 |
+
}
|
| 531 |
+
if order, ok := timestampRotationOrder(name); ok {
|
| 532 |
+
return order, true
|
| 533 |
+
}
|
| 534 |
+
return 0, false
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
func numericRotationOrder(name string) (int64, bool) {
|
| 538 |
+
if !strings.HasPrefix(name, defaultLogFileName+".") {
|
| 539 |
+
return 0, false
|
| 540 |
+
}
|
| 541 |
+
suffix := strings.TrimPrefix(name, defaultLogFileName+".")
|
| 542 |
+
if suffix == "" {
|
| 543 |
+
return 0, false
|
| 544 |
+
}
|
| 545 |
+
n, err := strconv.Atoi(suffix)
|
| 546 |
+
if err != nil {
|
| 547 |
+
return 0, false
|
| 548 |
+
}
|
| 549 |
+
return int64(n), true
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
func timestampRotationOrder(name string) (int64, bool) {
|
| 553 |
+
ext := filepath.Ext(defaultLogFileName)
|
| 554 |
+
base := strings.TrimSuffix(defaultLogFileName, ext)
|
| 555 |
+
if base == "" {
|
| 556 |
+
return 0, false
|
| 557 |
+
}
|
| 558 |
+
prefix := base + "-"
|
| 559 |
+
if !strings.HasPrefix(name, prefix) {
|
| 560 |
+
return 0, false
|
| 561 |
+
}
|
| 562 |
+
clean := strings.TrimPrefix(name, prefix)
|
| 563 |
+
if strings.HasSuffix(clean, ".gz") {
|
| 564 |
+
clean = strings.TrimSuffix(clean, ".gz")
|
| 565 |
+
}
|
| 566 |
+
if ext != "" {
|
| 567 |
+
if !strings.HasSuffix(clean, ext) {
|
| 568 |
+
return 0, false
|
| 569 |
+
}
|
| 570 |
+
clean = strings.TrimSuffix(clean, ext)
|
| 571 |
+
}
|
| 572 |
+
if clean == "" {
|
| 573 |
+
return 0, false
|
| 574 |
+
}
|
| 575 |
+
if idx := strings.IndexByte(clean, '.'); idx != -1 {
|
| 576 |
+
clean = clean[:idx]
|
| 577 |
+
}
|
| 578 |
+
parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local)
|
| 579 |
+
if err != nil {
|
| 580 |
+
return 0, false
|
| 581 |
+
}
|
| 582 |
+
return math.MaxInt64 - parsed.Unix(), true
|
| 583 |
+
}
|
internal/api/handlers/management/model_definitions.go
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"strings"
|
| 6 |
+
|
| 7 |
+
"github.com/gin-gonic/gin"
|
| 8 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// GetStaticModelDefinitions returns static model metadata for a given channel.
|
| 12 |
+
// Channel is provided via path param (:channel) or query param (?channel=...).
|
| 13 |
+
func (h *Handler) GetStaticModelDefinitions(c *gin.Context) {
|
| 14 |
+
channel := strings.TrimSpace(c.Param("channel"))
|
| 15 |
+
if channel == "" {
|
| 16 |
+
channel = strings.TrimSpace(c.Query("channel"))
|
| 17 |
+
}
|
| 18 |
+
if channel == "" {
|
| 19 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "channel is required"})
|
| 20 |
+
return
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
models := registry.GetStaticModelDefinitionsByChannel(channel)
|
| 24 |
+
if models == nil {
|
| 25 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown channel", "channel": channel})
|
| 26 |
+
return
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
c.JSON(http.StatusOK, gin.H{
|
| 30 |
+
"channel": strings.ToLower(strings.TrimSpace(channel)),
|
| 31 |
+
"models": models,
|
| 32 |
+
})
|
| 33 |
+
}
|
internal/api/handlers/management/oauth_callback.go
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/url"
|
| 7 |
+
"strings"
|
| 8 |
+
|
| 9 |
+
"github.com/gin-gonic/gin"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type oauthCallbackRequest struct {
|
| 13 |
+
Provider string `json:"provider"`
|
| 14 |
+
RedirectURL string `json:"redirect_url"`
|
| 15 |
+
Code string `json:"code"`
|
| 16 |
+
State string `json:"state"`
|
| 17 |
+
Error string `json:"error"`
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func (h *Handler) PostOAuthCallback(c *gin.Context) {
|
| 21 |
+
if h == nil || h.cfg == nil {
|
| 22 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "handler not initialized"})
|
| 23 |
+
return
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
var req oauthCallbackRequest
|
| 27 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 28 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"})
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
canonicalProvider, err := NormalizeOAuthProvider(req.Provider)
|
| 33 |
+
if err != nil {
|
| 34 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unsupported provider"})
|
| 35 |
+
return
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
state := strings.TrimSpace(req.State)
|
| 39 |
+
code := strings.TrimSpace(req.Code)
|
| 40 |
+
errMsg := strings.TrimSpace(req.Error)
|
| 41 |
+
|
| 42 |
+
if rawRedirect := strings.TrimSpace(req.RedirectURL); rawRedirect != "" {
|
| 43 |
+
u, errParse := url.Parse(rawRedirect)
|
| 44 |
+
if errParse != nil {
|
| 45 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid redirect_url"})
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
q := u.Query()
|
| 49 |
+
if state == "" {
|
| 50 |
+
state = strings.TrimSpace(q.Get("state"))
|
| 51 |
+
}
|
| 52 |
+
if code == "" {
|
| 53 |
+
code = strings.TrimSpace(q.Get("code"))
|
| 54 |
+
}
|
| 55 |
+
if errMsg == "" {
|
| 56 |
+
errMsg = strings.TrimSpace(q.Get("error"))
|
| 57 |
+
if errMsg == "" {
|
| 58 |
+
errMsg = strings.TrimSpace(q.Get("error_description"))
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if state == "" {
|
| 64 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "state is required"})
|
| 65 |
+
return
|
| 66 |
+
}
|
| 67 |
+
if err := ValidateOAuthState(state); err != nil {
|
| 68 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
if code == "" && errMsg == "" {
|
| 72 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "code or error is required"})
|
| 73 |
+
return
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
sessionProvider, sessionStatus, ok := GetOAuthSession(state)
|
| 77 |
+
if !ok {
|
| 78 |
+
c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
|
| 79 |
+
return
|
| 80 |
+
}
|
| 81 |
+
if sessionStatus != "" {
|
| 82 |
+
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
|
| 83 |
+
return
|
| 84 |
+
}
|
| 85 |
+
if !strings.EqualFold(sessionProvider, canonicalProvider) {
|
| 86 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "provider does not match state"})
|
| 87 |
+
return
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if _, errWrite := WriteOAuthCallbackFileForPendingSession(h.cfg.AuthDir, canonicalProvider, state, code, errMsg); errWrite != nil {
|
| 91 |
+
if errors.Is(errWrite, errOAuthSessionNotPending) {
|
| 92 |
+
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to persist oauth callback"})
|
| 96 |
+
return
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 100 |
+
}
|
internal/api/handlers/management/oauth_sessions.go
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"os"
|
| 8 |
+
"path/filepath"
|
| 9 |
+
"strings"
|
| 10 |
+
"sync"
|
| 11 |
+
"time"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
const (
|
| 15 |
+
oauthSessionTTL = 10 * time.Minute
|
| 16 |
+
maxOAuthStateLength = 128
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
var (
|
| 20 |
+
errInvalidOAuthState = errors.New("invalid oauth state")
|
| 21 |
+
errUnsupportedOAuthFlow = errors.New("unsupported oauth provider")
|
| 22 |
+
errOAuthSessionNotPending = errors.New("oauth session is not pending")
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
type oauthSession struct {
|
| 26 |
+
Provider string
|
| 27 |
+
Status string
|
| 28 |
+
CreatedAt time.Time
|
| 29 |
+
ExpiresAt time.Time
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
type oauthSessionStore struct {
|
| 33 |
+
mu sync.RWMutex
|
| 34 |
+
ttl time.Duration
|
| 35 |
+
sessions map[string]oauthSession
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
|
| 39 |
+
if ttl <= 0 {
|
| 40 |
+
ttl = oauthSessionTTL
|
| 41 |
+
}
|
| 42 |
+
return &oauthSessionStore{
|
| 43 |
+
ttl: ttl,
|
| 44 |
+
sessions: make(map[string]oauthSession),
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func (s *oauthSessionStore) purgeExpiredLocked(now time.Time) {
|
| 49 |
+
for state, session := range s.sessions {
|
| 50 |
+
if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
|
| 51 |
+
delete(s.sessions, state)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func (s *oauthSessionStore) Register(state, provider string) {
|
| 57 |
+
state = strings.TrimSpace(state)
|
| 58 |
+
provider = strings.ToLower(strings.TrimSpace(provider))
|
| 59 |
+
if state == "" || provider == "" {
|
| 60 |
+
return
|
| 61 |
+
}
|
| 62 |
+
now := time.Now()
|
| 63 |
+
|
| 64 |
+
s.mu.Lock()
|
| 65 |
+
defer s.mu.Unlock()
|
| 66 |
+
|
| 67 |
+
s.purgeExpiredLocked(now)
|
| 68 |
+
s.sessions[state] = oauthSession{
|
| 69 |
+
Provider: provider,
|
| 70 |
+
Status: "",
|
| 71 |
+
CreatedAt: now,
|
| 72 |
+
ExpiresAt: now.Add(s.ttl),
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func (s *oauthSessionStore) SetError(state, message string) {
|
| 77 |
+
state = strings.TrimSpace(state)
|
| 78 |
+
message = strings.TrimSpace(message)
|
| 79 |
+
if state == "" {
|
| 80 |
+
return
|
| 81 |
+
}
|
| 82 |
+
if message == "" {
|
| 83 |
+
message = "Authentication failed"
|
| 84 |
+
}
|
| 85 |
+
now := time.Now()
|
| 86 |
+
|
| 87 |
+
s.mu.Lock()
|
| 88 |
+
defer s.mu.Unlock()
|
| 89 |
+
|
| 90 |
+
s.purgeExpiredLocked(now)
|
| 91 |
+
session, ok := s.sessions[state]
|
| 92 |
+
if !ok {
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
session.Status = message
|
| 96 |
+
session.ExpiresAt = now.Add(s.ttl)
|
| 97 |
+
s.sessions[state] = session
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
func (s *oauthSessionStore) Complete(state string) {
|
| 101 |
+
state = strings.TrimSpace(state)
|
| 102 |
+
if state == "" {
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
now := time.Now()
|
| 106 |
+
|
| 107 |
+
s.mu.Lock()
|
| 108 |
+
defer s.mu.Unlock()
|
| 109 |
+
|
| 110 |
+
s.purgeExpiredLocked(now)
|
| 111 |
+
delete(s.sessions, state)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
func (s *oauthSessionStore) CompleteProvider(provider string) int {
|
| 115 |
+
provider = strings.ToLower(strings.TrimSpace(provider))
|
| 116 |
+
if provider == "" {
|
| 117 |
+
return 0
|
| 118 |
+
}
|
| 119 |
+
now := time.Now()
|
| 120 |
+
|
| 121 |
+
s.mu.Lock()
|
| 122 |
+
defer s.mu.Unlock()
|
| 123 |
+
|
| 124 |
+
s.purgeExpiredLocked(now)
|
| 125 |
+
removed := 0
|
| 126 |
+
for state, session := range s.sessions {
|
| 127 |
+
if strings.EqualFold(session.Provider, provider) {
|
| 128 |
+
delete(s.sessions, state)
|
| 129 |
+
removed++
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
return removed
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
func (s *oauthSessionStore) Get(state string) (oauthSession, bool) {
|
| 136 |
+
state = strings.TrimSpace(state)
|
| 137 |
+
now := time.Now()
|
| 138 |
+
|
| 139 |
+
s.mu.Lock()
|
| 140 |
+
defer s.mu.Unlock()
|
| 141 |
+
|
| 142 |
+
s.purgeExpiredLocked(now)
|
| 143 |
+
session, ok := s.sessions[state]
|
| 144 |
+
return session, ok
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
func (s *oauthSessionStore) IsPending(state, provider string) bool {
|
| 148 |
+
state = strings.TrimSpace(state)
|
| 149 |
+
provider = strings.ToLower(strings.TrimSpace(provider))
|
| 150 |
+
now := time.Now()
|
| 151 |
+
|
| 152 |
+
s.mu.Lock()
|
| 153 |
+
defer s.mu.Unlock()
|
| 154 |
+
|
| 155 |
+
s.purgeExpiredLocked(now)
|
| 156 |
+
session, ok := s.sessions[state]
|
| 157 |
+
if !ok {
|
| 158 |
+
return false
|
| 159 |
+
}
|
| 160 |
+
if session.Status != "" {
|
| 161 |
+
return false
|
| 162 |
+
}
|
| 163 |
+
if provider == "" {
|
| 164 |
+
return true
|
| 165 |
+
}
|
| 166 |
+
return strings.EqualFold(session.Provider, provider)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
var oauthSessions = newOAuthSessionStore(oauthSessionTTL)
|
| 170 |
+
|
| 171 |
+
func RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) }
|
| 172 |
+
|
| 173 |
+
func SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) }
|
| 174 |
+
|
| 175 |
+
func CompleteOAuthSession(state string) { oauthSessions.Complete(state) }
|
| 176 |
+
|
| 177 |
+
func CompleteOAuthSessionsByProvider(provider string) int {
|
| 178 |
+
return oauthSessions.CompleteProvider(provider)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
func GetOAuthSession(state string) (provider string, status string, ok bool) {
|
| 182 |
+
session, ok := oauthSessions.Get(state)
|
| 183 |
+
if !ok {
|
| 184 |
+
return "", "", false
|
| 185 |
+
}
|
| 186 |
+
return session.Provider, session.Status, true
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
func IsOAuthSessionPending(state, provider string) bool {
|
| 190 |
+
return oauthSessions.IsPending(state, provider)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
func ValidateOAuthState(state string) error {
|
| 194 |
+
trimmed := strings.TrimSpace(state)
|
| 195 |
+
if trimmed == "" {
|
| 196 |
+
return fmt.Errorf("%w: empty", errInvalidOAuthState)
|
| 197 |
+
}
|
| 198 |
+
if len(trimmed) > maxOAuthStateLength {
|
| 199 |
+
return fmt.Errorf("%w: too long", errInvalidOAuthState)
|
| 200 |
+
}
|
| 201 |
+
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") {
|
| 202 |
+
return fmt.Errorf("%w: contains path separator", errInvalidOAuthState)
|
| 203 |
+
}
|
| 204 |
+
if strings.Contains(trimmed, "..") {
|
| 205 |
+
return fmt.Errorf("%w: contains '..'", errInvalidOAuthState)
|
| 206 |
+
}
|
| 207 |
+
for _, r := range trimmed {
|
| 208 |
+
switch {
|
| 209 |
+
case r >= 'a' && r <= 'z':
|
| 210 |
+
case r >= 'A' && r <= 'Z':
|
| 211 |
+
case r >= '0' && r <= '9':
|
| 212 |
+
case r == '-' || r == '_' || r == '.':
|
| 213 |
+
default:
|
| 214 |
+
return fmt.Errorf("%w: invalid character", errInvalidOAuthState)
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
return nil
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
func NormalizeOAuthProvider(provider string) (string, error) {
|
| 221 |
+
switch strings.ToLower(strings.TrimSpace(provider)) {
|
| 222 |
+
case "anthropic", "claude":
|
| 223 |
+
return "anthropic", nil
|
| 224 |
+
case "codex", "openai":
|
| 225 |
+
return "codex", nil
|
| 226 |
+
case "gemini", "google":
|
| 227 |
+
return "gemini", nil
|
| 228 |
+
case "iflow", "i-flow":
|
| 229 |
+
return "iflow", nil
|
| 230 |
+
case "antigravity", "anti-gravity":
|
| 231 |
+
return "antigravity", nil
|
| 232 |
+
case "qwen":
|
| 233 |
+
return "qwen", nil
|
| 234 |
+
default:
|
| 235 |
+
return "", errUnsupportedOAuthFlow
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
type oauthCallbackFilePayload struct {
|
| 240 |
+
Code string `json:"code"`
|
| 241 |
+
State string `json:"state"`
|
| 242 |
+
Error string `json:"error"`
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {
|
| 246 |
+
if strings.TrimSpace(authDir) == "" {
|
| 247 |
+
return "", fmt.Errorf("auth dir is empty")
|
| 248 |
+
}
|
| 249 |
+
canonicalProvider, err := NormalizeOAuthProvider(provider)
|
| 250 |
+
if err != nil {
|
| 251 |
+
return "", err
|
| 252 |
+
}
|
| 253 |
+
if err := ValidateOAuthState(state); err != nil {
|
| 254 |
+
return "", err
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
fileName := fmt.Sprintf(".oauth-%s-%s.oauth", canonicalProvider, state)
|
| 258 |
+
filePath := filepath.Join(authDir, fileName)
|
| 259 |
+
payload := oauthCallbackFilePayload{
|
| 260 |
+
Code: strings.TrimSpace(code),
|
| 261 |
+
State: strings.TrimSpace(state),
|
| 262 |
+
Error: strings.TrimSpace(errorMessage),
|
| 263 |
+
}
|
| 264 |
+
data, err := json.Marshal(payload)
|
| 265 |
+
if err != nil {
|
| 266 |
+
return "", fmt.Errorf("marshal oauth callback payload: %w", err)
|
| 267 |
+
}
|
| 268 |
+
if err := os.WriteFile(filePath, data, 0o600); err != nil {
|
| 269 |
+
return "", fmt.Errorf("write oauth callback file: %w", err)
|
| 270 |
+
}
|
| 271 |
+
return filePath, nil
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {
|
| 275 |
+
canonicalProvider, err := NormalizeOAuthProvider(provider)
|
| 276 |
+
if err != nil {
|
| 277 |
+
return "", err
|
| 278 |
+
}
|
| 279 |
+
if !IsOAuthSessionPending(state, canonicalProvider) {
|
| 280 |
+
return "", errOAuthSessionNotPending
|
| 281 |
+
}
|
| 282 |
+
return WriteOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage)
|
| 283 |
+
}
|
internal/api/handlers/management/quota.go
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import "github.com/gin-gonic/gin"
|
| 4 |
+
|
| 5 |
+
// Quota exceeded toggles
|
| 6 |
+
func (h *Handler) GetSwitchProject(c *gin.Context) {
|
| 7 |
+
c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
|
| 8 |
+
}
|
| 9 |
+
func (h *Handler) PutSwitchProject(c *gin.Context) {
|
| 10 |
+
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
|
| 14 |
+
c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
|
| 15 |
+
}
|
| 16 |
+
func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
|
| 17 |
+
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
|
| 18 |
+
}
|
internal/api/handlers/management/usage.go
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type usageExportPayload struct {
|
| 13 |
+
Version int `json:"version"`
|
| 14 |
+
ExportedAt time.Time `json:"exported_at"`
|
| 15 |
+
Usage usage.StatisticsSnapshot `json:"usage"`
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
type usageImportPayload struct {
|
| 19 |
+
Version int `json:"version"`
|
| 20 |
+
Usage usage.StatisticsSnapshot `json:"usage"`
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// GetUsageStatistics returns the in-memory request statistics snapshot.
|
| 24 |
+
func (h *Handler) GetUsageStatistics(c *gin.Context) {
|
| 25 |
+
var snapshot usage.StatisticsSnapshot
|
| 26 |
+
if h != nil && h.usageStats != nil {
|
| 27 |
+
snapshot = h.usageStats.Snapshot()
|
| 28 |
+
}
|
| 29 |
+
c.JSON(http.StatusOK, gin.H{
|
| 30 |
+
"usage": snapshot,
|
| 31 |
+
"failed_requests": snapshot.FailureCount,
|
| 32 |
+
})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// ExportUsageStatistics returns a complete usage snapshot for backup/migration.
|
| 36 |
+
func (h *Handler) ExportUsageStatistics(c *gin.Context) {
|
| 37 |
+
var snapshot usage.StatisticsSnapshot
|
| 38 |
+
if h != nil && h.usageStats != nil {
|
| 39 |
+
snapshot = h.usageStats.Snapshot()
|
| 40 |
+
}
|
| 41 |
+
c.JSON(http.StatusOK, usageExportPayload{
|
| 42 |
+
Version: 1,
|
| 43 |
+
ExportedAt: time.Now().UTC(),
|
| 44 |
+
Usage: snapshot,
|
| 45 |
+
})
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// ImportUsageStatistics merges a previously exported usage snapshot into memory.
|
| 49 |
+
func (h *Handler) ImportUsageStatistics(c *gin.Context) {
|
| 50 |
+
if h == nil || h.usageStats == nil {
|
| 51 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"})
|
| 52 |
+
return
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
data, err := c.GetRawData()
|
| 56 |
+
if err != nil {
|
| 57 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
|
| 58 |
+
return
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
var payload usageImportPayload
|
| 62 |
+
if err := json.Unmarshal(data, &payload); err != nil {
|
| 63 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
if payload.Version != 0 && payload.Version != 1 {
|
| 67 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
|
| 68 |
+
return
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
result := h.usageStats.MergeSnapshot(payload.Usage)
|
| 72 |
+
snapshot := h.usageStats.Snapshot()
|
| 73 |
+
c.JSON(http.StatusOK, gin.H{
|
| 74 |
+
"added": result.Added,
|
| 75 |
+
"skipped": result.Skipped,
|
| 76 |
+
"total_requests": snapshot.TotalRequests,
|
| 77 |
+
"failed_requests": snapshot.FailureCount,
|
| 78 |
+
})
|
| 79 |
+
}
|
internal/api/handlers/management/vertex_import.go
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"fmt"
|
| 7 |
+
"io"
|
| 8 |
+
"net/http"
|
| 9 |
+
"strings"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
|
| 13 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.
|
| 17 |
+
func (h *Handler) ImportVertexCredential(c *gin.Context) {
|
| 18 |
+
if h == nil || h.cfg == nil {
|
| 19 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "config unavailable"})
|
| 20 |
+
return
|
| 21 |
+
}
|
| 22 |
+
if h.cfg.AuthDir == "" {
|
| 23 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth directory not configured"})
|
| 24 |
+
return
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
fileHeader, err := c.FormFile("file")
|
| 28 |
+
if err != nil {
|
| 29 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
| 30 |
+
return
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
file, err := fileHeader.Open()
|
| 34 |
+
if err != nil {
|
| 35 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
| 36 |
+
return
|
| 37 |
+
}
|
| 38 |
+
defer file.Close()
|
| 39 |
+
|
| 40 |
+
data, err := io.ReadAll(file)
|
| 41 |
+
if err != nil {
|
| 42 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
var serviceAccount map[string]any
|
| 47 |
+
if err := json.Unmarshal(data, &serviceAccount); err != nil {
|
| 48 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json", "message": err.Error()})
|
| 49 |
+
return
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
normalizedSA, err := vertex.NormalizeServiceAccountMap(serviceAccount)
|
| 53 |
+
if err != nil {
|
| 54 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service account", "message": err.Error()})
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
serviceAccount = normalizedSA
|
| 58 |
+
|
| 59 |
+
projectID := strings.TrimSpace(valueAsString(serviceAccount["project_id"]))
|
| 60 |
+
if projectID == "" {
|
| 61 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "project_id missing"})
|
| 62 |
+
return
|
| 63 |
+
}
|
| 64 |
+
email := strings.TrimSpace(valueAsString(serviceAccount["client_email"]))
|
| 65 |
+
|
| 66 |
+
location := strings.TrimSpace(c.PostForm("location"))
|
| 67 |
+
if location == "" {
|
| 68 |
+
location = strings.TrimSpace(c.Query("location"))
|
| 69 |
+
}
|
| 70 |
+
if location == "" {
|
| 71 |
+
location = "us-central1"
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
fileName := fmt.Sprintf("vertex-%s.json", sanitizeVertexFilePart(projectID))
|
| 75 |
+
label := labelForVertex(projectID, email)
|
| 76 |
+
storage := &vertex.VertexCredentialStorage{
|
| 77 |
+
ServiceAccount: serviceAccount,
|
| 78 |
+
ProjectID: projectID,
|
| 79 |
+
Email: email,
|
| 80 |
+
Location: location,
|
| 81 |
+
Type: "vertex",
|
| 82 |
+
}
|
| 83 |
+
metadata := map[string]any{
|
| 84 |
+
"service_account": serviceAccount,
|
| 85 |
+
"project_id": projectID,
|
| 86 |
+
"email": email,
|
| 87 |
+
"location": location,
|
| 88 |
+
"type": "vertex",
|
| 89 |
+
"label": label,
|
| 90 |
+
}
|
| 91 |
+
record := &coreauth.Auth{
|
| 92 |
+
ID: fileName,
|
| 93 |
+
Provider: "vertex",
|
| 94 |
+
FileName: fileName,
|
| 95 |
+
Storage: storage,
|
| 96 |
+
Label: label,
|
| 97 |
+
Metadata: metadata,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
ctx := context.Background()
|
| 101 |
+
if reqCtx := c.Request.Context(); reqCtx != nil {
|
| 102 |
+
ctx = reqCtx
|
| 103 |
+
}
|
| 104 |
+
savedPath, err := h.saveTokenRecord(ctx, record)
|
| 105 |
+
if err != nil {
|
| 106 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "save_failed", "message": err.Error()})
|
| 107 |
+
return
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
c.JSON(http.StatusOK, gin.H{
|
| 111 |
+
"status": "ok",
|
| 112 |
+
"auth-file": savedPath,
|
| 113 |
+
"project_id": projectID,
|
| 114 |
+
"email": email,
|
| 115 |
+
"location": location,
|
| 116 |
+
})
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
func valueAsString(v any) string {
|
| 120 |
+
if v == nil {
|
| 121 |
+
return ""
|
| 122 |
+
}
|
| 123 |
+
switch t := v.(type) {
|
| 124 |
+
case string:
|
| 125 |
+
return t
|
| 126 |
+
default:
|
| 127 |
+
return fmt.Sprint(t)
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
func sanitizeVertexFilePart(s string) string {
|
| 132 |
+
out := strings.TrimSpace(s)
|
| 133 |
+
replacers := []string{"/", "_", "\\", "_", ":", "_", " ", "-"}
|
| 134 |
+
for i := 0; i < len(replacers); i += 2 {
|
| 135 |
+
out = strings.ReplaceAll(out, replacers[i], replacers[i+1])
|
| 136 |
+
}
|
| 137 |
+
if out == "" {
|
| 138 |
+
return "vertex"
|
| 139 |
+
}
|
| 140 |
+
return out
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func labelForVertex(projectID, email string) string {
|
| 144 |
+
p := strings.TrimSpace(projectID)
|
| 145 |
+
e := strings.TrimSpace(email)
|
| 146 |
+
if p != "" && e != "" {
|
| 147 |
+
return fmt.Sprintf("%s (%s)", p, e)
|
| 148 |
+
}
|
| 149 |
+
if p != "" {
|
| 150 |
+
return p
|
| 151 |
+
}
|
| 152 |
+
if e != "" {
|
| 153 |
+
return e
|
| 154 |
+
}
|
| 155 |
+
return "vertex"
|
| 156 |
+
}
|
internal/api/middleware/request_logging.go
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
|
| 2 |
+
// This file contains the request logging middleware that captures comprehensive
|
| 3 |
+
// request and response data when enabled through configuration.
|
| 4 |
+
package middleware
|
| 5 |
+
|
| 6 |
+
import (
|
| 7 |
+
"bytes"
|
| 8 |
+
"io"
|
| 9 |
+
"net/http"
|
| 10 |
+
"strings"
|
| 11 |
+
|
| 12 |
+
"github.com/gin-gonic/gin"
|
| 13 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
| 14 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
// RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.
|
| 18 |
+
// It captures detailed information about the request and response, including headers and body,
|
| 19 |
+
// and uses the provided RequestLogger to record this data. When logging is disabled in the
|
| 20 |
+
// logger, it still captures data so that upstream errors can be persisted.
|
| 21 |
+
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
|
| 22 |
+
return func(c *gin.Context) {
|
| 23 |
+
if logger == nil {
|
| 24 |
+
c.Next()
|
| 25 |
+
return
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if c.Request.Method == http.MethodGet {
|
| 29 |
+
c.Next()
|
| 30 |
+
return
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
path := c.Request.URL.Path
|
| 34 |
+
if !shouldLogRequest(path) {
|
| 35 |
+
c.Next()
|
| 36 |
+
return
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Capture request information
|
| 40 |
+
requestInfo, err := captureRequestInfo(c)
|
| 41 |
+
if err != nil {
|
| 42 |
+
// Log error but continue processing
|
| 43 |
+
// In a real implementation, you might want to use a proper logger here
|
| 44 |
+
c.Next()
|
| 45 |
+
return
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Create response writer wrapper
|
| 49 |
+
wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)
|
| 50 |
+
if !logger.IsEnabled() {
|
| 51 |
+
wrapper.logOnErrorOnly = true
|
| 52 |
+
}
|
| 53 |
+
c.Writer = wrapper
|
| 54 |
+
|
| 55 |
+
// Process the request
|
| 56 |
+
c.Next()
|
| 57 |
+
|
| 58 |
+
// Finalize logging after request processing
|
| 59 |
+
if err = wrapper.Finalize(c); err != nil {
|
| 60 |
+
// Log error but don't interrupt the response
|
| 61 |
+
// In a real implementation, you might want to use a proper logger here
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// captureRequestInfo extracts relevant information from the incoming HTTP request.
|
| 67 |
+
// It captures the URL, method, headers, and body. The request body is read and then
|
| 68 |
+
// restored so that it can be processed by subsequent handlers.
|
| 69 |
+
func captureRequestInfo(c *gin.Context) (*RequestInfo, error) {
|
| 70 |
+
// Capture URL with sensitive query parameters masked
|
| 71 |
+
maskedQuery := util.MaskSensitiveQuery(c.Request.URL.RawQuery)
|
| 72 |
+
url := c.Request.URL.Path
|
| 73 |
+
if maskedQuery != "" {
|
| 74 |
+
url += "?" + maskedQuery
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Capture method
|
| 78 |
+
method := c.Request.Method
|
| 79 |
+
|
| 80 |
+
// Capture headers
|
| 81 |
+
headers := make(map[string][]string)
|
| 82 |
+
for key, values := range c.Request.Header {
|
| 83 |
+
headers[key] = values
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Capture request body
|
| 87 |
+
var body []byte
|
| 88 |
+
if c.Request.Body != nil {
|
| 89 |
+
// Read the body
|
| 90 |
+
bodyBytes, err := io.ReadAll(c.Request.Body)
|
| 91 |
+
if err != nil {
|
| 92 |
+
return nil, err
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Restore the body for the actual request processing
|
| 96 |
+
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
| 97 |
+
body = bodyBytes
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return &RequestInfo{
|
| 101 |
+
URL: url,
|
| 102 |
+
Method: method,
|
| 103 |
+
Headers: headers,
|
| 104 |
+
Body: body,
|
| 105 |
+
RequestID: logging.GetGinRequestID(c),
|
| 106 |
+
}, nil
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// shouldLogRequest determines whether the request should be logged.
|
| 110 |
+
// It skips management endpoints to avoid leaking secrets but allows
|
| 111 |
+
// all other routes, including module-provided ones, to honor request-log.
|
| 112 |
+
func shouldLogRequest(path string) bool {
|
| 113 |
+
if strings.HasPrefix(path, "/v0/management") || strings.HasPrefix(path, "/management") {
|
| 114 |
+
return false
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if strings.HasPrefix(path, "/api") {
|
| 118 |
+
return strings.HasPrefix(path, "/api/provider")
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return true
|
| 122 |
+
}
|
internal/api/middleware/response_writer.go
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package middleware provides Gin HTTP middleware for the CLI Proxy API server.
|
| 2 |
+
// It includes a sophisticated response writer wrapper designed to capture and log request and response data,
|
| 3 |
+
// including support for streaming responses, without impacting latency.
|
| 4 |
+
package middleware
|
| 5 |
+
|
| 6 |
+
import (
|
| 7 |
+
"bytes"
|
| 8 |
+
"net/http"
|
| 9 |
+
"strings"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
| 13 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
|
| 17 |
+
type RequestInfo struct {
|
| 18 |
+
URL string // URL is the request URL.
|
| 19 |
+
Method string // Method is the HTTP method (e.g., GET, POST).
|
| 20 |
+
Headers map[string][]string // Headers contains the request headers.
|
| 21 |
+
Body []byte // Body is the raw request body.
|
| 22 |
+
RequestID string // RequestID is the unique identifier for the request.
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data.
|
| 26 |
+
// It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.
|
| 27 |
+
type ResponseWriterWrapper struct {
|
| 28 |
+
gin.ResponseWriter
|
| 29 |
+
body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses.
|
| 30 |
+
isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
|
| 31 |
+
streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
|
| 32 |
+
chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
|
| 33 |
+
streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
|
| 34 |
+
logger logging.RequestLogger // logger is the instance of the request logger service.
|
| 35 |
+
requestInfo *RequestInfo // requestInfo holds the details of the original request.
|
| 36 |
+
statusCode int // statusCode stores the HTTP status code of the response.
|
| 37 |
+
headers map[string][]string // headers stores the response headers.
|
| 38 |
+
logOnErrorOnly bool // logOnErrorOnly enables logging only when an error response is detected.
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.
|
| 42 |
+
// It takes the original gin.ResponseWriter, a logger instance, and request information.
|
| 43 |
+
//
|
| 44 |
+
// Parameters:
|
| 45 |
+
// - w: The original gin.ResponseWriter to wrap.
|
| 46 |
+
// - logger: The logging service to use for recording requests.
|
| 47 |
+
// - requestInfo: The pre-captured information about the incoming request.
|
| 48 |
+
//
|
| 49 |
+
// Returns:
|
| 50 |
+
// - A pointer to a new ResponseWriterWrapper.
|
| 51 |
+
func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
|
| 52 |
+
return &ResponseWriterWrapper{
|
| 53 |
+
ResponseWriter: w,
|
| 54 |
+
body: &bytes.Buffer{},
|
| 55 |
+
logger: logger,
|
| 56 |
+
requestInfo: requestInfo,
|
| 57 |
+
headers: make(map[string][]string),
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Write wraps the underlying ResponseWriter's Write method to capture response data.
|
| 62 |
+
// For non-streaming responses, it writes to an internal buffer. For streaming responses,
|
| 63 |
+
// it sends data chunks to a non-blocking channel for asynchronous logging.
|
| 64 |
+
// CRITICAL: This method prioritizes writing to the client to ensure zero latency,
|
| 65 |
+
// handling logging operations subsequently.
|
| 66 |
+
func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
|
| 67 |
+
// Ensure headers are captured before first write
|
| 68 |
+
// This is critical because Write() may trigger WriteHeader() internally
|
| 69 |
+
w.ensureHeadersCaptured()
|
| 70 |
+
|
| 71 |
+
// CRITICAL: Write to client first (zero latency)
|
| 72 |
+
n, err := w.ResponseWriter.Write(data)
|
| 73 |
+
|
| 74 |
+
// THEN: Handle logging based on response type
|
| 75 |
+
if w.isStreaming && w.chunkChannel != nil {
|
| 76 |
+
// For streaming responses: Send to async logging channel (non-blocking)
|
| 77 |
+
select {
|
| 78 |
+
case w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy
|
| 79 |
+
default: // Channel full, skip logging to avoid blocking
|
| 80 |
+
}
|
| 81 |
+
return n, err
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if w.shouldBufferResponseBody() {
|
| 85 |
+
w.body.Write(data)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return n, err
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
func (w *ResponseWriterWrapper) shouldBufferResponseBody() bool {
|
| 92 |
+
if w.logger != nil && w.logger.IsEnabled() {
|
| 93 |
+
return true
|
| 94 |
+
}
|
| 95 |
+
if !w.logOnErrorOnly {
|
| 96 |
+
return false
|
| 97 |
+
}
|
| 98 |
+
status := w.statusCode
|
| 99 |
+
if status == 0 {
|
| 100 |
+
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok && statusWriter != nil {
|
| 101 |
+
status = statusWriter.Status()
|
| 102 |
+
} else {
|
| 103 |
+
status = http.StatusOK
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
return status >= http.StatusBadRequest
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// WriteString wraps the underlying ResponseWriter's WriteString method to capture response data.
|
| 110 |
+
// Some handlers (and fmt/io helpers) write via io.StringWriter; without this override, those writes
|
| 111 |
+
// bypass Write() and would be missing from request logs.
|
| 112 |
+
func (w *ResponseWriterWrapper) WriteString(data string) (int, error) {
|
| 113 |
+
w.ensureHeadersCaptured()
|
| 114 |
+
|
| 115 |
+
// CRITICAL: Write to client first (zero latency)
|
| 116 |
+
n, err := w.ResponseWriter.WriteString(data)
|
| 117 |
+
|
| 118 |
+
// THEN: Capture for logging
|
| 119 |
+
if w.isStreaming && w.chunkChannel != nil {
|
| 120 |
+
select {
|
| 121 |
+
case w.chunkChannel <- []byte(data):
|
| 122 |
+
default:
|
| 123 |
+
}
|
| 124 |
+
return n, err
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if w.shouldBufferResponseBody() {
|
| 128 |
+
w.body.WriteString(data)
|
| 129 |
+
}
|
| 130 |
+
return n, err
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// WriteHeader wraps the underlying ResponseWriter's WriteHeader method.
|
| 134 |
+
// It captures the status code, detects if the response is streaming based on the Content-Type header,
|
| 135 |
+
// and initializes the appropriate logging mechanism (standard or streaming).
|
| 136 |
+
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
| 137 |
+
w.statusCode = statusCode
|
| 138 |
+
|
| 139 |
+
// Capture response headers using the new method
|
| 140 |
+
w.captureCurrentHeaders()
|
| 141 |
+
|
| 142 |
+
// Detect streaming based on Content-Type
|
| 143 |
+
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
| 144 |
+
w.isStreaming = w.detectStreaming(contentType)
|
| 145 |
+
|
| 146 |
+
// If streaming, initialize streaming log writer
|
| 147 |
+
if w.isStreaming && w.logger.IsEnabled() {
|
| 148 |
+
streamWriter, err := w.logger.LogStreamingRequest(
|
| 149 |
+
w.requestInfo.URL,
|
| 150 |
+
w.requestInfo.Method,
|
| 151 |
+
w.requestInfo.Headers,
|
| 152 |
+
w.requestInfo.Body,
|
| 153 |
+
w.requestInfo.RequestID,
|
| 154 |
+
)
|
| 155 |
+
if err == nil {
|
| 156 |
+
w.streamWriter = streamWriter
|
| 157 |
+
w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
|
| 158 |
+
doneChan := make(chan struct{})
|
| 159 |
+
w.streamDone = doneChan
|
| 160 |
+
|
| 161 |
+
// Start async chunk processor
|
| 162 |
+
go w.processStreamingChunks(doneChan)
|
| 163 |
+
|
| 164 |
+
// Write status immediately
|
| 165 |
+
_ = streamWriter.WriteStatus(statusCode, w.headers)
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Call original WriteHeader
|
| 170 |
+
w.ResponseWriter.WriteHeader(statusCode)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// ensureHeadersCaptured is a helper function to make sure response headers are captured.
|
| 174 |
+
// It is safe to call this method multiple times; it will always refresh the headers
|
| 175 |
+
// with the latest state from the underlying ResponseWriter.
|
| 176 |
+
func (w *ResponseWriterWrapper) ensureHeadersCaptured() {
|
| 177 |
+
// Always capture the current headers to ensure we have the latest state
|
| 178 |
+
w.captureCurrentHeaders()
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// captureCurrentHeaders reads all headers from the underlying ResponseWriter and stores them
|
| 182 |
+
// in the wrapper's headers map. It creates copies of the header values to prevent race conditions.
|
| 183 |
+
func (w *ResponseWriterWrapper) captureCurrentHeaders() {
|
| 184 |
+
// Initialize headers map if needed
|
| 185 |
+
if w.headers == nil {
|
| 186 |
+
w.headers = make(map[string][]string)
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// Capture all current headers from the underlying ResponseWriter
|
| 190 |
+
for key, values := range w.ResponseWriter.Header() {
|
| 191 |
+
// Make a copy of the values slice to avoid reference issues
|
| 192 |
+
headerValues := make([]string, len(values))
|
| 193 |
+
copy(headerValues, values)
|
| 194 |
+
w.headers[key] = headerValues
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// detectStreaming determines if a response should be treated as a streaming response.
|
| 199 |
+
// It checks for a "text/event-stream" Content-Type or a '"stream": true'
|
| 200 |
+
// field in the original request body.
|
| 201 |
+
func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
|
| 202 |
+
// Check Content-Type for Server-Sent Events
|
| 203 |
+
if strings.Contains(contentType, "text/event-stream") {
|
| 204 |
+
return true
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// If a concrete Content-Type is already set (e.g., application/json for error responses),
|
| 208 |
+
// treat it as non-streaming instead of inferring from the request payload.
|
| 209 |
+
if strings.TrimSpace(contentType) != "" {
|
| 210 |
+
return false
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// Only fall back to request payload hints when Content-Type is not set yet.
|
| 214 |
+
if w.requestInfo != nil && len(w.requestInfo.Body) > 0 {
|
| 215 |
+
bodyStr := string(w.requestInfo.Body)
|
| 216 |
+
return strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`)
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return false
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
|
| 223 |
+
// It asynchronously writes each chunk to the streaming log writer.
|
| 224 |
+
func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
|
| 225 |
+
if done == nil {
|
| 226 |
+
return
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
defer close(done)
|
| 230 |
+
|
| 231 |
+
if w.streamWriter == nil || w.chunkChannel == nil {
|
| 232 |
+
return
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
for chunk := range w.chunkChannel {
|
| 236 |
+
w.streamWriter.WriteChunkAsync(chunk)
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// Finalize completes the logging process for the request and response.
|
| 241 |
+
// For streaming responses, it closes the chunk channel and the stream writer.
|
| 242 |
+
// For non-streaming responses, it logs the complete request and response details,
|
| 243 |
+
// including any API-specific request/response data stored in the Gin context.
|
| 244 |
+
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
| 245 |
+
if w.logger == nil {
|
| 246 |
+
return nil
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
finalStatusCode := w.statusCode
|
| 250 |
+
if finalStatusCode == 0 {
|
| 251 |
+
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {
|
| 252 |
+
finalStatusCode = statusWriter.Status()
|
| 253 |
+
} else {
|
| 254 |
+
finalStatusCode = 200
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
var slicesAPIResponseError []*interfaces.ErrorMessage
|
| 259 |
+
apiResponseError, isExist := c.Get("API_RESPONSE_ERROR")
|
| 260 |
+
if isExist {
|
| 261 |
+
if apiErrors, ok := apiResponseError.([]*interfaces.ErrorMessage); ok {
|
| 262 |
+
slicesAPIResponseError = apiErrors
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
hasAPIError := len(slicesAPIResponseError) > 0 || finalStatusCode >= http.StatusBadRequest
|
| 267 |
+
forceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()
|
| 268 |
+
if !w.logger.IsEnabled() && !forceLog {
|
| 269 |
+
return nil
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
if w.isStreaming && w.streamWriter != nil {
|
| 273 |
+
if w.chunkChannel != nil {
|
| 274 |
+
close(w.chunkChannel)
|
| 275 |
+
w.chunkChannel = nil
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
if w.streamDone != nil {
|
| 279 |
+
<-w.streamDone
|
| 280 |
+
w.streamDone = nil
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// Write API Request and Response to the streaming log before closing
|
| 284 |
+
apiRequest := w.extractAPIRequest(c)
|
| 285 |
+
if len(apiRequest) > 0 {
|
| 286 |
+
_ = w.streamWriter.WriteAPIRequest(apiRequest)
|
| 287 |
+
}
|
| 288 |
+
apiResponse := w.extractAPIResponse(c)
|
| 289 |
+
if len(apiResponse) > 0 {
|
| 290 |
+
_ = w.streamWriter.WriteAPIResponse(apiResponse)
|
| 291 |
+
}
|
| 292 |
+
if err := w.streamWriter.Close(); err != nil {
|
| 293 |
+
w.streamWriter = nil
|
| 294 |
+
return err
|
| 295 |
+
}
|
| 296 |
+
w.streamWriter = nil
|
| 297 |
+
return nil
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
|
| 304 |
+
w.ensureHeadersCaptured()
|
| 305 |
+
|
| 306 |
+
finalHeaders := make(map[string][]string, len(w.headers))
|
| 307 |
+
for key, values := range w.headers {
|
| 308 |
+
headerValues := make([]string, len(values))
|
| 309 |
+
copy(headerValues, values)
|
| 310 |
+
finalHeaders[key] = headerValues
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
return finalHeaders
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
func (w *ResponseWriterWrapper) extractAPIRequest(c *gin.Context) []byte {
|
| 317 |
+
apiRequest, isExist := c.Get("API_REQUEST")
|
| 318 |
+
if !isExist {
|
| 319 |
+
return nil
|
| 320 |
+
}
|
| 321 |
+
data, ok := apiRequest.([]byte)
|
| 322 |
+
if !ok || len(data) == 0 {
|
| 323 |
+
return nil
|
| 324 |
+
}
|
| 325 |
+
return data
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
func (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte {
|
| 329 |
+
apiResponse, isExist := c.Get("API_RESPONSE")
|
| 330 |
+
if !isExist {
|
| 331 |
+
return nil
|
| 332 |
+
}
|
| 333 |
+
data, ok := apiResponse.([]byte)
|
| 334 |
+
if !ok || len(data) == 0 {
|
| 335 |
+
return nil
|
| 336 |
+
}
|
| 337 |
+
return data
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
func (w *ResponseWriterWrapper) logRequest(statusCode int, headers map[string][]string, body []byte, apiRequestBody, apiResponseBody []byte, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
|
| 341 |
+
if w.requestInfo == nil {
|
| 342 |
+
return nil
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
var requestBody []byte
|
| 346 |
+
if len(w.requestInfo.Body) > 0 {
|
| 347 |
+
requestBody = w.requestInfo.Body
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
if loggerWithOptions, ok := w.logger.(interface {
|
| 351 |
+
LogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool, string) error
|
| 352 |
+
}); ok {
|
| 353 |
+
return loggerWithOptions.LogRequestWithOptions(
|
| 354 |
+
w.requestInfo.URL,
|
| 355 |
+
w.requestInfo.Method,
|
| 356 |
+
w.requestInfo.Headers,
|
| 357 |
+
requestBody,
|
| 358 |
+
statusCode,
|
| 359 |
+
headers,
|
| 360 |
+
body,
|
| 361 |
+
apiRequestBody,
|
| 362 |
+
apiResponseBody,
|
| 363 |
+
apiResponseErrors,
|
| 364 |
+
forceLog,
|
| 365 |
+
w.requestInfo.RequestID,
|
| 366 |
+
)
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
return w.logger.LogRequest(
|
| 370 |
+
w.requestInfo.URL,
|
| 371 |
+
w.requestInfo.Method,
|
| 372 |
+
w.requestInfo.Headers,
|
| 373 |
+
requestBody,
|
| 374 |
+
statusCode,
|
| 375 |
+
headers,
|
| 376 |
+
body,
|
| 377 |
+
apiRequestBody,
|
| 378 |
+
apiResponseBody,
|
| 379 |
+
apiResponseErrors,
|
| 380 |
+
w.requestInfo.RequestID,
|
| 381 |
+
)
|
| 382 |
+
}
|
internal/api/modules/amp/amp.go
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package amp implements the Amp CLI routing module, providing OAuth-based
|
| 2 |
+
// integration with Amp CLI for ChatGPT and Anthropic subscriptions.
|
| 3 |
+
package amp
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"fmt"
|
| 7 |
+
"net/http/httputil"
|
| 8 |
+
"strings"
|
| 9 |
+
"sync"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
| 13 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 14 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 15 |
+
log "github.com/sirupsen/logrus"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// Option configures the AmpModule.
|
| 19 |
+
type Option func(*AmpModule)
|
| 20 |
+
|
| 21 |
+
// AmpModule implements the RouteModuleV2 interface for Amp CLI integration.
|
| 22 |
+
// It provides:
|
| 23 |
+
// - Reverse proxy to Amp control plane for OAuth/management
|
| 24 |
+
// - Provider-specific route aliases (/api/provider/{provider}/...)
|
| 25 |
+
// - Automatic gzip decompression for misconfigured upstreams
|
| 26 |
+
// - Model mapping for routing unavailable models to alternatives
|
| 27 |
+
type AmpModule struct {
|
| 28 |
+
secretSource SecretSource
|
| 29 |
+
proxy *httputil.ReverseProxy
|
| 30 |
+
proxyMu sync.RWMutex // protects proxy for hot-reload
|
| 31 |
+
accessManager *sdkaccess.Manager
|
| 32 |
+
authMiddleware_ gin.HandlerFunc
|
| 33 |
+
modelMapper *DefaultModelMapper
|
| 34 |
+
enabled bool
|
| 35 |
+
registerOnce sync.Once
|
| 36 |
+
|
| 37 |
+
// restrictToLocalhost controls localhost-only access for management routes (hot-reloadable)
|
| 38 |
+
restrictToLocalhost bool
|
| 39 |
+
restrictMu sync.RWMutex
|
| 40 |
+
|
| 41 |
+
// configMu protects lastConfig for partial reload comparison
|
| 42 |
+
configMu sync.RWMutex
|
| 43 |
+
lastConfig *config.AmpCode
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// New creates a new Amp routing module with the given options.
|
| 47 |
+
// This is the preferred constructor using the Option pattern.
|
| 48 |
+
//
|
| 49 |
+
// Example:
|
| 50 |
+
//
|
| 51 |
+
// ampModule := amp.New(
|
| 52 |
+
// amp.WithAccessManager(accessManager),
|
| 53 |
+
// amp.WithAuthMiddleware(authMiddleware),
|
| 54 |
+
// amp.WithSecretSource(customSecret),
|
| 55 |
+
// )
|
| 56 |
+
func New(opts ...Option) *AmpModule {
|
| 57 |
+
m := &AmpModule{
|
| 58 |
+
secretSource: nil, // Will be created on demand if not provided
|
| 59 |
+
}
|
| 60 |
+
for _, opt := range opts {
|
| 61 |
+
opt(m)
|
| 62 |
+
}
|
| 63 |
+
return m
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// NewLegacy creates a new Amp routing module using the legacy constructor signature.
|
| 67 |
+
// This is provided for backwards compatibility.
|
| 68 |
+
//
|
| 69 |
+
// DEPRECATED: Use New with options instead.
|
| 70 |
+
func NewLegacy(accessManager *sdkaccess.Manager, authMiddleware gin.HandlerFunc) *AmpModule {
|
| 71 |
+
return New(
|
| 72 |
+
WithAccessManager(accessManager),
|
| 73 |
+
WithAuthMiddleware(authMiddleware),
|
| 74 |
+
)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// WithSecretSource sets a custom secret source for the module.
|
| 78 |
+
func WithSecretSource(source SecretSource) Option {
|
| 79 |
+
return func(m *AmpModule) {
|
| 80 |
+
m.secretSource = source
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// WithAccessManager sets the access manager for the module.
|
| 85 |
+
func WithAccessManager(am *sdkaccess.Manager) Option {
|
| 86 |
+
return func(m *AmpModule) {
|
| 87 |
+
m.accessManager = am
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// WithAuthMiddleware sets the authentication middleware for provider routes.
|
| 92 |
+
func WithAuthMiddleware(middleware gin.HandlerFunc) Option {
|
| 93 |
+
return func(m *AmpModule) {
|
| 94 |
+
m.authMiddleware_ = middleware
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Name returns the module identifier
|
| 99 |
+
func (m *AmpModule) Name() string {
|
| 100 |
+
return "amp-routing"
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// forceModelMappings returns whether model mappings should take precedence over local API keys
|
| 104 |
+
func (m *AmpModule) forceModelMappings() bool {
|
| 105 |
+
m.configMu.RLock()
|
| 106 |
+
defer m.configMu.RUnlock()
|
| 107 |
+
if m.lastConfig == nil {
|
| 108 |
+
return false
|
| 109 |
+
}
|
| 110 |
+
return m.lastConfig.ForceModelMappings
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Register sets up Amp routes if configured.
|
| 114 |
+
// This implements the RouteModuleV2 interface with Context.
|
| 115 |
+
// Routes are registered only once via sync.Once for idempotent behavior.
|
| 116 |
+
func (m *AmpModule) Register(ctx modules.Context) error {
|
| 117 |
+
settings := ctx.Config.AmpCode
|
| 118 |
+
upstreamURL := strings.TrimSpace(settings.UpstreamURL)
|
| 119 |
+
|
| 120 |
+
// Determine auth middleware (from module or context)
|
| 121 |
+
auth := m.getAuthMiddleware(ctx)
|
| 122 |
+
|
| 123 |
+
// Use registerOnce to ensure routes are only registered once
|
| 124 |
+
var regErr error
|
| 125 |
+
m.registerOnce.Do(func() {
|
| 126 |
+
// Initialize model mapper from config (for routing unavailable models to alternatives)
|
| 127 |
+
m.modelMapper = NewModelMapper(settings.ModelMappings)
|
| 128 |
+
|
| 129 |
+
// Store initial config for partial reload comparison
|
| 130 |
+
settingsCopy := settings
|
| 131 |
+
m.lastConfig = &settingsCopy
|
| 132 |
+
|
| 133 |
+
// Initialize localhost restriction setting (hot-reloadable)
|
| 134 |
+
m.setRestrictToLocalhost(settings.RestrictManagementToLocalhost)
|
| 135 |
+
|
| 136 |
+
// Always register provider aliases - these work without an upstream
|
| 137 |
+
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
|
| 138 |
+
|
| 139 |
+
// Register management proxy routes once; middleware will gate access when upstream is unavailable.
|
| 140 |
+
// Pass auth middleware to require valid API key for all management routes.
|
| 141 |
+
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, auth)
|
| 142 |
+
|
| 143 |
+
// If no upstream URL, skip proxy routes but provider aliases are still available
|
| 144 |
+
if upstreamURL == "" {
|
| 145 |
+
log.Debug("amp upstream proxy disabled (no upstream URL configured)")
|
| 146 |
+
log.Debug("amp provider alias routes registered")
|
| 147 |
+
m.enabled = false
|
| 148 |
+
return
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if err := m.enableUpstreamProxy(upstreamURL, &settings); err != nil {
|
| 152 |
+
regErr = fmt.Errorf("failed to create amp proxy: %w", err)
|
| 153 |
+
return
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
log.Debug("amp provider alias routes registered")
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
return regErr
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// getAuthMiddleware returns the authentication middleware, preferring the
|
| 163 |
+
// module's configured middleware, then the context middleware, then a fallback.
|
| 164 |
+
func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc {
|
| 165 |
+
if m.authMiddleware_ != nil {
|
| 166 |
+
return m.authMiddleware_
|
| 167 |
+
}
|
| 168 |
+
if ctx.AuthMiddleware != nil {
|
| 169 |
+
return ctx.AuthMiddleware
|
| 170 |
+
}
|
| 171 |
+
// Fallback: no authentication (should not happen in production)
|
| 172 |
+
log.Warn("amp module: no auth middleware provided, allowing all requests")
|
| 173 |
+
return func(c *gin.Context) {
|
| 174 |
+
c.Next()
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// OnConfigUpdated handles configuration updates with partial reload support.
|
| 179 |
+
// Only updates components that have actually changed to avoid unnecessary work.
|
| 180 |
+
// Supports hot-reload for: model-mappings, upstream-api-key, upstream-url, restrict-management-to-localhost.
|
| 181 |
+
func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
| 182 |
+
newSettings := cfg.AmpCode
|
| 183 |
+
|
| 184 |
+
// Get previous config for comparison
|
| 185 |
+
m.configMu.RLock()
|
| 186 |
+
oldSettings := m.lastConfig
|
| 187 |
+
m.configMu.RUnlock()
|
| 188 |
+
|
| 189 |
+
if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {
|
| 190 |
+
m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)
|
| 194 |
+
oldUpstreamURL := ""
|
| 195 |
+
if oldSettings != nil {
|
| 196 |
+
oldUpstreamURL = strings.TrimSpace(oldSettings.UpstreamURL)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
if !m.enabled && newUpstreamURL != "" {
|
| 200 |
+
if err := m.enableUpstreamProxy(newUpstreamURL, &newSettings); err != nil {
|
| 201 |
+
log.Errorf("amp config: failed to enable upstream proxy for %s: %v", newUpstreamURL, err)
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Check model mappings change
|
| 206 |
+
modelMappingsChanged := m.hasModelMappingsChanged(oldSettings, &newSettings)
|
| 207 |
+
if modelMappingsChanged {
|
| 208 |
+
if m.modelMapper != nil {
|
| 209 |
+
m.modelMapper.UpdateMappings(newSettings.ModelMappings)
|
| 210 |
+
} else if m.enabled {
|
| 211 |
+
log.Warnf("amp model mapper not initialized, skipping model mapping update")
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
if m.enabled {
|
| 216 |
+
// Check upstream URL change - now supports hot-reload
|
| 217 |
+
if newUpstreamURL == "" && oldUpstreamURL != "" {
|
| 218 |
+
m.setProxy(nil)
|
| 219 |
+
m.enabled = false
|
| 220 |
+
} else if oldUpstreamURL != "" && newUpstreamURL != oldUpstreamURL && newUpstreamURL != "" {
|
| 221 |
+
// Recreate proxy with new URL
|
| 222 |
+
proxy, err := createReverseProxy(newUpstreamURL, m.secretSource)
|
| 223 |
+
if err != nil {
|
| 224 |
+
log.Errorf("amp config: failed to create proxy for new upstream URL %s: %v", newUpstreamURL, err)
|
| 225 |
+
} else {
|
| 226 |
+
m.setProxy(proxy)
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// Check API key change (both default and per-client mappings)
|
| 231 |
+
apiKeyChanged := m.hasAPIKeyChanged(oldSettings, &newSettings)
|
| 232 |
+
upstreamAPIKeysChanged := m.hasUpstreamAPIKeysChanged(oldSettings, &newSettings)
|
| 233 |
+
if apiKeyChanged || upstreamAPIKeysChanged {
|
| 234 |
+
if m.secretSource != nil {
|
| 235 |
+
if ms, ok := m.secretSource.(*MappedSecretSource); ok {
|
| 236 |
+
if apiKeyChanged {
|
| 237 |
+
ms.UpdateDefaultExplicitKey(newSettings.UpstreamAPIKey)
|
| 238 |
+
ms.InvalidateCache()
|
| 239 |
+
}
|
| 240 |
+
if upstreamAPIKeysChanged {
|
| 241 |
+
ms.UpdateMappings(newSettings.UpstreamAPIKeys)
|
| 242 |
+
}
|
| 243 |
+
} else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
| 244 |
+
ms.UpdateExplicitKey(newSettings.UpstreamAPIKey)
|
| 245 |
+
ms.InvalidateCache()
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Store current config for next comparison
|
| 253 |
+
m.configMu.Lock()
|
| 254 |
+
settingsCopy := newSettings // copy struct
|
| 255 |
+
m.lastConfig = &settingsCopy
|
| 256 |
+
m.configMu.Unlock()
|
| 257 |
+
|
| 258 |
+
return nil
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
func (m *AmpModule) enableUpstreamProxy(upstreamURL string, settings *config.AmpCode) error {
|
| 262 |
+
if m.secretSource == nil {
|
| 263 |
+
// Create MultiSourceSecret as the default source, then wrap with MappedSecretSource
|
| 264 |
+
defaultSource := NewMultiSourceSecret(settings.UpstreamAPIKey, 0 /* default 5min */)
|
| 265 |
+
mappedSource := NewMappedSecretSource(defaultSource)
|
| 266 |
+
mappedSource.UpdateMappings(settings.UpstreamAPIKeys)
|
| 267 |
+
m.secretSource = mappedSource
|
| 268 |
+
} else if ms, ok := m.secretSource.(*MappedSecretSource); ok {
|
| 269 |
+
ms.UpdateDefaultExplicitKey(settings.UpstreamAPIKey)
|
| 270 |
+
ms.InvalidateCache()
|
| 271 |
+
ms.UpdateMappings(settings.UpstreamAPIKeys)
|
| 272 |
+
} else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
| 273 |
+
// Legacy path: wrap existing MultiSourceSecret with MappedSecretSource
|
| 274 |
+
ms.UpdateExplicitKey(settings.UpstreamAPIKey)
|
| 275 |
+
ms.InvalidateCache()
|
| 276 |
+
mappedSource := NewMappedSecretSource(ms)
|
| 277 |
+
mappedSource.UpdateMappings(settings.UpstreamAPIKeys)
|
| 278 |
+
m.secretSource = mappedSource
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
proxy, err := createReverseProxy(upstreamURL, m.secretSource)
|
| 282 |
+
if err != nil {
|
| 283 |
+
return err
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
m.setProxy(proxy)
|
| 287 |
+
m.enabled = true
|
| 288 |
+
|
| 289 |
+
log.Infof("amp upstream proxy enabled for: %s", upstreamURL)
|
| 290 |
+
return nil
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// hasModelMappingsChanged compares old and new model mappings.
|
| 294 |
+
func (m *AmpModule) hasModelMappingsChanged(old *config.AmpCode, new *config.AmpCode) bool {
|
| 295 |
+
if old == nil {
|
| 296 |
+
return len(new.ModelMappings) > 0
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
if len(old.ModelMappings) != len(new.ModelMappings) {
|
| 300 |
+
return true
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// Build map for efficient and robust comparison
|
| 304 |
+
type mappingInfo struct {
|
| 305 |
+
to string
|
| 306 |
+
regex bool
|
| 307 |
+
}
|
| 308 |
+
oldMap := make(map[string]mappingInfo, len(old.ModelMappings))
|
| 309 |
+
for _, mapping := range old.ModelMappings {
|
| 310 |
+
oldMap[strings.TrimSpace(mapping.From)] = mappingInfo{
|
| 311 |
+
to: strings.TrimSpace(mapping.To),
|
| 312 |
+
regex: mapping.Regex,
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
for _, mapping := range new.ModelMappings {
|
| 317 |
+
from := strings.TrimSpace(mapping.From)
|
| 318 |
+
to := strings.TrimSpace(mapping.To)
|
| 319 |
+
if oldVal, exists := oldMap[from]; !exists || oldVal.to != to || oldVal.regex != mapping.Regex {
|
| 320 |
+
return true
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
return false
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// hasAPIKeyChanged compares old and new API keys.
|
| 328 |
+
func (m *AmpModule) hasAPIKeyChanged(old *config.AmpCode, new *config.AmpCode) bool {
|
| 329 |
+
oldKey := ""
|
| 330 |
+
if old != nil {
|
| 331 |
+
oldKey = strings.TrimSpace(old.UpstreamAPIKey)
|
| 332 |
+
}
|
| 333 |
+
newKey := strings.TrimSpace(new.UpstreamAPIKey)
|
| 334 |
+
return oldKey != newKey
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// hasUpstreamAPIKeysChanged compares old and new per-client upstream API key mappings.
|
| 338 |
+
func (m *AmpModule) hasUpstreamAPIKeysChanged(old *config.AmpCode, new *config.AmpCode) bool {
|
| 339 |
+
if old == nil {
|
| 340 |
+
return len(new.UpstreamAPIKeys) > 0
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
if len(old.UpstreamAPIKeys) != len(new.UpstreamAPIKeys) {
|
| 344 |
+
return true
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// Build map for comparison: upstreamKey -> set of clientKeys
|
| 348 |
+
type entryInfo struct {
|
| 349 |
+
upstreamKey string
|
| 350 |
+
clientKeys map[string]struct{}
|
| 351 |
+
}
|
| 352 |
+
oldEntries := make([]entryInfo, len(old.UpstreamAPIKeys))
|
| 353 |
+
for i, entry := range old.UpstreamAPIKeys {
|
| 354 |
+
clientKeys := make(map[string]struct{}, len(entry.APIKeys))
|
| 355 |
+
for _, k := range entry.APIKeys {
|
| 356 |
+
trimmed := strings.TrimSpace(k)
|
| 357 |
+
if trimmed == "" {
|
| 358 |
+
continue
|
| 359 |
+
}
|
| 360 |
+
clientKeys[trimmed] = struct{}{}
|
| 361 |
+
}
|
| 362 |
+
oldEntries[i] = entryInfo{
|
| 363 |
+
upstreamKey: strings.TrimSpace(entry.UpstreamAPIKey),
|
| 364 |
+
clientKeys: clientKeys,
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
for i, newEntry := range new.UpstreamAPIKeys {
|
| 369 |
+
if i >= len(oldEntries) {
|
| 370 |
+
return true
|
| 371 |
+
}
|
| 372 |
+
oldE := oldEntries[i]
|
| 373 |
+
if strings.TrimSpace(newEntry.UpstreamAPIKey) != oldE.upstreamKey {
|
| 374 |
+
return true
|
| 375 |
+
}
|
| 376 |
+
newKeys := make(map[string]struct{}, len(newEntry.APIKeys))
|
| 377 |
+
for _, k := range newEntry.APIKeys {
|
| 378 |
+
trimmed := strings.TrimSpace(k)
|
| 379 |
+
if trimmed == "" {
|
| 380 |
+
continue
|
| 381 |
+
}
|
| 382 |
+
newKeys[trimmed] = struct{}{}
|
| 383 |
+
}
|
| 384 |
+
if len(newKeys) != len(oldE.clientKeys) {
|
| 385 |
+
return true
|
| 386 |
+
}
|
| 387 |
+
for k := range newKeys {
|
| 388 |
+
if _, ok := oldE.clientKeys[k]; !ok {
|
| 389 |
+
return true
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
return false
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// GetModelMapper returns the model mapper instance (for testing/debugging).
|
| 398 |
+
func (m *AmpModule) GetModelMapper() *DefaultModelMapper {
|
| 399 |
+
return m.modelMapper
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// getProxy returns the current proxy instance (thread-safe for hot-reload).
|
| 403 |
+
func (m *AmpModule) getProxy() *httputil.ReverseProxy {
|
| 404 |
+
m.proxyMu.RLock()
|
| 405 |
+
defer m.proxyMu.RUnlock()
|
| 406 |
+
return m.proxy
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// setProxy updates the proxy instance (thread-safe for hot-reload).
|
| 410 |
+
func (m *AmpModule) setProxy(proxy *httputil.ReverseProxy) {
|
| 411 |
+
m.proxyMu.Lock()
|
| 412 |
+
defer m.proxyMu.Unlock()
|
| 413 |
+
m.proxy = proxy
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
// IsRestrictedToLocalhost returns whether management routes are restricted to localhost.
|
| 417 |
+
func (m *AmpModule) IsRestrictedToLocalhost() bool {
|
| 418 |
+
m.restrictMu.RLock()
|
| 419 |
+
defer m.restrictMu.RUnlock()
|
| 420 |
+
return m.restrictToLocalhost
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
// setRestrictToLocalhost updates the localhost restriction setting.
|
| 424 |
+
func (m *AmpModule) setRestrictToLocalhost(restrict bool) {
|
| 425 |
+
m.restrictMu.Lock()
|
| 426 |
+
defer m.restrictMu.Unlock()
|
| 427 |
+
m.restrictToLocalhost = restrict
|
| 428 |
+
}
|
internal/api/modules/amp/amp_test.go
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package amp
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http/httptest"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
| 13 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 14 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 15 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
func TestAmpModule_Name(t *testing.T) {
|
| 19 |
+
m := New()
|
| 20 |
+
if m.Name() != "amp-routing" {
|
| 21 |
+
t.Fatalf("want amp-routing, got %s", m.Name())
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func TestAmpModule_New(t *testing.T) {
|
| 26 |
+
accessManager := sdkaccess.NewManager()
|
| 27 |
+
authMiddleware := func(c *gin.Context) { c.Next() }
|
| 28 |
+
|
| 29 |
+
m := NewLegacy(accessManager, authMiddleware)
|
| 30 |
+
|
| 31 |
+
if m.accessManager != accessManager {
|
| 32 |
+
t.Fatal("accessManager not set")
|
| 33 |
+
}
|
| 34 |
+
if m.authMiddleware_ == nil {
|
| 35 |
+
t.Fatal("authMiddleware not set")
|
| 36 |
+
}
|
| 37 |
+
if m.enabled {
|
| 38 |
+
t.Fatal("enabled should be false initially")
|
| 39 |
+
}
|
| 40 |
+
if m.proxy != nil {
|
| 41 |
+
t.Fatal("proxy should be nil initially")
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func TestAmpModule_Register_WithUpstream(t *testing.T) {
|
| 46 |
+
gin.SetMode(gin.TestMode)
|
| 47 |
+
r := gin.New()
|
| 48 |
+
|
| 49 |
+
// Fake upstream to ensure URL is valid
|
| 50 |
+
upstream := httptest.NewServer(nil)
|
| 51 |
+
defer upstream.Close()
|
| 52 |
+
|
| 53 |
+
accessManager := sdkaccess.NewManager()
|
| 54 |
+
base := &handlers.BaseAPIHandler{}
|
| 55 |
+
|
| 56 |
+
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
| 57 |
+
|
| 58 |
+
cfg := &config.Config{
|
| 59 |
+
AmpCode: config.AmpCode{
|
| 60 |
+
UpstreamURL: upstream.URL,
|
| 61 |
+
UpstreamAPIKey: "test-key",
|
| 62 |
+
},
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
| 66 |
+
if err := m.Register(ctx); err != nil {
|
| 67 |
+
t.Fatalf("register error: %v", err)
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if !m.enabled {
|
| 71 |
+
t.Fatal("module should be enabled with upstream URL")
|
| 72 |
+
}
|
| 73 |
+
if m.proxy == nil {
|
| 74 |
+
t.Fatal("proxy should be initialized")
|
| 75 |
+
}
|
| 76 |
+
if m.secretSource == nil {
|
| 77 |
+
t.Fatal("secretSource should be initialized")
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
func TestAmpModule_Register_WithoutUpstream(t *testing.T) {
|
| 82 |
+
gin.SetMode(gin.TestMode)
|
| 83 |
+
r := gin.New()
|
| 84 |
+
|
| 85 |
+
accessManager := sdkaccess.NewManager()
|
| 86 |
+
base := &handlers.BaseAPIHandler{}
|
| 87 |
+
|
| 88 |
+
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
| 89 |
+
|
| 90 |
+
cfg := &config.Config{
|
| 91 |
+
AmpCode: config.AmpCode{
|
| 92 |
+
UpstreamURL: "", // No upstream
|
| 93 |
+
},
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
| 97 |
+
if err := m.Register(ctx); err != nil {
|
| 98 |
+
t.Fatalf("register should not error without upstream: %v", err)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if m.enabled {
|
| 102 |
+
t.Fatal("module should be disabled without upstream URL")
|
| 103 |
+
}
|
| 104 |
+
if m.proxy != nil {
|
| 105 |
+
t.Fatal("proxy should not be initialized without upstream")
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// But provider aliases should still be registered
|
| 109 |
+
req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
|
| 110 |
+
w := httptest.NewRecorder()
|
| 111 |
+
r.ServeHTTP(w, req)
|
| 112 |
+
|
| 113 |
+
if w.Code == 404 {
|
| 114 |
+
t.Fatal("provider aliases should be registered even without upstream")
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func TestAmpModule_Register_InvalidUpstream(t *testing.T) {
|
| 119 |
+
gin.SetMode(gin.TestMode)
|
| 120 |
+
r := gin.New()
|
| 121 |
+
|
| 122 |
+
accessManager := sdkaccess.NewManager()
|
| 123 |
+
base := &handlers.BaseAPIHandler{}
|
| 124 |
+
|
| 125 |
+
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
| 126 |
+
|
| 127 |
+
cfg := &config.Config{
|
| 128 |
+
AmpCode: config.AmpCode{
|
| 129 |
+
UpstreamURL: "://invalid-url",
|
| 130 |
+
},
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
| 134 |
+
if err := m.Register(ctx); err == nil {
|
| 135 |
+
t.Fatal("expected error for invalid upstream URL")
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
func TestAmpModule_OnConfigUpdated_CacheInvalidation(t *testing.T) {
|
| 140 |
+
tmpDir := t.TempDir()
|
| 141 |
+
p := filepath.Join(tmpDir, "secrets.json")
|
| 142 |
+
if err := os.WriteFile(p, []byte(`{"apiKey@https://ampcode.com/":"v1"}`), 0600); err != nil {
|
| 143 |
+
t.Fatal(err)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
m := &AmpModule{enabled: true}
|
| 147 |
+
ms := NewMultiSourceSecretWithPath("", p, time.Minute)
|
| 148 |
+
m.secretSource = ms
|
| 149 |
+
m.lastConfig = &config.AmpCode{
|
| 150 |
+
UpstreamAPIKey: "old-key",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Warm the cache
|
| 154 |
+
if _, err := ms.Get(context.Background()); err != nil {
|
| 155 |
+
t.Fatal(err)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if ms.cache == nil {
|
| 159 |
+
t.Fatal("expected cache to be set")
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Update config - should invalidate cache
|
| 163 |
+
if err := m.OnConfigUpdated(&config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://x", UpstreamAPIKey: "new-key"}}); err != nil {
|
| 164 |
+
t.Fatal(err)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if ms.cache != nil {
|
| 168 |
+
t.Fatal("expected cache to be invalidated")
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
func TestAmpModule_OnConfigUpdated_NotEnabled(t *testing.T) {
|
| 173 |
+
m := &AmpModule{enabled: false}
|
| 174 |
+
|
| 175 |
+
// Should not error or panic when disabled
|
| 176 |
+
if err := m.OnConfigUpdated(&config.Config{}); err != nil {
|
| 177 |
+
t.Fatalf("unexpected error: %v", err)
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
func TestAmpModule_OnConfigUpdated_URLRemoved(t *testing.T) {
|
| 182 |
+
m := &AmpModule{enabled: true}
|
| 183 |
+
ms := NewMultiSourceSecret("", 0)
|
| 184 |
+
m.secretSource = ms
|
| 185 |
+
|
| 186 |
+
// Config update with empty URL - should log warning but not error
|
| 187 |
+
cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: ""}}
|
| 188 |
+
|
| 189 |
+
if err := m.OnConfigUpdated(cfg); err != nil {
|
| 190 |
+
t.Fatalf("unexpected error: %v", err)
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
func TestAmpModule_OnConfigUpdated_NonMultiSourceSecret(t *testing.T) {
|
| 195 |
+
// Test that OnConfigUpdated doesn't panic with StaticSecretSource
|
| 196 |
+
m := &AmpModule{enabled: true}
|
| 197 |
+
m.secretSource = NewStaticSecretSource("static-key")
|
| 198 |
+
|
| 199 |
+
cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://example.com"}}
|
| 200 |
+
|
| 201 |
+
// Should not error or panic
|
| 202 |
+
if err := m.OnConfigUpdated(cfg); err != nil {
|
| 203 |
+
t.Fatalf("unexpected error: %v", err)
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
func TestAmpModule_AuthMiddleware_Fallback(t *testing.T) {
|
| 208 |
+
gin.SetMode(gin.TestMode)
|
| 209 |
+
r := gin.New()
|
| 210 |
+
|
| 211 |
+
// Create module with no auth middleware
|
| 212 |
+
m := &AmpModule{authMiddleware_: nil}
|
| 213 |
+
|
| 214 |
+
// Get the fallback middleware via getAuthMiddleware
|
| 215 |
+
ctx := modules.Context{Engine: r, AuthMiddleware: nil}
|
| 216 |
+
middleware := m.getAuthMiddleware(ctx)
|
| 217 |
+
|
| 218 |
+
if middleware == nil {
|
| 219 |
+
t.Fatal("getAuthMiddleware should return a fallback, not nil")
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// Test that it works
|
| 223 |
+
called := false
|
| 224 |
+
r.GET("/test", middleware, func(c *gin.Context) {
|
| 225 |
+
called = true
|
| 226 |
+
c.String(200, "ok")
|
| 227 |
+
})
|
| 228 |
+
|
| 229 |
+
req := httptest.NewRequest("GET", "/test", nil)
|
| 230 |
+
w := httptest.NewRecorder()
|
| 231 |
+
r.ServeHTTP(w, req)
|
| 232 |
+
|
| 233 |
+
if !called {
|
| 234 |
+
t.Fatal("fallback middleware should allow requests through")
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
func TestAmpModule_SecretSource_FromConfig(t *testing.T) {
|
| 239 |
+
gin.SetMode(gin.TestMode)
|
| 240 |
+
r := gin.New()
|
| 241 |
+
|
| 242 |
+
upstream := httptest.NewServer(nil)
|
| 243 |
+
defer upstream.Close()
|
| 244 |
+
|
| 245 |
+
accessManager := sdkaccess.NewManager()
|
| 246 |
+
base := &handlers.BaseAPIHandler{}
|
| 247 |
+
|
| 248 |
+
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
| 249 |
+
|
| 250 |
+
// Config with explicit API key
|
| 251 |
+
cfg := &config.Config{
|
| 252 |
+
AmpCode: config.AmpCode{
|
| 253 |
+
UpstreamURL: upstream.URL,
|
| 254 |
+
UpstreamAPIKey: "config-key",
|
| 255 |
+
},
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
| 259 |
+
if err := m.Register(ctx); err != nil {
|
| 260 |
+
t.Fatalf("register error: %v", err)
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// Secret source should be MultiSourceSecret with config key
|
| 264 |
+
if m.secretSource == nil {
|
| 265 |
+
t.Fatal("secretSource should be set")
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Verify it returns the config key
|
| 269 |
+
key, err := m.secretSource.Get(context.Background())
|
| 270 |
+
if err != nil {
|
| 271 |
+
t.Fatalf("Get error: %v", err)
|
| 272 |
+
}
|
| 273 |
+
if key != "config-key" {
|
| 274 |
+
t.Fatalf("want config-key, got %s", key)
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
func TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) {
|
| 279 |
+
gin.SetMode(gin.TestMode)
|
| 280 |
+
|
| 281 |
+
scenarios := []struct {
|
| 282 |
+
name string
|
| 283 |
+
configURL string
|
| 284 |
+
}{
|
| 285 |
+
{"with_upstream", "http://example.com"},
|
| 286 |
+
{"without_upstream", ""},
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
for _, scenario := range scenarios {
|
| 290 |
+
t.Run(scenario.name, func(t *testing.T) {
|
| 291 |
+
r := gin.New()
|
| 292 |
+
accessManager := sdkaccess.NewManager()
|
| 293 |
+
base := &handlers.BaseAPIHandler{}
|
| 294 |
+
|
| 295 |
+
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
| 296 |
+
|
| 297 |
+
cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: scenario.configURL}}
|
| 298 |
+
|
| 299 |
+
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
| 300 |
+
if err := m.Register(ctx); err != nil && scenario.configURL != "" {
|
| 301 |
+
t.Fatalf("register error: %v", err)
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// Provider aliases should always be available
|
| 305 |
+
req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
|
| 306 |
+
w := httptest.NewRecorder()
|
| 307 |
+
r.ServeHTTP(w, req)
|
| 308 |
+
|
| 309 |
+
if w.Code == 404 {
|
| 310 |
+
t.Fatal("provider aliases should be registered")
|
| 311 |
+
}
|
| 312 |
+
})
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
func TestAmpModule_hasUpstreamAPIKeysChanged_DetectsRemovedKeyWithDuplicateInput(t *testing.T) {
|
| 317 |
+
m := &AmpModule{}
|
| 318 |
+
|
| 319 |
+
oldCfg := &config.AmpCode{
|
| 320 |
+
UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
|
| 321 |
+
{UpstreamAPIKey: "u1", APIKeys: []string{"k1", "k2"}},
|
| 322 |
+
},
|
| 323 |
+
}
|
| 324 |
+
newCfg := &config.AmpCode{
|
| 325 |
+
UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
|
| 326 |
+
{UpstreamAPIKey: "u1", APIKeys: []string{"k1", "k1"}},
|
| 327 |
+
},
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
if !m.hasUpstreamAPIKeysChanged(oldCfg, newCfg) {
|
| 331 |
+
t.Fatal("expected change to be detected when k2 is removed but new list contains duplicates")
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
func TestAmpModule_hasUpstreamAPIKeysChanged_IgnoresEmptyAndWhitespaceKeys(t *testing.T) {
|
| 336 |
+
m := &AmpModule{}
|
| 337 |
+
|
| 338 |
+
oldCfg := &config.AmpCode{
|
| 339 |
+
UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
|
| 340 |
+
{UpstreamAPIKey: "u1", APIKeys: []string{"k1", "k2"}},
|
| 341 |
+
},
|
| 342 |
+
}
|
| 343 |
+
newCfg := &config.AmpCode{
|
| 344 |
+
UpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{
|
| 345 |
+
{UpstreamAPIKey: "u1", APIKeys: []string{" k1 ", "", "k2", " "}},
|
| 346 |
+
},
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
if m.hasUpstreamAPIKeysChanged(oldCfg, newCfg) {
|
| 350 |
+
t.Fatal("expected no change when only whitespace/empty entries differ")
|
| 351 |
+
}
|
| 352 |
+
}
|
internal/api/modules/amp/fallback_handlers.go
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package amp
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"io"
|
| 6 |
+
"net/http/httputil"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/gin-gonic/gin"
|
| 11 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
| 12 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 13 |
+
log "github.com/sirupsen/logrus"
|
| 14 |
+
"github.com/tidwall/gjson"
|
| 15 |
+
"github.com/tidwall/sjson"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// AmpRouteType represents the type of routing decision made for an Amp request
|
| 19 |
+
type AmpRouteType string
|
| 20 |
+
|
| 21 |
+
const (
|
| 22 |
+
// RouteTypeLocalProvider indicates the request is handled by a local OAuth provider (free)
|
| 23 |
+
RouteTypeLocalProvider AmpRouteType = "LOCAL_PROVIDER"
|
| 24 |
+
// RouteTypeModelMapping indicates the request was remapped to another available model (free)
|
| 25 |
+
RouteTypeModelMapping AmpRouteType = "MODEL_MAPPING"
|
| 26 |
+
// RouteTypeAmpCredits indicates the request is forwarded to ampcode.com (uses Amp credits)
|
| 27 |
+
RouteTypeAmpCredits AmpRouteType = "AMP_CREDITS"
|
| 28 |
+
// RouteTypeNoProvider indicates no provider or fallback available
|
| 29 |
+
RouteTypeNoProvider AmpRouteType = "NO_PROVIDER"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
// MappedModelContextKey is the Gin context key for passing mapped model names.
|
| 33 |
+
const MappedModelContextKey = "mapped_model"
|
| 34 |
+
|
| 35 |
+
// logAmpRouting logs the routing decision for an Amp request with structured fields
|
| 36 |
+
func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {
|
| 37 |
+
fields := log.Fields{
|
| 38 |
+
"component": "amp-routing",
|
| 39 |
+
"route_type": string(routeType),
|
| 40 |
+
"requested_model": requestedModel,
|
| 41 |
+
"path": path,
|
| 42 |
+
"timestamp": time.Now().Format(time.RFC3339),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if resolvedModel != "" && resolvedModel != requestedModel {
|
| 46 |
+
fields["resolved_model"] = resolvedModel
|
| 47 |
+
}
|
| 48 |
+
if provider != "" {
|
| 49 |
+
fields["provider"] = provider
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
switch routeType {
|
| 53 |
+
case RouteTypeLocalProvider:
|
| 54 |
+
fields["cost"] = "free"
|
| 55 |
+
fields["source"] = "local_oauth"
|
| 56 |
+
log.WithFields(fields).Debugf("amp using local provider for model: %s", requestedModel)
|
| 57 |
+
|
| 58 |
+
case RouteTypeModelMapping:
|
| 59 |
+
fields["cost"] = "free"
|
| 60 |
+
fields["source"] = "local_oauth"
|
| 61 |
+
fields["mapping"] = requestedModel + " -> " + resolvedModel
|
| 62 |
+
// model mapping already logged in mapper; avoid duplicate here
|
| 63 |
+
|
| 64 |
+
case RouteTypeAmpCredits:
|
| 65 |
+
fields["cost"] = "amp_credits"
|
| 66 |
+
fields["source"] = "ampcode.com"
|
| 67 |
+
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
| 68 |
+
log.WithFields(fields).Warnf("forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local provider, add to config: ampcode.model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel)
|
| 69 |
+
|
| 70 |
+
case RouteTypeNoProvider:
|
| 71 |
+
fields["cost"] = "none"
|
| 72 |
+
fields["source"] = "error"
|
| 73 |
+
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
| 74 |
+
log.WithFields(fields).Warnf("no provider available for model_id: %s", requestedModel)
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// FallbackHandler wraps a standard handler with fallback logic to ampcode.com
|
| 79 |
+
// when the model's provider is not available in CLIProxyAPI
|
| 80 |
+
type FallbackHandler struct {
|
| 81 |
+
getProxy func() *httputil.ReverseProxy
|
| 82 |
+
modelMapper ModelMapper
|
| 83 |
+
forceModelMappings func() bool
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// NewFallbackHandler creates a new fallback handler wrapper
|
| 87 |
+
// The getProxy function allows lazy evaluation of the proxy (useful when proxy is created after routes)
|
| 88 |
+
func NewFallbackHandler(getProxy func() *httputil.ReverseProxy) *FallbackHandler {
|
| 89 |
+
return &FallbackHandler{
|
| 90 |
+
getProxy: getProxy,
|
| 91 |
+
forceModelMappings: func() bool { return false },
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// NewFallbackHandlerWithMapper creates a new fallback handler with model mapping support
|
| 96 |
+
func NewFallbackHandlerWithMapper(getProxy func() *httputil.ReverseProxy, mapper ModelMapper, forceModelMappings func() bool) *FallbackHandler {
|
| 97 |
+
if forceModelMappings == nil {
|
| 98 |
+
forceModelMappings = func() bool { return false }
|
| 99 |
+
}
|
| 100 |
+
return &FallbackHandler{
|
| 101 |
+
getProxy: getProxy,
|
| 102 |
+
modelMapper: mapper,
|
| 103 |
+
forceModelMappings: forceModelMappings,
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// SetModelMapper sets the model mapper for this handler (allows late binding)
|
| 108 |
+
func (fh *FallbackHandler) SetModelMapper(mapper ModelMapper) {
|
| 109 |
+
fh.modelMapper = mapper
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// WrapHandler wraps a gin.HandlerFunc with fallback logic
|
| 113 |
+
// If the model's provider is not configured in CLIProxyAPI, it forwards to ampcode.com
|
| 114 |
+
func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc {
|
| 115 |
+
return func(c *gin.Context) {
|
| 116 |
+
requestPath := c.Request.URL.Path
|
| 117 |
+
|
| 118 |
+
// Read the request body to extract the model name
|
| 119 |
+
bodyBytes, err := io.ReadAll(c.Request.Body)
|
| 120 |
+
if err != nil {
|
| 121 |
+
log.Errorf("amp fallback: failed to read request body: %v", err)
|
| 122 |
+
handler(c)
|
| 123 |
+
return
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Restore the body for the handler to read
|
| 127 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 128 |
+
|
| 129 |
+
// Try to extract model from request body or URL path (for Gemini)
|
| 130 |
+
modelName := extractModelFromRequest(bodyBytes, c)
|
| 131 |
+
if modelName == "" {
|
| 132 |
+
// Can't determine model, proceed with normal handler
|
| 133 |
+
handler(c)
|
| 134 |
+
return
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Normalize model (handles dynamic thinking suffixes)
|
| 138 |
+
suffixResult := thinking.ParseSuffix(modelName)
|
| 139 |
+
normalizedModel := suffixResult.ModelName
|
| 140 |
+
thinkingSuffix := ""
|
| 141 |
+
if suffixResult.HasSuffix {
|
| 142 |
+
thinkingSuffix = "(" + suffixResult.RawSuffix + ")"
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
resolveMappedModel := func() (string, []string) {
|
| 146 |
+
if fh.modelMapper == nil {
|
| 147 |
+
return "", nil
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
mappedModel := fh.modelMapper.MapModel(modelName)
|
| 151 |
+
if mappedModel == "" {
|
| 152 |
+
mappedModel = fh.modelMapper.MapModel(normalizedModel)
|
| 153 |
+
}
|
| 154 |
+
mappedModel = strings.TrimSpace(mappedModel)
|
| 155 |
+
if mappedModel == "" {
|
| 156 |
+
return "", nil
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Preserve dynamic thinking suffix (e.g. "(xhigh)") when mapping applies, unless the target
|
| 160 |
+
// already specifies its own thinking suffix.
|
| 161 |
+
if thinkingSuffix != "" {
|
| 162 |
+
mappedSuffixResult := thinking.ParseSuffix(mappedModel)
|
| 163 |
+
if !mappedSuffixResult.HasSuffix {
|
| 164 |
+
mappedModel += thinkingSuffix
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
mappedBaseModel := thinking.ParseSuffix(mappedModel).ModelName
|
| 169 |
+
mappedProviders := util.GetProviderName(mappedBaseModel)
|
| 170 |
+
if len(mappedProviders) == 0 {
|
| 171 |
+
return "", nil
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
return mappedModel, mappedProviders
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// Track resolved model for logging (may change if mapping is applied)
|
| 178 |
+
resolvedModel := normalizedModel
|
| 179 |
+
usedMapping := false
|
| 180 |
+
var providers []string
|
| 181 |
+
|
| 182 |
+
// Check if model mappings should be forced ahead of local API keys
|
| 183 |
+
forceMappings := fh.forceModelMappings != nil && fh.forceModelMappings()
|
| 184 |
+
|
| 185 |
+
if forceMappings {
|
| 186 |
+
// FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)
|
| 187 |
+
// This allows users to route Amp requests to their preferred OAuth providers
|
| 188 |
+
if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
|
| 189 |
+
// Mapping found and provider available - rewrite the model in request body
|
| 190 |
+
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
| 191 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 192 |
+
// Store mapped model in context for handlers that check it (like gemini bridge)
|
| 193 |
+
c.Set(MappedModelContextKey, mappedModel)
|
| 194 |
+
resolvedModel = mappedModel
|
| 195 |
+
usedMapping = true
|
| 196 |
+
providers = mappedProviders
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// If no mapping applied, check for local providers
|
| 200 |
+
if !usedMapping {
|
| 201 |
+
providers = util.GetProviderName(normalizedModel)
|
| 202 |
+
}
|
| 203 |
+
} else {
|
| 204 |
+
// DEFAULT MODE: Check local providers first, then mappings as fallback
|
| 205 |
+
providers = util.GetProviderName(normalizedModel)
|
| 206 |
+
|
| 207 |
+
if len(providers) == 0 {
|
| 208 |
+
// No providers configured - check if we have a model mapping
|
| 209 |
+
if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
|
| 210 |
+
// Mapping found and provider available - rewrite the model in request body
|
| 211 |
+
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
| 212 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 213 |
+
// Store mapped model in context for handlers that check it (like gemini bridge)
|
| 214 |
+
c.Set(MappedModelContextKey, mappedModel)
|
| 215 |
+
resolvedModel = mappedModel
|
| 216 |
+
usedMapping = true
|
| 217 |
+
providers = mappedProviders
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// If no providers available, fallback to ampcode.com
|
| 223 |
+
if len(providers) == 0 {
|
| 224 |
+
proxy := fh.getProxy()
|
| 225 |
+
if proxy != nil {
|
| 226 |
+
// Log: Forwarding to ampcode.com (uses Amp credits)
|
| 227 |
+
logAmpRouting(RouteTypeAmpCredits, modelName, "", "", requestPath)
|
| 228 |
+
|
| 229 |
+
// Restore body again for the proxy
|
| 230 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 231 |
+
|
| 232 |
+
// Forward to ampcode.com
|
| 233 |
+
proxy.ServeHTTP(c.Writer, c.Request)
|
| 234 |
+
return
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// No proxy available, let the normal handler return the error
|
| 238 |
+
logAmpRouting(RouteTypeNoProvider, modelName, "", "", requestPath)
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Log the routing decision
|
| 242 |
+
providerName := ""
|
| 243 |
+
if len(providers) > 0 {
|
| 244 |
+
providerName = providers[0]
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
if usedMapping {
|
| 248 |
+
// Log: Model was mapped to another model
|
| 249 |
+
log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
|
| 250 |
+
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
|
| 251 |
+
rewriter := NewResponseRewriter(c.Writer, modelName)
|
| 252 |
+
c.Writer = rewriter
|
| 253 |
+
// Filter Anthropic-Beta header only for local handling paths
|
| 254 |
+
filterAntropicBetaHeader(c)
|
| 255 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 256 |
+
handler(c)
|
| 257 |
+
rewriter.Flush()
|
| 258 |
+
log.Debugf("amp model mapping: response %s -> %s", resolvedModel, modelName)
|
| 259 |
+
} else if len(providers) > 0 {
|
| 260 |
+
// Log: Using local provider (free)
|
| 261 |
+
logAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)
|
| 262 |
+
// Filter Anthropic-Beta header only for local handling paths
|
| 263 |
+
filterAntropicBetaHeader(c)
|
| 264 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 265 |
+
handler(c)
|
| 266 |
+
} else {
|
| 267 |
+
// No provider, no mapping, no proxy: fall back to the wrapped handler so it can return an error response
|
| 268 |
+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
| 269 |
+
handler(c)
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// filterAntropicBetaHeader filters Anthropic-Beta header to remove features requiring special subscription
|
| 275 |
+
// This is needed when using local providers (bypassing the Amp proxy)
|
| 276 |
+
func filterAntropicBetaHeader(c *gin.Context) {
|
| 277 |
+
if betaHeader := c.Request.Header.Get("Anthropic-Beta"); betaHeader != "" {
|
| 278 |
+
if filtered := filterBetaFeatures(betaHeader, "context-1m-2025-08-07"); filtered != "" {
|
| 279 |
+
c.Request.Header.Set("Anthropic-Beta", filtered)
|
| 280 |
+
} else {
|
| 281 |
+
c.Request.Header.Del("Anthropic-Beta")
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// rewriteModelInRequest replaces the model name in a JSON request body
|
| 287 |
+
func rewriteModelInRequest(body []byte, newModel string) []byte {
|
| 288 |
+
if !gjson.GetBytes(body, "model").Exists() {
|
| 289 |
+
return body
|
| 290 |
+
}
|
| 291 |
+
result, err := sjson.SetBytes(body, "model", newModel)
|
| 292 |
+
if err != nil {
|
| 293 |
+
log.Warnf("amp model mapping: failed to rewrite model in request body: %v", err)
|
| 294 |
+
return body
|
| 295 |
+
}
|
| 296 |
+
return result
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// extractModelFromRequest attempts to extract the model name from various request formats
|
| 300 |
+
func extractModelFromRequest(body []byte, c *gin.Context) string {
|
| 301 |
+
// First try to parse from JSON body (OpenAI, Claude, etc.)
|
| 302 |
+
// Check common model field names
|
| 303 |
+
if result := gjson.GetBytes(body, "model"); result.Exists() && result.Type == gjson.String {
|
| 304 |
+
return result.String()
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// For Gemini requests, model is in the URL path
|
| 308 |
+
// Standard format: /models/{model}:generateContent -> :action parameter
|
| 309 |
+
if action := c.Param("action"); action != "" {
|
| 310 |
+
// Split by colon to get model name (e.g., "gemini-pro:generateContent" -> "gemini-pro")
|
| 311 |
+
parts := strings.Split(action, ":")
|
| 312 |
+
if len(parts) > 0 && parts[0] != "" {
|
| 313 |
+
return parts[0]
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
// AMP CLI format: /publishers/google/models/{model}:method -> *path parameter
|
| 318 |
+
// Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
|
| 319 |
+
if path := c.Param("path"); path != "" {
|
| 320 |
+
// Look for /models/{model}:method pattern
|
| 321 |
+
if idx := strings.Index(path, "/models/"); idx >= 0 {
|
| 322 |
+
modelPart := path[idx+8:] // Skip "/models/"
|
| 323 |
+
// Split by colon to get model name
|
| 324 |
+
if colonIdx := strings.Index(modelPart, ":"); colonIdx > 0 {
|
| 325 |
+
return modelPart[:colonIdx]
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
return ""
|
| 331 |
+
}
|
internal/api/modules/amp/fallback_handlers_test.go
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package amp
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"net/http"
|
| 7 |
+
"net/http/httptest"
|
| 8 |
+
"net/http/httputil"
|
| 9 |
+
"testing"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 13 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
|
| 17 |
+
gin.SetMode(gin.TestMode)
|
| 18 |
+
|
| 19 |
+
reg := registry.GetGlobalRegistry()
|
| 20 |
+
reg.RegisterClient("test-client-amp-fallback", "codex", []*registry.ModelInfo{
|
| 21 |
+
{ID: "test/gpt-5.2", OwnedBy: "openai", Type: "codex"},
|
| 22 |
+
})
|
| 23 |
+
defer reg.UnregisterClient("test-client-amp-fallback")
|
| 24 |
+
|
| 25 |
+
mapper := NewModelMapper([]config.AmpModelMapping{
|
| 26 |
+
{From: "gpt-5.2", To: "test/gpt-5.2"},
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy { return nil }, mapper, nil)
|
| 30 |
+
|
| 31 |
+
handler := func(c *gin.Context) {
|
| 32 |
+
var req struct {
|
| 33 |
+
Model string `json:"model"`
|
| 34 |
+
}
|
| 35 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 36 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
c.JSON(http.StatusOK, gin.H{
|
| 41 |
+
"model": req.Model,
|
| 42 |
+
"seen_model": req.Model,
|
| 43 |
+
})
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
r := gin.New()
|
| 47 |
+
r.POST("/chat/completions", fallback.WrapHandler(handler))
|
| 48 |
+
|
| 49 |
+
reqBody := []byte(`{"model":"gpt-5.2(xhigh)"}`)
|
| 50 |
+
req := httptest.NewRequest(http.MethodPost, "/chat/completions", bytes.NewReader(reqBody))
|
| 51 |
+
req.Header.Set("Content-Type", "application/json")
|
| 52 |
+
w := httptest.NewRecorder()
|
| 53 |
+
r.ServeHTTP(w, req)
|
| 54 |
+
|
| 55 |
+
if w.Code != http.StatusOK {
|
| 56 |
+
t.Fatalf("Expected status 200, got %d", w.Code)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
var resp struct {
|
| 60 |
+
Model string `json:"model"`
|
| 61 |
+
SeenModel string `json:"seen_model"`
|
| 62 |
+
}
|
| 63 |
+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
| 64 |
+
t.Fatalf("Failed to parse response JSON: %v", err)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if resp.Model != "gpt-5.2(xhigh)" {
|
| 68 |
+
t.Errorf("Expected response model gpt-5.2(xhigh), got %s", resp.Model)
|
| 69 |
+
}
|
| 70 |
+
if resp.SeenModel != "test/gpt-5.2(xhigh)" {
|
| 71 |
+
t.Errorf("Expected handler to see test/gpt-5.2(xhigh), got %s", resp.SeenModel)
|
| 72 |
+
}
|
| 73 |
+
}
|
internal/api/modules/amp/gemini_bridge.go
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package amp
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"strings"
|
| 5 |
+
|
| 6 |
+
"github.com/gin-gonic/gin"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
// createGeminiBridgeHandler creates a handler that bridges AMP CLI's non-standard Gemini paths
|
| 10 |
+
// to our standard Gemini handler by rewriting the request context.
|
| 11 |
+
//
|
| 12 |
+
// AMP CLI format: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
|
| 13 |
+
// Standard format: /models/gemini-3-pro-preview:streamGenerateContent
|
| 14 |
+
//
|
| 15 |
+
// This extracts the model+method from the AMP path and sets it as the :action parameter
|
| 16 |
+
// so the standard Gemini handler can process it.
|
| 17 |
+
//
|
| 18 |
+
// The handler parameter should be a Gemini-compatible handler that expects the :action param.
|
| 19 |
+
func createGeminiBridgeHandler(handler gin.HandlerFunc) gin.HandlerFunc {
|
| 20 |
+
return func(c *gin.Context) {
|
| 21 |
+
// Get the full path from the catch-all parameter
|
| 22 |
+
path := c.Param("path")
|
| 23 |
+
|
| 24 |
+
// Extract model:method from AMP CLI path format
|
| 25 |
+
// Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
|
| 26 |
+
const modelsPrefix = "/models/"
|
| 27 |
+
if idx := strings.Index(path, modelsPrefix); idx >= 0 {
|
| 28 |
+
// Extract everything after modelsPrefix
|
| 29 |
+
actionPart := path[idx+len(modelsPrefix):]
|
| 30 |
+
|
| 31 |
+
// Check if model was mapped by FallbackHandler
|
| 32 |
+
if mappedModel, exists := c.Get(MappedModelContextKey); exists {
|
| 33 |
+
if strModel, ok := mappedModel.(string); ok && strModel != "" {
|
| 34 |
+
// Replace the model part in the action
|
| 35 |
+
// actionPart is like "model-name:method"
|
| 36 |
+
if colonIdx := strings.Index(actionPart, ":"); colonIdx > 0 {
|
| 37 |
+
method := actionPart[colonIdx:] // ":method"
|
| 38 |
+
actionPart = strModel + method
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// Set this as the :action parameter that the Gemini handler expects
|
| 44 |
+
c.Params = append(c.Params, gin.Param{
|
| 45 |
+
Key: "action",
|
| 46 |
+
Value: actionPart,
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
// Call the handler
|
| 50 |
+
handler(c)
|
| 51 |
+
return
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// If we can't parse the path, return 400
|
| 55 |
+
c.JSON(400, gin.H{
|
| 56 |
+
"error": "Invalid Gemini API path format",
|
| 57 |
+
})
|
| 58 |
+
}
|
| 59 |
+
}
|