Commit ·
bf9e111
0
Parent(s):
Clean deploy to HF Space
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 +95 -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 +51 -0
- .goreleaser.yml +39 -0
- Dockerfile +88 -0
- Dockerfile.hf-spaces +88 -0
- LICENSE +22 -0
- README.hf-spaces.md +115 -0
- README.md +167 -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
- docker-build.ps1 +53 -0
- docker-build.sh +180 -0
- docker-compose.yml +28 -0
- docs/sdk-access.md +176 -0
- docs/sdk-access_CN.md +176 -0
- docs/sdk-advanced.md +138 -0
- docs/sdk-advanced_CN.md +131 -0
- docs/sdk-usage.md +163 -0
- docs/sdk-usage_CN.md +164 -0
- docs/sdk-watcher.md +32 -0
- docs/sdk-watcher_CN.md +32 -0
- examples/custom-provider/main.go +225 -0
- examples/http-request/main.go +140 -0
- examples/translator/main.go +42 -0
- go.mod +79 -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 +1522 -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
.dockerignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git and GitHub folders
|
| 2 |
+
.git/*
|
| 3 |
+
.github/*
|
| 4 |
+
|
| 5 |
+
# Docker and CI/CD related files
|
| 6 |
+
docker-compose.yml
|
| 7 |
+
.dockerignore
|
| 8 |
+
.gitignore
|
| 9 |
+
.goreleaser.yml
|
| 10 |
+
Dockerfile
|
| 11 |
+
|
| 12 |
+
# Documentation and license
|
| 13 |
+
docs/*
|
| 14 |
+
README.md
|
| 15 |
+
README_CN.md
|
| 16 |
+
LICENSE
|
| 17 |
+
|
| 18 |
+
# Runtime data folders (should be mounted as volumes)
|
| 19 |
+
auths/*
|
| 20 |
+
logs/*
|
| 21 |
+
conv/*
|
| 22 |
+
config.yaml
|
| 23 |
+
|
| 24 |
+
# Development/editor
|
| 25 |
+
bin/*
|
| 26 |
+
.vscode/*
|
| 27 |
+
.claude/*
|
| 28 |
+
.codex/*
|
| 29 |
+
.gemini/*
|
| 30 |
+
.serena/*
|
| 31 |
+
.agent/*
|
| 32 |
+
.agents/*
|
| 33 |
+
.opencode/*
|
| 34 |
+
.bmad/*
|
| 35 |
+
_bmad/*
|
| 36 |
+
_bmad-output/*
|
.env.example
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example environment configuration for CLIProxyAPI.
|
| 2 |
+
# Copy this file to `.env` and uncomment the variables you need.
|
| 3 |
+
#
|
| 4 |
+
# NOTE: Environment variables are only required when using remote storage options.
|
| 5 |
+
# For local file-based storage (default), no environment variables need to be set.
|
| 6 |
+
|
| 7 |
+
# ------------------------------------------------------------------------------
|
| 8 |
+
# Management Web UI
|
| 9 |
+
# ------------------------------------------------------------------------------
|
| 10 |
+
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
|
| 11 |
+
|
| 12 |
+
# ------------------------------------------------------------------------------
|
| 13 |
+
# Postgres Token Store (optional)
|
| 14 |
+
# ------------------------------------------------------------------------------
|
| 15 |
+
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
|
| 16 |
+
# PGSTORE_SCHEMA=public
|
| 17 |
+
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
|
| 18 |
+
|
| 19 |
+
# ------------------------------------------------------------------------------
|
| 20 |
+
# Git-Backed Config Store (optional)
|
| 21 |
+
# ------------------------------------------------------------------------------
|
| 22 |
+
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
|
| 23 |
+
# GITSTORE_GIT_USERNAME=git-user
|
| 24 |
+
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
|
| 25 |
+
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
|
| 26 |
+
|
| 27 |
+
# ------------------------------------------------------------------------------
|
| 28 |
+
# Object Store Token Store (optional)
|
| 29 |
+
# ------------------------------------------------------------------------------
|
| 30 |
+
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
|
| 31 |
+
# OBJECTSTORE_BUCKET=cli-proxy-config
|
| 32 |
+
# OBJECTSTORE_ACCESS_KEY=your_access_key
|
| 33 |
+
# OBJECTSTORE_SECRET_KEY=your_secret_key
|
| 34 |
+
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore
|
.factory/settings.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"customModels": [
|
| 3 |
+
{
|
| 4 |
+
"model": "gpt-5.1-codex-max",
|
| 5 |
+
"displayName": "gpt-5.1-codex-max",
|
| 6 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 7 |
+
"apiKey": "shin",
|
| 8 |
+
"provider": "generic-chat-completion-api"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"model": "GPT-5.1",
|
| 12 |
+
"displayName": "GPT-5.1",
|
| 13 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 14 |
+
"apiKey": "shin",
|
| 15 |
+
"provider": "generic-chat-completion-api"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"model": "gpt-5.2-codex",
|
| 19 |
+
"displayName": "gpt-5.2-codex",
|
| 20 |
+
"baseUrl": "https://shinmen07.up.railway.app/v1",
|
| 21 |
+
"apiKey": "shin",
|
| 22 |
+
"provider": "generic-chat-completion-api"
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"model": "gpt-5.2",
|
| 26 |
+
"displayName": "gpt-5.2",
|
| 27 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 28 |
+
"apiKey": "shin",
|
| 29 |
+
"provider": "generic-chat-completion-api"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"model": "gpt-5.1-codex",
|
| 33 |
+
"displayName": "gpt-5.1-codex",
|
| 34 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 35 |
+
"apiKey": "shin",
|
| 36 |
+
"provider": "generic-chat-completion-api"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"model": "gemini-claude-sonnet-4-5",
|
| 40 |
+
"displayName": "gemini-claude-sonnet-4-5",
|
| 41 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 42 |
+
"apiKey": "shin",
|
| 43 |
+
"provider": "generic-chat-completion-api"
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"model": "gemini-claude-opus-4-5-thinking",
|
| 47 |
+
"displayName": "gemini-claude-opus-4-5-thinking",
|
| 48 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 49 |
+
"apiKey": "shin",
|
| 50 |
+
"provider": "generic-chat-completion-api"
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"model": "gemini-3-pro-preview",
|
| 54 |
+
"displayName": "gemini-3-pro-preview",
|
| 55 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 56 |
+
"apiKey": "shin",
|
| 57 |
+
"provider": "generic-chat-completion-api"
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"model": "z-ai/glm4.7",
|
| 61 |
+
"displayName": "z-ai/glm4.7",
|
| 62 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 63 |
+
"apiKey": "shin",
|
| 64 |
+
"provider": "generic-chat-completion-api"
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"model": "glm-4.7",
|
| 68 |
+
"displayName": "glm-4.7",
|
| 69 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 70 |
+
"apiKey": "shin",
|
| 71 |
+
"provider": "generic-chat-completion-api"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"model": "minimaxai/minimax-m2.1",
|
| 75 |
+
"displayName": "minimaxai/minimax-m2.1",
|
| 76 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 77 |
+
"apiKey": "shin",
|
| 78 |
+
"provider": "generic-chat-completion-api"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"model": "claude-haiku-4.5",
|
| 82 |
+
"displayName": "claude-haiku-4.5",
|
| 83 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 84 |
+
"apiKey": "shin",
|
| 85 |
+
"provider": "generic-chat-completion-api"
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"model": "claude-opus-4.5",
|
| 89 |
+
"displayName": "claude-opus-4.5",
|
| 90 |
+
"baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
|
| 91 |
+
"apiKey": "shin",
|
| 92 |
+
"provider": "generic-chat-completion-api"
|
| 93 |
+
}
|
| 94 |
+
]
|
| 95 |
+
}
|
.github/FUNDING.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
github: [router-for-me]
|
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Is it a request payload issue?**
|
| 11 |
+
[ ] Yes, this is a request payload issue. I am using a client/cURL to send a request payload, but I received an unexpected error.
|
| 12 |
+
[ ] No, it's another issue.
|
| 13 |
+
|
| 14 |
+
**If it's a request payload issue, you MUST know**
|
| 15 |
+
Our team doesn't have any GODs or ORACLEs or MIND READERs. Please make sure to attach the request log or curl payload.
|
| 16 |
+
|
| 17 |
+
**Describe the bug**
|
| 18 |
+
A clear and concise description of what the bug is.
|
| 19 |
+
|
| 20 |
+
**CLI Type**
|
| 21 |
+
What type of CLI account do you use? (gemini-cli, gemini, codex, claude code or openai-compatibility)
|
| 22 |
+
|
| 23 |
+
**Model Name**
|
| 24 |
+
What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
|
| 25 |
+
|
| 26 |
+
**LLM Client**
|
| 27 |
+
What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
|
| 28 |
+
|
| 29 |
+
**Request Information**
|
| 30 |
+
The best way is to paste the cURL command of the HTTP request here.
|
| 31 |
+
Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
|
| 32 |
+
|
| 33 |
+
**Expected behavior**
|
| 34 |
+
A clear and concise description of what you expected to happen.
|
| 35 |
+
|
| 36 |
+
**Screenshots**
|
| 37 |
+
If applicable, add screenshots to help explain your problem.
|
| 38 |
+
|
| 39 |
+
**OS Type**
|
| 40 |
+
- OS: [e.g. macOS]
|
| 41 |
+
- Version [e.g. 15.6.0]
|
| 42 |
+
|
| 43 |
+
**Additional context**
|
| 44 |
+
Add any other context about the problem here.
|
.github/workflows/docker-image.yml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: docker-image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- v*
|
| 7 |
+
|
| 8 |
+
env:
|
| 9 |
+
APP_NAME: CLIProxyAPI
|
| 10 |
+
DOCKERHUB_REPO: eceasy/cli-proxy-api
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
docker:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- name: Checkout
|
| 17 |
+
uses: actions/checkout@v4
|
| 18 |
+
- name: Set up QEMU
|
| 19 |
+
uses: docker/setup-qemu-action@v3
|
| 20 |
+
- name: Set up Docker Buildx
|
| 21 |
+
uses: docker/setup-buildx-action@v3
|
| 22 |
+
- name: Login to DockerHub
|
| 23 |
+
uses: docker/login-action@v3
|
| 24 |
+
with:
|
| 25 |
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
| 26 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 27 |
+
- name: Generate Build Metadata
|
| 28 |
+
run: |
|
| 29 |
+
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
| 30 |
+
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
| 31 |
+
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
| 32 |
+
- name: Build and push
|
| 33 |
+
uses: docker/build-push-action@v6
|
| 34 |
+
with:
|
| 35 |
+
context: .
|
| 36 |
+
platforms: |
|
| 37 |
+
linux/amd64
|
| 38 |
+
linux/arm64
|
| 39 |
+
push: true
|
| 40 |
+
build-args: |
|
| 41 |
+
VERSION=${{ env.VERSION }}
|
| 42 |
+
COMMIT=${{ env.COMMIT }}
|
| 43 |
+
BUILD_DATE=${{ env.BUILD_DATE }}
|
| 44 |
+
tags: |
|
| 45 |
+
${{ env.DOCKERHUB_REPO }}:latest
|
| 46 |
+
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
|
.github/workflows/pr-path-guard.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: translator-path-guard
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
types:
|
| 6 |
+
- opened
|
| 7 |
+
- synchronize
|
| 8 |
+
- reopened
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
ensure-no-translator-changes:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
with:
|
| 16 |
+
fetch-depth: 0
|
| 17 |
+
- name: Detect internal/translator changes
|
| 18 |
+
id: changed-files
|
| 19 |
+
uses: tj-actions/changed-files@v45
|
| 20 |
+
with:
|
| 21 |
+
files: |
|
| 22 |
+
internal/translator/**
|
| 23 |
+
- name: Fail when restricted paths change
|
| 24 |
+
if: steps.changed-files.outputs.any_changed == 'true'
|
| 25 |
+
run: |
|
| 26 |
+
echo "Changes under internal/translator are not allowed in pull requests."
|
| 27 |
+
echo "You need to create an issue for our maintenance team to make the necessary changes."
|
| 28 |
+
exit 1
|
.github/workflows/pr-test-build.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: pr-test-build
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
|
| 6 |
+
permissions:
|
| 7 |
+
contents: read
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
build:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout
|
| 14 |
+
uses: actions/checkout@v4
|
| 15 |
+
- name: Set up Go
|
| 16 |
+
uses: actions/setup-go@v5
|
| 17 |
+
with:
|
| 18 |
+
go-version-file: go.mod
|
| 19 |
+
cache: true
|
| 20 |
+
- name: Build
|
| 21 |
+
run: |
|
| 22 |
+
go build -o test-output ./cmd/server
|
| 23 |
+
rm -f test-output
|
.github/workflows/release.yaml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: goreleaser
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
# run only against tags
|
| 6 |
+
tags:
|
| 7 |
+
- '*'
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: write
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
goreleaser:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- uses: actions/checkout@v4
|
| 17 |
+
with:
|
| 18 |
+
fetch-depth: 0
|
| 19 |
+
- run: git fetch --force --tags
|
| 20 |
+
- uses: actions/setup-go@v4
|
| 21 |
+
with:
|
| 22 |
+
go-version: '>=1.24.0'
|
| 23 |
+
cache: true
|
| 24 |
+
- name: Generate Build Metadata
|
| 25 |
+
run: |
|
| 26 |
+
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
| 27 |
+
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
| 28 |
+
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
| 29 |
+
- uses: goreleaser/goreleaser-action@v4
|
| 30 |
+
with:
|
| 31 |
+
distribution: goreleaser
|
| 32 |
+
version: latest
|
| 33 |
+
args: release --clean
|
| 34 |
+
env:
|
| 35 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 36 |
+
VERSION: ${{ env.VERSION }}
|
| 37 |
+
COMMIT: ${{ env.COMMIT }}
|
| 38 |
+
BUILD_DATE: ${{ env.BUILD_DATE }}
|
.gitignore
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Binaries
|
| 2 |
+
cli-proxy-api
|
| 3 |
+
*.exe
|
| 4 |
+
|
| 5 |
+
# Configuration
|
| 6 |
+
config.yaml
|
| 7 |
+
.env
|
| 8 |
+
|
| 9 |
+
# Generated content
|
| 10 |
+
bin/*
|
| 11 |
+
logs/*
|
| 12 |
+
conv/*
|
| 13 |
+
temp/*
|
| 14 |
+
refs/*
|
| 15 |
+
|
| 16 |
+
# Storage backends
|
| 17 |
+
pgstore/*
|
| 18 |
+
gitstore/*
|
| 19 |
+
objectstore/*
|
| 20 |
+
|
| 21 |
+
# Static assets
|
| 22 |
+
static/*
|
| 23 |
+
|
| 24 |
+
# Authentication data
|
| 25 |
+
auths/*
|
| 26 |
+
!auths/.gitkeep
|
| 27 |
+
|
| 28 |
+
# Documentation
|
| 29 |
+
docs/*
|
| 30 |
+
AGENTS.md
|
| 31 |
+
CLAUDE.md
|
| 32 |
+
GEMINI.md
|
| 33 |
+
|
| 34 |
+
# Tooling metadata
|
| 35 |
+
.vscode/*
|
| 36 |
+
.codex/*
|
| 37 |
+
.claude/*
|
| 38 |
+
.gemini/*
|
| 39 |
+
.serena/*
|
| 40 |
+
.agent/*
|
| 41 |
+
.agents/*
|
| 42 |
+
.agents/*
|
| 43 |
+
.opencode/*
|
| 44 |
+
.bmad/*
|
| 45 |
+
_bmad/*
|
| 46 |
+
_bmad-output/*
|
| 47 |
+
|
| 48 |
+
# macOS
|
| 49 |
+
.DS_Store
|
| 50 |
+
._*
|
| 51 |
+
shin
|
.goreleaser.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
builds:
|
| 2 |
+
- id: "cli-proxy-api"
|
| 3 |
+
env:
|
| 4 |
+
- CGO_ENABLED=0
|
| 5 |
+
goos:
|
| 6 |
+
- linux
|
| 7 |
+
- windows
|
| 8 |
+
- darwin
|
| 9 |
+
goarch:
|
| 10 |
+
- amd64
|
| 11 |
+
- arm64
|
| 12 |
+
main: ./cmd/server/
|
| 13 |
+
binary: cli-proxy-api
|
| 14 |
+
ldflags:
|
| 15 |
+
- -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
|
| 16 |
+
archives:
|
| 17 |
+
- id: "cli-proxy-api"
|
| 18 |
+
format: tar.gz
|
| 19 |
+
format_overrides:
|
| 20 |
+
- goos: windows
|
| 21 |
+
format: zip
|
| 22 |
+
files:
|
| 23 |
+
- LICENSE
|
| 24 |
+
- README.md
|
| 25 |
+
- README_CN.md
|
| 26 |
+
- config.example.yaml
|
| 27 |
+
|
| 28 |
+
checksum:
|
| 29 |
+
name_template: 'checksums.txt'
|
| 30 |
+
|
| 31 |
+
snapshot:
|
| 32 |
+
name_template: "{{ incpatch .Version }}-next"
|
| 33 |
+
|
| 34 |
+
changelog:
|
| 35 |
+
sort: asc
|
| 36 |
+
filters:
|
| 37 |
+
exclude:
|
| 38 |
+
- '^docs:'
|
| 39 |
+
- '^test:'
|
Dockerfile
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces
|
| 2 |
+
# This Dockerfile is optimized for running CLIProxyAPI with Kiro integration on HF Spaces
|
| 3 |
+
|
| 4 |
+
FROM golang:1.24-alpine AS builder
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY go.mod go.sum ./
|
| 9 |
+
|
| 10 |
+
RUN go mod download
|
| 11 |
+
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
ARG VERSION=dev
|
| 15 |
+
ARG COMMIT=none
|
| 16 |
+
ARG BUILD_DATE=unknown
|
| 17 |
+
|
| 18 |
+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
|
| 19 |
+
|
| 20 |
+
FROM python:3.11-slim
|
| 21 |
+
|
| 22 |
+
# Install necessary packages
|
| 23 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 24 |
+
tzdata \
|
| 25 |
+
ca-certificates \
|
| 26 |
+
curl \
|
| 27 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 28 |
+
|
| 29 |
+
# Create app directory
|
| 30 |
+
RUN mkdir -p /app
|
| 31 |
+
|
| 32 |
+
# Copy the Go binary from builder
|
| 33 |
+
COPY --from=builder /app/CLIProxyAPI /app/CLIProxyAPI
|
| 34 |
+
|
| 35 |
+
# Copy config files
|
| 36 |
+
COPY config.example.yaml /app/config.example.yaml
|
| 37 |
+
|
| 38 |
+
# Copy kiro-gateway for Python-based Kiro authentication utilities (optional)
|
| 39 |
+
COPY kiro-gateway/requirements.txt /app/kiro-requirements.txt
|
| 40 |
+
RUN pip install --no-cache-dir -r /app/kiro-requirements.txt 2>/dev/null || true
|
| 41 |
+
|
| 42 |
+
# Copy kiro-gateway source for reference utilities
|
| 43 |
+
COPY kiro-gateway/kiro /app/kiro/
|
| 44 |
+
|
| 45 |
+
WORKDIR /app
|
| 46 |
+
|
| 47 |
+
# HF Spaces requires port 7860
|
| 48 |
+
EXPOSE 7860
|
| 49 |
+
|
| 50 |
+
# Set environment variables for HF Spaces
|
| 51 |
+
ENV TZ=UTC
|
| 52 |
+
ENV PORT=7860
|
| 53 |
+
ENV HOST=0.0.0.0
|
| 54 |
+
|
| 55 |
+
# Create a startup script that handles HF Spaces configuration
|
| 56 |
+
RUN echo '#!/bin/sh\n\
|
| 57 |
+
set -e\n\
|
| 58 |
+
\n\
|
| 59 |
+
# Create config from environment variables if provided\n\
|
| 60 |
+
# Only KIRO_REFRESH_TOKEN is required - profile-arn is auto-fetched from token refresh\n\
|
| 61 |
+
if [ -n "$KIRO_REFRESH_TOKEN" ]; then\n\
|
| 62 |
+
cat > /app/config.yaml << EOF\n\
|
| 63 |
+
host: "0.0.0.0"\n\
|
| 64 |
+
port: 7860\n\
|
| 65 |
+
auth-dir: "/app/auth"\n\
|
| 66 |
+
debug: ${DEBUG:-false}\n\
|
| 67 |
+
\n\
|
| 68 |
+
api-keys:\n\
|
| 69 |
+
- "${API_KEY:-default-key}"\n\
|
| 70 |
+
\n\
|
| 71 |
+
kiro-api-key:\n\
|
| 72 |
+
- refresh-token: "$KIRO_REFRESH_TOKEN"\n\
|
| 73 |
+
region: "${KIRO_REGION:-us-east-1}"\n\
|
| 74 |
+
EOF\n\
|
| 75 |
+
echo "Config created from environment variables"\n\
|
| 76 |
+
elif [ -f /app/config.yaml ]; then\n\
|
| 77 |
+
echo "Using existing config.yaml"\n\
|
| 78 |
+
else\n\
|
| 79 |
+
cp /app/config.example.yaml /app/config.yaml\n\
|
| 80 |
+
echo "Using example config"\n\
|
| 81 |
+
fi\n\
|
| 82 |
+
\n\
|
| 83 |
+
mkdir -p /app/auth\n\
|
| 84 |
+
\n\
|
| 85 |
+
exec ./CLIProxyAPI -c /app/config.yaml\n\
|
| 86 |
+
' > /app/start.sh && chmod +x /app/start.sh
|
| 87 |
+
|
| 88 |
+
CMD ["/app/start.sh"]
|
Dockerfile.hf-spaces
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces
|
| 2 |
+
# This Dockerfile is optimized for running CLIProxyAPI with Kiro integration on HF Spaces
|
| 3 |
+
|
| 4 |
+
FROM golang:1.24-alpine AS builder
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY go.mod go.sum ./
|
| 9 |
+
|
| 10 |
+
RUN go mod download
|
| 11 |
+
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
ARG VERSION=dev
|
| 15 |
+
ARG COMMIT=none
|
| 16 |
+
ARG BUILD_DATE=unknown
|
| 17 |
+
|
| 18 |
+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
|
| 19 |
+
|
| 20 |
+
FROM python:3.11-slim
|
| 21 |
+
|
| 22 |
+
# Install necessary packages
|
| 23 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 24 |
+
tzdata \
|
| 25 |
+
ca-certificates \
|
| 26 |
+
curl \
|
| 27 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 28 |
+
|
| 29 |
+
# Create app directory
|
| 30 |
+
RUN mkdir -p /app
|
| 31 |
+
|
| 32 |
+
# Copy the Go binary from builder
|
| 33 |
+
COPY --from=builder /app/CLIProxyAPI /app/CLIProxyAPI
|
| 34 |
+
|
| 35 |
+
# Copy config files
|
| 36 |
+
COPY config.example.yaml /app/config.example.yaml
|
| 37 |
+
|
| 38 |
+
# Copy kiro-gateway for Python-based Kiro authentication utilities (optional)
|
| 39 |
+
COPY kiro-gateway/requirements.txt /app/kiro-requirements.txt
|
| 40 |
+
RUN pip install --no-cache-dir -r /app/kiro-requirements.txt 2>/dev/null || true
|
| 41 |
+
|
| 42 |
+
# Copy kiro-gateway source for reference utilities
|
| 43 |
+
COPY kiro-gateway/kiro /app/kiro/
|
| 44 |
+
|
| 45 |
+
WORKDIR /app
|
| 46 |
+
|
| 47 |
+
# HF Spaces requires port 7860
|
| 48 |
+
EXPOSE 7860
|
| 49 |
+
|
| 50 |
+
# Set environment variables for HF Spaces
|
| 51 |
+
ENV TZ=UTC
|
| 52 |
+
ENV PORT=7860
|
| 53 |
+
ENV HOST=0.0.0.0
|
| 54 |
+
|
| 55 |
+
# Create a startup script that handles HF Spaces configuration
|
| 56 |
+
RUN echo '#!/bin/sh\n\
|
| 57 |
+
set -e\n\
|
| 58 |
+
\n\
|
| 59 |
+
# Create config from environment variables if provided\n\
|
| 60 |
+
# Only KIRO_REFRESH_TOKEN is required - profile-arn is auto-fetched from token refresh\n\
|
| 61 |
+
if [ -n "$KIRO_REFRESH_TOKEN" ]; then\n\
|
| 62 |
+
cat > /app/config.yaml << EOF\n\
|
| 63 |
+
host: "0.0.0.0"\n\
|
| 64 |
+
port: 7860\n\
|
| 65 |
+
auth-dir: "/app/auth"\n\
|
| 66 |
+
debug: ${DEBUG:-false}\n\
|
| 67 |
+
\n\
|
| 68 |
+
api-keys:\n\
|
| 69 |
+
- "${API_KEY:-default-key}"\n\
|
| 70 |
+
\n\
|
| 71 |
+
kiro-api-key:\n\
|
| 72 |
+
- refresh-token: "$KIRO_REFRESH_TOKEN"\n\
|
| 73 |
+
region: "${KIRO_REGION:-us-east-1}"\n\
|
| 74 |
+
EOF\n\
|
| 75 |
+
echo "Config created from environment variables"\n\
|
| 76 |
+
elif [ -f /app/config.yaml ]; then\n\
|
| 77 |
+
echo "Using existing config.yaml"\n\
|
| 78 |
+
else\n\
|
| 79 |
+
cp /app/config.example.yaml /app/config.yaml\n\
|
| 80 |
+
echo "Using example config"\n\
|
| 81 |
+
fi\n\
|
| 82 |
+
\n\
|
| 83 |
+
mkdir -p /app/auth\n\
|
| 84 |
+
\n\
|
| 85 |
+
exec ./CLIProxyAPI -c /app/config.yaml\n\
|
| 86 |
+
' > /app/start.sh && chmod +x /app/start.sh
|
| 87 |
+
|
| 88 |
+
CMD ["/app/start.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025-2005.9 Luis Pater
|
| 4 |
+
Copyright (c) 2025.9-present Router-For.ME
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
README.hf-spaces.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying CLIProxyAPI on Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
This guide explains how to deploy CLIProxyAPI with Kiro integration on Hugging Face Spaces.
|
| 4 |
+
|
| 5 |
+
## Quick Start
|
| 6 |
+
|
| 7 |
+
1. Create a new Space on Hugging Face with "Docker" SDK
|
| 8 |
+
2. Set the following Secrets in your Space settings:
|
| 9 |
+
|
| 10 |
+
### Required Secrets
|
| 11 |
+
|
| 12 |
+
| Secret Name | Description |
|
| 13 |
+
|-------------|-------------|
|
| 14 |
+
| `KIRO_REFRESH_TOKEN` | Your Kiro refresh token (this is all you need!) |
|
| 15 |
+
| `API_KEY` | API key for authenticating requests to the proxy |
|
| 16 |
+
|
| 17 |
+
### Optional Secrets
|
| 18 |
+
|
| 19 |
+
| Secret Name | Default | Description |
|
| 20 |
+
|-------------|---------|-------------|
|
| 21 |
+
| `KIRO_REGION` | `us-east-1` | AWS region for Kiro API |
|
| 22 |
+
| `DEBUG` | `false` | Enable debug logging |
|
| 23 |
+
|
| 24 |
+
**Note:** `profile-arn` is automatically fetched from the token refresh API - you don't need to provide it!
|
| 25 |
+
|
| 26 |
+
## Getting Kiro Credentials
|
| 27 |
+
|
| 28 |
+
### Option 1: From Kiro Desktop App
|
| 29 |
+
|
| 30 |
+
1. Open Kiro IDE/Desktop app
|
| 31 |
+
2. Sign in to your account
|
| 32 |
+
3. Locate the credentials file (usually in `~/.kiro/` or similar)
|
| 33 |
+
4. Extract the `refreshToken` value
|
| 34 |
+
|
| 35 |
+
### Option 2: From kiro-cli
|
| 36 |
+
|
| 37 |
+
1. Install kiro-cli: `npm install -g @kiro/cli`
|
| 38 |
+
2. Login: `kiro login`
|
| 39 |
+
3. Find the database: `~/.local/share/kiro-cli/data.sqlite3`
|
| 40 |
+
4. Extract credentials using SQLite
|
| 41 |
+
|
| 42 |
+
### Option 3: Using AWS SSO
|
| 43 |
+
|
| 44 |
+
If you have AWS SSO configured for CodeWhisperer:
|
| 45 |
+
1. Get your SSO credentials
|
| 46 |
+
2. Use the AWS SSO OIDC flow for token refresh
|
| 47 |
+
|
| 48 |
+
## Dockerfile
|
| 49 |
+
|
| 50 |
+
Use `Dockerfile.hf-spaces` for HF Spaces deployment:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
# Rename for HF Spaces
|
| 54 |
+
cp Dockerfile.hf-spaces Dockerfile
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Environment Variables
|
| 58 |
+
|
| 59 |
+
The startup script will automatically create a `config.yaml` from environment variables:
|
| 60 |
+
|
| 61 |
+
```yaml
|
| 62 |
+
host: "0.0.0.0"
|
| 63 |
+
port: 7860
|
| 64 |
+
auth-dir: "/app/auth"
|
| 65 |
+
debug: false
|
| 66 |
+
|
| 67 |
+
api-keys:
|
| 68 |
+
- "your-api-key"
|
| 69 |
+
|
| 70 |
+
kiro-api-key:
|
| 71 |
+
- refresh-token: "your-kiro-refresh-token"
|
| 72 |
+
region: "us-east-1"
|
| 73 |
+
# profile-arn is auto-fetched from token refresh!
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## Usage
|
| 77 |
+
|
| 78 |
+
Once deployed, use the Space URL as your API base URL:
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
# OpenAI-compatible endpoint
|
| 82 |
+
curl https://your-username-your-space.hf.space/v1/chat/completions \
|
| 83 |
+
-H "Authorization: Bearer your-api-key" \
|
| 84 |
+
-H "Content-Type: application/json" \
|
| 85 |
+
-d '{
|
| 86 |
+
"model": "claude-sonnet-4.5",
|
| 87 |
+
"messages": [{"role": "user", "content": "Hello!"}]
|
| 88 |
+
}'
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Supported Models (via Kiro)
|
| 92 |
+
|
| 93 |
+
| Model ID | Description |
|
| 94 |
+
|----------|-------------|
|
| 95 |
+
| `auto` | Automatic model selection |
|
| 96 |
+
| `claude-sonnet-4` | Claude 4 Sonnet |
|
| 97 |
+
| `claude-sonnet-4.5` | Claude 4.5 Sonnet |
|
| 98 |
+
| `claude-haiku-4.5` | Claude 4.5 Haiku |
|
| 99 |
+
| `claude-opus-4.5` | Claude 4.5 Opus (may require paid tier) |
|
| 100 |
+
| `claude-3.7-sonnet` | Claude 3.7 Sonnet (legacy, hidden but functional) |
|
| 101 |
+
|
| 102 |
+
## Troubleshooting
|
| 103 |
+
|
| 104 |
+
### Token Refresh Errors
|
| 105 |
+
- Ensure your refresh token is valid
|
| 106 |
+
- Check that the profile ARN matches your account
|
| 107 |
+
- Verify the region is correct
|
| 108 |
+
|
| 109 |
+
### 403 Errors
|
| 110 |
+
- Token may have expired - restart the Space to trigger refresh
|
| 111 |
+
- Check if your Kiro account has access to the requested models
|
| 112 |
+
|
| 113 |
+
### Connection Issues
|
| 114 |
+
- If behind a firewall, you may need to configure proxy settings
|
| 115 |
+
- Ensure port 7860 is the exposed port (HF Spaces default)
|
README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CLIProxyAPI
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# CLI Proxy API
|
| 12 |
+
|
| 13 |
+
English | [中文](README_CN.md)
|
| 14 |
+
|
| 15 |
+
A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
|
| 16 |
+
|
| 17 |
+
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
|
| 18 |
+
|
| 19 |
+
So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
|
| 20 |
+
|
| 21 |
+
## Sponsor
|
| 22 |
+
|
| 23 |
+
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
| 24 |
+
|
| 25 |
+
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
| 26 |
+
|
| 27 |
+
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.7 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
| 28 |
+
|
| 29 |
+
Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
<table>
|
| 34 |
+
<tbody>
|
| 35 |
+
<tr>
|
| 36 |
+
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
| 37 |
+
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
|
| 38 |
+
</tr>
|
| 39 |
+
<tr>
|
| 40 |
+
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
|
| 41 |
+
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
|
| 42 |
+
</tr>
|
| 43 |
+
</tbody>
|
| 44 |
+
</table>
|
| 45 |
+
|
| 46 |
+
## Overview
|
| 47 |
+
|
| 48 |
+
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
|
| 49 |
+
- OpenAI Codex support (GPT models) via OAuth login
|
| 50 |
+
- Claude Code support via OAuth login
|
| 51 |
+
- Qwen Code support via OAuth login
|
| 52 |
+
- iFlow support via OAuth login
|
| 53 |
+
- Amp CLI and IDE extensions support with provider routing
|
| 54 |
+
- Streaming and non-streaming responses
|
| 55 |
+
- Function calling/tools support
|
| 56 |
+
- Multimodal input support (text and images)
|
| 57 |
+
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
|
| 58 |
+
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
|
| 59 |
+
- Generative Language API Key support
|
| 60 |
+
- AI Studio Build multi-account load balancing
|
| 61 |
+
- Gemini CLI multi-account load balancing
|
| 62 |
+
- Claude Code multi-account load balancing
|
| 63 |
+
- Qwen Code multi-account load balancing
|
| 64 |
+
- iFlow multi-account load balancing
|
| 65 |
+
- OpenAI Codex multi-account load balancing
|
| 66 |
+
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
| 67 |
+
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
|
| 68 |
+
|
| 69 |
+
## Getting Started
|
| 70 |
+
|
| 71 |
+
CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
|
| 72 |
+
|
| 73 |
+
## Management API
|
| 74 |
+
|
| 75 |
+
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
|
| 76 |
+
|
| 77 |
+
## Amp CLI Support
|
| 78 |
+
|
| 79 |
+
CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
|
| 80 |
+
|
| 81 |
+
- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
|
| 82 |
+
- Management proxy for OAuth authentication and account features
|
| 83 |
+
- Smart model fallback with automatic routing
|
| 84 |
+
- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
|
| 85 |
+
- Security-first design with localhost-only management endpoints
|
| 86 |
+
|
| 87 |
+
**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
|
| 88 |
+
|
| 89 |
+
## SDK Docs
|
| 90 |
+
|
| 91 |
+
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
|
| 92 |
+
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
|
| 93 |
+
- Access: [docs/sdk-access.md](docs/sdk-access.md)
|
| 94 |
+
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
|
| 95 |
+
- Custom Provider Example: `examples/custom-provider`
|
| 96 |
+
|
| 97 |
+
## Contributing
|
| 98 |
+
|
| 99 |
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
| 100 |
+
|
| 101 |
+
1. Fork the repository
|
| 102 |
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
| 103 |
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
| 104 |
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
| 105 |
+
5. Open a Pull Request
|
| 106 |
+
|
| 107 |
+
## Who is with us?
|
| 108 |
+
|
| 109 |
+
Those projects are based on CLIProxyAPI:
|
| 110 |
+
|
| 111 |
+
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
| 112 |
+
|
| 113 |
+
Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
|
| 114 |
+
|
| 115 |
+
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
| 116 |
+
|
| 117 |
+
Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
|
| 118 |
+
|
| 119 |
+
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
| 120 |
+
|
| 121 |
+
CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
|
| 122 |
+
|
| 123 |
+
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
| 124 |
+
|
| 125 |
+
Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
|
| 126 |
+
|
| 127 |
+
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
| 128 |
+
|
| 129 |
+
Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
|
| 130 |
+
|
| 131 |
+
### [CodMate](https://github.com/loocor/CodMate)
|
| 132 |
+
|
| 133 |
+
Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
|
| 134 |
+
|
| 135 |
+
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
| 136 |
+
|
| 137 |
+
Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
|
| 138 |
+
|
| 139 |
+
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
|
| 140 |
+
|
| 141 |
+
VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.
|
| 142 |
+
|
| 143 |
+
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
|
| 144 |
+
|
| 145 |
+
Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas via CLIProxyAPI. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed.
|
| 146 |
+
|
| 147 |
+
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
|
| 148 |
+
|
| 149 |
+
A lightweight web admin panel for CLIProxyAPI with health checks, resource monitoring, real-time logs, auto-update, request statistics and pricing display. Supports one-click installation and systemd service.
|
| 150 |
+
|
| 151 |
+
> [!NOTE]
|
| 152 |
+
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
| 153 |
+
|
| 154 |
+
## More choices
|
| 155 |
+
|
| 156 |
+
Those projects are ports of CLIProxyAPI or inspired by it:
|
| 157 |
+
|
| 158 |
+
### [9Router](https://github.com/decolua/9router)
|
| 159 |
+
|
| 160 |
+
A Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.
|
| 161 |
+
|
| 162 |
+
> [!NOTE]
|
| 163 |
+
> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
|
| 164 |
+
|
| 165 |
+
## License
|
| 166 |
+
|
| 167 |
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
README_CN.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLI 代理 API
|
| 2 |
+
|
| 3 |
+
[English](README.md) | 中文
|
| 4 |
+
|
| 5 |
+
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
|
| 6 |
+
|
| 7 |
+
现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
|
| 8 |
+
|
| 9 |
+
您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
|
| 10 |
+
|
| 11 |
+
## 赞助商
|
| 12 |
+
|
| 13 |
+
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
| 14 |
+
|
| 15 |
+
本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
|
| 16 |
+
|
| 17 |
+
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7,为开发者提供顶尖的编码体验。
|
| 18 |
+
|
| 19 |
+
智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
<table>
|
| 24 |
+
<tbody>
|
| 25 |
+
<tr>
|
| 26 |
+
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
| 27 |
+
<td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
|
| 28 |
+
</tr>
|
| 29 |
+
<tr>
|
| 30 |
+
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
|
| 31 |
+
<td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
|
| 32 |
+
</tr>
|
| 33 |
+
</tbody>
|
| 34 |
+
</table>
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
## 功能特性
|
| 38 |
+
|
| 39 |
+
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
| 40 |
+
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
| 41 |
+
- 新增 Claude Code 支持(OAuth 登录)
|
| 42 |
+
- 新增 Qwen Code 支持(OAuth 登录)
|
| 43 |
+
- 新增 iFlow 支持(OAuth 登录)
|
| 44 |
+
- 支持流式与非流式响应
|
| 45 |
+
- 函数调用/工具支持
|
| 46 |
+
- 多模态输入(文本、图片)
|
| 47 |
+
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
| 48 |
+
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
| 49 |
+
- 支持 Gemini AIStudio API 密钥
|
| 50 |
+
- 支持 AI Studio Build 多账户轮询
|
| 51 |
+
- 支持 Gemini CLI 多账户轮询
|
| 52 |
+
- 支持 Claude Code 多账户轮询
|
| 53 |
+
- 支持 Qwen Code 多账户轮询
|
| 54 |
+
- 支持 iFlow 多账户轮询
|
| 55 |
+
- 支持 OpenAI Codex 多账户轮询
|
| 56 |
+
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
| 57 |
+
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
|
| 58 |
+
|
| 59 |
+
## 新手入门
|
| 60 |
+
|
| 61 |
+
CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
|
| 62 |
+
|
| 63 |
+
## 管理 API 文档
|
| 64 |
+
|
| 65 |
+
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
|
| 66 |
+
|
| 67 |
+
## Amp CLI 支持
|
| 68 |
+
|
| 69 |
+
CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
|
| 70 |
+
|
| 71 |
+
- 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
|
| 72 |
+
- 管理代理,处理 OAuth 认证和账号功能
|
| 73 |
+
- 智能模型回退与自动路由
|
| 74 |
+
- 以安全为先的设计,管理端点仅限 localhost
|
| 75 |
+
|
| 76 |
+
**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
|
| 77 |
+
|
| 78 |
+
## SDK 文档
|
| 79 |
+
|
| 80 |
+
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
|
| 81 |
+
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
|
| 82 |
+
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
|
| 83 |
+
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
|
| 84 |
+
- 自定义 Provider 示例:`examples/custom-provider`
|
| 85 |
+
|
| 86 |
+
## 贡献
|
| 87 |
+
|
| 88 |
+
欢迎贡献!请随时提交 Pull Request。
|
| 89 |
+
|
| 90 |
+
1. Fork 仓库
|
| 91 |
+
2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
|
| 92 |
+
3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
|
| 93 |
+
4. 推送到分支(`git push origin feature/amazing-feature`)
|
| 94 |
+
5. 打开 Pull Request
|
| 95 |
+
|
| 96 |
+
## 谁与我们在一起?
|
| 97 |
+
|
| 98 |
+
这些项目基于 CLIProxyAPI:
|
| 99 |
+
|
| 100 |
+
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
| 101 |
+
|
| 102 |
+
一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
|
| 103 |
+
|
| 104 |
+
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
| 105 |
+
|
| 106 |
+
一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
|
| 107 |
+
|
| 108 |
+
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
| 109 |
+
|
| 110 |
+
CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
|
| 111 |
+
|
| 112 |
+
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
| 113 |
+
|
| 114 |
+
基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。
|
| 115 |
+
|
| 116 |
+
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
| 117 |
+
|
| 118 |
+
原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
|
| 119 |
+
|
| 120 |
+
### [CodMate](https://github.com/loocor/CodMate)
|
| 121 |
+
|
| 122 |
+
原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
|
| 123 |
+
|
| 124 |
+
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
| 125 |
+
|
| 126 |
+
原生 Windows CLIProxyAPI 分支,集成 TUI、系统托盘及多服务商 OAuth 认证,专为 AI 编程工具打造,无需 API 密钥。
|
| 127 |
+
|
| 128 |
+
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
|
| 129 |
+
|
| 130 |
+
一款 VSCode 扩展,提供了在 VSCode 中快速切换 Claude Code 模型的功能,内置 CLIProxyAPI 作为其后端,支持后台自动启动和关闭。
|
| 131 |
+
|
| 132 |
+
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
|
| 133 |
+
|
| 134 |
+
Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI 监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。
|
| 135 |
+
|
| 136 |
+
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
|
| 137 |
+
|
| 138 |
+
面向 CLIProxyAPI 的 Web 管理面板,提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示,支持一键安装与 systemd 服务。
|
| 139 |
+
|
| 140 |
+
> [!NOTE]
|
| 141 |
+
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
| 142 |
+
|
| 143 |
+
## 更多选择
|
| 144 |
+
|
| 145 |
+
以下项目是 CLIProxyAPI 的移植版或受其启发:
|
| 146 |
+
|
| 147 |
+
### [9Router](https://github.com/decolua/9router)
|
| 148 |
+
|
| 149 |
+
基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
|
| 150 |
+
|
| 151 |
+
> [!NOTE]
|
| 152 |
+
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
|
| 153 |
+
|
| 154 |
+
## 许可证
|
| 155 |
+
|
| 156 |
+
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
| 157 |
+
|
| 158 |
+
## 写给所有中国网友的
|
| 159 |
+
|
| 160 |
+
QQ 群:188637136
|
| 161 |
+
|
| 162 |
+
或
|
| 163 |
+
|
| 164 |
+
Telegram 群:https://t.me/CLIProxyAPI
|
assets/cubence.png
ADDED
|
assets/packycode.png
ADDED
|
auths/.gitkeep
ADDED
|
File without changes
|
cmd/server/main.go
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package main provides the entry point for the CLI Proxy API server.
|
| 2 |
+
// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
|
| 3 |
+
// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
|
| 4 |
+
package main
|
| 5 |
+
|
| 6 |
+
import (
|
| 7 |
+
"context"
|
| 8 |
+
"errors"
|
| 9 |
+
"flag"
|
| 10 |
+
"fmt"
|
| 11 |
+
"io/fs"
|
| 12 |
+
"net/url"
|
| 13 |
+
"os"
|
| 14 |
+
"path/filepath"
|
| 15 |
+
"strings"
|
| 16 |
+
"time"
|
| 17 |
+
|
| 18 |
+
"github.com/joho/godotenv"
|
| 19 |
+
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
| 20 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
| 21 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
|
| 22 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 23 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
| 24 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
| 25 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
| 26 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
|
| 27 |
+
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
| 28 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
| 29 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 30 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 31 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 32 |
+
log "github.com/sirupsen/logrus"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
var (
|
| 36 |
+
Version = "dev"
|
| 37 |
+
Commit = "none"
|
| 38 |
+
BuildDate = "unknown"
|
| 39 |
+
DefaultConfigPath = ""
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
// init initializes the shared logger setup.
|
| 43 |
+
func init() {
|
| 44 |
+
logging.SetupBaseLogger()
|
| 45 |
+
buildinfo.Version = Version
|
| 46 |
+
buildinfo.Commit = Commit
|
| 47 |
+
buildinfo.BuildDate = BuildDate
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// main is the entry point of the application.
|
| 51 |
+
// It parses command-line flags, loads configuration, and starts the appropriate
|
| 52 |
+
// service based on the provided flags (login, codex-login, or server mode).
|
| 53 |
+
func main() {
|
| 54 |
+
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
| 55 |
+
|
| 56 |
+
// Command-line flags to control the application's behavior.
|
| 57 |
+
var login bool
|
| 58 |
+
var codexLogin bool
|
| 59 |
+
var claudeLogin bool
|
| 60 |
+
var qwenLogin bool
|
| 61 |
+
var iflowLogin bool
|
| 62 |
+
var iflowCookie bool
|
| 63 |
+
var noBrowser bool
|
| 64 |
+
var oauthCallbackPort int
|
| 65 |
+
var antigravityLogin bool
|
| 66 |
+
var projectID string
|
| 67 |
+
var vertexImport string
|
| 68 |
+
var configPath string
|
| 69 |
+
var password string
|
| 70 |
+
|
| 71 |
+
// Define command-line flags for different operation modes.
|
| 72 |
+
flag.BoolVar(&login, "login", false, "Login Google Account")
|
| 73 |
+
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
| 74 |
+
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
| 75 |
+
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
|
| 76 |
+
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
| 77 |
+
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
| 78 |
+
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
| 79 |
+
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
|
| 80 |
+
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
| 81 |
+
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
| 82 |
+
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
| 83 |
+
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
| 84 |
+
flag.StringVar(&password, "password", "", "")
|
| 85 |
+
|
| 86 |
+
flag.CommandLine.Usage = func() {
|
| 87 |
+
out := flag.CommandLine.Output()
|
| 88 |
+
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
|
| 89 |
+
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
| 90 |
+
if f.Name == "password" {
|
| 91 |
+
return
|
| 92 |
+
}
|
| 93 |
+
s := fmt.Sprintf(" -%s", f.Name)
|
| 94 |
+
name, unquoteUsage := flag.UnquoteUsage(f)
|
| 95 |
+
if name != "" {
|
| 96 |
+
s += " " + name
|
| 97 |
+
}
|
| 98 |
+
if len(s) <= 4 {
|
| 99 |
+
s += " "
|
| 100 |
+
} else {
|
| 101 |
+
s += "\n "
|
| 102 |
+
}
|
| 103 |
+
if unquoteUsage != "" {
|
| 104 |
+
s += unquoteUsage
|
| 105 |
+
}
|
| 106 |
+
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
|
| 107 |
+
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
| 108 |
+
}
|
| 109 |
+
_, _ = fmt.Fprint(out, s+"\n")
|
| 110 |
+
})
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Parse the command-line flags.
|
| 114 |
+
flag.Parse()
|
| 115 |
+
|
| 116 |
+
// Core application variables.
|
| 117 |
+
var err error
|
| 118 |
+
var cfg *config.Config
|
| 119 |
+
var isCloudDeploy bool
|
| 120 |
+
var (
|
| 121 |
+
usePostgresStore bool
|
| 122 |
+
pgStoreDSN string
|
| 123 |
+
pgStoreSchema string
|
| 124 |
+
pgStoreLocalPath string
|
| 125 |
+
pgStoreInst *store.PostgresStore
|
| 126 |
+
useGitStore bool
|
| 127 |
+
gitStoreRemoteURL string
|
| 128 |
+
gitStoreUser string
|
| 129 |
+
gitStorePassword string
|
| 130 |
+
gitStoreLocalPath string
|
| 131 |
+
gitStoreInst *store.GitTokenStore
|
| 132 |
+
gitStoreRoot string
|
| 133 |
+
useObjectStore bool
|
| 134 |
+
objectStoreEndpoint string
|
| 135 |
+
objectStoreAccess string
|
| 136 |
+
objectStoreSecret string
|
| 137 |
+
objectStoreBucket string
|
| 138 |
+
objectStoreLocalPath string
|
| 139 |
+
objectStoreInst *store.ObjectTokenStore
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
wd, err := os.Getwd()
|
| 143 |
+
if err != nil {
|
| 144 |
+
log.Errorf("failed to get working directory: %v", err)
|
| 145 |
+
return
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Load environment variables from .env if present.
|
| 149 |
+
if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
|
| 150 |
+
if !errors.Is(errLoad, os.ErrNotExist) {
|
| 151 |
+
log.WithError(errLoad).Warn("failed to load .env file")
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
lookupEnv := func(keys ...string) (string, bool) {
|
| 156 |
+
for _, key := range keys {
|
| 157 |
+
if value, ok := os.LookupEnv(key); ok {
|
| 158 |
+
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
| 159 |
+
return trimmed, true
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
return "", false
|
| 164 |
+
}
|
| 165 |
+
writableBase := util.WritablePath()
|
| 166 |
+
if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
|
| 167 |
+
usePostgresStore = true
|
| 168 |
+
pgStoreDSN = value
|
| 169 |
+
}
|
| 170 |
+
if usePostgresStore {
|
| 171 |
+
if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
|
| 172 |
+
pgStoreSchema = value
|
| 173 |
+
}
|
| 174 |
+
if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
|
| 175 |
+
pgStoreLocalPath = value
|
| 176 |
+
}
|
| 177 |
+
if pgStoreLocalPath == "" {
|
| 178 |
+
if writableBase != "" {
|
| 179 |
+
pgStoreLocalPath = writableBase
|
| 180 |
+
} else {
|
| 181 |
+
pgStoreLocalPath = wd
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
useGitStore = false
|
| 185 |
+
}
|
| 186 |
+
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
|
| 187 |
+
useGitStore = true
|
| 188 |
+
gitStoreRemoteURL = value
|
| 189 |
+
}
|
| 190 |
+
if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
|
| 191 |
+
gitStoreUser = value
|
| 192 |
+
}
|
| 193 |
+
if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
|
| 194 |
+
gitStorePassword = value
|
| 195 |
+
}
|
| 196 |
+
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
|
| 197 |
+
gitStoreLocalPath = value
|
| 198 |
+
}
|
| 199 |
+
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
|
| 200 |
+
useObjectStore = true
|
| 201 |
+
objectStoreEndpoint = value
|
| 202 |
+
}
|
| 203 |
+
if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
|
| 204 |
+
objectStoreAccess = value
|
| 205 |
+
}
|
| 206 |
+
if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
|
| 207 |
+
objectStoreSecret = value
|
| 208 |
+
}
|
| 209 |
+
if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
|
| 210 |
+
objectStoreBucket = value
|
| 211 |
+
}
|
| 212 |
+
if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
|
| 213 |
+
objectStoreLocalPath = value
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Check for cloud deploy mode only on first execution
|
| 217 |
+
// Read env var name in uppercase: DEPLOY
|
| 218 |
+
deployEnv := os.Getenv("DEPLOY")
|
| 219 |
+
if deployEnv == "cloud" {
|
| 220 |
+
isCloudDeploy = true
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// Determine and load the configuration file.
|
| 224 |
+
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
|
| 225 |
+
var configFilePath string
|
| 226 |
+
if usePostgresStore {
|
| 227 |
+
if pgStoreLocalPath == "" {
|
| 228 |
+
pgStoreLocalPath = wd
|
| 229 |
+
}
|
| 230 |
+
pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
|
| 231 |
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
| 232 |
+
pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
|
| 233 |
+
DSN: pgStoreDSN,
|
| 234 |
+
Schema: pgStoreSchema,
|
| 235 |
+
SpoolDir: pgStoreLocalPath,
|
| 236 |
+
})
|
| 237 |
+
cancel()
|
| 238 |
+
if err != nil {
|
| 239 |
+
log.Errorf("failed to initialize postgres token store: %v", err)
|
| 240 |
+
return
|
| 241 |
+
}
|
| 242 |
+
examplePath := filepath.Join(wd, "config.example.yaml")
|
| 243 |
+
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
| 244 |
+
if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
| 245 |
+
cancel()
|
| 246 |
+
log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
cancel()
|
| 250 |
+
configFilePath = pgStoreInst.ConfigPath()
|
| 251 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 252 |
+
if err == nil {
|
| 253 |
+
cfg.AuthDir = pgStoreInst.AuthDir()
|
| 254 |
+
log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
|
| 255 |
+
}
|
| 256 |
+
} else if useObjectStore {
|
| 257 |
+
if objectStoreLocalPath == "" {
|
| 258 |
+
if writableBase != "" {
|
| 259 |
+
objectStoreLocalPath = writableBase
|
| 260 |
+
} else {
|
| 261 |
+
objectStoreLocalPath = wd
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
|
| 265 |
+
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
|
| 266 |
+
useSSL := true
|
| 267 |
+
if strings.Contains(resolvedEndpoint, "://") {
|
| 268 |
+
parsed, errParse := url.Parse(resolvedEndpoint)
|
| 269 |
+
if errParse != nil {
|
| 270 |
+
log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
|
| 271 |
+
return
|
| 272 |
+
}
|
| 273 |
+
switch strings.ToLower(parsed.Scheme) {
|
| 274 |
+
case "http":
|
| 275 |
+
useSSL = false
|
| 276 |
+
case "https":
|
| 277 |
+
useSSL = true
|
| 278 |
+
default:
|
| 279 |
+
log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
|
| 280 |
+
return
|
| 281 |
+
}
|
| 282 |
+
if parsed.Host == "" {
|
| 283 |
+
log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
|
| 284 |
+
return
|
| 285 |
+
}
|
| 286 |
+
resolvedEndpoint = parsed.Host
|
| 287 |
+
if parsed.Path != "" && parsed.Path != "/" {
|
| 288 |
+
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
|
| 292 |
+
objCfg := store.ObjectStoreConfig{
|
| 293 |
+
Endpoint: resolvedEndpoint,
|
| 294 |
+
Bucket: objectStoreBucket,
|
| 295 |
+
AccessKey: objectStoreAccess,
|
| 296 |
+
SecretKey: objectStoreSecret,
|
| 297 |
+
LocalRoot: objectStoreRoot,
|
| 298 |
+
UseSSL: useSSL,
|
| 299 |
+
PathStyle: true,
|
| 300 |
+
}
|
| 301 |
+
objectStoreInst, err = store.NewObjectTokenStore(objCfg)
|
| 302 |
+
if err != nil {
|
| 303 |
+
log.Errorf("failed to initialize object token store: %v", err)
|
| 304 |
+
return
|
| 305 |
+
}
|
| 306 |
+
examplePath := filepath.Join(wd, "config.example.yaml")
|
| 307 |
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
| 308 |
+
if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
| 309 |
+
cancel()
|
| 310 |
+
log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
|
| 311 |
+
return
|
| 312 |
+
}
|
| 313 |
+
cancel()
|
| 314 |
+
configFilePath = objectStoreInst.ConfigPath()
|
| 315 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 316 |
+
if err == nil {
|
| 317 |
+
if cfg == nil {
|
| 318 |
+
cfg = &config.Config{}
|
| 319 |
+
}
|
| 320 |
+
cfg.AuthDir = objectStoreInst.AuthDir()
|
| 321 |
+
log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
|
| 322 |
+
}
|
| 323 |
+
} else if useGitStore {
|
| 324 |
+
if gitStoreLocalPath == "" {
|
| 325 |
+
if writableBase != "" {
|
| 326 |
+
gitStoreLocalPath = writableBase
|
| 327 |
+
} else {
|
| 328 |
+
gitStoreLocalPath = wd
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
|
| 332 |
+
authDir := filepath.Join(gitStoreRoot, "auths")
|
| 333 |
+
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
|
| 334 |
+
gitStoreInst.SetBaseDir(authDir)
|
| 335 |
+
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
|
| 336 |
+
log.Errorf("failed to prepare git token store: %v", errRepo)
|
| 337 |
+
return
|
| 338 |
+
}
|
| 339 |
+
configFilePath = gitStoreInst.ConfigPath()
|
| 340 |
+
if configFilePath == "" {
|
| 341 |
+
configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
|
| 342 |
+
}
|
| 343 |
+
if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
|
| 344 |
+
examplePath := filepath.Join(wd, "config.example.yaml")
|
| 345 |
+
if _, errExample := os.Stat(examplePath); errExample != nil {
|
| 346 |
+
log.Errorf("failed to find template config file: %v", errExample)
|
| 347 |
+
return
|
| 348 |
+
}
|
| 349 |
+
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
|
| 350 |
+
log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
|
| 351 |
+
return
|
| 352 |
+
}
|
| 353 |
+
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
|
| 354 |
+
log.Errorf("failed to commit initial git-backed config: %v", errCommit)
|
| 355 |
+
return
|
| 356 |
+
}
|
| 357 |
+
log.Infof("git-backed config initialized from template: %s", configFilePath)
|
| 358 |
+
} else if statErr != nil {
|
| 359 |
+
log.Errorf("failed to inspect git-backed config: %v", statErr)
|
| 360 |
+
return
|
| 361 |
+
}
|
| 362 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 363 |
+
if err == nil {
|
| 364 |
+
cfg.AuthDir = gitStoreInst.AuthDir()
|
| 365 |
+
log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
|
| 366 |
+
}
|
| 367 |
+
} else if configPath != "" {
|
| 368 |
+
configFilePath = configPath
|
| 369 |
+
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
|
| 370 |
+
} else {
|
| 371 |
+
wd, err = os.Getwd()
|
| 372 |
+
if err != nil {
|
| 373 |
+
log.Errorf("failed to get working directory: %v", err)
|
| 374 |
+
return
|
| 375 |
+
}
|
| 376 |
+
configFilePath = filepath.Join(wd, "config.yaml")
|
| 377 |
+
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
| 378 |
+
}
|
| 379 |
+
if err != nil {
|
| 380 |
+
log.Errorf("failed to load config: %v", err)
|
| 381 |
+
return
|
| 382 |
+
}
|
| 383 |
+
if cfg == nil {
|
| 384 |
+
cfg = &config.Config{}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// In cloud deploy mode, check if we have a valid configuration
|
| 388 |
+
var configFileExists bool
|
| 389 |
+
if isCloudDeploy {
|
| 390 |
+
if info, errStat := os.Stat(configFilePath); errStat != nil {
|
| 391 |
+
// Don't mislead: API server will not start until configuration is provided.
|
| 392 |
+
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
|
| 393 |
+
configFileExists = false
|
| 394 |
+
} else if info.IsDir() {
|
| 395 |
+
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
|
| 396 |
+
configFileExists = false
|
| 397 |
+
} else if cfg.Port == 0 {
|
| 398 |
+
// LoadConfigOptional returns empty config when file is empty or invalid.
|
| 399 |
+
// Config file exists but is empty or invalid; treat as missing config
|
| 400 |
+
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
|
| 401 |
+
configFileExists = false
|
| 402 |
+
} else {
|
| 403 |
+
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
| 404 |
+
configFileExists = true
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
| 408 |
+
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
| 409 |
+
|
| 410 |
+
if err = logging.ConfigureLogOutput(cfg); err != nil {
|
| 411 |
+
log.Errorf("failed to configure log output: %v", err)
|
| 412 |
+
return
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
| 416 |
+
|
| 417 |
+
// Set the log level based on the configuration.
|
| 418 |
+
util.SetLogLevel(cfg)
|
| 419 |
+
|
| 420 |
+
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
| 421 |
+
log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
|
| 422 |
+
return
|
| 423 |
+
} else {
|
| 424 |
+
cfg.AuthDir = resolvedAuthDir
|
| 425 |
+
}
|
| 426 |
+
managementasset.SetCurrentConfig(cfg)
|
| 427 |
+
|
| 428 |
+
// Create login options to be used in authentication flows.
|
| 429 |
+
options := &cmd.LoginOptions{
|
| 430 |
+
NoBrowser: noBrowser,
|
| 431 |
+
CallbackPort: oauthCallbackPort,
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// Register the shared token store once so all components use the same persistence backend.
|
| 435 |
+
if usePostgresStore {
|
| 436 |
+
sdkAuth.RegisterTokenStore(pgStoreInst)
|
| 437 |
+
} else if useObjectStore {
|
| 438 |
+
sdkAuth.RegisterTokenStore(objectStoreInst)
|
| 439 |
+
} else if useGitStore {
|
| 440 |
+
sdkAuth.RegisterTokenStore(gitStoreInst)
|
| 441 |
+
} else {
|
| 442 |
+
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// Register built-in access providers before constructing services.
|
| 446 |
+
configaccess.Register()
|
| 447 |
+
|
| 448 |
+
// Handle different command modes based on the provided flags.
|
| 449 |
+
|
| 450 |
+
if vertexImport != "" {
|
| 451 |
+
// Handle Vertex service account import
|
| 452 |
+
cmd.DoVertexImport(cfg, vertexImport)
|
| 453 |
+
} else if login {
|
| 454 |
+
// Handle Google/Gemini login
|
| 455 |
+
cmd.DoLogin(cfg, projectID, options)
|
| 456 |
+
} else if antigravityLogin {
|
| 457 |
+
// Handle Antigravity login
|
| 458 |
+
cmd.DoAntigravityLogin(cfg, options)
|
| 459 |
+
} else if codexLogin {
|
| 460 |
+
// Handle Codex login
|
| 461 |
+
cmd.DoCodexLogin(cfg, options)
|
| 462 |
+
} else if claudeLogin {
|
| 463 |
+
// Handle Claude login
|
| 464 |
+
cmd.DoClaudeLogin(cfg, options)
|
| 465 |
+
} else if qwenLogin {
|
| 466 |
+
cmd.DoQwenLogin(cfg, options)
|
| 467 |
+
} else if iflowLogin {
|
| 468 |
+
cmd.DoIFlowLogin(cfg, options)
|
| 469 |
+
} else if iflowCookie {
|
| 470 |
+
cmd.DoIFlowCookieAuth(cfg, options)
|
| 471 |
+
} else {
|
| 472 |
+
// In cloud deploy mode without config file, just wait for shutdown signals
|
| 473 |
+
if isCloudDeploy && !configFileExists {
|
| 474 |
+
// No config file available, just wait for shutdown
|
| 475 |
+
cmd.WaitForCloudDeploy()
|
| 476 |
+
return
|
| 477 |
+
}
|
| 478 |
+
// Start the main proxy service
|
| 479 |
+
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
| 480 |
+
cmd.StartService(cfg, configFilePath, password)
|
| 481 |
+
}
|
| 482 |
+
}
|
docker-build.ps1
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# build.ps1 - Windows PowerShell Build Script
|
| 2 |
+
#
|
| 3 |
+
# This script automates the process of building and running the Docker container
|
| 4 |
+
# with version information dynamically injected at build time.
|
| 5 |
+
|
| 6 |
+
# Stop script execution on any error
|
| 7 |
+
$ErrorActionPreference = "Stop"
|
| 8 |
+
|
| 9 |
+
# --- Step 1: Choose Environment ---
|
| 10 |
+
Write-Host "Please select an option:"
|
| 11 |
+
Write-Host "1) Run using Pre-built Image (Recommended)"
|
| 12 |
+
Write-Host "2) Build from Source and Run (For Developers)"
|
| 13 |
+
$choice = Read-Host -Prompt "Enter choice [1-2]"
|
| 14 |
+
|
| 15 |
+
# --- Step 2: Execute based on choice ---
|
| 16 |
+
switch ($choice) {
|
| 17 |
+
"1" {
|
| 18 |
+
Write-Host "--- Running with Pre-built Image ---"
|
| 19 |
+
docker compose up -d --remove-orphans --no-build
|
| 20 |
+
Write-Host "Services are starting from remote image."
|
| 21 |
+
Write-Host "Run 'docker compose logs -f' to see the logs."
|
| 22 |
+
}
|
| 23 |
+
"2" {
|
| 24 |
+
Write-Host "--- Building from Source and Running ---"
|
| 25 |
+
|
| 26 |
+
# Get Version Information
|
| 27 |
+
$VERSION = (git describe --tags --always --dirty)
|
| 28 |
+
$COMMIT = (git rev-parse --short HEAD)
|
| 29 |
+
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
| 30 |
+
|
| 31 |
+
Write-Host "Building with the following info:"
|
| 32 |
+
Write-Host " Version: $VERSION"
|
| 33 |
+
Write-Host " Commit: $COMMIT"
|
| 34 |
+
Write-Host " Build Date: $BUILD_DATE"
|
| 35 |
+
Write-Host "----------------------------------------"
|
| 36 |
+
|
| 37 |
+
# Build and start the services with a local-only image tag
|
| 38 |
+
$env:CLI_PROXY_IMAGE = "cli-proxy-api:local"
|
| 39 |
+
|
| 40 |
+
Write-Host "Building the Docker image..."
|
| 41 |
+
docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
|
| 42 |
+
|
| 43 |
+
Write-Host "Starting the services..."
|
| 44 |
+
docker compose up -d --remove-orphans --pull never
|
| 45 |
+
|
| 46 |
+
Write-Host "Build complete. Services are starting."
|
| 47 |
+
Write-Host "Run 'docker compose logs -f' to see the logs."
|
| 48 |
+
}
|
| 49 |
+
default {
|
| 50 |
+
Write-Host "Invalid choice. Please enter 1 or 2."
|
| 51 |
+
exit 1
|
| 52 |
+
}
|
| 53 |
+
}
|
docker-build.sh
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# build.sh - Linux/macOS Build Script
|
| 4 |
+
#
|
| 5 |
+
# This script automates the process of building and running the Docker container
|
| 6 |
+
# with version information dynamically injected at build time.
|
| 7 |
+
|
| 8 |
+
# Hidden feature: Preserve usage statistics across rebuilds
|
| 9 |
+
# Usage: ./docker-build.sh --with-usage
|
| 10 |
+
# First run prompts for management API key, saved to temp/stats/.api_secret
|
| 11 |
+
|
| 12 |
+
set -euo pipefail
|
| 13 |
+
|
| 14 |
+
STATS_DIR="temp/stats"
|
| 15 |
+
STATS_FILE="${STATS_DIR}/.usage_backup.json"
|
| 16 |
+
SECRET_FILE="${STATS_DIR}/.api_secret"
|
| 17 |
+
WITH_USAGE=false
|
| 18 |
+
|
| 19 |
+
get_port() {
|
| 20 |
+
if [[ -f "config.yaml" ]]; then
|
| 21 |
+
grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/'
|
| 22 |
+
else
|
| 23 |
+
echo "8317"
|
| 24 |
+
fi
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export_stats_api_secret() {
|
| 28 |
+
if [[ -f "${SECRET_FILE}" ]]; then
|
| 29 |
+
API_SECRET=$(cat "${SECRET_FILE}")
|
| 30 |
+
else
|
| 31 |
+
if [[ ! -d "${STATS_DIR}" ]]; then
|
| 32 |
+
mkdir -p "${STATS_DIR}"
|
| 33 |
+
fi
|
| 34 |
+
echo "First time using --with-usage. Management API key required."
|
| 35 |
+
read -r -p "Enter management key: " -s API_SECRET
|
| 36 |
+
echo
|
| 37 |
+
echo "${API_SECRET}" > "${SECRET_FILE}"
|
| 38 |
+
chmod 600 "${SECRET_FILE}"
|
| 39 |
+
fi
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
check_container_running() {
|
| 43 |
+
local port
|
| 44 |
+
port=$(get_port)
|
| 45 |
+
|
| 46 |
+
if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
|
| 47 |
+
echo "Error: cli-proxy-api service is not responding at localhost:${port}"
|
| 48 |
+
echo "Please start the container first or use without --with-usage flag."
|
| 49 |
+
exit 1
|
| 50 |
+
fi
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export_stats() {
|
| 54 |
+
local port
|
| 55 |
+
port=$(get_port)
|
| 56 |
+
|
| 57 |
+
if [[ ! -d "${STATS_DIR}" ]]; then
|
| 58 |
+
mkdir -p "${STATS_DIR}"
|
| 59 |
+
fi
|
| 60 |
+
check_container_running
|
| 61 |
+
echo "Exporting usage statistics..."
|
| 62 |
+
EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \
|
| 63 |
+
"http://localhost:${port}/v0/management/usage/export")
|
| 64 |
+
HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1)
|
| 65 |
+
RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d')
|
| 66 |
+
|
| 67 |
+
if [[ "${HTTP_CODE}" != "200" ]]; then
|
| 68 |
+
echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}"
|
| 69 |
+
exit 1
|
| 70 |
+
fi
|
| 71 |
+
|
| 72 |
+
echo "${RESPONSE_BODY}" > "${STATS_FILE}"
|
| 73 |
+
echo "Statistics exported to ${STATS_FILE}"
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
import_stats() {
|
| 77 |
+
local port
|
| 78 |
+
port=$(get_port)
|
| 79 |
+
|
| 80 |
+
echo "Importing usage statistics..."
|
| 81 |
+
IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
| 82 |
+
-H "X-Management-Key: ${API_SECRET}" \
|
| 83 |
+
-H "Content-Type: application/json" \
|
| 84 |
+
-d @"${STATS_FILE}" \
|
| 85 |
+
"http://localhost:${port}/v0/management/usage/import")
|
| 86 |
+
IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1)
|
| 87 |
+
IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d')
|
| 88 |
+
|
| 89 |
+
if [[ "${IMPORT_CODE}" == "200" ]]; then
|
| 90 |
+
echo "Statistics imported successfully"
|
| 91 |
+
else
|
| 92 |
+
echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}"
|
| 93 |
+
fi
|
| 94 |
+
|
| 95 |
+
rm -f "${STATS_FILE}"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
wait_for_service() {
|
| 99 |
+
local port
|
| 100 |
+
port=$(get_port)
|
| 101 |
+
|
| 102 |
+
echo "Waiting for service to be ready..."
|
| 103 |
+
for i in {1..30}; do
|
| 104 |
+
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
|
| 105 |
+
break
|
| 106 |
+
fi
|
| 107 |
+
sleep 1
|
| 108 |
+
done
|
| 109 |
+
sleep 2
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if [[ "${1:-}" == "--with-usage" ]]; then
|
| 113 |
+
WITH_USAGE=true
|
| 114 |
+
export_stats_api_secret
|
| 115 |
+
fi
|
| 116 |
+
|
| 117 |
+
# --- Step 1: Choose Environment ---
|
| 118 |
+
echo "Please select an option:"
|
| 119 |
+
echo "1) Run using Pre-built Image (Recommended)"
|
| 120 |
+
echo "2) Build from Source and Run (For Developers)"
|
| 121 |
+
read -r -p "Enter choice [1-2]: " choice
|
| 122 |
+
|
| 123 |
+
# --- Step 2: Execute based on choice ---
|
| 124 |
+
case "$choice" in
|
| 125 |
+
1)
|
| 126 |
+
echo "--- Running with Pre-built Image ---"
|
| 127 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 128 |
+
export_stats
|
| 129 |
+
fi
|
| 130 |
+
docker compose up -d --remove-orphans --no-build
|
| 131 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 132 |
+
wait_for_service
|
| 133 |
+
import_stats
|
| 134 |
+
fi
|
| 135 |
+
echo "Services are starting from remote image."
|
| 136 |
+
echo "Run 'docker compose logs -f' to see the logs."
|
| 137 |
+
;;
|
| 138 |
+
2)
|
| 139 |
+
echo "--- Building from Source and Running ---"
|
| 140 |
+
|
| 141 |
+
# Get Version Information
|
| 142 |
+
VERSION="$(git describe --tags --always --dirty)"
|
| 143 |
+
COMMIT="$(git rev-parse --short HEAD)"
|
| 144 |
+
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
| 145 |
+
|
| 146 |
+
echo "Building with the following info:"
|
| 147 |
+
echo " Version: ${VERSION}"
|
| 148 |
+
echo " Commit: ${COMMIT}"
|
| 149 |
+
echo " Build Date: ${BUILD_DATE}"
|
| 150 |
+
echo "----------------------------------------"
|
| 151 |
+
|
| 152 |
+
# Build and start the services with a local-only image tag
|
| 153 |
+
export CLI_PROXY_IMAGE="cli-proxy-api:local"
|
| 154 |
+
|
| 155 |
+
echo "Building the Docker image..."
|
| 156 |
+
docker compose build \
|
| 157 |
+
--build-arg VERSION="${VERSION}" \
|
| 158 |
+
--build-arg COMMIT="${COMMIT}" \
|
| 159 |
+
--build-arg BUILD_DATE="${BUILD_DATE}"
|
| 160 |
+
|
| 161 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 162 |
+
export_stats
|
| 163 |
+
fi
|
| 164 |
+
|
| 165 |
+
echo "Starting the services..."
|
| 166 |
+
docker compose up -d --remove-orphans --pull never
|
| 167 |
+
|
| 168 |
+
if [[ "${WITH_USAGE}" == "true" ]]; then
|
| 169 |
+
wait_for_service
|
| 170 |
+
import_stats
|
| 171 |
+
fi
|
| 172 |
+
|
| 173 |
+
echo "Build complete. Services are starting."
|
| 174 |
+
echo "Run 'docker compose logs -f' to see the logs."
|
| 175 |
+
;;
|
| 176 |
+
*)
|
| 177 |
+
echo "Invalid choice. Please enter 1 or 2."
|
| 178 |
+
exit 1
|
| 179 |
+
;;
|
| 180 |
+
esac
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
cli-proxy-api:
|
| 3 |
+
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
|
| 4 |
+
pull_policy: always
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
args:
|
| 9 |
+
VERSION: ${VERSION:-dev}
|
| 10 |
+
COMMIT: ${COMMIT:-none}
|
| 11 |
+
BUILD_DATE: ${BUILD_DATE:-unknown}
|
| 12 |
+
container_name: cli-proxy-api
|
| 13 |
+
# env_file:
|
| 14 |
+
# - .env
|
| 15 |
+
environment:
|
| 16 |
+
DEPLOY: ${DEPLOY:-}
|
| 17 |
+
ports:
|
| 18 |
+
- "8317:8317"
|
| 19 |
+
- "8085:8085"
|
| 20 |
+
- "1455:1455"
|
| 21 |
+
- "54545:54545"
|
| 22 |
+
- "51121:51121"
|
| 23 |
+
- "11451:11451"
|
| 24 |
+
volumes:
|
| 25 |
+
- ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml
|
| 26 |
+
- ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
|
| 27 |
+
- ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
|
| 28 |
+
restart: unless-stopped
|
docs/sdk-access.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# @sdk/access SDK Reference
|
| 2 |
+
|
| 3 |
+
The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime.
|
| 4 |
+
|
| 5 |
+
## Importing
|
| 6 |
+
|
| 7 |
+
```go
|
| 8 |
+
import (
|
| 9 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 10 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 11 |
+
)
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
|
| 15 |
+
|
| 16 |
+
## Manager Lifecycle
|
| 17 |
+
|
| 18 |
+
```go
|
| 19 |
+
manager := sdkaccess.NewManager()
|
| 20 |
+
providers, err := sdkaccess.BuildProviders(cfg)
|
| 21 |
+
if err != nil {
|
| 22 |
+
return err
|
| 23 |
+
}
|
| 24 |
+
manager.SetProviders(providers)
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
* `NewManager` constructs an empty manager.
|
| 28 |
+
* `SetProviders` replaces the provider slice using a defensive copy.
|
| 29 |
+
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
|
| 30 |
+
* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider.
|
| 31 |
+
|
| 32 |
+
## Authenticating Requests
|
| 33 |
+
|
| 34 |
+
```go
|
| 35 |
+
result, err := manager.Authenticate(ctx, req)
|
| 36 |
+
switch {
|
| 37 |
+
case err == nil:
|
| 38 |
+
// Authentication succeeded; result describes the provider and principal.
|
| 39 |
+
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
| 40 |
+
// No recognizable credentials were supplied.
|
| 41 |
+
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
| 42 |
+
// Supplied credentials were present but rejected.
|
| 43 |
+
default:
|
| 44 |
+
// Transport-level failure was returned by a provider.
|
| 45 |
+
}
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting.
|
| 49 |
+
|
| 50 |
+
If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors.
|
| 51 |
+
|
| 52 |
+
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
|
| 53 |
+
|
| 54 |
+
## Configuration Layout
|
| 55 |
+
|
| 56 |
+
The manager expects access providers under the `auth.providers` key inside `config.yaml`:
|
| 57 |
+
|
| 58 |
+
```yaml
|
| 59 |
+
auth:
|
| 60 |
+
providers:
|
| 61 |
+
- name: inline-api
|
| 62 |
+
type: config-api-key
|
| 63 |
+
api-keys:
|
| 64 |
+
- sk-test-123
|
| 65 |
+
- sk-prod-456
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options.
|
| 69 |
+
|
| 70 |
+
### Loading providers from external SDK modules
|
| 71 |
+
|
| 72 |
+
To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect:
|
| 73 |
+
|
| 74 |
+
```yaml
|
| 75 |
+
auth:
|
| 76 |
+
providers:
|
| 77 |
+
- name: partner-auth
|
| 78 |
+
type: partner-token
|
| 79 |
+
sdk: github.com/acme/xplatform/sdk/access/providers/partner
|
| 80 |
+
config:
|
| 81 |
+
region: us-west-2
|
| 82 |
+
audience: cli-proxy
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
```go
|
| 86 |
+
import (
|
| 87 |
+
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
|
| 88 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 89 |
+
)
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
|
| 93 |
+
|
| 94 |
+
## Built-in Providers
|
| 95 |
+
|
| 96 |
+
The SDK ships with one provider out of the box:
|
| 97 |
+
|
| 98 |
+
- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found.
|
| 99 |
+
|
| 100 |
+
Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
|
| 101 |
+
|
| 102 |
+
### Metadata and auditing
|
| 103 |
+
|
| 104 |
+
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing.
|
| 105 |
+
|
| 106 |
+
## Writing Custom Providers
|
| 107 |
+
|
| 108 |
+
```go
|
| 109 |
+
type customProvider struct{}
|
| 110 |
+
|
| 111 |
+
func (p *customProvider) Identifier() string { return "my-provider" }
|
| 112 |
+
|
| 113 |
+
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
| 114 |
+
token := r.Header.Get("X-Custom")
|
| 115 |
+
if token == "" {
|
| 116 |
+
return nil, sdkaccess.ErrNoCredentials
|
| 117 |
+
}
|
| 118 |
+
if token != "expected" {
|
| 119 |
+
return nil, sdkaccess.ErrInvalidCredential
|
| 120 |
+
}
|
| 121 |
+
return &sdkaccess.Result{
|
| 122 |
+
Provider: p.Identifier(),
|
| 123 |
+
Principal: "service-user",
|
| 124 |
+
Metadata: map[string]string{"source": "x-custom"},
|
| 125 |
+
}, nil
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
func init() {
|
| 129 |
+
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
| 130 |
+
return &customProvider{}, nil
|
| 131 |
+
})
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs.
|
| 136 |
+
|
| 137 |
+
## Error Semantics
|
| 138 |
+
|
| 139 |
+
- `ErrNoCredentials`: no credentials were present or recognized by any provider.
|
| 140 |
+
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
|
| 141 |
+
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
|
| 142 |
+
|
| 143 |
+
Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked.
|
| 144 |
+
|
| 145 |
+
## Integration with cliproxy Service
|
| 146 |
+
|
| 147 |
+
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers:
|
| 148 |
+
|
| 149 |
+
```go
|
| 150 |
+
coreCfg, _ := config.LoadConfig("config.yaml")
|
| 151 |
+
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
| 152 |
+
manager := sdkaccess.NewManager()
|
| 153 |
+
manager.SetProviders(providers)
|
| 154 |
+
|
| 155 |
+
svc, _ := cliproxy.NewBuilder().
|
| 156 |
+
WithConfig(coreCfg).
|
| 157 |
+
WithAccessManager(manager).
|
| 158 |
+
Build()
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary.
|
| 162 |
+
|
| 163 |
+
### Hot reloading providers
|
| 164 |
+
|
| 165 |
+
When configuration changes, rebuild providers and swap them into the manager:
|
| 166 |
+
|
| 167 |
+
```go
|
| 168 |
+
providers, err := sdkaccess.BuildProviders(newCfg)
|
| 169 |
+
if err != nil {
|
| 170 |
+
log.Errorf("reload auth providers failed: %v", err)
|
| 171 |
+
return
|
| 172 |
+
}
|
| 173 |
+
accessManager.SetProviders(providers)
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process.
|
docs/sdk-access_CN.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# @sdk/access 开发指引
|
| 2 |
+
|
| 3 |
+
`github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。
|
| 4 |
+
|
| 5 |
+
## 引用方式
|
| 6 |
+
|
| 7 |
+
```go
|
| 8 |
+
import (
|
| 9 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 10 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 11 |
+
)
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
|
| 15 |
+
|
| 16 |
+
## 管理器生命周期
|
| 17 |
+
|
| 18 |
+
```go
|
| 19 |
+
manager := sdkaccess.NewManager()
|
| 20 |
+
providers, err := sdkaccess.BuildProviders(cfg)
|
| 21 |
+
if err != nil {
|
| 22 |
+
return err
|
| 23 |
+
}
|
| 24 |
+
manager.SetProviders(providers)
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
- `NewManager` 创建空管理器。
|
| 28 |
+
- `SetProviders` 替换提供者切片并做防御性拷贝。
|
| 29 |
+
- `Providers` 返回适合并发读取的快照。
|
| 30 |
+
- `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
|
| 31 |
+
|
| 32 |
+
## 认证请求
|
| 33 |
+
|
| 34 |
+
```go
|
| 35 |
+
result, err := manager.Authenticate(ctx, req)
|
| 36 |
+
switch {
|
| 37 |
+
case err == nil:
|
| 38 |
+
// Authentication succeeded; result carries provider and principal.
|
| 39 |
+
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
| 40 |
+
// No recognizable credentials were supplied.
|
| 41 |
+
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
| 42 |
+
// Credentials were present but rejected.
|
| 43 |
+
default:
|
| 44 |
+
// Provider surfaced a transport-level failure.
|
| 45 |
+
}
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。
|
| 49 |
+
|
| 50 |
+
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
|
| 51 |
+
|
| 52 |
+
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
|
| 53 |
+
|
| 54 |
+
## 配置结构
|
| 55 |
+
|
| 56 |
+
在 `config.yaml` 的 `auth.providers` 下定义访问提供者:
|
| 57 |
+
|
| 58 |
+
```yaml
|
| 59 |
+
auth:
|
| 60 |
+
providers:
|
| 61 |
+
- name: inline-api
|
| 62 |
+
type: config-api-key
|
| 63 |
+
api-keys:
|
| 64 |
+
- sk-test-123
|
| 65 |
+
- sk-prod-456
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
|
| 69 |
+
|
| 70 |
+
### 引入外部 SDK 提供者
|
| 71 |
+
|
| 72 |
+
若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
|
| 73 |
+
|
| 74 |
+
```yaml
|
| 75 |
+
auth:
|
| 76 |
+
providers:
|
| 77 |
+
- name: partner-auth
|
| 78 |
+
type: partner-token
|
| 79 |
+
sdk: github.com/acme/xplatform/sdk/access/providers/partner
|
| 80 |
+
config:
|
| 81 |
+
region: us-west-2
|
| 82 |
+
audience: cli-proxy
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
```go
|
| 86 |
+
import (
|
| 87 |
+
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
|
| 88 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 89 |
+
)
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。
|
| 93 |
+
|
| 94 |
+
## 内建提供者
|
| 95 |
+
|
| 96 |
+
当前 SDK 默认内置:
|
| 97 |
+
|
| 98 |
+
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。
|
| 99 |
+
|
| 100 |
+
导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
|
| 101 |
+
|
| 102 |
+
### 元数据与审计
|
| 103 |
+
|
| 104 |
+
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key` 或 `query-key`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。
|
| 105 |
+
|
| 106 |
+
## 编写自定义提供者
|
| 107 |
+
|
| 108 |
+
```go
|
| 109 |
+
type customProvider struct{}
|
| 110 |
+
|
| 111 |
+
func (p *customProvider) Identifier() string { return "my-provider" }
|
| 112 |
+
|
| 113 |
+
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
| 114 |
+
token := r.Header.Get("X-Custom")
|
| 115 |
+
if token == "" {
|
| 116 |
+
return nil, sdkaccess.ErrNoCredentials
|
| 117 |
+
}
|
| 118 |
+
if token != "expected" {
|
| 119 |
+
return nil, sdkaccess.ErrInvalidCredential
|
| 120 |
+
}
|
| 121 |
+
return &sdkaccess.Result{
|
| 122 |
+
Provider: p.Identifier(),
|
| 123 |
+
Principal: "service-user",
|
| 124 |
+
Metadata: map[string]string{"source": "x-custom"},
|
| 125 |
+
}, nil
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
func init() {
|
| 129 |
+
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
| 130 |
+
return &customProvider{}, nil
|
| 131 |
+
})
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。
|
| 136 |
+
|
| 137 |
+
## 错误语义
|
| 138 |
+
|
| 139 |
+
- `ErrNoCredentials`:任何提供者都未识别到凭证。
|
| 140 |
+
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
|
| 141 |
+
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。
|
| 142 |
+
|
| 143 |
+
自定义错误(例如网络异常)会马上冒泡返回。
|
| 144 |
+
|
| 145 |
+
## 与 cliproxy 集成
|
| 146 |
+
|
| 147 |
+
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
|
| 148 |
+
|
| 149 |
+
```go
|
| 150 |
+
coreCfg, _ := config.LoadConfig("config.yaml")
|
| 151 |
+
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
| 152 |
+
manager := sdkaccess.NewManager()
|
| 153 |
+
manager.SetProviders(providers)
|
| 154 |
+
|
| 155 |
+
svc, _ := cliproxy.NewBuilder().
|
| 156 |
+
WithConfig(coreCfg).
|
| 157 |
+
WithAccessManager(manager).
|
| 158 |
+
Build()
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。
|
| 162 |
+
|
| 163 |
+
### 动态热更新提供者
|
| 164 |
+
|
| 165 |
+
当配置发生变化时,可以重新构建提供者并替换当前列表:
|
| 166 |
+
|
| 167 |
+
```go
|
| 168 |
+
providers, err := sdkaccess.BuildProviders(newCfg)
|
| 169 |
+
if err != nil {
|
| 170 |
+
log.Errorf("reload auth providers failed: %v", err)
|
| 171 |
+
return
|
| 172 |
+
}
|
| 173 |
+
accessManager.SetProviders(providers)
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。
|
docs/sdk-advanced.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SDK Advanced: Executors & Translators
|
| 2 |
+
|
| 3 |
+
This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
|
| 4 |
+
- Implement a provider executor that talks to your upstream API
|
| 5 |
+
- Register request/response translators for schema conversion
|
| 6 |
+
- Register models so they appear in `/v1/models`
|
| 7 |
+
|
| 8 |
+
The examples use Go 1.24+ and the v6 module path.
|
| 9 |
+
|
| 10 |
+
## Concepts
|
| 11 |
+
|
| 12 |
+
- Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests.
|
| 13 |
+
- Translator registry: schema conversion functions routed by `sdk/translator`. The built‑in handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones.
|
| 14 |
+
- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
|
| 15 |
+
|
| 16 |
+
## 1) Implement a Provider Executor
|
| 17 |
+
|
| 18 |
+
Create a type that satisfies `auth.ProviderExecutor`.
|
| 19 |
+
|
| 20 |
+
```go
|
| 21 |
+
package myprov
|
| 22 |
+
|
| 23 |
+
import (
|
| 24 |
+
"context"
|
| 25 |
+
"net/http"
|
| 26 |
+
|
| 27 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 28 |
+
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
type Executor struct{}
|
| 32 |
+
|
| 33 |
+
func (Executor) Identifier() string { return "myprov" }
|
| 34 |
+
|
| 35 |
+
// Optional: mutate outbound HTTP requests with credentials
|
| 36 |
+
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
| 37 |
+
// Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
| 38 |
+
return nil
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
| 42 |
+
// Build HTTP request based on req.Payload (already translated into provider format)
|
| 43 |
+
// Use per‑auth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
|
| 44 |
+
// Perform call and return provider JSON payload
|
| 45 |
+
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
| 49 |
+
ch := make(chan clipexec.StreamChunk, 1)
|
| 50 |
+
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
|
| 51 |
+
return ch, nil
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
| 55 |
+
// Optionally refresh tokens and return updated auth
|
| 56 |
+
return a, nil
|
| 57 |
+
}
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Register the executor with the core manager before starting the service:
|
| 61 |
+
|
| 62 |
+
```go
|
| 63 |
+
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
| 64 |
+
core.RegisterExecutor(myprov.Executor{})
|
| 65 |
+
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
|
| 69 |
+
|
| 70 |
+
## 2) Register Translators
|
| 71 |
+
|
| 72 |
+
The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`’s default registry.
|
| 73 |
+
|
| 74 |
+
Direction matters:
|
| 75 |
+
- Request: register from inbound schema to provider schema
|
| 76 |
+
- Response: register from provider schema back to inbound schema
|
| 77 |
+
|
| 78 |
+
Example: Convert OpenAI Chat → MyProv Chat and back.
|
| 79 |
+
|
| 80 |
+
```go
|
| 81 |
+
package myprov
|
| 82 |
+
|
| 83 |
+
import (
|
| 84 |
+
"context"
|
| 85 |
+
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
const (
|
| 89 |
+
FOpenAI = sdktr.Format("openai.chat")
|
| 90 |
+
FMyProv = sdktr.Format("myprov.chat")
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
func init() {
|
| 94 |
+
sdktr.Register(FOpenAI, FMyProv,
|
| 95 |
+
// Request transform (model, rawJSON, stream)
|
| 96 |
+
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
| 97 |
+
// Response transform (stream & non‑stream)
|
| 98 |
+
sdktr.ResponseTransform{
|
| 99 |
+
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
| 100 |
+
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
| 101 |
+
},
|
| 102 |
+
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
| 103 |
+
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
| 104 |
+
},
|
| 105 |
+
},
|
| 106 |
+
)
|
| 107 |
+
}
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
|
| 111 |
+
|
| 112 |
+
## 3) Register Models
|
| 113 |
+
|
| 114 |
+
Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
|
| 115 |
+
|
| 116 |
+
```go
|
| 117 |
+
models := []*cliproxy.ModelInfo{
|
| 118 |
+
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
| 119 |
+
}
|
| 120 |
+
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
The embedded server calls this automatically for built‑in providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks.
|
| 124 |
+
|
| 125 |
+
## Credentials & Transports
|
| 126 |
+
|
| 127 |
+
- Use `Manager.SetRoundTripperProvider` to inject per‑auth `*http.Transport` (e.g., proxy):
|
| 128 |
+
```go
|
| 129 |
+
core.SetRoundTripperProvider(myProvider) // returns transport per auth
|
| 130 |
+
```
|
| 131 |
+
- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
|
| 132 |
+
|
| 133 |
+
## Testing Tips
|
| 134 |
+
|
| 135 |
+
- Enable request logging: Management API GET/PUT `/v0/management/request-log`
|
| 136 |
+
- Toggle debug logs: Management API GET/PUT `/v0/management/debug`
|
| 137 |
+
- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher
|
| 138 |
+
|
docs/sdk-advanced_CN.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SDK 高级指南:执行器与翻译器
|
| 2 |
+
|
| 3 |
+
本文介绍如何使用 SDK 扩展内嵌代理:
|
| 4 |
+
- 实现自定义 Provider 执行器以调用你的上游 API
|
| 5 |
+
- 注册请求/响应翻译器进行协议转换
|
| 6 |
+
- 注册模型以出现在 `/v1/models`
|
| 7 |
+
|
| 8 |
+
示例基于 Go 1.24+ 与 v6 模块路径。
|
| 9 |
+
|
| 10 |
+
## 概念
|
| 11 |
+
|
| 12 |
+
- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key(如 `gemini`、`claude`、`codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
|
| 13 |
+
- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
|
| 14 |
+
- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
|
| 15 |
+
|
| 16 |
+
## 1) 实现 Provider 执行器
|
| 17 |
+
|
| 18 |
+
创建类型满足 `auth.ProviderExecutor` 接口。
|
| 19 |
+
|
| 20 |
+
```go
|
| 21 |
+
package myprov
|
| 22 |
+
|
| 23 |
+
import (
|
| 24 |
+
"context"
|
| 25 |
+
"net/http"
|
| 26 |
+
|
| 27 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 28 |
+
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
type Executor struct{}
|
| 32 |
+
|
| 33 |
+
func (Executor) Identifier() string { return "myprov" }
|
| 34 |
+
|
| 35 |
+
// 可选:在原始 HTTP 请求上注入凭据
|
| 36 |
+
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
| 37 |
+
// 例如:req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
|
| 38 |
+
return nil
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
| 42 |
+
// 基于 req.Payload 构造上游请求,返回上游 JSON 负载
|
| 43 |
+
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
| 47 |
+
ch := make(chan clipexec.StreamChunk, 1)
|
| 48 |
+
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
|
| 49 |
+
return ch, nil
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
在启动服务前将执行器注册到核心管理器:
|
| 56 |
+
|
| 57 |
+
```go
|
| 58 |
+
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
| 59 |
+
core.RegisterExecutor(myprov.Executor{})
|
| 60 |
+
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
当凭据的 `Provider` 为 `"myprov"` 时,管理器会将请求路由到你的执行器。
|
| 64 |
+
|
| 65 |
+
## 2) 注册翻译器
|
| 66 |
+
|
| 67 |
+
内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
|
| 68 |
+
|
| 69 |
+
方向很重要:
|
| 70 |
+
- 请求:从“入站格式”转换为“provider 格式”
|
| 71 |
+
- 响应:从“provider 格式”转换回“入站格式”
|
| 72 |
+
|
| 73 |
+
示例:OpenAI Chat → MyProv Chat 及其反向。
|
| 74 |
+
|
| 75 |
+
```go
|
| 76 |
+
package myprov
|
| 77 |
+
|
| 78 |
+
import (
|
| 79 |
+
"context"
|
| 80 |
+
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
const (
|
| 84 |
+
FOpenAI = sdktr.Format("openai.chat")
|
| 85 |
+
FMyProv = sdktr.Format("myprov.chat")
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
func init() {
|
| 89 |
+
sdktr.Register(FOpenAI, FMyProv,
|
| 90 |
+
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
| 91 |
+
sdktr.ResponseTransform{
|
| 92 |
+
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
| 93 |
+
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
| 94 |
+
},
|
| 95 |
+
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
| 96 |
+
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
| 97 |
+
},
|
| 98 |
+
},
|
| 99 |
+
)
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
|
| 104 |
+
|
| 105 |
+
## 3) 注册模型
|
| 106 |
+
|
| 107 |
+
通过全局模型注册表将模型暴露到 `/v1/models`:
|
| 108 |
+
|
| 109 |
+
```go
|
| 110 |
+
models := []*cliproxy.ModelInfo{
|
| 111 |
+
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
| 112 |
+
}
|
| 113 |
+
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
|
| 117 |
+
|
| 118 |
+
## 凭据与传输
|
| 119 |
+
|
| 120 |
+
- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
|
| 121 |
+
```go
|
| 122 |
+
core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
|
| 123 |
+
```
|
| 124 |
+
- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
|
| 125 |
+
|
| 126 |
+
## 测试建议
|
| 127 |
+
|
| 128 |
+
- 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
|
| 129 |
+
- 切换调试日志:管理 API GET/PUT `/v0/management/debug`
|
| 130 |
+
- 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用
|
| 131 |
+
|
docs/sdk-usage.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLI Proxy SDK Guide
|
| 2 |
+
|
| 3 |
+
The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hot‑reload, and translation layers without depending on the CLI binary.
|
| 4 |
+
|
| 5 |
+
## Install & Import
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
```go
|
| 12 |
+
import (
|
| 13 |
+
"context"
|
| 14 |
+
"errors"
|
| 15 |
+
"time"
|
| 16 |
+
|
| 17 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 18 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
| 19 |
+
)
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Note the `/v6` module path.
|
| 23 |
+
|
| 24 |
+
## Minimal Embed
|
| 25 |
+
|
| 26 |
+
```go
|
| 27 |
+
cfg, err := config.LoadConfig("config.yaml")
|
| 28 |
+
if err != nil { panic(err) }
|
| 29 |
+
|
| 30 |
+
svc, err := cliproxy.NewBuilder().
|
| 31 |
+
WithConfig(cfg).
|
| 32 |
+
WithConfigPath("config.yaml"). // absolute or working-dir relative
|
| 33 |
+
Build()
|
| 34 |
+
if err != nil { panic(err) }
|
| 35 |
+
|
| 36 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 37 |
+
defer cancel()
|
| 38 |
+
|
| 39 |
+
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
| 40 |
+
panic(err)
|
| 41 |
+
}
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
|
| 45 |
+
|
| 46 |
+
## Server Options (middleware, routes, logs)
|
| 47 |
+
|
| 48 |
+
The server accepts options via `WithServerOptions`:
|
| 49 |
+
|
| 50 |
+
```go
|
| 51 |
+
svc, _ := cliproxy.NewBuilder().
|
| 52 |
+
WithConfig(cfg).
|
| 53 |
+
WithConfigPath("config.yaml").
|
| 54 |
+
WithServerOptions(
|
| 55 |
+
// Add global middleware
|
| 56 |
+
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
| 57 |
+
// Tweak gin engine early (CORS, trusted proxies, etc.)
|
| 58 |
+
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
| 59 |
+
// Add your own routes after defaults
|
| 60 |
+
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
| 61 |
+
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
| 62 |
+
}),
|
| 63 |
+
// Override request log writer/dir
|
| 64 |
+
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
| 65 |
+
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
| 66 |
+
}),
|
| 67 |
+
).
|
| 68 |
+
Build()
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
These options mirror the internals used by the CLI server.
|
| 72 |
+
|
| 73 |
+
## Management API (when embedded)
|
| 74 |
+
|
| 75 |
+
- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
|
| 76 |
+
- Remote access additionally requires `remote-management.allow-remote: true`.
|
| 77 |
+
- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
|
| 78 |
+
|
| 79 |
+
## Using the Core Auth Manager
|
| 80 |
+
|
| 81 |
+
The service uses a core `auth.Manager` for selection, execution, and auto‑refresh. When embedding, you can provide your own manager to customize transports or hooks:
|
| 82 |
+
|
| 83 |
+
```go
|
| 84 |
+
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
| 85 |
+
core.SetRoundTripperProvider(myRTProvider) // per‑auth *http.Transport
|
| 86 |
+
|
| 87 |
+
svc, _ := cliproxy.NewBuilder().
|
| 88 |
+
WithConfig(cfg).
|
| 89 |
+
WithConfigPath("config.yaml").
|
| 90 |
+
WithCoreAuthManager(core).
|
| 91 |
+
Build()
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Implement a custom per‑auth transport:
|
| 95 |
+
|
| 96 |
+
```go
|
| 97 |
+
type myRTProvider struct{}
|
| 98 |
+
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
| 99 |
+
if a == nil || a.ProxyURL == "" { return nil }
|
| 100 |
+
u, _ := url.Parse(a.ProxyURL)
|
| 101 |
+
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
| 102 |
+
}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
Programmatic execution is available on the manager:
|
| 106 |
+
|
| 107 |
+
```go
|
| 108 |
+
// Non‑streaming
|
| 109 |
+
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
| 110 |
+
|
| 111 |
+
// Streaming
|
| 112 |
+
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
| 113 |
+
for ch := range chunks { /* ... */ }
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Note: Built‑in provider executors are wired automatically when you run the `Service`. If you want to use `Manager` stand‑alone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`.
|
| 117 |
+
|
| 118 |
+
## Custom Client Sources
|
| 119 |
+
|
| 120 |
+
Replace the default loaders if your creds live outside the local filesystem:
|
| 121 |
+
|
| 122 |
+
```go
|
| 123 |
+
type memoryTokenProvider struct{}
|
| 124 |
+
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
| 125 |
+
// Populate from memory/remote store and return counts
|
| 126 |
+
return &cliproxy.TokenClientResult{}, nil
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
svc, _ := cliproxy.NewBuilder().
|
| 130 |
+
WithConfig(cfg).
|
| 131 |
+
WithConfigPath("config.yaml").
|
| 132 |
+
WithTokenClientProvider(&memoryTokenProvider{}).
|
| 133 |
+
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
| 134 |
+
Build()
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Hooks
|
| 138 |
+
|
| 139 |
+
Observe lifecycle without patching internals:
|
| 140 |
+
|
| 141 |
+
```go
|
| 142 |
+
hooks := cliproxy.Hooks{
|
| 143 |
+
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
| 144 |
+
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
| 145 |
+
}
|
| 146 |
+
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
## Shutdown
|
| 150 |
+
|
| 151 |
+
`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
|
| 152 |
+
|
| 153 |
+
```go
|
| 154 |
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
| 155 |
+
defer cancel()
|
| 156 |
+
_ = svc.Shutdown(ctx)
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
## Notes
|
| 160 |
+
|
| 161 |
+
- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
|
| 162 |
+
- Request logging can be toggled at runtime via the Management API.
|
| 163 |
+
- Gemini Web features (`gemini-web.*`) are honored in the embedded server.
|
docs/sdk-usage_CN.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLI Proxy SDK 使用指南
|
| 2 |
+
|
| 3 |
+
`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
|
| 4 |
+
|
| 5 |
+
## 安装与导入
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
```go
|
| 12 |
+
import (
|
| 13 |
+
"context"
|
| 14 |
+
"errors"
|
| 15 |
+
"time"
|
| 16 |
+
|
| 17 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 18 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
| 19 |
+
)
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
注意模块路径包含 `/v6`。
|
| 23 |
+
|
| 24 |
+
## 最小可用示例
|
| 25 |
+
|
| 26 |
+
```go
|
| 27 |
+
cfg, err := config.LoadConfig("config.yaml")
|
| 28 |
+
if err != nil { panic(err) }
|
| 29 |
+
|
| 30 |
+
svc, err := cliproxy.NewBuilder().
|
| 31 |
+
WithConfig(cfg).
|
| 32 |
+
WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
|
| 33 |
+
Build()
|
| 34 |
+
if err != nil { panic(err) }
|
| 35 |
+
|
| 36 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 37 |
+
defer cancel()
|
| 38 |
+
|
| 39 |
+
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
| 40 |
+
panic(err)
|
| 41 |
+
}
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
|
| 45 |
+
|
| 46 |
+
## 服务器可选项(中间件、路由、日志)
|
| 47 |
+
|
| 48 |
+
通过 `WithServerOptions` 自定义:
|
| 49 |
+
|
| 50 |
+
```go
|
| 51 |
+
svc, _ := cliproxy.NewBuilder().
|
| 52 |
+
WithConfig(cfg).
|
| 53 |
+
WithConfigPath("config.yaml").
|
| 54 |
+
WithServerOptions(
|
| 55 |
+
// 追加全局中间件
|
| 56 |
+
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
| 57 |
+
// 提前调整 gin 引擎(如 CORS、trusted proxies)
|
| 58 |
+
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
| 59 |
+
// 在默认路由之后追加自定义路由
|
| 60 |
+
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
| 61 |
+
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
| 62 |
+
}),
|
| 63 |
+
// 覆盖请求日志的创建(启用/目录)
|
| 64 |
+
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
| 65 |
+
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
| 66 |
+
}),
|
| 67 |
+
).
|
| 68 |
+
Build()
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
这些选项与 CLI 服务器内部用法保持一致。
|
| 72 |
+
|
| 73 |
+
## 管理 API(内嵌时)
|
| 74 |
+
|
| 75 |
+
- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
|
| 76 |
+
- 远程访问还需要 `remote-management.allow-remote: true`。
|
| 77 |
+
- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`。
|
| 78 |
+
|
| 79 |
+
## 使用核心鉴权管理器
|
| 80 |
+
|
| 81 |
+
服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
|
| 82 |
+
|
| 83 |
+
```go
|
| 84 |
+
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
| 85 |
+
core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
|
| 86 |
+
|
| 87 |
+
svc, _ := cliproxy.NewBuilder().
|
| 88 |
+
WithConfig(cfg).
|
| 89 |
+
WithConfigPath("config.yaml").
|
| 90 |
+
WithCoreAuthManager(core).
|
| 91 |
+
Build()
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
实现每个账户的自定义传输:
|
| 95 |
+
|
| 96 |
+
```go
|
| 97 |
+
type myRTProvider struct{}
|
| 98 |
+
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
| 99 |
+
if a == nil || a.ProxyURL == "" { return nil }
|
| 100 |
+
u, _ := url.Parse(a.ProxyURL)
|
| 101 |
+
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
| 102 |
+
}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
管理器提供编程式执行接口:
|
| 106 |
+
|
| 107 |
+
```go
|
| 108 |
+
// 非流式
|
| 109 |
+
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
| 110 |
+
|
| 111 |
+
// 流式
|
| 112 |
+
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
| 113 |
+
for ch := range chunks { /* ... */ }
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
|
| 117 |
+
|
| 118 |
+
## 自定义凭据来源
|
| 119 |
+
|
| 120 |
+
当凭据不在本地文件系统时,替换默认加载器:
|
| 121 |
+
|
| 122 |
+
```go
|
| 123 |
+
type memoryTokenProvider struct{}
|
| 124 |
+
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
| 125 |
+
// 从内存/远端加载并返回数量统计
|
| 126 |
+
return &cliproxy.TokenClientResult{}, nil
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
svc, _ := cliproxy.NewBuilder().
|
| 130 |
+
WithConfig(cfg).
|
| 131 |
+
WithConfigPath("config.yaml").
|
| 132 |
+
WithTokenClientProvider(&memoryTokenProvider{}).
|
| 133 |
+
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
| 134 |
+
Build()
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## 启动钩子
|
| 138 |
+
|
| 139 |
+
无需修改内部代码即可观察生命周期:
|
| 140 |
+
|
| 141 |
+
```go
|
| 142 |
+
hooks := cliproxy.Hooks{
|
| 143 |
+
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
| 144 |
+
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
| 145 |
+
}
|
| 146 |
+
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
## 关闭
|
| 150 |
+
|
| 151 |
+
`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
|
| 152 |
+
|
| 153 |
+
```go
|
| 154 |
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
| 155 |
+
defer cancel()
|
| 156 |
+
_ = svc.Shutdown(ctx)
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
## 说明
|
| 160 |
+
|
| 161 |
+
- 热更新:`config.yaml` 与 `auths/` 变化会被自动侦测并应用。
|
| 162 |
+
- 请求日志可通过管理 API 在运行时开关。
|
| 163 |
+
- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。
|
| 164 |
+
|
docs/sdk-watcher.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SDK Watcher Integration
|
| 2 |
+
|
| 3 |
+
The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled.
|
| 4 |
+
|
| 5 |
+
## Update Queue Contract
|
| 6 |
+
|
| 7 |
+
- `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`.
|
| 8 |
+
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts.
|
| 9 |
+
- The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop.
|
| 10 |
+
|
| 11 |
+
## Watcher Behaviour
|
| 12 |
+
|
| 13 |
+
- `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals.
|
| 14 |
+
- Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream.
|
| 15 |
+
- The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly.
|
| 16 |
+
|
| 17 |
+
## High-Frequency Change Handling
|
| 18 |
+
|
| 19 |
+
- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
|
| 20 |
+
- Back-pressure is absorbed in two places:
|
| 21 |
+
- The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
|
| 22 |
+
- The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
|
| 23 |
+
- If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states.
|
| 24 |
+
|
| 25 |
+
## Usage Checklist
|
| 26 |
+
|
| 27 |
+
1. Instantiate the SDK service (builder or manual construction).
|
| 28 |
+
2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
|
| 29 |
+
3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
|
| 30 |
+
4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`.
|
| 31 |
+
|
| 32 |
+
Following this flow keeps auth changes responsive while avoiding full reloads for every edit.
|
docs/sdk-watcher_CN.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SDK Watcher集成说明
|
| 2 |
+
|
| 3 |
+
本文档介绍SDK服务与文件监控器之间的增量更新队列,包括接口契约、高频变更下的处理策略以及接入步骤。
|
| 4 |
+
|
| 5 |
+
## 更新队列契约
|
| 6 |
+
|
| 7 |
+
- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add`、`modify`或`delete`,`ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`。
|
| 8 |
+
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher,必须在watcher启动前完成。
|
| 9 |
+
- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道,并在`consumeAuthUpdates`中使用专职goroutine消费;消费侧会主动“抽干”积压事件,降低切换开销。
|
| 10 |
+
|
| 11 |
+
## Watcher行为
|
| 12 |
+
|
| 13 |
+
- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
|
| 14 |
+
- 以凭据ID为维度对更新进行合并,同一凭据在短时间内的多次变更只会保留最新状态(例如先写后删只会下发`delete`)。
|
| 15 |
+
- watcher内部运行异步分发循环:生产者只向内存缓冲追加事件并唤醒分发协程,即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环,确保协程正常退出。
|
| 16 |
+
|
| 17 |
+
## 高频变更处理
|
| 18 |
+
|
| 19 |
+
- 分发循环与服务消费协程相互独立,因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
|
| 20 |
+
- 背压通过两级缓冲吸收:
|
| 21 |
+
- 分发缓冲(map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
|
| 22 |
+
- 服务端通道的256容量加上消费侧的“抽干”逻辑,可平稳处理多个突发批次。
|
| 23 |
+
- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
|
| 24 |
+
|
| 25 |
+
## 接入步骤
|
| 26 |
+
|
| 27 |
+
1. 实例化SDK Service(构建器或手工创建)。
|
| 28 |
+
2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
|
| 29 |
+
3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道,然后再启动watcher。
|
| 30 |
+
4. Reload回调专注于配置更新;认证增量会通过队列送达,并由`handleAuthUpdate`自动应用。
|
| 31 |
+
|
| 32 |
+
遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。
|
examples/custom-provider/main.go
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package main demonstrates how to create a custom AI provider executor
|
| 2 |
+
// and integrate it with the CLI Proxy API server. This example shows how to:
|
| 3 |
+
// - Create a custom executor that implements the Executor interface
|
| 4 |
+
// - Register custom translators for request/response transformation
|
| 5 |
+
// - Integrate the custom provider with the SDK server
|
| 6 |
+
// - Register custom models in the model registry
|
| 7 |
+
//
|
| 8 |
+
// This example uses a simple echo service (httpbin.org) as the upstream API
|
| 9 |
+
// for demonstration purposes. In a real implementation, you would replace
|
| 10 |
+
// this with your actual AI service provider.
|
| 11 |
+
package main
|
| 12 |
+
|
| 13 |
+
import (
|
| 14 |
+
"bytes"
|
| 15 |
+
"context"
|
| 16 |
+
"errors"
|
| 17 |
+
"fmt"
|
| 18 |
+
"io"
|
| 19 |
+
"net/http"
|
| 20 |
+
"net/url"
|
| 21 |
+
"os"
|
| 22 |
+
"path/filepath"
|
| 23 |
+
"strings"
|
| 24 |
+
"time"
|
| 25 |
+
|
| 26 |
+
"github.com/gin-gonic/gin"
|
| 27 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
|
| 28 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 29 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
| 30 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 31 |
+
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
| 32 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 33 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
|
| 34 |
+
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
const (
|
| 38 |
+
// providerKey is the identifier for our custom provider.
|
| 39 |
+
providerKey = "myprov"
|
| 40 |
+
|
| 41 |
+
// fOpenAI represents the OpenAI chat format.
|
| 42 |
+
fOpenAI = sdktr.Format("openai.chat")
|
| 43 |
+
|
| 44 |
+
// fMyProv represents our custom provider's chat format.
|
| 45 |
+
fMyProv = sdktr.Format("myprov.chat")
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
// init registers trivial translators for demonstration purposes.
|
| 49 |
+
// In a real implementation, you would implement proper request/response
|
| 50 |
+
// transformation logic between OpenAI format and your provider's format.
|
| 51 |
+
func init() {
|
| 52 |
+
sdktr.Register(fOpenAI, fMyProv,
|
| 53 |
+
func(model string, raw []byte, stream bool) []byte { return raw },
|
| 54 |
+
sdktr.ResponseTransform{
|
| 55 |
+
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
| 56 |
+
return []string{string(raw)}
|
| 57 |
+
},
|
| 58 |
+
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
| 59 |
+
return string(raw)
|
| 60 |
+
},
|
| 61 |
+
},
|
| 62 |
+
)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// MyExecutor is a minimal provider implementation for demonstration purposes.
|
| 66 |
+
// It implements the Executor interface to handle requests to a custom AI provider.
|
| 67 |
+
type MyExecutor struct{}
|
| 68 |
+
|
| 69 |
+
// Identifier returns the unique identifier for this executor.
|
| 70 |
+
func (MyExecutor) Identifier() string { return providerKey }
|
| 71 |
+
|
| 72 |
+
// PrepareRequest optionally injects credentials to raw HTTP requests.
|
| 73 |
+
// This method is called before each request to allow the executor to modify
|
| 74 |
+
// the HTTP request with authentication headers or other necessary modifications.
|
| 75 |
+
//
|
| 76 |
+
// Parameters:
|
| 77 |
+
// - req: The HTTP request to prepare
|
| 78 |
+
// - a: The authentication information
|
| 79 |
+
//
|
| 80 |
+
// Returns:
|
| 81 |
+
// - error: An error if request preparation fails
|
| 82 |
+
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
| 83 |
+
if req == nil || a == nil {
|
| 84 |
+
return nil
|
| 85 |
+
}
|
| 86 |
+
if a.Attributes != nil {
|
| 87 |
+
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
|
| 88 |
+
req.Header.Set("Authorization", "Bearer "+ak)
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
return nil
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
func buildHTTPClient(a *coreauth.Auth) *http.Client {
|
| 95 |
+
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
|
| 96 |
+
return http.DefaultClient
|
| 97 |
+
}
|
| 98 |
+
u, err := url.Parse(a.ProxyURL)
|
| 99 |
+
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
| 100 |
+
return http.DefaultClient
|
| 101 |
+
}
|
| 102 |
+
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
func upstreamEndpoint(a *coreauth.Auth) string {
|
| 106 |
+
if a != nil && a.Attributes != nil {
|
| 107 |
+
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
|
| 108 |
+
return ep
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
// Demo echo endpoint; replace with your upstream.
|
| 112 |
+
return "https://httpbin.org/post"
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
| 116 |
+
client := buildHTTPClient(a)
|
| 117 |
+
endpoint := upstreamEndpoint(a)
|
| 118 |
+
|
| 119 |
+
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
|
| 120 |
+
if errNew != nil {
|
| 121 |
+
return clipexec.Response{}, errNew
|
| 122 |
+
}
|
| 123 |
+
httpReq.Header.Set("Content-Type", "application/json")
|
| 124 |
+
|
| 125 |
+
// Inject credentials via PrepareRequest hook.
|
| 126 |
+
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
| 127 |
+
return clipexec.Response{}, errPrep
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
resp, errDo := client.Do(httpReq)
|
| 131 |
+
if errDo != nil {
|
| 132 |
+
return clipexec.Response{}, errDo
|
| 133 |
+
}
|
| 134 |
+
defer func() {
|
| 135 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 136 |
+
fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose)
|
| 137 |
+
}
|
| 138 |
+
}()
|
| 139 |
+
body, _ := io.ReadAll(resp.Body)
|
| 140 |
+
return clipexec.Response{Payload: body}, nil
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
| 144 |
+
if req == nil {
|
| 145 |
+
return nil, fmt.Errorf("myprov executor: request is nil")
|
| 146 |
+
}
|
| 147 |
+
if ctx == nil {
|
| 148 |
+
ctx = req.Context()
|
| 149 |
+
}
|
| 150 |
+
httpReq := req.WithContext(ctx)
|
| 151 |
+
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
| 152 |
+
return nil, errPrep
|
| 153 |
+
}
|
| 154 |
+
client := buildHTTPClient(a)
|
| 155 |
+
return client.Do(httpReq)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
| 159 |
+
return clipexec.Response{}, errors.New("count tokens not implemented")
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
| 163 |
+
ch := make(chan clipexec.StreamChunk, 1)
|
| 164 |
+
go func() {
|
| 165 |
+
defer close(ch)
|
| 166 |
+
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
|
| 167 |
+
}()
|
| 168 |
+
return ch, nil
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
| 172 |
+
return a, nil
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
func main() {
|
| 176 |
+
cfg, err := config.LoadConfig("config.yaml")
|
| 177 |
+
if err != nil {
|
| 178 |
+
panic(err)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
tokenStore := sdkAuth.GetTokenStore()
|
| 182 |
+
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
| 183 |
+
dirSetter.SetBaseDir(cfg.AuthDir)
|
| 184 |
+
}
|
| 185 |
+
core := coreauth.NewManager(tokenStore, nil, nil)
|
| 186 |
+
core.RegisterExecutor(MyExecutor{})
|
| 187 |
+
|
| 188 |
+
hooks := cliproxy.Hooks{
|
| 189 |
+
OnAfterStart: func(s *cliproxy.Service) {
|
| 190 |
+
// Register demo models for the custom provider so they appear in /v1/models.
|
| 191 |
+
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
|
| 192 |
+
for _, a := range core.List() {
|
| 193 |
+
if strings.EqualFold(a.Provider, providerKey) {
|
| 194 |
+
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
svc, err := cliproxy.NewBuilder().
|
| 201 |
+
WithConfig(cfg).
|
| 202 |
+
WithConfigPath("config.yaml").
|
| 203 |
+
WithCoreAuthManager(core).
|
| 204 |
+
WithServerOptions(
|
| 205 |
+
// Optional: add a simple middleware + custom request logger
|
| 206 |
+
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
|
| 207 |
+
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
| 208 |
+
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
| 209 |
+
}),
|
| 210 |
+
).
|
| 211 |
+
WithHooks(hooks).
|
| 212 |
+
Build()
|
| 213 |
+
if err != nil {
|
| 214 |
+
panic(err)
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 218 |
+
defer cancel()
|
| 219 |
+
|
| 220 |
+
if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {
|
| 221 |
+
panic(errRun)
|
| 222 |
+
}
|
| 223 |
+
_ = os.Stderr // keep os import used (demo only)
|
| 224 |
+
_ = time.Second
|
| 225 |
+
}
|
examples/http-request/main.go
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest
|
| 2 |
+
// to execute arbitrary HTTP requests with provider credentials injected.
|
| 3 |
+
//
|
| 4 |
+
// This example registers a minimal custom executor that injects an Authorization
|
| 5 |
+
// header from auth.Attributes["api_key"], then performs two requests against
|
| 6 |
+
// httpbin.org to show the injected headers.
|
| 7 |
+
package main
|
| 8 |
+
|
| 9 |
+
import (
|
| 10 |
+
"bytes"
|
| 11 |
+
"context"
|
| 12 |
+
"errors"
|
| 13 |
+
"fmt"
|
| 14 |
+
"io"
|
| 15 |
+
"net/http"
|
| 16 |
+
"strings"
|
| 17 |
+
"time"
|
| 18 |
+
|
| 19 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 20 |
+
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
| 21 |
+
log "github.com/sirupsen/logrus"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
const providerKey = "echo"
|
| 25 |
+
|
| 26 |
+
// EchoExecutor is a minimal provider implementation for demonstration purposes.
|
| 27 |
+
type EchoExecutor struct{}
|
| 28 |
+
|
| 29 |
+
func (EchoExecutor) Identifier() string { return providerKey }
|
| 30 |
+
|
| 31 |
+
func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
|
| 32 |
+
if req == nil || auth == nil {
|
| 33 |
+
return nil
|
| 34 |
+
}
|
| 35 |
+
if auth.Attributes != nil {
|
| 36 |
+
if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" {
|
| 37 |
+
req.Header.Set("Authorization", "Bearer "+apiKey)
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
return nil
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
| 44 |
+
if req == nil {
|
| 45 |
+
return nil, fmt.Errorf("echo executor: request is nil")
|
| 46 |
+
}
|
| 47 |
+
if ctx == nil {
|
| 48 |
+
ctx = req.Context()
|
| 49 |
+
}
|
| 50 |
+
httpReq := req.WithContext(ctx)
|
| 51 |
+
if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {
|
| 52 |
+
return nil, errPrep
|
| 53 |
+
}
|
| 54 |
+
return http.DefaultClient.Do(httpReq)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
| 58 |
+
return clipexec.Response{}, errors.New("echo executor: Execute not implemented")
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
| 62 |
+
return nil, errors.New("echo executor: ExecuteStream not implemented")
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
|
| 66 |
+
return nil, errors.New("echo executor: Refresh not implemented")
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
| 70 |
+
return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented")
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func main() {
|
| 74 |
+
log.SetLevel(log.InfoLevel)
|
| 75 |
+
|
| 76 |
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
| 77 |
+
defer cancel()
|
| 78 |
+
|
| 79 |
+
core := coreauth.NewManager(nil, nil, nil)
|
| 80 |
+
core.RegisterExecutor(EchoExecutor{})
|
| 81 |
+
|
| 82 |
+
auth := &coreauth.Auth{
|
| 83 |
+
ID: "demo-echo",
|
| 84 |
+
Provider: providerKey,
|
| 85 |
+
Attributes: map[string]string{
|
| 86 |
+
"api_key": "demo-api-key",
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Example 1: Build a prepared request and execute it using your own http.Client.
|
| 91 |
+
reqPrepared, errReqPrepared := core.NewHttpRequest(
|
| 92 |
+
ctx,
|
| 93 |
+
auth,
|
| 94 |
+
http.MethodGet,
|
| 95 |
+
"https://httpbin.org/anything",
|
| 96 |
+
nil,
|
| 97 |
+
http.Header{"X-Example": []string{"prepared"}},
|
| 98 |
+
)
|
| 99 |
+
if errReqPrepared != nil {
|
| 100 |
+
panic(errReqPrepared)
|
| 101 |
+
}
|
| 102 |
+
respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)
|
| 103 |
+
if errDoPrepared != nil {
|
| 104 |
+
panic(errDoPrepared)
|
| 105 |
+
}
|
| 106 |
+
defer func() {
|
| 107 |
+
if errClose := respPrepared.Body.Close(); errClose != nil {
|
| 108 |
+
log.Errorf("close response body error: %v", errClose)
|
| 109 |
+
}
|
| 110 |
+
}()
|
| 111 |
+
bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)
|
| 112 |
+
if errReadPrepared != nil {
|
| 113 |
+
panic(errReadPrepared)
|
| 114 |
+
}
|
| 115 |
+
fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared)
|
| 116 |
+
|
| 117 |
+
// Example 2: Execute a raw request via core.HttpRequest (auto inject + do).
|
| 118 |
+
rawBody := []byte(`{"hello":"world"}`)
|
| 119 |
+
rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody))
|
| 120 |
+
if errRawReq != nil {
|
| 121 |
+
panic(errRawReq)
|
| 122 |
+
}
|
| 123 |
+
rawReq.Header.Set("Content-Type", "application/json")
|
| 124 |
+
rawReq.Header.Set("X-Example", "executed")
|
| 125 |
+
|
| 126 |
+
respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)
|
| 127 |
+
if errDoExec != nil {
|
| 128 |
+
panic(errDoExec)
|
| 129 |
+
}
|
| 130 |
+
defer func() {
|
| 131 |
+
if errClose := respExec.Body.Close(); errClose != nil {
|
| 132 |
+
log.Errorf("close response body error: %v", errClose)
|
| 133 |
+
}
|
| 134 |
+
}()
|
| 135 |
+
bodyExec, errReadExec := io.ReadAll(respExec.Body)
|
| 136 |
+
if errReadExec != nil {
|
| 137 |
+
panic(errReadExec)
|
| 138 |
+
}
|
| 139 |
+
fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec)
|
| 140 |
+
}
|
examples/translator/main.go
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
|
| 7 |
+
"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
| 8 |
+
_ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func main() {
|
| 12 |
+
rawRequest := []byte(`{"messages":[{"content":[{"text":"Hello! Gemini","type":"text"}],"role":"user"}],"model":"gemini-2.5-pro","stream":false}`)
|
| 13 |
+
fmt.Println("Has gemini->openai response translator:", translator.HasResponseTransformerByFormatName(
|
| 14 |
+
translator.FormatGemini,
|
| 15 |
+
translator.FormatOpenAI,
|
| 16 |
+
))
|
| 17 |
+
|
| 18 |
+
translatedRequest := translator.TranslateRequestByFormatName(
|
| 19 |
+
translator.FormatOpenAI,
|
| 20 |
+
translator.FormatGemini,
|
| 21 |
+
"gemini-2.5-pro",
|
| 22 |
+
rawRequest,
|
| 23 |
+
false,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
fmt.Printf("Translated request to Gemini format:\n%s\n\n", translatedRequest)
|
| 27 |
+
|
| 28 |
+
claudeResponse := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"Okay, here's what's going through my mind. I need to schedule a meeting"},{"thoughtSignature":"","functionCall":{"name":"schedule_meeting","args":{"topic":"Q3 planning","attendees":["Bob","Alice"],"time":"10:00","date":"2025-03-27"}}}]},"finishReason":"STOP","avgLogprobs":-0.50018133435930523}],"usageMetadata":{"promptTokenCount":117,"candidatesTokenCount":28,"totalTokenCount":474,"trafficType":"PROVISIONED_THROUGHPUT","promptTokensDetails":[{"modality":"TEXT","tokenCount":117}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":28}],"thoughtsTokenCount":329},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T04:12:55.249090Z","responseId":"x7OeaIKaD6CU48APvNXDyA4"}`)
|
| 29 |
+
|
| 30 |
+
convertedResponse := translator.TranslateNonStreamByFormatName(
|
| 31 |
+
context.Background(),
|
| 32 |
+
translator.FormatGemini,
|
| 33 |
+
translator.FormatOpenAI,
|
| 34 |
+
"gemini-2.5-pro",
|
| 35 |
+
rawRequest,
|
| 36 |
+
translatedRequest,
|
| 37 |
+
claudeResponse,
|
| 38 |
+
nil,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
fmt.Printf("Converted response for OpenAI clients:\n%s\n", convertedResponse)
|
| 42 |
+
}
|
go.mod
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module github.com/router-for-me/CLIProxyAPI/v6
|
| 2 |
+
|
| 3 |
+
go 1.24.0
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/andybalholm/brotli v1.0.6
|
| 7 |
+
github.com/fsnotify/fsnotify v1.9.0
|
| 8 |
+
github.com/gin-gonic/gin v1.10.1
|
| 9 |
+
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
|
| 10 |
+
github.com/google/uuid v1.6.0
|
| 11 |
+
github.com/gorilla/websocket v1.5.3
|
| 12 |
+
github.com/jackc/pgx/v5 v5.7.6
|
| 13 |
+
github.com/joho/godotenv v1.5.1
|
| 14 |
+
github.com/klauspost/compress v1.17.4
|
| 15 |
+
github.com/minio/minio-go/v7 v7.0.66
|
| 16 |
+
github.com/sirupsen/logrus v1.9.3
|
| 17 |
+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
| 18 |
+
github.com/stretchr/testify v1.11.1
|
| 19 |
+
github.com/tidwall/gjson v1.18.0
|
| 20 |
+
github.com/tidwall/sjson v1.2.5
|
| 21 |
+
github.com/tiktoken-go/tokenizer v0.7.0
|
| 22 |
+
golang.org/x/crypto v0.45.0
|
| 23 |
+
golang.org/x/net v0.47.0
|
| 24 |
+
golang.org/x/oauth2 v0.30.0
|
| 25 |
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
| 26 |
+
gopkg.in/yaml.v3 v3.0.1
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
require (
|
| 30 |
+
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
| 31 |
+
github.com/Microsoft/go-winio v0.6.2 // indirect
|
| 32 |
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
| 33 |
+
github.com/bytedance/sonic v1.11.6 // indirect
|
| 34 |
+
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
| 35 |
+
github.com/cloudflare/circl v1.6.1 // indirect
|
| 36 |
+
github.com/cloudwego/base64x v0.1.4 // indirect
|
| 37 |
+
github.com/cloudwego/iasm v0.2.0 // indirect
|
| 38 |
+
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
| 39 |
+
github.com/davecgh/go-spew v1.1.1 // indirect
|
| 40 |
+
github.com/dlclark/regexp2 v1.11.5 // indirect
|
| 41 |
+
github.com/dustin/go-humanize v1.0.1 // indirect
|
| 42 |
+
github.com/emirpasic/gods v1.18.1 // indirect
|
| 43 |
+
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
| 44 |
+
github.com/gin-contrib/sse v0.1.0 // indirect
|
| 45 |
+
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
| 46 |
+
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
|
| 47 |
+
github.com/go-playground/locales v0.14.1 // indirect
|
| 48 |
+
github.com/go-playground/universal-translator v0.18.1 // indirect
|
| 49 |
+
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
| 50 |
+
github.com/goccy/go-json v0.10.2 // indirect
|
| 51 |
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
| 52 |
+
github.com/jackc/pgpassfile v1.0.0 // indirect
|
| 53 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
| 54 |
+
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
| 55 |
+
github.com/json-iterator/go v1.1.12 // indirect
|
| 56 |
+
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
| 57 |
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
| 58 |
+
github.com/leodido/go-urn v1.4.0 // indirect
|
| 59 |
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
| 60 |
+
github.com/minio/md5-simd v1.1.2 // indirect
|
| 61 |
+
github.com/minio/sha256-simd v1.0.1 // indirect
|
| 62 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
| 63 |
+
github.com/modern-go/reflect2 v1.0.2 // indirect
|
| 64 |
+
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
| 65 |
+
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
| 66 |
+
github.com/pmezard/go-difflib v1.0.0 // indirect
|
| 67 |
+
github.com/rs/xid v1.5.0 // indirect
|
| 68 |
+
github.com/sergi/go-diff v1.4.0 // indirect
|
| 69 |
+
github.com/tidwall/match v1.1.1 // indirect
|
| 70 |
+
github.com/tidwall/pretty v1.2.0 // indirect
|
| 71 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
| 72 |
+
github.com/ugorji/go/codec v1.2.12 // indirect
|
| 73 |
+
golang.org/x/arch v0.8.0 // indirect
|
| 74 |
+
golang.org/x/sync v0.18.0 // indirect
|
| 75 |
+
golang.org/x/sys v0.38.0 // indirect
|
| 76 |
+
golang.org/x/text v0.31.0 // indirect
|
| 77 |
+
google.golang.org/protobuf v1.34.1 // indirect
|
| 78 |
+
gopkg.in/ini.v1 v1.67.0 // indirect
|
| 79 |
+
)
|
go.sum
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
| 2 |
+
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
| 3 |
+
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
| 4 |
+
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
| 5 |
+
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
| 6 |
+
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
| 7 |
+
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
| 8 |
+
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
| 9 |
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
| 10 |
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
| 11 |
+
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
| 12 |
+
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
| 13 |
+
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
| 14 |
+
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
| 15 |
+
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
| 16 |
+
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
| 17 |
+
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
| 18 |
+
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
| 19 |
+
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
| 20 |
+
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
| 21 |
+
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
| 22 |
+
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
| 23 |
+
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
| 24 |
+
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
| 25 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 26 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
| 27 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 28 |
+
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
| 29 |
+
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
| 30 |
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
| 31 |
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
| 32 |
+
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
| 33 |
+
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
| 34 |
+
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
| 35 |
+
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
| 36 |
+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
| 37 |
+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
| 38 |
+
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
| 39 |
+
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
| 40 |
+
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
| 41 |
+
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
| 42 |
+
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
| 43 |
+
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
| 44 |
+
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
| 45 |
+
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
| 46 |
+
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
| 47 |
+
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
| 48 |
+
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
|
| 49 |
+
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
|
| 50 |
+
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
|
| 51 |
+
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
|
| 52 |
+
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=
|
| 53 |
+
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=
|
| 54 |
+
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
| 55 |
+
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
| 56 |
+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
| 57 |
+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
| 58 |
+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
| 59 |
+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
| 60 |
+
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
| 61 |
+
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
| 62 |
+
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
| 63 |
+
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
| 64 |
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
| 65 |
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
| 66 |
+
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
| 67 |
+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
| 68 |
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
| 69 |
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
| 70 |
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
| 71 |
+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
| 72 |
+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
| 73 |
+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
| 74 |
+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
| 75 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
| 76 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
| 77 |
+
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
| 78 |
+
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
| 79 |
+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
| 80 |
+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
| 81 |
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
| 82 |
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
| 83 |
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
| 84 |
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
| 85 |
+
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
| 86 |
+
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
| 87 |
+
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
| 88 |
+
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
| 89 |
+
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
| 90 |
+
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
| 91 |
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
| 92 |
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
| 93 |
+
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
| 94 |
+
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
| 95 |
+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
| 96 |
+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
| 97 |
+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
| 98 |
+
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
| 99 |
+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
| 100 |
+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
| 101 |
+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
| 102 |
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
| 103 |
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
| 104 |
+
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
| 105 |
+
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
| 106 |
+
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
|
| 107 |
+
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
| 108 |
+
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
| 109 |
+
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
| 110 |
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 111 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
| 112 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 113 |
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
| 114 |
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
| 115 |
+
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
| 116 |
+
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
| 117 |
+
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
| 118 |
+
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
| 119 |
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
| 120 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
| 121 |
+
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
| 122 |
+
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
| 123 |
+
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
| 124 |
+
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
| 125 |
+
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
| 126 |
+
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
| 127 |
+
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
| 128 |
+
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
| 129 |
+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
| 130 |
+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
| 131 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
| 132 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
| 133 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
| 134 |
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
| 135 |
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
| 136 |
+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
| 137 |
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 138 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 139 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
| 140 |
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
| 141 |
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
| 142 |
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
| 143 |
+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
| 144 |
+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
| 145 |
+
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
| 146 |
+
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
| 147 |
+
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
| 148 |
+
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
| 149 |
+
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
| 150 |
+
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
| 151 |
+
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
| 152 |
+
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
| 153 |
+
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
| 154 |
+
github.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw=
|
| 155 |
+
github.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
|
| 156 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
| 157 |
+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
| 158 |
+
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
| 159 |
+
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
| 160 |
+
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
| 161 |
+
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
| 162 |
+
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
| 163 |
+
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
| 164 |
+
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
| 165 |
+
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
| 166 |
+
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
| 167 |
+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
| 168 |
+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
| 169 |
+
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
| 170 |
+
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
| 171 |
+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 172 |
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 173 |
+
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
| 174 |
+
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
| 175 |
+
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
| 176 |
+
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
| 177 |
+
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
| 178 |
+
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
| 179 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
| 180 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 181 |
+
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
| 182 |
+
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
| 183 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 184 |
+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 185 |
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
| 186 |
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
| 187 |
+
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
| 188 |
+
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
| 189 |
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
| 190 |
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
| 191 |
+
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
| 192 |
+
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
| 193 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 194 |
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
| 195 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 196 |
+
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
| 197 |
+
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
internal/access/config_access/provider.go
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package configaccess
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strings"
|
| 7 |
+
"sync"
|
| 8 |
+
|
| 9 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 10 |
+
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
var registerOnce sync.Once
|
| 14 |
+
|
| 15 |
+
// Register ensures the config-access provider is available to the access manager.
|
| 16 |
+
func Register() {
|
| 17 |
+
registerOnce.Do(func() {
|
| 18 |
+
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
|
| 19 |
+
})
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type provider struct {
|
| 23 |
+
name string
|
| 24 |
+
keys map[string]struct{}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
|
| 28 |
+
name := cfg.Name
|
| 29 |
+
if name == "" {
|
| 30 |
+
name = sdkconfig.DefaultAccessProviderName
|
| 31 |
+
}
|
| 32 |
+
keys := make(map[string]struct{}, len(cfg.APIKeys))
|
| 33 |
+
for _, key := range cfg.APIKeys {
|
| 34 |
+
if key == "" {
|
| 35 |
+
continue
|
| 36 |
+
}
|
| 37 |
+
keys[key] = struct{}{}
|
| 38 |
+
}
|
| 39 |
+
return &provider{name: name, keys: keys}, nil
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func (p *provider) Identifier() string {
|
| 43 |
+
if p == nil || p.name == "" {
|
| 44 |
+
return sdkconfig.DefaultAccessProviderName
|
| 45 |
+
}
|
| 46 |
+
return p.name
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
| 50 |
+
if p == nil {
|
| 51 |
+
return nil, sdkaccess.ErrNotHandled
|
| 52 |
+
}
|
| 53 |
+
if len(p.keys) == 0 {
|
| 54 |
+
return nil, sdkaccess.ErrNotHandled
|
| 55 |
+
}
|
| 56 |
+
authHeader := r.Header.Get("Authorization")
|
| 57 |
+
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
|
| 58 |
+
authHeaderAnthropic := r.Header.Get("X-Api-Key")
|
| 59 |
+
queryKey := ""
|
| 60 |
+
queryAuthToken := ""
|
| 61 |
+
if r.URL != nil {
|
| 62 |
+
queryKey = r.URL.Query().Get("key")
|
| 63 |
+
queryAuthToken = r.URL.Query().Get("auth_token")
|
| 64 |
+
}
|
| 65 |
+
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
|
| 66 |
+
return nil, sdkaccess.ErrNoCredentials
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
apiKey := extractBearerToken(authHeader)
|
| 70 |
+
|
| 71 |
+
candidates := []struct {
|
| 72 |
+
value string
|
| 73 |
+
source string
|
| 74 |
+
}{
|
| 75 |
+
{apiKey, "authorization"},
|
| 76 |
+
{authHeaderGoogle, "x-goog-api-key"},
|
| 77 |
+
{authHeaderAnthropic, "x-api-key"},
|
| 78 |
+
{queryKey, "query-key"},
|
| 79 |
+
{queryAuthToken, "query-auth-token"},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
for _, candidate := range candidates {
|
| 83 |
+
if candidate.value == "" {
|
| 84 |
+
continue
|
| 85 |
+
}
|
| 86 |
+
if _, ok := p.keys[candidate.value]; ok {
|
| 87 |
+
return &sdkaccess.Result{
|
| 88 |
+
Provider: p.Identifier(),
|
| 89 |
+
Principal: candidate.value,
|
| 90 |
+
Metadata: map[string]string{
|
| 91 |
+
"source": candidate.source,
|
| 92 |
+
},
|
| 93 |
+
}, nil
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return nil, sdkaccess.ErrInvalidCredential
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
func extractBearerToken(header string) string {
|
| 101 |
+
if header == "" {
|
| 102 |
+
return ""
|
| 103 |
+
}
|
| 104 |
+
parts := strings.SplitN(header, " ", 2)
|
| 105 |
+
if len(parts) != 2 {
|
| 106 |
+
return header
|
| 107 |
+
}
|
| 108 |
+
if strings.ToLower(parts[0]) != "bearer" {
|
| 109 |
+
return header
|
| 110 |
+
}
|
| 111 |
+
return strings.TrimSpace(parts[1])
|
| 112 |
+
}
|
internal/access/reconcile.go
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package access
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"reflect"
|
| 6 |
+
"sort"
|
| 7 |
+
"strings"
|
| 8 |
+
|
| 9 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 10 |
+
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
| 11 |
+
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 12 |
+
log "github.com/sirupsen/logrus"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// ReconcileProviders builds the desired provider list by reusing existing providers when possible
|
| 16 |
+
// and creating or removing providers only when their configuration changed. It returns the final
|
| 17 |
+
// ordered provider slice along with the identifiers of providers that were added, updated, or
|
| 18 |
+
// removed compared to the previous configuration.
|
| 19 |
+
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
|
| 20 |
+
if newCfg == nil {
|
| 21 |
+
return nil, nil, nil, nil, nil
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
existingMap := make(map[string]sdkaccess.Provider, len(existing))
|
| 25 |
+
for _, provider := range existing {
|
| 26 |
+
if provider == nil {
|
| 27 |
+
continue
|
| 28 |
+
}
|
| 29 |
+
existingMap[provider.Identifier()] = provider
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
oldCfgMap := accessProviderMap(oldCfg)
|
| 33 |
+
newEntries := collectProviderEntries(newCfg)
|
| 34 |
+
|
| 35 |
+
result = make([]sdkaccess.Provider, 0, len(newEntries))
|
| 36 |
+
finalIDs := make(map[string]struct{}, len(newEntries))
|
| 37 |
+
|
| 38 |
+
isInlineProvider := func(id string) bool {
|
| 39 |
+
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
|
| 40 |
+
}
|
| 41 |
+
appendChange := func(list *[]string, id string) {
|
| 42 |
+
if isInlineProvider(id) {
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
*list = append(*list, id)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
for _, providerCfg := range newEntries {
|
| 49 |
+
key := providerIdentifier(providerCfg)
|
| 50 |
+
if key == "" {
|
| 51 |
+
continue
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
|
| 55 |
+
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
| 56 |
+
isAliased := oldCfgProvider == providerCfg
|
| 57 |
+
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
| 58 |
+
if existingProvider, okExisting := existingMap[key]; okExisting {
|
| 59 |
+
result = append(result, existingProvider)
|
| 60 |
+
finalIDs[key] = struct{}{}
|
| 61 |
+
continue
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
| 67 |
+
if buildErr != nil {
|
| 68 |
+
return nil, nil, nil, nil, buildErr
|
| 69 |
+
}
|
| 70 |
+
if _, ok := oldCfgMap[key]; ok {
|
| 71 |
+
if _, existed := existingMap[key]; existed {
|
| 72 |
+
appendChange(&updated, key)
|
| 73 |
+
} else {
|
| 74 |
+
appendChange(&added, key)
|
| 75 |
+
}
|
| 76 |
+
} else {
|
| 77 |
+
appendChange(&added, key)
|
| 78 |
+
}
|
| 79 |
+
result = append(result, provider)
|
| 80 |
+
finalIDs[key] = struct{}{}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if len(result) == 0 {
|
| 84 |
+
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
|
| 85 |
+
key := providerIdentifier(inline)
|
| 86 |
+
if key != "" {
|
| 87 |
+
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
| 88 |
+
if providerConfigEqual(oldCfgProvider, inline) {
|
| 89 |
+
if existingProvider, okExisting := existingMap[key]; okExisting {
|
| 90 |
+
result = append(result, existingProvider)
|
| 91 |
+
finalIDs[key] = struct{}{}
|
| 92 |
+
goto inlineDone
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
|
| 97 |
+
if buildErr != nil {
|
| 98 |
+
return nil, nil, nil, nil, buildErr
|
| 99 |
+
}
|
| 100 |
+
if _, existed := existingMap[key]; existed {
|
| 101 |
+
appendChange(&updated, key)
|
| 102 |
+
} else if _, hadOld := oldCfgMap[key]; hadOld {
|
| 103 |
+
appendChange(&updated, key)
|
| 104 |
+
} else {
|
| 105 |
+
appendChange(&added, key)
|
| 106 |
+
}
|
| 107 |
+
result = append(result, provider)
|
| 108 |
+
finalIDs[key] = struct{}{}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
inlineDone:
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
removedSet := make(map[string]struct{})
|
| 115 |
+
for id := range existingMap {
|
| 116 |
+
if _, ok := finalIDs[id]; !ok {
|
| 117 |
+
if isInlineProvider(id) {
|
| 118 |
+
continue
|
| 119 |
+
}
|
| 120 |
+
removedSet[id] = struct{}{}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
removed = make([]string, 0, len(removedSet))
|
| 125 |
+
for id := range removedSet {
|
| 126 |
+
removed = append(removed, id)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
sort.Strings(added)
|
| 130 |
+
sort.Strings(updated)
|
| 131 |
+
sort.Strings(removed)
|
| 132 |
+
|
| 133 |
+
return result, added, updated, removed, nil
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// ApplyAccessProviders reconciles the configured access providers against the
|
| 137 |
+
// currently registered providers and updates the manager. It logs a concise
|
| 138 |
+
// summary of the detected changes and returns whether any provider changed.
|
| 139 |
+
func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {
|
| 140 |
+
if manager == nil || newCfg == nil {
|
| 141 |
+
return false, nil
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
existing := manager.Providers()
|
| 145 |
+
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
|
| 146 |
+
if err != nil {
|
| 147 |
+
log.Errorf("failed to reconcile request auth providers: %v", err)
|
| 148 |
+
return false, fmt.Errorf("reconciling access providers: %w", err)
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
manager.SetProviders(providers)
|
| 152 |
+
|
| 153 |
+
if len(added)+len(updated)+len(removed) > 0 {
|
| 154 |
+
log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
|
| 155 |
+
log.Debugf("auth providers changes details - added=%v updated=%v removed=%v", added, updated, removed)
|
| 156 |
+
return true, nil
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
log.Debug("auth providers unchanged after config update")
|
| 160 |
+
return false, nil
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
|
| 164 |
+
result := make(map[string]*sdkConfig.AccessProvider)
|
| 165 |
+
if cfg == nil {
|
| 166 |
+
return result
|
| 167 |
+
}
|
| 168 |
+
for i := range cfg.Access.Providers {
|
| 169 |
+
providerCfg := &cfg.Access.Providers[i]
|
| 170 |
+
if providerCfg.Type == "" {
|
| 171 |
+
continue
|
| 172 |
+
}
|
| 173 |
+
key := providerIdentifier(providerCfg)
|
| 174 |
+
if key == "" {
|
| 175 |
+
continue
|
| 176 |
+
}
|
| 177 |
+
result[key] = providerCfg
|
| 178 |
+
}
|
| 179 |
+
if len(result) == 0 && len(cfg.APIKeys) > 0 {
|
| 180 |
+
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
|
| 181 |
+
if key := providerIdentifier(provider); key != "" {
|
| 182 |
+
result[key] = provider
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
return result
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
|
| 190 |
+
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
|
| 191 |
+
for i := range cfg.Access.Providers {
|
| 192 |
+
providerCfg := &cfg.Access.Providers[i]
|
| 193 |
+
if providerCfg.Type == "" {
|
| 194 |
+
continue
|
| 195 |
+
}
|
| 196 |
+
if key := providerIdentifier(providerCfg); key != "" {
|
| 197 |
+
entries = append(entries, providerCfg)
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
|
| 201 |
+
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
|
| 202 |
+
entries = append(entries, inline)
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
return entries
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
|
| 209 |
+
if provider == nil {
|
| 210 |
+
return ""
|
| 211 |
+
}
|
| 212 |
+
if name := strings.TrimSpace(provider.Name); name != "" {
|
| 213 |
+
return name
|
| 214 |
+
}
|
| 215 |
+
typ := strings.TrimSpace(provider.Type)
|
| 216 |
+
if typ == "" {
|
| 217 |
+
return ""
|
| 218 |
+
}
|
| 219 |
+
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
|
| 220 |
+
return sdkConfig.DefaultAccessProviderName
|
| 221 |
+
}
|
| 222 |
+
return typ
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
|
| 226 |
+
if a == nil || b == nil {
|
| 227 |
+
return a == nil && b == nil
|
| 228 |
+
}
|
| 229 |
+
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
|
| 230 |
+
return false
|
| 231 |
+
}
|
| 232 |
+
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
|
| 233 |
+
return false
|
| 234 |
+
}
|
| 235 |
+
if !stringSetEqual(a.APIKeys, b.APIKeys) {
|
| 236 |
+
return false
|
| 237 |
+
}
|
| 238 |
+
if len(a.Config) != len(b.Config) {
|
| 239 |
+
return false
|
| 240 |
+
}
|
| 241 |
+
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
|
| 242 |
+
return false
|
| 243 |
+
}
|
| 244 |
+
return true
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
func stringSetEqual(a, b []string) bool {
|
| 248 |
+
if len(a) != len(b) {
|
| 249 |
+
return false
|
| 250 |
+
}
|
| 251 |
+
if len(a) == 0 {
|
| 252 |
+
return true
|
| 253 |
+
}
|
| 254 |
+
seen := make(map[string]int, len(a))
|
| 255 |
+
for _, val := range a {
|
| 256 |
+
seen[val]++
|
| 257 |
+
}
|
| 258 |
+
for _, val := range b {
|
| 259 |
+
count := seen[val]
|
| 260 |
+
if count == 0 {
|
| 261 |
+
return false
|
| 262 |
+
}
|
| 263 |
+
if count == 1 {
|
| 264 |
+
delete(seen, val)
|
| 265 |
+
} else {
|
| 266 |
+
seen[val] = count - 1
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
return len(seen) == 0
|
| 270 |
+
}
|
internal/api/handlers/management/api_tools.go
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"fmt"
|
| 7 |
+
"io"
|
| 8 |
+
"net"
|
| 9 |
+
"net/http"
|
| 10 |
+
"net/url"
|
| 11 |
+
"strings"
|
| 12 |
+
"time"
|
| 13 |
+
|
| 14 |
+
"github.com/gin-gonic/gin"
|
| 15 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
| 16 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 17 |
+
log "github.com/sirupsen/logrus"
|
| 18 |
+
"golang.org/x/net/proxy"
|
| 19 |
+
"golang.org/x/oauth2"
|
| 20 |
+
"golang.org/x/oauth2/google"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
const defaultAPICallTimeout = 60 * time.Second
|
| 24 |
+
|
| 25 |
+
const (
|
| 26 |
+
geminiOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
| 27 |
+
geminiOAuthClientSecret = "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"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
var geminiOAuthScopes = []string{
|
| 31 |
+
"https://www.googleapis.com/auth/cloud-platform",
|
| 32 |
+
"https://www.googleapis.com/auth/userinfo.email",
|
| 33 |
+
"https://www.googleapis.com/auth/userinfo.profile",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const (
|
| 37 |
+
antigravityOAuthClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
| 38 |
+
antigravityOAuthClientSecret = "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"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
var antigravityOAuthTokenURL = "https://oauth2.googleapis.com/token"
|
| 42 |
+
|
| 43 |
+
type apiCallRequest struct {
|
| 44 |
+
AuthIndexSnake *string `json:"auth_index"`
|
| 45 |
+
AuthIndexCamel *string `json:"authIndex"`
|
| 46 |
+
AuthIndexPascal *string `json:"AuthIndex"`
|
| 47 |
+
Method string `json:"method"`
|
| 48 |
+
URL string `json:"url"`
|
| 49 |
+
Header map[string]string `json:"header"`
|
| 50 |
+
Data string `json:"data"`
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
type apiCallResponse struct {
|
| 54 |
+
StatusCode int `json:"status_code"`
|
| 55 |
+
Header map[string][]string `json:"header"`
|
| 56 |
+
Body string `json:"body"`
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// APICall makes a generic HTTP request on behalf of the management API caller.
|
| 60 |
+
// It is protected by the management middleware.
|
| 61 |
+
//
|
| 62 |
+
// Endpoint:
|
| 63 |
+
//
|
| 64 |
+
// POST /v0/management/api-call
|
| 65 |
+
//
|
| 66 |
+
// Authentication:
|
| 67 |
+
//
|
| 68 |
+
// Same as other management APIs (requires a management key and remote-management rules).
|
| 69 |
+
// You can provide the key via:
|
| 70 |
+
// - Authorization: Bearer <key>
|
| 71 |
+
// - X-Management-Key: <key>
|
| 72 |
+
//
|
| 73 |
+
// Request JSON:
|
| 74 |
+
// - auth_index / authIndex / AuthIndex (optional):
|
| 75 |
+
// The credential "auth_index" from GET /v0/management/auth-files (or other endpoints returning it).
|
| 76 |
+
// If omitted or not found, credential-specific proxy/token substitution is skipped.
|
| 77 |
+
// - method (required): HTTP method, e.g. GET, POST, PUT, PATCH, DELETE.
|
| 78 |
+
// - url (required): Absolute URL including scheme and host, e.g. "https://api.example.com/v1/ping".
|
| 79 |
+
// - header (optional): Request headers map.
|
| 80 |
+
// Supports magic variable "$TOKEN$" which is replaced using the selected credential:
|
| 81 |
+
// 1) metadata.access_token
|
| 82 |
+
// 2) attributes.api_key
|
| 83 |
+
// 3) metadata.token / metadata.id_token / metadata.cookie
|
| 84 |
+
// Example: {"Authorization":"Bearer $TOKEN$"}.
|
| 85 |
+
// Note: if you need to override the HTTP Host header, set header["Host"].
|
| 86 |
+
// - data (optional): Raw request body as string (useful for POST/PUT/PATCH).
|
| 87 |
+
//
|
| 88 |
+
// Proxy selection (highest priority first):
|
| 89 |
+
// 1. Selected credential proxy_url
|
| 90 |
+
// 2. Global config proxy-url
|
| 91 |
+
// 3. Direct connect (environment proxies are not used)
|
| 92 |
+
//
|
| 93 |
+
// Response JSON (returned with HTTP 200 when the APICall itself succeeds):
|
| 94 |
+
// - status_code: Upstream HTTP status code.
|
| 95 |
+
// - header: Upstream response headers.
|
| 96 |
+
// - body: Upstream response body as string.
|
| 97 |
+
//
|
| 98 |
+
// Example:
|
| 99 |
+
//
|
| 100 |
+
// curl -sS -X POST "http://127.0.0.1:8317/v0/management/api-call" \
|
| 101 |
+
// -H "Authorization: Bearer <MANAGEMENT_KEY>" \
|
| 102 |
+
// -H "Content-Type: application/json" \
|
| 103 |
+
// -d '{"auth_index":"<AUTH_INDEX>","method":"GET","url":"https://api.example.com/v1/ping","header":{"Authorization":"Bearer $TOKEN$"}}'
|
| 104 |
+
//
|
| 105 |
+
// curl -sS -X POST "http://127.0.0.1:8317/v0/management/api-call" \
|
| 106 |
+
// -H "Authorization: Bearer 831227" \
|
| 107 |
+
// -H "Content-Type: application/json" \
|
| 108 |
+
// -d '{"auth_index":"<AUTH_INDEX>","method":"POST","url":"https://api.example.com/v1/fetchAvailableModels","header":{"Authorization":"Bearer $TOKEN$","Content-Type":"application/json","User-Agent":"cliproxyapi"},"data":"{}"}'
|
| 109 |
+
func (h *Handler) APICall(c *gin.Context) {
|
| 110 |
+
var body apiCallRequest
|
| 111 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
| 112 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 113 |
+
return
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
method := strings.ToUpper(strings.TrimSpace(body.Method))
|
| 117 |
+
if method == "" {
|
| 118 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing method"})
|
| 119 |
+
return
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
urlStr := strings.TrimSpace(body.URL)
|
| 123 |
+
if urlStr == "" {
|
| 124 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
|
| 125 |
+
return
|
| 126 |
+
}
|
| 127 |
+
parsedURL, errParseURL := url.Parse(urlStr)
|
| 128 |
+
if errParseURL != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
| 129 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
| 130 |
+
return
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
authIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel, body.AuthIndexPascal)
|
| 134 |
+
auth := h.authByIndex(authIndex)
|
| 135 |
+
|
| 136 |
+
reqHeaders := body.Header
|
| 137 |
+
if reqHeaders == nil {
|
| 138 |
+
reqHeaders = map[string]string{}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
var hostOverride string
|
| 142 |
+
var token string
|
| 143 |
+
var tokenResolved bool
|
| 144 |
+
var tokenErr error
|
| 145 |
+
for key, value := range reqHeaders {
|
| 146 |
+
if !strings.Contains(value, "$TOKEN$") {
|
| 147 |
+
continue
|
| 148 |
+
}
|
| 149 |
+
if !tokenResolved {
|
| 150 |
+
token, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth)
|
| 151 |
+
tokenResolved = true
|
| 152 |
+
}
|
| 153 |
+
if auth != nil && token == "" {
|
| 154 |
+
if tokenErr != nil {
|
| 155 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "auth token refresh failed"})
|
| 156 |
+
return
|
| 157 |
+
}
|
| 158 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "auth token not found"})
|
| 159 |
+
return
|
| 160 |
+
}
|
| 161 |
+
if token == "" {
|
| 162 |
+
continue
|
| 163 |
+
}
|
| 164 |
+
reqHeaders[key] = strings.ReplaceAll(value, "$TOKEN$", token)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
var requestBody io.Reader
|
| 168 |
+
if body.Data != "" {
|
| 169 |
+
requestBody = strings.NewReader(body.Data)
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), method, urlStr, requestBody)
|
| 173 |
+
if errNewRequest != nil {
|
| 174 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to build request"})
|
| 175 |
+
return
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
for key, value := range reqHeaders {
|
| 179 |
+
if strings.EqualFold(key, "host") {
|
| 180 |
+
hostOverride = strings.TrimSpace(value)
|
| 181 |
+
continue
|
| 182 |
+
}
|
| 183 |
+
req.Header.Set(key, value)
|
| 184 |
+
}
|
| 185 |
+
if hostOverride != "" {
|
| 186 |
+
req.Host = hostOverride
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
httpClient := &http.Client{
|
| 190 |
+
Timeout: defaultAPICallTimeout,
|
| 191 |
+
}
|
| 192 |
+
httpClient.Transport = h.apiCallTransport(auth)
|
| 193 |
+
|
| 194 |
+
resp, errDo := httpClient.Do(req)
|
| 195 |
+
if errDo != nil {
|
| 196 |
+
log.WithError(errDo).Debug("management APICall request failed")
|
| 197 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
|
| 198 |
+
return
|
| 199 |
+
}
|
| 200 |
+
defer func() {
|
| 201 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 202 |
+
log.Errorf("response body close error: %v", errClose)
|
| 203 |
+
}
|
| 204 |
+
}()
|
| 205 |
+
|
| 206 |
+
respBody, errReadAll := io.ReadAll(resp.Body)
|
| 207 |
+
if errReadAll != nil {
|
| 208 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
|
| 209 |
+
return
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
c.JSON(http.StatusOK, apiCallResponse{
|
| 213 |
+
StatusCode: resp.StatusCode,
|
| 214 |
+
Header: resp.Header,
|
| 215 |
+
Body: string(respBody),
|
| 216 |
+
})
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
func firstNonEmptyString(values ...*string) string {
|
| 220 |
+
for _, v := range values {
|
| 221 |
+
if v == nil {
|
| 222 |
+
continue
|
| 223 |
+
}
|
| 224 |
+
if out := strings.TrimSpace(*v); out != "" {
|
| 225 |
+
return out
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
return ""
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
func tokenValueForAuth(auth *coreauth.Auth) string {
|
| 232 |
+
if auth == nil {
|
| 233 |
+
return ""
|
| 234 |
+
}
|
| 235 |
+
if v := tokenValueFromMetadata(auth.Metadata); v != "" {
|
| 236 |
+
return v
|
| 237 |
+
}
|
| 238 |
+
if auth.Attributes != nil {
|
| 239 |
+
if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
|
| 240 |
+
return v
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {
|
| 244 |
+
if v := tokenValueFromMetadata(shared.MetadataSnapshot()); v != "" {
|
| 245 |
+
return v
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
return ""
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
func (h *Handler) resolveTokenForAuth(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 252 |
+
if auth == nil {
|
| 253 |
+
return "", nil
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
| 257 |
+
if provider == "gemini-cli" {
|
| 258 |
+
token, errToken := h.refreshGeminiOAuthAccessToken(ctx, auth)
|
| 259 |
+
return token, errToken
|
| 260 |
+
}
|
| 261 |
+
if provider == "antigravity" {
|
| 262 |
+
token, errToken := h.refreshAntigravityOAuthAccessToken(ctx, auth)
|
| 263 |
+
return token, errToken
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
return tokenValueForAuth(auth), nil
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
func (h *Handler) refreshGeminiOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 270 |
+
if ctx == nil {
|
| 271 |
+
ctx = context.Background()
|
| 272 |
+
}
|
| 273 |
+
if auth == nil {
|
| 274 |
+
return "", nil
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
metadata, updater := geminiOAuthMetadata(auth)
|
| 278 |
+
if len(metadata) == 0 {
|
| 279 |
+
return "", fmt.Errorf("gemini oauth metadata missing")
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
base := make(map[string]any)
|
| 283 |
+
if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil {
|
| 284 |
+
base = cloneMap(tokenRaw)
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
var token oauth2.Token
|
| 288 |
+
if len(base) > 0 {
|
| 289 |
+
if raw, errMarshal := json.Marshal(base); errMarshal == nil {
|
| 290 |
+
_ = json.Unmarshal(raw, &token)
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
if token.AccessToken == "" {
|
| 295 |
+
token.AccessToken = stringValue(metadata, "access_token")
|
| 296 |
+
}
|
| 297 |
+
if token.RefreshToken == "" {
|
| 298 |
+
token.RefreshToken = stringValue(metadata, "refresh_token")
|
| 299 |
+
}
|
| 300 |
+
if token.TokenType == "" {
|
| 301 |
+
token.TokenType = stringValue(metadata, "token_type")
|
| 302 |
+
}
|
| 303 |
+
if token.Expiry.IsZero() {
|
| 304 |
+
if expiry := stringValue(metadata, "expiry"); expiry != "" {
|
| 305 |
+
if ts, errParseTime := time.Parse(time.RFC3339, expiry); errParseTime == nil {
|
| 306 |
+
token.Expiry = ts
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
conf := &oauth2.Config{
|
| 312 |
+
ClientID: geminiOAuthClientID,
|
| 313 |
+
ClientSecret: geminiOAuthClientSecret,
|
| 314 |
+
Scopes: geminiOAuthScopes,
|
| 315 |
+
Endpoint: google.Endpoint,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
ctxToken := ctx
|
| 319 |
+
httpClient := &http.Client{
|
| 320 |
+
Timeout: defaultAPICallTimeout,
|
| 321 |
+
Transport: h.apiCallTransport(auth),
|
| 322 |
+
}
|
| 323 |
+
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)
|
| 324 |
+
|
| 325 |
+
src := conf.TokenSource(ctxToken, &token)
|
| 326 |
+
currentToken, errToken := src.Token()
|
| 327 |
+
if errToken != nil {
|
| 328 |
+
return "", errToken
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
merged := buildOAuthTokenMap(base, currentToken)
|
| 332 |
+
fields := buildOAuthTokenFields(currentToken, merged)
|
| 333 |
+
if updater != nil {
|
| 334 |
+
updater(fields)
|
| 335 |
+
}
|
| 336 |
+
return strings.TrimSpace(currentToken.AccessToken), nil
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
func (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 340 |
+
if ctx == nil {
|
| 341 |
+
ctx = context.Background()
|
| 342 |
+
}
|
| 343 |
+
if auth == nil {
|
| 344 |
+
return "", nil
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
metadata := auth.Metadata
|
| 348 |
+
if len(metadata) == 0 {
|
| 349 |
+
return "", fmt.Errorf("antigravity oauth metadata missing")
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
current := strings.TrimSpace(tokenValueFromMetadata(metadata))
|
| 353 |
+
if current != "" && !antigravityTokenNeedsRefresh(metadata) {
|
| 354 |
+
return current, nil
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
refreshToken := stringValue(metadata, "refresh_token")
|
| 358 |
+
if refreshToken == "" {
|
| 359 |
+
return "", fmt.Errorf("antigravity refresh token missing")
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
tokenURL := strings.TrimSpace(antigravityOAuthTokenURL)
|
| 363 |
+
if tokenURL == "" {
|
| 364 |
+
tokenURL = "https://oauth2.googleapis.com/token"
|
| 365 |
+
}
|
| 366 |
+
form := url.Values{}
|
| 367 |
+
form.Set("client_id", antigravityOAuthClientID)
|
| 368 |
+
form.Set("client_secret", antigravityOAuthClientSecret)
|
| 369 |
+
form.Set("grant_type", "refresh_token")
|
| 370 |
+
form.Set("refresh_token", refreshToken)
|
| 371 |
+
|
| 372 |
+
req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
| 373 |
+
if errReq != nil {
|
| 374 |
+
return "", errReq
|
| 375 |
+
}
|
| 376 |
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
| 377 |
+
|
| 378 |
+
httpClient := &http.Client{
|
| 379 |
+
Timeout: defaultAPICallTimeout,
|
| 380 |
+
Transport: h.apiCallTransport(auth),
|
| 381 |
+
}
|
| 382 |
+
resp, errDo := httpClient.Do(req)
|
| 383 |
+
if errDo != nil {
|
| 384 |
+
return "", errDo
|
| 385 |
+
}
|
| 386 |
+
defer func() {
|
| 387 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 388 |
+
log.Errorf("response body close error: %v", errClose)
|
| 389 |
+
}
|
| 390 |
+
}()
|
| 391 |
+
|
| 392 |
+
bodyBytes, errRead := io.ReadAll(resp.Body)
|
| 393 |
+
if errRead != nil {
|
| 394 |
+
return "", errRead
|
| 395 |
+
}
|
| 396 |
+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
| 397 |
+
return "", fmt.Errorf("antigravity oauth token refresh failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
var tokenResp struct {
|
| 401 |
+
AccessToken string `json:"access_token"`
|
| 402 |
+
RefreshToken string `json:"refresh_token"`
|
| 403 |
+
ExpiresIn int64 `json:"expires_in"`
|
| 404 |
+
TokenType string `json:"token_type"`
|
| 405 |
+
}
|
| 406 |
+
if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {
|
| 407 |
+
return "", errUnmarshal
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
if strings.TrimSpace(tokenResp.AccessToken) == "" {
|
| 411 |
+
return "", fmt.Errorf("antigravity oauth token refresh returned empty access_token")
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
if auth.Metadata == nil {
|
| 415 |
+
auth.Metadata = make(map[string]any)
|
| 416 |
+
}
|
| 417 |
+
now := time.Now()
|
| 418 |
+
auth.Metadata["access_token"] = strings.TrimSpace(tokenResp.AccessToken)
|
| 419 |
+
if strings.TrimSpace(tokenResp.RefreshToken) != "" {
|
| 420 |
+
auth.Metadata["refresh_token"] = strings.TrimSpace(tokenResp.RefreshToken)
|
| 421 |
+
}
|
| 422 |
+
if tokenResp.ExpiresIn > 0 {
|
| 423 |
+
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
|
| 424 |
+
auth.Metadata["timestamp"] = now.UnixMilli()
|
| 425 |
+
auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
|
| 426 |
+
}
|
| 427 |
+
auth.Metadata["type"] = "antigravity"
|
| 428 |
+
|
| 429 |
+
if h != nil && h.authManager != nil {
|
| 430 |
+
auth.LastRefreshedAt = now
|
| 431 |
+
auth.UpdatedAt = now
|
| 432 |
+
_, _ = h.authManager.Update(ctx, auth)
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
return strings.TrimSpace(tokenResp.AccessToken), nil
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
func antigravityTokenNeedsRefresh(metadata map[string]any) bool {
|
| 439 |
+
// Refresh a bit early to avoid requests racing token expiry.
|
| 440 |
+
const skew = 30 * time.Second
|
| 441 |
+
|
| 442 |
+
if metadata == nil {
|
| 443 |
+
return true
|
| 444 |
+
}
|
| 445 |
+
if expStr, ok := metadata["expired"].(string); ok {
|
| 446 |
+
if ts, errParse := time.Parse(time.RFC3339, strings.TrimSpace(expStr)); errParse == nil {
|
| 447 |
+
return !ts.After(time.Now().Add(skew))
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
expiresIn := int64Value(metadata["expires_in"])
|
| 451 |
+
timestampMs := int64Value(metadata["timestamp"])
|
| 452 |
+
if expiresIn > 0 && timestampMs > 0 {
|
| 453 |
+
exp := time.UnixMilli(timestampMs).Add(time.Duration(expiresIn) * time.Second)
|
| 454 |
+
return !exp.After(time.Now().Add(skew))
|
| 455 |
+
}
|
| 456 |
+
return true
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
func int64Value(raw any) int64 {
|
| 460 |
+
switch typed := raw.(type) {
|
| 461 |
+
case int:
|
| 462 |
+
return int64(typed)
|
| 463 |
+
case int32:
|
| 464 |
+
return int64(typed)
|
| 465 |
+
case int64:
|
| 466 |
+
return typed
|
| 467 |
+
case uint:
|
| 468 |
+
return int64(typed)
|
| 469 |
+
case uint32:
|
| 470 |
+
return int64(typed)
|
| 471 |
+
case uint64:
|
| 472 |
+
if typed > uint64(^uint64(0)>>1) {
|
| 473 |
+
return 0
|
| 474 |
+
}
|
| 475 |
+
return int64(typed)
|
| 476 |
+
case float32:
|
| 477 |
+
return int64(typed)
|
| 478 |
+
case float64:
|
| 479 |
+
return int64(typed)
|
| 480 |
+
case json.Number:
|
| 481 |
+
if i, errParse := typed.Int64(); errParse == nil {
|
| 482 |
+
return i
|
| 483 |
+
}
|
| 484 |
+
case string:
|
| 485 |
+
if s := strings.TrimSpace(typed); s != "" {
|
| 486 |
+
if i, errParse := json.Number(s).Int64(); errParse == nil {
|
| 487 |
+
return i
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
return 0
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
func geminiOAuthMetadata(auth *coreauth.Auth) (map[string]any, func(map[string]any)) {
|
| 495 |
+
if auth == nil {
|
| 496 |
+
return nil, nil
|
| 497 |
+
}
|
| 498 |
+
if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {
|
| 499 |
+
snapshot := shared.MetadataSnapshot()
|
| 500 |
+
return snapshot, func(fields map[string]any) { shared.MergeMetadata(fields) }
|
| 501 |
+
}
|
| 502 |
+
return auth.Metadata, func(fields map[string]any) {
|
| 503 |
+
if auth.Metadata == nil {
|
| 504 |
+
auth.Metadata = make(map[string]any)
|
| 505 |
+
}
|
| 506 |
+
for k, v := range fields {
|
| 507 |
+
auth.Metadata[k] = v
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
func stringValue(metadata map[string]any, key string) string {
|
| 513 |
+
if len(metadata) == 0 || key == "" {
|
| 514 |
+
return ""
|
| 515 |
+
}
|
| 516 |
+
if v, ok := metadata[key].(string); ok {
|
| 517 |
+
return strings.TrimSpace(v)
|
| 518 |
+
}
|
| 519 |
+
return ""
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
func cloneMap(in map[string]any) map[string]any {
|
| 523 |
+
if len(in) == 0 {
|
| 524 |
+
return nil
|
| 525 |
+
}
|
| 526 |
+
out := make(map[string]any, len(in))
|
| 527 |
+
for k, v := range in {
|
| 528 |
+
out[k] = v
|
| 529 |
+
}
|
| 530 |
+
return out
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
func buildOAuthTokenMap(base map[string]any, tok *oauth2.Token) map[string]any {
|
| 534 |
+
merged := cloneMap(base)
|
| 535 |
+
if merged == nil {
|
| 536 |
+
merged = make(map[string]any)
|
| 537 |
+
}
|
| 538 |
+
if tok == nil {
|
| 539 |
+
return merged
|
| 540 |
+
}
|
| 541 |
+
if raw, errMarshal := json.Marshal(tok); errMarshal == nil {
|
| 542 |
+
var tokenMap map[string]any
|
| 543 |
+
if errUnmarshal := json.Unmarshal(raw, &tokenMap); errUnmarshal == nil {
|
| 544 |
+
for k, v := range tokenMap {
|
| 545 |
+
merged[k] = v
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
return merged
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
func buildOAuthTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any {
|
| 553 |
+
fields := make(map[string]any, 5)
|
| 554 |
+
if tok != nil && tok.AccessToken != "" {
|
| 555 |
+
fields["access_token"] = tok.AccessToken
|
| 556 |
+
}
|
| 557 |
+
if tok != nil && tok.TokenType != "" {
|
| 558 |
+
fields["token_type"] = tok.TokenType
|
| 559 |
+
}
|
| 560 |
+
if tok != nil && tok.RefreshToken != "" {
|
| 561 |
+
fields["refresh_token"] = tok.RefreshToken
|
| 562 |
+
}
|
| 563 |
+
if tok != nil && !tok.Expiry.IsZero() {
|
| 564 |
+
fields["expiry"] = tok.Expiry.Format(time.RFC3339)
|
| 565 |
+
}
|
| 566 |
+
if len(merged) > 0 {
|
| 567 |
+
fields["token"] = cloneMap(merged)
|
| 568 |
+
}
|
| 569 |
+
return fields
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
func tokenValueFromMetadata(metadata map[string]any) string {
|
| 573 |
+
if len(metadata) == 0 {
|
| 574 |
+
return ""
|
| 575 |
+
}
|
| 576 |
+
if v, ok := metadata["accessToken"].(string); ok && strings.TrimSpace(v) != "" {
|
| 577 |
+
return strings.TrimSpace(v)
|
| 578 |
+
}
|
| 579 |
+
if v, ok := metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 580 |
+
return strings.TrimSpace(v)
|
| 581 |
+
}
|
| 582 |
+
if tokenRaw, ok := metadata["token"]; ok && tokenRaw != nil {
|
| 583 |
+
switch typed := tokenRaw.(type) {
|
| 584 |
+
case string:
|
| 585 |
+
if v := strings.TrimSpace(typed); v != "" {
|
| 586 |
+
return v
|
| 587 |
+
}
|
| 588 |
+
case map[string]any:
|
| 589 |
+
if v, ok := typed["access_token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 590 |
+
return strings.TrimSpace(v)
|
| 591 |
+
}
|
| 592 |
+
if v, ok := typed["accessToken"].(string); ok && strings.TrimSpace(v) != "" {
|
| 593 |
+
return strings.TrimSpace(v)
|
| 594 |
+
}
|
| 595 |
+
case map[string]string:
|
| 596 |
+
if v := strings.TrimSpace(typed["access_token"]); v != "" {
|
| 597 |
+
return v
|
| 598 |
+
}
|
| 599 |
+
if v := strings.TrimSpace(typed["accessToken"]); v != "" {
|
| 600 |
+
return v
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
}
|
| 604 |
+
if v, ok := metadata["token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 605 |
+
return strings.TrimSpace(v)
|
| 606 |
+
}
|
| 607 |
+
if v, ok := metadata["id_token"].(string); ok && strings.TrimSpace(v) != "" {
|
| 608 |
+
return strings.TrimSpace(v)
|
| 609 |
+
}
|
| 610 |
+
if v, ok := metadata["cookie"].(string); ok && strings.TrimSpace(v) != "" {
|
| 611 |
+
return strings.TrimSpace(v)
|
| 612 |
+
}
|
| 613 |
+
return ""
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
func (h *Handler) authByIndex(authIndex string) *coreauth.Auth {
|
| 617 |
+
authIndex = strings.TrimSpace(authIndex)
|
| 618 |
+
if authIndex == "" || h == nil || h.authManager == nil {
|
| 619 |
+
return nil
|
| 620 |
+
}
|
| 621 |
+
auths := h.authManager.List()
|
| 622 |
+
for _, auth := range auths {
|
| 623 |
+
if auth == nil {
|
| 624 |
+
continue
|
| 625 |
+
}
|
| 626 |
+
auth.EnsureIndex()
|
| 627 |
+
if auth.Index == authIndex {
|
| 628 |
+
return auth
|
| 629 |
+
}
|
| 630 |
+
}
|
| 631 |
+
return nil
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {
|
| 635 |
+
var proxyCandidates []string
|
| 636 |
+
if auth != nil {
|
| 637 |
+
if proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != "" {
|
| 638 |
+
proxyCandidates = append(proxyCandidates, proxyStr)
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
if h != nil && h.cfg != nil {
|
| 642 |
+
if proxyStr := strings.TrimSpace(h.cfg.ProxyURL); proxyStr != "" {
|
| 643 |
+
proxyCandidates = append(proxyCandidates, proxyStr)
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
for _, proxyStr := range proxyCandidates {
|
| 648 |
+
if transport := buildProxyTransport(proxyStr); transport != nil {
|
| 649 |
+
return transport
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
transport, ok := http.DefaultTransport.(*http.Transport)
|
| 654 |
+
if !ok || transport == nil {
|
| 655 |
+
return &http.Transport{Proxy: nil}
|
| 656 |
+
}
|
| 657 |
+
clone := transport.Clone()
|
| 658 |
+
clone.Proxy = nil
|
| 659 |
+
return clone
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
func buildProxyTransport(proxyStr string) *http.Transport {
|
| 663 |
+
proxyStr = strings.TrimSpace(proxyStr)
|
| 664 |
+
if proxyStr == "" {
|
| 665 |
+
return nil
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
proxyURL, errParse := url.Parse(proxyStr)
|
| 669 |
+
if errParse != nil {
|
| 670 |
+
log.WithError(errParse).Debug("parse proxy URL failed")
|
| 671 |
+
return nil
|
| 672 |
+
}
|
| 673 |
+
if proxyURL.Scheme == "" || proxyURL.Host == "" {
|
| 674 |
+
log.Debug("proxy URL missing scheme/host")
|
| 675 |
+
return nil
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
if proxyURL.Scheme == "socks5" {
|
| 679 |
+
var proxyAuth *proxy.Auth
|
| 680 |
+
if proxyURL.User != nil {
|
| 681 |
+
username := proxyURL.User.Username()
|
| 682 |
+
password, _ := proxyURL.User.Password()
|
| 683 |
+
proxyAuth = &proxy.Auth{User: username, Password: password}
|
| 684 |
+
}
|
| 685 |
+
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
|
| 686 |
+
if errSOCKS5 != nil {
|
| 687 |
+
log.WithError(errSOCKS5).Debug("create SOCKS5 dialer failed")
|
| 688 |
+
return nil
|
| 689 |
+
}
|
| 690 |
+
return &http.Transport{
|
| 691 |
+
Proxy: nil,
|
| 692 |
+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
| 693 |
+
return dialer.Dial(network, addr)
|
| 694 |
+
},
|
| 695 |
+
}
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
| 699 |
+
return &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
|
| 703 |
+
return nil
|
| 704 |
+
}
|
internal/api/handlers/management/api_tools_test.go
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"io"
|
| 7 |
+
"net/http"
|
| 8 |
+
"net/http/httptest"
|
| 9 |
+
"net/url"
|
| 10 |
+
"strings"
|
| 11 |
+
"sync"
|
| 12 |
+
"testing"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
type memoryAuthStore struct {
|
| 19 |
+
mu sync.Mutex
|
| 20 |
+
items map[string]*coreauth.Auth
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (s *memoryAuthStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
|
| 24 |
+
_ = ctx
|
| 25 |
+
s.mu.Lock()
|
| 26 |
+
defer s.mu.Unlock()
|
| 27 |
+
out := make([]*coreauth.Auth, 0, len(s.items))
|
| 28 |
+
for _, a := range s.items {
|
| 29 |
+
out = append(out, a.Clone())
|
| 30 |
+
}
|
| 31 |
+
return out, nil
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func (s *memoryAuthStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
| 35 |
+
_ = ctx
|
| 36 |
+
if auth == nil {
|
| 37 |
+
return "", nil
|
| 38 |
+
}
|
| 39 |
+
s.mu.Lock()
|
| 40 |
+
if s.items == nil {
|
| 41 |
+
s.items = make(map[string]*coreauth.Auth)
|
| 42 |
+
}
|
| 43 |
+
s.items[auth.ID] = auth.Clone()
|
| 44 |
+
s.mu.Unlock()
|
| 45 |
+
return auth.ID, nil
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func (s *memoryAuthStore) Delete(ctx context.Context, id string) error {
|
| 49 |
+
_ = ctx
|
| 50 |
+
s.mu.Lock()
|
| 51 |
+
delete(s.items, id)
|
| 52 |
+
s.mu.Unlock()
|
| 53 |
+
return nil
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestResolveTokenForAuth_Antigravity_RefreshesExpiredToken(t *testing.T) {
|
| 57 |
+
var callCount int
|
| 58 |
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 59 |
+
callCount++
|
| 60 |
+
if r.Method != http.MethodPost {
|
| 61 |
+
t.Fatalf("expected POST, got %s", r.Method)
|
| 62 |
+
}
|
| 63 |
+
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
| 64 |
+
t.Fatalf("unexpected content-type: %s", ct)
|
| 65 |
+
}
|
| 66 |
+
bodyBytes, _ := io.ReadAll(r.Body)
|
| 67 |
+
_ = r.Body.Close()
|
| 68 |
+
values, err := url.ParseQuery(string(bodyBytes))
|
| 69 |
+
if err != nil {
|
| 70 |
+
t.Fatalf("parse form: %v", err)
|
| 71 |
+
}
|
| 72 |
+
if values.Get("grant_type") != "refresh_token" {
|
| 73 |
+
t.Fatalf("unexpected grant_type: %s", values.Get("grant_type"))
|
| 74 |
+
}
|
| 75 |
+
if values.Get("refresh_token") != "rt" {
|
| 76 |
+
t.Fatalf("unexpected refresh_token: %s", values.Get("refresh_token"))
|
| 77 |
+
}
|
| 78 |
+
if values.Get("client_id") != antigravityOAuthClientID {
|
| 79 |
+
t.Fatalf("unexpected client_id: %s", values.Get("client_id"))
|
| 80 |
+
}
|
| 81 |
+
if values.Get("client_secret") != antigravityOAuthClientSecret {
|
| 82 |
+
t.Fatalf("unexpected client_secret")
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
w.Header().Set("Content-Type", "application/json")
|
| 86 |
+
_ = json.NewEncoder(w).Encode(map[string]any{
|
| 87 |
+
"access_token": "new-token",
|
| 88 |
+
"refresh_token": "rt2",
|
| 89 |
+
"expires_in": int64(3600),
|
| 90 |
+
"token_type": "Bearer",
|
| 91 |
+
})
|
| 92 |
+
}))
|
| 93 |
+
t.Cleanup(srv.Close)
|
| 94 |
+
|
| 95 |
+
originalURL := antigravityOAuthTokenURL
|
| 96 |
+
antigravityOAuthTokenURL = srv.URL
|
| 97 |
+
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
| 98 |
+
|
| 99 |
+
store := &memoryAuthStore{}
|
| 100 |
+
manager := coreauth.NewManager(store, nil, nil)
|
| 101 |
+
|
| 102 |
+
auth := &coreauth.Auth{
|
| 103 |
+
ID: "antigravity-test.json",
|
| 104 |
+
FileName: "antigravity-test.json",
|
| 105 |
+
Provider: "antigravity",
|
| 106 |
+
Metadata: map[string]any{
|
| 107 |
+
"type": "antigravity",
|
| 108 |
+
"access_token": "old-token",
|
| 109 |
+
"refresh_token": "rt",
|
| 110 |
+
"expires_in": int64(3600),
|
| 111 |
+
"timestamp": time.Now().Add(-2 * time.Hour).UnixMilli(),
|
| 112 |
+
"expired": time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
|
| 113 |
+
},
|
| 114 |
+
}
|
| 115 |
+
if _, err := manager.Register(context.Background(), auth); err != nil {
|
| 116 |
+
t.Fatalf("register auth: %v", err)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
h := &Handler{authManager: manager}
|
| 120 |
+
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
| 121 |
+
if err != nil {
|
| 122 |
+
t.Fatalf("resolveTokenForAuth: %v", err)
|
| 123 |
+
}
|
| 124 |
+
if token != "new-token" {
|
| 125 |
+
t.Fatalf("expected refreshed token, got %q", token)
|
| 126 |
+
}
|
| 127 |
+
if callCount != 1 {
|
| 128 |
+
t.Fatalf("expected 1 refresh call, got %d", callCount)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
updated, ok := manager.GetByID(auth.ID)
|
| 132 |
+
if !ok || updated == nil {
|
| 133 |
+
t.Fatalf("expected auth in manager after update")
|
| 134 |
+
}
|
| 135 |
+
if got := tokenValueFromMetadata(updated.Metadata); got != "new-token" {
|
| 136 |
+
t.Fatalf("expected manager metadata updated, got %q", got)
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
func TestResolveTokenForAuth_Antigravity_SkipsRefreshWhenTokenValid(t *testing.T) {
|
| 141 |
+
var callCount int
|
| 142 |
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 143 |
+
callCount++
|
| 144 |
+
w.WriteHeader(http.StatusInternalServerError)
|
| 145 |
+
}))
|
| 146 |
+
t.Cleanup(srv.Close)
|
| 147 |
+
|
| 148 |
+
originalURL := antigravityOAuthTokenURL
|
| 149 |
+
antigravityOAuthTokenURL = srv.URL
|
| 150 |
+
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
| 151 |
+
|
| 152 |
+
auth := &coreauth.Auth{
|
| 153 |
+
ID: "antigravity-valid.json",
|
| 154 |
+
FileName: "antigravity-valid.json",
|
| 155 |
+
Provider: "antigravity",
|
| 156 |
+
Metadata: map[string]any{
|
| 157 |
+
"type": "antigravity",
|
| 158 |
+
"access_token": "ok-token",
|
| 159 |
+
"expired": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
|
| 160 |
+
},
|
| 161 |
+
}
|
| 162 |
+
h := &Handler{}
|
| 163 |
+
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
| 164 |
+
if err != nil {
|
| 165 |
+
t.Fatalf("resolveTokenForAuth: %v", err)
|
| 166 |
+
}
|
| 167 |
+
if token != "ok-token" {
|
| 168 |
+
t.Fatalf("expected existing token, got %q", token)
|
| 169 |
+
}
|
| 170 |
+
if callCount != 0 {
|
| 171 |
+
t.Fatalf("expected no refresh calls, got %d", callCount)
|
| 172 |
+
}
|
| 173 |
+
}
|
internal/api/handlers/management/auth_files.go
ADDED
|
@@ -0,0 +1,2191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"crypto/sha256"
|
| 7 |
+
"encoding/hex"
|
| 8 |
+
"encoding/json"
|
| 9 |
+
"errors"
|
| 10 |
+
"fmt"
|
| 11 |
+
"io"
|
| 12 |
+
"net"
|
| 13 |
+
"net/http"
|
| 14 |
+
"os"
|
| 15 |
+
"path/filepath"
|
| 16 |
+
"sort"
|
| 17 |
+
"strconv"
|
| 18 |
+
"strings"
|
| 19 |
+
"sync"
|
| 20 |
+
"time"
|
| 21 |
+
|
| 22 |
+
"github.com/gin-gonic/gin"
|
| 23 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
|
| 24 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
| 25 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
| 26 |
+
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
| 27 |
+
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
| 28 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
| 29 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
| 30 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
| 31 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
| 32 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 33 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 34 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 35 |
+
log "github.com/sirupsen/logrus"
|
| 36 |
+
"github.com/tidwall/gjson"
|
| 37 |
+
"golang.org/x/oauth2"
|
| 38 |
+
"golang.org/x/oauth2/google"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
|
| 42 |
+
|
| 43 |
+
const (
|
| 44 |
+
anthropicCallbackPort = 54545
|
| 45 |
+
geminiCallbackPort = 8085
|
| 46 |
+
codexCallbackPort = 1455
|
| 47 |
+
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
|
| 48 |
+
geminiCLIVersion = "v1internal"
|
| 49 |
+
geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
|
| 50 |
+
geminiCLIApiClient = "gl-node/22.17.0"
|
| 51 |
+
geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
type callbackForwarder struct {
|
| 55 |
+
provider string
|
| 56 |
+
server *http.Server
|
| 57 |
+
done chan struct{}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
var (
|
| 61 |
+
callbackForwardersMu sync.Mutex
|
| 62 |
+
callbackForwarders = make(map[int]*callbackForwarder)
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
|
| 66 |
+
if len(meta) == 0 {
|
| 67 |
+
return time.Time{}, false
|
| 68 |
+
}
|
| 69 |
+
for _, key := range lastRefreshKeys {
|
| 70 |
+
if val, ok := meta[key]; ok {
|
| 71 |
+
if ts, ok1 := parseLastRefreshValue(val); ok1 {
|
| 72 |
+
return ts, true
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
return time.Time{}, false
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
func parseLastRefreshValue(v any) (time.Time, bool) {
|
| 80 |
+
switch val := v.(type) {
|
| 81 |
+
case string:
|
| 82 |
+
s := strings.TrimSpace(val)
|
| 83 |
+
if s == "" {
|
| 84 |
+
return time.Time{}, false
|
| 85 |
+
}
|
| 86 |
+
layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
|
| 87 |
+
for _, layout := range layouts {
|
| 88 |
+
if ts, err := time.Parse(layout, s); err == nil {
|
| 89 |
+
return ts.UTC(), true
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
|
| 93 |
+
return time.Unix(unix, 0).UTC(), true
|
| 94 |
+
}
|
| 95 |
+
case float64:
|
| 96 |
+
if val <= 0 {
|
| 97 |
+
return time.Time{}, false
|
| 98 |
+
}
|
| 99 |
+
return time.Unix(int64(val), 0).UTC(), true
|
| 100 |
+
case int64:
|
| 101 |
+
if val <= 0 {
|
| 102 |
+
return time.Time{}, false
|
| 103 |
+
}
|
| 104 |
+
return time.Unix(val, 0).UTC(), true
|
| 105 |
+
case int:
|
| 106 |
+
if val <= 0 {
|
| 107 |
+
return time.Time{}, false
|
| 108 |
+
}
|
| 109 |
+
return time.Unix(int64(val), 0).UTC(), true
|
| 110 |
+
case json.Number:
|
| 111 |
+
if i, err := val.Int64(); err == nil && i > 0 {
|
| 112 |
+
return time.Unix(i, 0).UTC(), true
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
return time.Time{}, false
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func isWebUIRequest(c *gin.Context) bool {
|
| 119 |
+
raw := strings.TrimSpace(c.Query("is_webui"))
|
| 120 |
+
if raw == "" {
|
| 121 |
+
return false
|
| 122 |
+
}
|
| 123 |
+
switch strings.ToLower(raw) {
|
| 124 |
+
case "1", "true", "yes", "on":
|
| 125 |
+
return true
|
| 126 |
+
default:
|
| 127 |
+
return false
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) {
|
| 132 |
+
callbackForwardersMu.Lock()
|
| 133 |
+
prev := callbackForwarders[port]
|
| 134 |
+
if prev != nil {
|
| 135 |
+
delete(callbackForwarders, port)
|
| 136 |
+
}
|
| 137 |
+
callbackForwardersMu.Unlock()
|
| 138 |
+
|
| 139 |
+
if prev != nil {
|
| 140 |
+
stopForwarderInstance(port, prev)
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
| 144 |
+
ln, err := net.Listen("tcp", addr)
|
| 145 |
+
if err != nil {
|
| 146 |
+
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 150 |
+
target := targetBase
|
| 151 |
+
if raw := r.URL.RawQuery; raw != "" {
|
| 152 |
+
if strings.Contains(target, "?") {
|
| 153 |
+
target = target + "&" + raw
|
| 154 |
+
} else {
|
| 155 |
+
target = target + "?" + raw
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
w.Header().Set("Cache-Control", "no-store")
|
| 159 |
+
http.Redirect(w, r, target, http.StatusFound)
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
srv := &http.Server{
|
| 163 |
+
Handler: handler,
|
| 164 |
+
ReadHeaderTimeout: 5 * time.Second,
|
| 165 |
+
WriteTimeout: 5 * time.Second,
|
| 166 |
+
}
|
| 167 |
+
done := make(chan struct{})
|
| 168 |
+
|
| 169 |
+
go func() {
|
| 170 |
+
if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
|
| 171 |
+
log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider)
|
| 172 |
+
}
|
| 173 |
+
close(done)
|
| 174 |
+
}()
|
| 175 |
+
|
| 176 |
+
forwarder := &callbackForwarder{
|
| 177 |
+
provider: provider,
|
| 178 |
+
server: srv,
|
| 179 |
+
done: done,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
callbackForwardersMu.Lock()
|
| 183 |
+
callbackForwarders[port] = forwarder
|
| 184 |
+
callbackForwardersMu.Unlock()
|
| 185 |
+
|
| 186 |
+
log.Infof("callback forwarder for %s listening on %s", provider, addr)
|
| 187 |
+
|
| 188 |
+
return forwarder, nil
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
func stopCallbackForwarder(port int) {
|
| 192 |
+
callbackForwardersMu.Lock()
|
| 193 |
+
forwarder := callbackForwarders[port]
|
| 194 |
+
if forwarder != nil {
|
| 195 |
+
delete(callbackForwarders, port)
|
| 196 |
+
}
|
| 197 |
+
callbackForwardersMu.Unlock()
|
| 198 |
+
|
| 199 |
+
stopForwarderInstance(port, forwarder)
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
|
| 203 |
+
if forwarder == nil {
|
| 204 |
+
return
|
| 205 |
+
}
|
| 206 |
+
callbackForwardersMu.Lock()
|
| 207 |
+
if current := callbackForwarders[port]; current == forwarder {
|
| 208 |
+
delete(callbackForwarders, port)
|
| 209 |
+
}
|
| 210 |
+
callbackForwardersMu.Unlock()
|
| 211 |
+
|
| 212 |
+
stopForwarderInstance(port, forwarder)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
func stopForwarderInstance(port int, forwarder *callbackForwarder) {
|
| 216 |
+
if forwarder == nil || forwarder.server == nil {
|
| 217 |
+
return
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
| 221 |
+
defer cancel()
|
| 222 |
+
|
| 223 |
+
if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
| 224 |
+
log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
select {
|
| 228 |
+
case <-forwarder.done:
|
| 229 |
+
case <-time.After(2 * time.Second):
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
log.Infof("callback forwarder on port %d stopped", port)
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
func (h *Handler) managementCallbackURL(path string) (string, error) {
|
| 236 |
+
if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
|
| 237 |
+
return "", fmt.Errorf("server port is not configured")
|
| 238 |
+
}
|
| 239 |
+
if !strings.HasPrefix(path, "/") {
|
| 240 |
+
path = "/" + path
|
| 241 |
+
}
|
| 242 |
+
scheme := "http"
|
| 243 |
+
if h.cfg.TLS.Enable {
|
| 244 |
+
scheme = "https"
|
| 245 |
+
}
|
| 246 |
+
return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
func (h *Handler) ListAuthFiles(c *gin.Context) {
|
| 250 |
+
if h == nil {
|
| 251 |
+
c.JSON(500, gin.H{"error": "handler not initialized"})
|
| 252 |
+
return
|
| 253 |
+
}
|
| 254 |
+
if h.authManager == nil {
|
| 255 |
+
h.listAuthFilesFromDisk(c)
|
| 256 |
+
return
|
| 257 |
+
}
|
| 258 |
+
auths := h.authManager.List()
|
| 259 |
+
files := make([]gin.H, 0, len(auths))
|
| 260 |
+
for _, auth := range auths {
|
| 261 |
+
if entry := h.buildAuthFileEntry(auth); entry != nil {
|
| 262 |
+
files = append(files, entry)
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
sort.Slice(files, func(i, j int) bool {
|
| 266 |
+
nameI, _ := files[i]["name"].(string)
|
| 267 |
+
nameJ, _ := files[j]["name"].(string)
|
| 268 |
+
return strings.ToLower(nameI) < strings.ToLower(nameJ)
|
| 269 |
+
})
|
| 270 |
+
c.JSON(200, gin.H{"files": files})
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// GetAuthFileModels returns the models supported by a specific auth file
|
| 274 |
+
func (h *Handler) GetAuthFileModels(c *gin.Context) {
|
| 275 |
+
name := c.Query("name")
|
| 276 |
+
if name == "" {
|
| 277 |
+
c.JSON(400, gin.H{"error": "name is required"})
|
| 278 |
+
return
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Try to find auth ID via authManager
|
| 282 |
+
var authID string
|
| 283 |
+
if h.authManager != nil {
|
| 284 |
+
auths := h.authManager.List()
|
| 285 |
+
for _, auth := range auths {
|
| 286 |
+
if auth.FileName == name || auth.ID == name {
|
| 287 |
+
authID = auth.ID
|
| 288 |
+
break
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
if authID == "" {
|
| 294 |
+
authID = name // fallback to filename as ID
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// Get models from registry
|
| 298 |
+
reg := registry.GetGlobalRegistry()
|
| 299 |
+
models := reg.GetModelsForClient(authID)
|
| 300 |
+
|
| 301 |
+
result := make([]gin.H, 0, len(models))
|
| 302 |
+
for _, m := range models {
|
| 303 |
+
entry := gin.H{
|
| 304 |
+
"id": m.ID,
|
| 305 |
+
}
|
| 306 |
+
if m.DisplayName != "" {
|
| 307 |
+
entry["display_name"] = m.DisplayName
|
| 308 |
+
}
|
| 309 |
+
if m.Type != "" {
|
| 310 |
+
entry["type"] = m.Type
|
| 311 |
+
}
|
| 312 |
+
if m.OwnedBy != "" {
|
| 313 |
+
entry["owned_by"] = m.OwnedBy
|
| 314 |
+
}
|
| 315 |
+
result = append(result, entry)
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
c.JSON(200, gin.H{"models": result})
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// List auth files from disk when the auth manager is unavailable.
|
| 322 |
+
func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
|
| 323 |
+
entries, err := os.ReadDir(h.cfg.AuthDir)
|
| 324 |
+
if err != nil {
|
| 325 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
| 326 |
+
return
|
| 327 |
+
}
|
| 328 |
+
files := make([]gin.H, 0)
|
| 329 |
+
for _, e := range entries {
|
| 330 |
+
if e.IsDir() {
|
| 331 |
+
continue
|
| 332 |
+
}
|
| 333 |
+
name := e.Name()
|
| 334 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 335 |
+
continue
|
| 336 |
+
}
|
| 337 |
+
if info, errInfo := e.Info(); errInfo == nil {
|
| 338 |
+
fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
|
| 339 |
+
|
| 340 |
+
// Read file to get type field
|
| 341 |
+
full := filepath.Join(h.cfg.AuthDir, name)
|
| 342 |
+
if data, errRead := os.ReadFile(full); errRead == nil {
|
| 343 |
+
typeValue := gjson.GetBytes(data, "type").String()
|
| 344 |
+
emailValue := gjson.GetBytes(data, "email").String()
|
| 345 |
+
fileData["type"] = typeValue
|
| 346 |
+
fileData["email"] = emailValue
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
files = append(files, fileData)
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
c.JSON(200, gin.H{"files": files})
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
|
| 356 |
+
if auth == nil {
|
| 357 |
+
return nil
|
| 358 |
+
}
|
| 359 |
+
auth.EnsureIndex()
|
| 360 |
+
runtimeOnly := isRuntimeOnlyAuth(auth)
|
| 361 |
+
if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
|
| 362 |
+
return nil
|
| 363 |
+
}
|
| 364 |
+
path := strings.TrimSpace(authAttribute(auth, "path"))
|
| 365 |
+
if path == "" && !runtimeOnly {
|
| 366 |
+
return nil
|
| 367 |
+
}
|
| 368 |
+
name := strings.TrimSpace(auth.FileName)
|
| 369 |
+
if name == "" {
|
| 370 |
+
name = auth.ID
|
| 371 |
+
}
|
| 372 |
+
entry := gin.H{
|
| 373 |
+
"id": auth.ID,
|
| 374 |
+
"auth_index": auth.Index,
|
| 375 |
+
"name": name,
|
| 376 |
+
"type": strings.TrimSpace(auth.Provider),
|
| 377 |
+
"provider": strings.TrimSpace(auth.Provider),
|
| 378 |
+
"label": auth.Label,
|
| 379 |
+
"status": auth.Status,
|
| 380 |
+
"status_message": auth.StatusMessage,
|
| 381 |
+
"disabled": auth.Disabled,
|
| 382 |
+
"unavailable": auth.Unavailable,
|
| 383 |
+
"runtime_only": runtimeOnly,
|
| 384 |
+
"source": "memory",
|
| 385 |
+
"size": int64(0),
|
| 386 |
+
}
|
| 387 |
+
if email := authEmail(auth); email != "" {
|
| 388 |
+
entry["email"] = email
|
| 389 |
+
}
|
| 390 |
+
if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
|
| 391 |
+
if accountType != "" {
|
| 392 |
+
entry["account_type"] = accountType
|
| 393 |
+
}
|
| 394 |
+
if account != "" {
|
| 395 |
+
entry["account"] = account
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
if !auth.CreatedAt.IsZero() {
|
| 399 |
+
entry["created_at"] = auth.CreatedAt
|
| 400 |
+
}
|
| 401 |
+
if !auth.UpdatedAt.IsZero() {
|
| 402 |
+
entry["modtime"] = auth.UpdatedAt
|
| 403 |
+
entry["updated_at"] = auth.UpdatedAt
|
| 404 |
+
}
|
| 405 |
+
if !auth.LastRefreshedAt.IsZero() {
|
| 406 |
+
entry["last_refresh"] = auth.LastRefreshedAt
|
| 407 |
+
}
|
| 408 |
+
if path != "" {
|
| 409 |
+
entry["path"] = path
|
| 410 |
+
entry["source"] = "file"
|
| 411 |
+
if info, err := os.Stat(path); err == nil {
|
| 412 |
+
entry["size"] = info.Size()
|
| 413 |
+
entry["modtime"] = info.ModTime()
|
| 414 |
+
} else if os.IsNotExist(err) {
|
| 415 |
+
// Hide credentials removed from disk but still lingering in memory.
|
| 416 |
+
if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
|
| 417 |
+
return nil
|
| 418 |
+
}
|
| 419 |
+
entry["source"] = "memory"
|
| 420 |
+
} else {
|
| 421 |
+
log.WithError(err).Warnf("failed to stat auth file %s", path)
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
if claims := extractCodexIDTokenClaims(auth); claims != nil {
|
| 425 |
+
entry["id_token"] = claims
|
| 426 |
+
}
|
| 427 |
+
return entry
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
|
| 431 |
+
if auth == nil || auth.Metadata == nil {
|
| 432 |
+
return nil
|
| 433 |
+
}
|
| 434 |
+
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
|
| 435 |
+
return nil
|
| 436 |
+
}
|
| 437 |
+
idTokenRaw, ok := auth.Metadata["id_token"].(string)
|
| 438 |
+
if !ok {
|
| 439 |
+
return nil
|
| 440 |
+
}
|
| 441 |
+
idToken := strings.TrimSpace(idTokenRaw)
|
| 442 |
+
if idToken == "" {
|
| 443 |
+
return nil
|
| 444 |
+
}
|
| 445 |
+
claims, err := codex.ParseJWTToken(idToken)
|
| 446 |
+
if err != nil || claims == nil {
|
| 447 |
+
return nil
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
result := gin.H{}
|
| 451 |
+
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
|
| 452 |
+
result["chatgpt_account_id"] = v
|
| 453 |
+
}
|
| 454 |
+
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
|
| 455 |
+
result["plan_type"] = v
|
| 456 |
+
}
|
| 457 |
+
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
|
| 458 |
+
result["chatgpt_subscription_active_start"] = v
|
| 459 |
+
}
|
| 460 |
+
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
|
| 461 |
+
result["chatgpt_subscription_active_until"] = v
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
if len(result) == 0 {
|
| 465 |
+
return nil
|
| 466 |
+
}
|
| 467 |
+
return result
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
func authEmail(auth *coreauth.Auth) string {
|
| 471 |
+
if auth == nil {
|
| 472 |
+
return ""
|
| 473 |
+
}
|
| 474 |
+
if auth.Metadata != nil {
|
| 475 |
+
if v, ok := auth.Metadata["email"].(string); ok {
|
| 476 |
+
return strings.TrimSpace(v)
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
if auth.Attributes != nil {
|
| 480 |
+
if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
|
| 481 |
+
return v
|
| 482 |
+
}
|
| 483 |
+
if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
|
| 484 |
+
return v
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
return ""
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
func authAttribute(auth *coreauth.Auth, key string) string {
|
| 491 |
+
if auth == nil || len(auth.Attributes) == 0 {
|
| 492 |
+
return ""
|
| 493 |
+
}
|
| 494 |
+
return auth.Attributes[key]
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
|
| 498 |
+
if auth == nil || len(auth.Attributes) == 0 {
|
| 499 |
+
return false
|
| 500 |
+
}
|
| 501 |
+
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// Download single auth file by name
|
| 505 |
+
func (h *Handler) DownloadAuthFile(c *gin.Context) {
|
| 506 |
+
name := c.Query("name")
|
| 507 |
+
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
| 508 |
+
c.JSON(400, gin.H{"error": "invalid name"})
|
| 509 |
+
return
|
| 510 |
+
}
|
| 511 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 512 |
+
c.JSON(400, gin.H{"error": "name must end with .json"})
|
| 513 |
+
return
|
| 514 |
+
}
|
| 515 |
+
full := filepath.Join(h.cfg.AuthDir, name)
|
| 516 |
+
data, err := os.ReadFile(full)
|
| 517 |
+
if err != nil {
|
| 518 |
+
if os.IsNotExist(err) {
|
| 519 |
+
c.JSON(404, gin.H{"error": "file not found"})
|
| 520 |
+
} else {
|
| 521 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
| 522 |
+
}
|
| 523 |
+
return
|
| 524 |
+
}
|
| 525 |
+
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
|
| 526 |
+
c.Data(200, "application/json", data)
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// Upload auth file: multipart or raw JSON with ?name=
|
| 530 |
+
func (h *Handler) UploadAuthFile(c *gin.Context) {
|
| 531 |
+
if h.authManager == nil {
|
| 532 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
| 533 |
+
return
|
| 534 |
+
}
|
| 535 |
+
ctx := c.Request.Context()
|
| 536 |
+
if file, err := c.FormFile("file"); err == nil && file != nil {
|
| 537 |
+
name := filepath.Base(file.Filename)
|
| 538 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 539 |
+
c.JSON(400, gin.H{"error": "file must be .json"})
|
| 540 |
+
return
|
| 541 |
+
}
|
| 542 |
+
dst := filepath.Join(h.cfg.AuthDir, name)
|
| 543 |
+
if !filepath.IsAbs(dst) {
|
| 544 |
+
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
| 545 |
+
dst = abs
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
|
| 549 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
|
| 550 |
+
return
|
| 551 |
+
}
|
| 552 |
+
data, errRead := os.ReadFile(dst)
|
| 553 |
+
if errRead != nil {
|
| 554 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
|
| 555 |
+
return
|
| 556 |
+
}
|
| 557 |
+
if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
|
| 558 |
+
c.JSON(500, gin.H{"error": errReg.Error()})
|
| 559 |
+
return
|
| 560 |
+
}
|
| 561 |
+
c.JSON(200, gin.H{"status": "ok"})
|
| 562 |
+
return
|
| 563 |
+
}
|
| 564 |
+
name := c.Query("name")
|
| 565 |
+
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
| 566 |
+
c.JSON(400, gin.H{"error": "invalid name"})
|
| 567 |
+
return
|
| 568 |
+
}
|
| 569 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 570 |
+
c.JSON(400, gin.H{"error": "name must end with .json"})
|
| 571 |
+
return
|
| 572 |
+
}
|
| 573 |
+
data, err := io.ReadAll(c.Request.Body)
|
| 574 |
+
if err != nil {
|
| 575 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 576 |
+
return
|
| 577 |
+
}
|
| 578 |
+
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
| 579 |
+
if !filepath.IsAbs(dst) {
|
| 580 |
+
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
| 581 |
+
dst = abs
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
|
| 585 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
|
| 586 |
+
return
|
| 587 |
+
}
|
| 588 |
+
if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
|
| 589 |
+
c.JSON(500, gin.H{"error": err.Error()})
|
| 590 |
+
return
|
| 591 |
+
}
|
| 592 |
+
c.JSON(200, gin.H{"status": "ok"})
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
// Delete auth files: single by name or all
|
| 596 |
+
func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
| 597 |
+
if h.authManager == nil {
|
| 598 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
| 599 |
+
return
|
| 600 |
+
}
|
| 601 |
+
ctx := c.Request.Context()
|
| 602 |
+
if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
|
| 603 |
+
entries, err := os.ReadDir(h.cfg.AuthDir)
|
| 604 |
+
if err != nil {
|
| 605 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
| 606 |
+
return
|
| 607 |
+
}
|
| 608 |
+
deleted := 0
|
| 609 |
+
for _, e := range entries {
|
| 610 |
+
if e.IsDir() {
|
| 611 |
+
continue
|
| 612 |
+
}
|
| 613 |
+
name := e.Name()
|
| 614 |
+
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
| 615 |
+
continue
|
| 616 |
+
}
|
| 617 |
+
full := filepath.Join(h.cfg.AuthDir, name)
|
| 618 |
+
if !filepath.IsAbs(full) {
|
| 619 |
+
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
| 620 |
+
full = abs
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
if err = os.Remove(full); err == nil {
|
| 624 |
+
if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
|
| 625 |
+
c.JSON(500, gin.H{"error": errDel.Error()})
|
| 626 |
+
return
|
| 627 |
+
}
|
| 628 |
+
deleted++
|
| 629 |
+
h.disableAuth(ctx, full)
|
| 630 |
+
}
|
| 631 |
+
}
|
| 632 |
+
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
|
| 633 |
+
return
|
| 634 |
+
}
|
| 635 |
+
name := c.Query("name")
|
| 636 |
+
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
| 637 |
+
c.JSON(400, gin.H{"error": "invalid name"})
|
| 638 |
+
return
|
| 639 |
+
}
|
| 640 |
+
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
| 641 |
+
if !filepath.IsAbs(full) {
|
| 642 |
+
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
| 643 |
+
full = abs
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
if err := os.Remove(full); err != nil {
|
| 647 |
+
if os.IsNotExist(err) {
|
| 648 |
+
c.JSON(404, gin.H{"error": "file not found"})
|
| 649 |
+
} else {
|
| 650 |
+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
|
| 651 |
+
}
|
| 652 |
+
return
|
| 653 |
+
}
|
| 654 |
+
if err := h.deleteTokenRecord(ctx, full); err != nil {
|
| 655 |
+
c.JSON(500, gin.H{"error": err.Error()})
|
| 656 |
+
return
|
| 657 |
+
}
|
| 658 |
+
h.disableAuth(ctx, full)
|
| 659 |
+
c.JSON(200, gin.H{"status": "ok"})
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
func (h *Handler) authIDForPath(path string) string {
|
| 663 |
+
path = strings.TrimSpace(path)
|
| 664 |
+
if path == "" {
|
| 665 |
+
return ""
|
| 666 |
+
}
|
| 667 |
+
if h == nil || h.cfg == nil {
|
| 668 |
+
return path
|
| 669 |
+
}
|
| 670 |
+
authDir := strings.TrimSpace(h.cfg.AuthDir)
|
| 671 |
+
if authDir == "" {
|
| 672 |
+
return path
|
| 673 |
+
}
|
| 674 |
+
if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" {
|
| 675 |
+
return rel
|
| 676 |
+
}
|
| 677 |
+
return path
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
|
| 681 |
+
if h.authManager == nil {
|
| 682 |
+
return nil
|
| 683 |
+
}
|
| 684 |
+
if path == "" {
|
| 685 |
+
return fmt.Errorf("auth path is empty")
|
| 686 |
+
}
|
| 687 |
+
if data == nil {
|
| 688 |
+
var err error
|
| 689 |
+
data, err = os.ReadFile(path)
|
| 690 |
+
if err != nil {
|
| 691 |
+
return fmt.Errorf("failed to read auth file: %w", err)
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
metadata := make(map[string]any)
|
| 695 |
+
if err := json.Unmarshal(data, &metadata); err != nil {
|
| 696 |
+
return fmt.Errorf("invalid auth file: %w", err)
|
| 697 |
+
}
|
| 698 |
+
provider, _ := metadata["type"].(string)
|
| 699 |
+
if provider == "" {
|
| 700 |
+
provider = "unknown"
|
| 701 |
+
}
|
| 702 |
+
label := provider
|
| 703 |
+
if email, ok := metadata["email"].(string); ok && email != "" {
|
| 704 |
+
label = email
|
| 705 |
+
}
|
| 706 |
+
lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
|
| 707 |
+
|
| 708 |
+
authID := h.authIDForPath(path)
|
| 709 |
+
if authID == "" {
|
| 710 |
+
authID = path
|
| 711 |
+
}
|
| 712 |
+
attr := map[string]string{
|
| 713 |
+
"path": path,
|
| 714 |
+
"source": path,
|
| 715 |
+
}
|
| 716 |
+
auth := &coreauth.Auth{
|
| 717 |
+
ID: authID,
|
| 718 |
+
Provider: provider,
|
| 719 |
+
FileName: filepath.Base(path),
|
| 720 |
+
Label: label,
|
| 721 |
+
Status: coreauth.StatusActive,
|
| 722 |
+
Attributes: attr,
|
| 723 |
+
Metadata: metadata,
|
| 724 |
+
CreatedAt: time.Now(),
|
| 725 |
+
UpdatedAt: time.Now(),
|
| 726 |
+
}
|
| 727 |
+
if hasLastRefresh {
|
| 728 |
+
auth.LastRefreshedAt = lastRefresh
|
| 729 |
+
}
|
| 730 |
+
if existing, ok := h.authManager.GetByID(authID); ok {
|
| 731 |
+
auth.CreatedAt = existing.CreatedAt
|
| 732 |
+
if !hasLastRefresh {
|
| 733 |
+
auth.LastRefreshedAt = existing.LastRefreshedAt
|
| 734 |
+
}
|
| 735 |
+
auth.NextRefreshAfter = existing.NextRefreshAfter
|
| 736 |
+
auth.Runtime = existing.Runtime
|
| 737 |
+
_, err := h.authManager.Update(ctx, auth)
|
| 738 |
+
return err
|
| 739 |
+
}
|
| 740 |
+
_, err := h.authManager.Register(ctx, auth)
|
| 741 |
+
return err
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
// PatchAuthFileStatus toggles the disabled state of an auth file
|
| 745 |
+
func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
|
| 746 |
+
if h.authManager == nil {
|
| 747 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
| 748 |
+
return
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
var req struct {
|
| 752 |
+
Name string `json:"name"`
|
| 753 |
+
Disabled *bool `json:"disabled"`
|
| 754 |
+
}
|
| 755 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 756 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
| 757 |
+
return
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
name := strings.TrimSpace(req.Name)
|
| 761 |
+
if name == "" {
|
| 762 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
| 763 |
+
return
|
| 764 |
+
}
|
| 765 |
+
if req.Disabled == nil {
|
| 766 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
|
| 767 |
+
return
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
ctx := c.Request.Context()
|
| 771 |
+
|
| 772 |
+
// Find auth by name or ID
|
| 773 |
+
var targetAuth *coreauth.Auth
|
| 774 |
+
if auth, ok := h.authManager.GetByID(name); ok {
|
| 775 |
+
targetAuth = auth
|
| 776 |
+
} else {
|
| 777 |
+
auths := h.authManager.List()
|
| 778 |
+
for _, auth := range auths {
|
| 779 |
+
if auth.FileName == name {
|
| 780 |
+
targetAuth = auth
|
| 781 |
+
break
|
| 782 |
+
}
|
| 783 |
+
}
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
if targetAuth == nil {
|
| 787 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
| 788 |
+
return
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
// Update disabled state
|
| 792 |
+
targetAuth.Disabled = *req.Disabled
|
| 793 |
+
if *req.Disabled {
|
| 794 |
+
targetAuth.Status = coreauth.StatusDisabled
|
| 795 |
+
targetAuth.StatusMessage = "disabled via management API"
|
| 796 |
+
} else {
|
| 797 |
+
targetAuth.Status = coreauth.StatusActive
|
| 798 |
+
targetAuth.StatusMessage = ""
|
| 799 |
+
}
|
| 800 |
+
targetAuth.UpdatedAt = time.Now()
|
| 801 |
+
|
| 802 |
+
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
| 803 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
| 804 |
+
return
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
| 811 |
+
if h == nil || h.authManager == nil {
|
| 812 |
+
return
|
| 813 |
+
}
|
| 814 |
+
authID := h.authIDForPath(id)
|
| 815 |
+
if authID == "" {
|
| 816 |
+
authID = strings.TrimSpace(id)
|
| 817 |
+
}
|
| 818 |
+
if authID == "" {
|
| 819 |
+
return
|
| 820 |
+
}
|
| 821 |
+
if auth, ok := h.authManager.GetByID(authID); ok {
|
| 822 |
+
auth.Disabled = true
|
| 823 |
+
auth.Status = coreauth.StatusDisabled
|
| 824 |
+
auth.StatusMessage = "removed via management API"
|
| 825 |
+
auth.UpdatedAt = time.Now()
|
| 826 |
+
_, _ = h.authManager.Update(ctx, auth)
|
| 827 |
+
}
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
|
| 831 |
+
if strings.TrimSpace(path) == "" {
|
| 832 |
+
return fmt.Errorf("auth path is empty")
|
| 833 |
+
}
|
| 834 |
+
store := h.tokenStoreWithBaseDir()
|
| 835 |
+
if store == nil {
|
| 836 |
+
return fmt.Errorf("token store unavailable")
|
| 837 |
+
}
|
| 838 |
+
return store.Delete(ctx, path)
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
func (h *Handler) tokenStoreWithBaseDir() coreauth.Store {
|
| 842 |
+
if h == nil {
|
| 843 |
+
return nil
|
| 844 |
+
}
|
| 845 |
+
store := h.tokenStore
|
| 846 |
+
if store == nil {
|
| 847 |
+
store = sdkAuth.GetTokenStore()
|
| 848 |
+
h.tokenStore = store
|
| 849 |
+
}
|
| 850 |
+
if h.cfg != nil {
|
| 851 |
+
if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
|
| 852 |
+
dirSetter.SetBaseDir(h.cfg.AuthDir)
|
| 853 |
+
}
|
| 854 |
+
}
|
| 855 |
+
return store
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
|
| 859 |
+
if record == nil {
|
| 860 |
+
return "", fmt.Errorf("token record is nil")
|
| 861 |
+
}
|
| 862 |
+
store := h.tokenStoreWithBaseDir()
|
| 863 |
+
if store == nil {
|
| 864 |
+
return "", fmt.Errorf("token store unavailable")
|
| 865 |
+
}
|
| 866 |
+
return store.Save(ctx, record)
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
| 870 |
+
ctx := context.Background()
|
| 871 |
+
|
| 872 |
+
fmt.Println("Initializing Claude authentication...")
|
| 873 |
+
|
| 874 |
+
// Generate PKCE codes
|
| 875 |
+
pkceCodes, err := claude.GeneratePKCECodes()
|
| 876 |
+
if err != nil {
|
| 877 |
+
log.Errorf("Failed to generate PKCE codes: %v", err)
|
| 878 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
|
| 879 |
+
return
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
// Generate random state parameter
|
| 883 |
+
state, err := misc.GenerateRandomState()
|
| 884 |
+
if err != nil {
|
| 885 |
+
log.Errorf("Failed to generate state parameter: %v", err)
|
| 886 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
|
| 887 |
+
return
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
// Initialize Claude auth service
|
| 891 |
+
anthropicAuth := claude.NewClaudeAuth(h.cfg)
|
| 892 |
+
|
| 893 |
+
// Generate authorization URL (then override redirect_uri to reuse server port)
|
| 894 |
+
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
|
| 895 |
+
if err != nil {
|
| 896 |
+
log.Errorf("Failed to generate authorization URL: %v", err)
|
| 897 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
| 898 |
+
return
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
RegisterOAuthSession(state, "anthropic")
|
| 902 |
+
|
| 903 |
+
isWebUI := isWebUIRequest(c)
|
| 904 |
+
var forwarder *callbackForwarder
|
| 905 |
+
if isWebUI {
|
| 906 |
+
targetURL, errTarget := h.managementCallbackURL("/anthropic/callback")
|
| 907 |
+
if errTarget != nil {
|
| 908 |
+
log.WithError(errTarget).Error("failed to compute anthropic callback target")
|
| 909 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 910 |
+
return
|
| 911 |
+
}
|
| 912 |
+
var errStart error
|
| 913 |
+
if forwarder, errStart = startCallbackForwarder(anthropicCallbackPort, "anthropic", targetURL); errStart != nil {
|
| 914 |
+
log.WithError(errStart).Error("failed to start anthropic callback forwarder")
|
| 915 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 916 |
+
return
|
| 917 |
+
}
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
go func() {
|
| 921 |
+
if isWebUI {
|
| 922 |
+
defer stopCallbackForwarderInstance(anthropicCallbackPort, forwarder)
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
// Helper: wait for callback file
|
| 926 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
|
| 927 |
+
waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
|
| 928 |
+
deadline := time.Now().Add(timeout)
|
| 929 |
+
for {
|
| 930 |
+
if !IsOAuthSessionPending(state, "anthropic") {
|
| 931 |
+
return nil, errOAuthSessionNotPending
|
| 932 |
+
}
|
| 933 |
+
if time.Now().After(deadline) {
|
| 934 |
+
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
|
| 935 |
+
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
| 936 |
+
}
|
| 937 |
+
data, errRead := os.ReadFile(path)
|
| 938 |
+
if errRead == nil {
|
| 939 |
+
var m map[string]string
|
| 940 |
+
_ = json.Unmarshal(data, &m)
|
| 941 |
+
_ = os.Remove(path)
|
| 942 |
+
return m, nil
|
| 943 |
+
}
|
| 944 |
+
time.Sleep(500 * time.Millisecond)
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
fmt.Println("Waiting for authentication callback...")
|
| 949 |
+
// Wait up to 5 minutes
|
| 950 |
+
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
|
| 951 |
+
if errWait != nil {
|
| 952 |
+
if errors.Is(errWait, errOAuthSessionNotPending) {
|
| 953 |
+
return
|
| 954 |
+
}
|
| 955 |
+
authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
|
| 956 |
+
log.Error(claude.GetUserFriendlyMessage(authErr))
|
| 957 |
+
return
|
| 958 |
+
}
|
| 959 |
+
if errStr := resultMap["error"]; errStr != "" {
|
| 960 |
+
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
|
| 961 |
+
log.Error(claude.GetUserFriendlyMessage(oauthErr))
|
| 962 |
+
SetOAuthSessionError(state, "Bad request")
|
| 963 |
+
return
|
| 964 |
+
}
|
| 965 |
+
if resultMap["state"] != state {
|
| 966 |
+
authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
|
| 967 |
+
log.Error(claude.GetUserFriendlyMessage(authErr))
|
| 968 |
+
SetOAuthSessionError(state, "State code error")
|
| 969 |
+
return
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
// Parse code (Claude may append state after '#')
|
| 973 |
+
rawCode := resultMap["code"]
|
| 974 |
+
code := strings.Split(rawCode, "#")[0]
|
| 975 |
+
|
| 976 |
+
// Exchange code for tokens using internal auth service
|
| 977 |
+
bundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes)
|
| 978 |
+
if errExchange != nil {
|
| 979 |
+
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange)
|
| 980 |
+
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
| 981 |
+
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
|
| 982 |
+
return
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
// Create token storage
|
| 986 |
+
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
|
| 987 |
+
record := &coreauth.Auth{
|
| 988 |
+
ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
| 989 |
+
Provider: "claude",
|
| 990 |
+
FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
| 991 |
+
Storage: tokenStorage,
|
| 992 |
+
Metadata: map[string]any{"email": tokenStorage.Email},
|
| 993 |
+
}
|
| 994 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 995 |
+
if errSave != nil {
|
| 996 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 997 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 998 |
+
return
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1002 |
+
if bundle.APIKey != "" {
|
| 1003 |
+
fmt.Println("API key obtained and saved")
|
| 1004 |
+
}
|
| 1005 |
+
fmt.Println("You can now use Claude services through this CLI")
|
| 1006 |
+
CompleteOAuthSession(state)
|
| 1007 |
+
CompleteOAuthSessionsByProvider("anthropic")
|
| 1008 |
+
}()
|
| 1009 |
+
|
| 1010 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
| 1014 |
+
ctx := context.Background()
|
| 1015 |
+
proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
|
| 1016 |
+
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)
|
| 1017 |
+
|
| 1018 |
+
// Optional project ID from query
|
| 1019 |
+
projectID := c.Query("project_id")
|
| 1020 |
+
|
| 1021 |
+
fmt.Println("Initializing Google authentication...")
|
| 1022 |
+
|
| 1023 |
+
// OAuth2 configuration using exported constants from internal/auth/gemini
|
| 1024 |
+
conf := &oauth2.Config{
|
| 1025 |
+
ClientID: geminiAuth.ClientID,
|
| 1026 |
+
ClientSecret: geminiAuth.ClientSecret,
|
| 1027 |
+
RedirectURL: fmt.Sprintf("http://localhost:%d/oauth2callback", geminiAuth.DefaultCallbackPort),
|
| 1028 |
+
Scopes: geminiAuth.Scopes,
|
| 1029 |
+
Endpoint: google.Endpoint,
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
// Build authorization URL and return it immediately
|
| 1033 |
+
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
| 1034 |
+
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
| 1035 |
+
|
| 1036 |
+
RegisterOAuthSession(state, "gemini")
|
| 1037 |
+
|
| 1038 |
+
isWebUI := isWebUIRequest(c)
|
| 1039 |
+
var forwarder *callbackForwarder
|
| 1040 |
+
if isWebUI {
|
| 1041 |
+
targetURL, errTarget := h.managementCallbackURL("/google/callback")
|
| 1042 |
+
if errTarget != nil {
|
| 1043 |
+
log.WithError(errTarget).Error("failed to compute gemini callback target")
|
| 1044 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 1045 |
+
return
|
| 1046 |
+
}
|
| 1047 |
+
var errStart error
|
| 1048 |
+
if forwarder, errStart = startCallbackForwarder(geminiCallbackPort, "gemini", targetURL); errStart != nil {
|
| 1049 |
+
log.WithError(errStart).Error("failed to start gemini callback forwarder")
|
| 1050 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 1051 |
+
return
|
| 1052 |
+
}
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
go func() {
|
| 1056 |
+
if isWebUI {
|
| 1057 |
+
defer stopCallbackForwarderInstance(geminiCallbackPort, forwarder)
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
// Wait for callback file written by server route
|
| 1061 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
|
| 1062 |
+
fmt.Println("Waiting for authentication callback...")
|
| 1063 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1064 |
+
var authCode string
|
| 1065 |
+
for {
|
| 1066 |
+
if !IsOAuthSessionPending(state, "gemini") {
|
| 1067 |
+
return
|
| 1068 |
+
}
|
| 1069 |
+
if time.Now().After(deadline) {
|
| 1070 |
+
log.Error("oauth flow timed out")
|
| 1071 |
+
SetOAuthSessionError(state, "OAuth flow timed out")
|
| 1072 |
+
return
|
| 1073 |
+
}
|
| 1074 |
+
if data, errR := os.ReadFile(waitFile); errR == nil {
|
| 1075 |
+
var m map[string]string
|
| 1076 |
+
_ = json.Unmarshal(data, &m)
|
| 1077 |
+
_ = os.Remove(waitFile)
|
| 1078 |
+
if errStr := m["error"]; errStr != "" {
|
| 1079 |
+
log.Errorf("Authentication failed: %s", errStr)
|
| 1080 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1081 |
+
return
|
| 1082 |
+
}
|
| 1083 |
+
authCode = m["code"]
|
| 1084 |
+
if authCode == "" {
|
| 1085 |
+
log.Errorf("Authentication failed: code not found")
|
| 1086 |
+
SetOAuthSessionError(state, "Authentication failed: code not found")
|
| 1087 |
+
return
|
| 1088 |
+
}
|
| 1089 |
+
break
|
| 1090 |
+
}
|
| 1091 |
+
time.Sleep(500 * time.Millisecond)
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
// Exchange authorization code for token
|
| 1095 |
+
token, err := conf.Exchange(ctx, authCode)
|
| 1096 |
+
if err != nil {
|
| 1097 |
+
log.Errorf("Failed to exchange token: %v", err)
|
| 1098 |
+
SetOAuthSessionError(state, "Failed to exchange token")
|
| 1099 |
+
return
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
requestedProjectID := strings.TrimSpace(projectID)
|
| 1103 |
+
|
| 1104 |
+
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
| 1105 |
+
authHTTPClient := conf.Client(ctx, token)
|
| 1106 |
+
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
| 1107 |
+
if errNewRequest != nil {
|
| 1108 |
+
log.Errorf("Could not get user info: %v", errNewRequest)
|
| 1109 |
+
SetOAuthSessionError(state, "Could not get user info")
|
| 1110 |
+
return
|
| 1111 |
+
}
|
| 1112 |
+
req.Header.Set("Content-Type", "application/json")
|
| 1113 |
+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
| 1114 |
+
|
| 1115 |
+
resp, errDo := authHTTPClient.Do(req)
|
| 1116 |
+
if errDo != nil {
|
| 1117 |
+
log.Errorf("Failed to execute request: %v", errDo)
|
| 1118 |
+
SetOAuthSessionError(state, "Failed to execute request")
|
| 1119 |
+
return
|
| 1120 |
+
}
|
| 1121 |
+
defer func() {
|
| 1122 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 1123 |
+
log.Printf("warn: failed to close response body: %v", errClose)
|
| 1124 |
+
}
|
| 1125 |
+
}()
|
| 1126 |
+
|
| 1127 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 1128 |
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
| 1129 |
+
log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
| 1130 |
+
SetOAuthSessionError(state, fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode))
|
| 1131 |
+
return
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
email := gjson.GetBytes(bodyBytes, "email").String()
|
| 1135 |
+
if email != "" {
|
| 1136 |
+
fmt.Printf("Authenticated user email: %s\n", email)
|
| 1137 |
+
} else {
|
| 1138 |
+
fmt.Println("Failed to get user email from token")
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
// Marshal/unmarshal oauth2.Token to generic map and enrich fields
|
| 1142 |
+
var ifToken map[string]any
|
| 1143 |
+
jsonData, _ := json.Marshal(token)
|
| 1144 |
+
if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
|
| 1145 |
+
log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
|
| 1146 |
+
SetOAuthSessionError(state, "Failed to unmarshal token")
|
| 1147 |
+
return
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
|
| 1151 |
+
ifToken["client_id"] = geminiAuth.ClientID
|
| 1152 |
+
ifToken["client_secret"] = geminiAuth.ClientSecret
|
| 1153 |
+
ifToken["scopes"] = geminiAuth.Scopes
|
| 1154 |
+
ifToken["universe_domain"] = "googleapis.com"
|
| 1155 |
+
|
| 1156 |
+
ts := geminiAuth.GeminiTokenStorage{
|
| 1157 |
+
Token: ifToken,
|
| 1158 |
+
ProjectID: requestedProjectID,
|
| 1159 |
+
Email: email,
|
| 1160 |
+
Auto: requestedProjectID == "",
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
|
| 1164 |
+
gemAuth := geminiAuth.NewGeminiAuth()
|
| 1165 |
+
gemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, &geminiAuth.WebLoginOptions{
|
| 1166 |
+
NoBrowser: true,
|
| 1167 |
+
})
|
| 1168 |
+
if errGetClient != nil {
|
| 1169 |
+
log.Errorf("failed to get authenticated client: %v", errGetClient)
|
| 1170 |
+
SetOAuthSessionError(state, "Failed to get authenticated client")
|
| 1171 |
+
return
|
| 1172 |
+
}
|
| 1173 |
+
fmt.Println("Authentication successful.")
|
| 1174 |
+
|
| 1175 |
+
if strings.EqualFold(requestedProjectID, "ALL") {
|
| 1176 |
+
ts.Auto = false
|
| 1177 |
+
projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts)
|
| 1178 |
+
if errAll != nil {
|
| 1179 |
+
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errAll)
|
| 1180 |
+
SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
|
| 1181 |
+
return
|
| 1182 |
+
}
|
| 1183 |
+
if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil {
|
| 1184 |
+
log.Errorf("Failed to verify Cloud AI API status: %v", errVerify)
|
| 1185 |
+
SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
|
| 1186 |
+
return
|
| 1187 |
+
}
|
| 1188 |
+
ts.ProjectID = strings.Join(projects, ",")
|
| 1189 |
+
ts.Checked = true
|
| 1190 |
+
} else {
|
| 1191 |
+
if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
|
| 1192 |
+
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
|
| 1193 |
+
SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
|
| 1194 |
+
return
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
if strings.TrimSpace(ts.ProjectID) == "" {
|
| 1198 |
+
log.Error("Onboarding did not return a project ID")
|
| 1199 |
+
SetOAuthSessionError(state, "Failed to resolve project ID")
|
| 1200 |
+
return
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
|
| 1204 |
+
if errCheck != nil {
|
| 1205 |
+
log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
|
| 1206 |
+
SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
|
| 1207 |
+
return
|
| 1208 |
+
}
|
| 1209 |
+
ts.Checked = isChecked
|
| 1210 |
+
if !isChecked {
|
| 1211 |
+
log.Error("Cloud AI API is not enabled for the selected project")
|
| 1212 |
+
SetOAuthSessionError(state, "Cloud AI API not enabled")
|
| 1213 |
+
return
|
| 1214 |
+
}
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
recordMetadata := map[string]any{
|
| 1218 |
+
"email": ts.Email,
|
| 1219 |
+
"project_id": ts.ProjectID,
|
| 1220 |
+
"auto": ts.Auto,
|
| 1221 |
+
"checked": ts.Checked,
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
fileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true)
|
| 1225 |
+
record := &coreauth.Auth{
|
| 1226 |
+
ID: fileName,
|
| 1227 |
+
Provider: "gemini",
|
| 1228 |
+
FileName: fileName,
|
| 1229 |
+
Storage: &ts,
|
| 1230 |
+
Metadata: recordMetadata,
|
| 1231 |
+
}
|
| 1232 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1233 |
+
if errSave != nil {
|
| 1234 |
+
log.Errorf("Failed to save token to file: %v", errSave)
|
| 1235 |
+
SetOAuthSessionError(state, "Failed to save token to file")
|
| 1236 |
+
return
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
CompleteOAuthSession(state)
|
| 1240 |
+
CompleteOAuthSessionsByProvider("gemini")
|
| 1241 |
+
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
|
| 1242 |
+
}()
|
| 1243 |
+
|
| 1244 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
func (h *Handler) RequestCodexToken(c *gin.Context) {
|
| 1248 |
+
ctx := context.Background()
|
| 1249 |
+
|
| 1250 |
+
fmt.Println("Initializing Codex authentication...")
|
| 1251 |
+
|
| 1252 |
+
// Generate PKCE codes
|
| 1253 |
+
pkceCodes, err := codex.GeneratePKCECodes()
|
| 1254 |
+
if err != nil {
|
| 1255 |
+
log.Errorf("Failed to generate PKCE codes: %v", err)
|
| 1256 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
|
| 1257 |
+
return
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
// Generate random state parameter
|
| 1261 |
+
state, err := misc.GenerateRandomState()
|
| 1262 |
+
if err != nil {
|
| 1263 |
+
log.Errorf("Failed to generate state parameter: %v", err)
|
| 1264 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
|
| 1265 |
+
return
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
// Initialize Codex auth service
|
| 1269 |
+
openaiAuth := codex.NewCodexAuth(h.cfg)
|
| 1270 |
+
|
| 1271 |
+
// Generate authorization URL
|
| 1272 |
+
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
|
| 1273 |
+
if err != nil {
|
| 1274 |
+
log.Errorf("Failed to generate authorization URL: %v", err)
|
| 1275 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
| 1276 |
+
return
|
| 1277 |
+
}
|
| 1278 |
+
|
| 1279 |
+
RegisterOAuthSession(state, "codex")
|
| 1280 |
+
|
| 1281 |
+
isWebUI := isWebUIRequest(c)
|
| 1282 |
+
var forwarder *callbackForwarder
|
| 1283 |
+
if isWebUI {
|
| 1284 |
+
targetURL, errTarget := h.managementCallbackURL("/codex/callback")
|
| 1285 |
+
if errTarget != nil {
|
| 1286 |
+
log.WithError(errTarget).Error("failed to compute codex callback target")
|
| 1287 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 1288 |
+
return
|
| 1289 |
+
}
|
| 1290 |
+
var errStart error
|
| 1291 |
+
if forwarder, errStart = startCallbackForwarder(codexCallbackPort, "codex", targetURL); errStart != nil {
|
| 1292 |
+
log.WithError(errStart).Error("failed to start codex callback forwarder")
|
| 1293 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 1294 |
+
return
|
| 1295 |
+
}
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
go func() {
|
| 1299 |
+
if isWebUI {
|
| 1300 |
+
defer stopCallbackForwarderInstance(codexCallbackPort, forwarder)
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
// Wait for callback file
|
| 1304 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
|
| 1305 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1306 |
+
var code string
|
| 1307 |
+
for {
|
| 1308 |
+
if !IsOAuthSessionPending(state, "codex") {
|
| 1309 |
+
return
|
| 1310 |
+
}
|
| 1311 |
+
if time.Now().After(deadline) {
|
| 1312 |
+
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
|
| 1313 |
+
log.Error(codex.GetUserFriendlyMessage(authErr))
|
| 1314 |
+
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
|
| 1315 |
+
return
|
| 1316 |
+
}
|
| 1317 |
+
if data, errR := os.ReadFile(waitFile); errR == nil {
|
| 1318 |
+
var m map[string]string
|
| 1319 |
+
_ = json.Unmarshal(data, &m)
|
| 1320 |
+
_ = os.Remove(waitFile)
|
| 1321 |
+
if errStr := m["error"]; errStr != "" {
|
| 1322 |
+
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
|
| 1323 |
+
log.Error(codex.GetUserFriendlyMessage(oauthErr))
|
| 1324 |
+
SetOAuthSessionError(state, "Bad Request")
|
| 1325 |
+
return
|
| 1326 |
+
}
|
| 1327 |
+
if m["state"] != state {
|
| 1328 |
+
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
|
| 1329 |
+
SetOAuthSessionError(state, "State code error")
|
| 1330 |
+
log.Error(codex.GetUserFriendlyMessage(authErr))
|
| 1331 |
+
return
|
| 1332 |
+
}
|
| 1333 |
+
code = m["code"]
|
| 1334 |
+
break
|
| 1335 |
+
}
|
| 1336 |
+
time.Sleep(500 * time.Millisecond)
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
log.Debug("Authorization code received, exchanging for tokens...")
|
| 1340 |
+
// Exchange code for tokens using internal auth service
|
| 1341 |
+
bundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes)
|
| 1342 |
+
if errExchange != nil {
|
| 1343 |
+
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange)
|
| 1344 |
+
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
|
| 1345 |
+
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
| 1346 |
+
return
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
// Extract additional info for filename generation
|
| 1350 |
+
claims, _ := codex.ParseJWTToken(bundle.TokenData.IDToken)
|
| 1351 |
+
planType := ""
|
| 1352 |
+
hashAccountID := ""
|
| 1353 |
+
if claims != nil {
|
| 1354 |
+
planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
|
| 1355 |
+
if accountID := claims.GetAccountID(); accountID != "" {
|
| 1356 |
+
digest := sha256.Sum256([]byte(accountID))
|
| 1357 |
+
hashAccountID = hex.EncodeToString(digest[:])[:8]
|
| 1358 |
+
}
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
// Create token storage and persist
|
| 1362 |
+
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
|
| 1363 |
+
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
|
| 1364 |
+
record := &coreauth.Auth{
|
| 1365 |
+
ID: fileName,
|
| 1366 |
+
Provider: "codex",
|
| 1367 |
+
FileName: fileName,
|
| 1368 |
+
Storage: tokenStorage,
|
| 1369 |
+
Metadata: map[string]any{
|
| 1370 |
+
"email": tokenStorage.Email,
|
| 1371 |
+
"account_id": tokenStorage.AccountID,
|
| 1372 |
+
},
|
| 1373 |
+
}
|
| 1374 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1375 |
+
if errSave != nil {
|
| 1376 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 1377 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 1378 |
+
return
|
| 1379 |
+
}
|
| 1380 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1381 |
+
if bundle.APIKey != "" {
|
| 1382 |
+
fmt.Println("API key obtained and saved")
|
| 1383 |
+
}
|
| 1384 |
+
fmt.Println("You can now use Codex services through this CLI")
|
| 1385 |
+
CompleteOAuthSession(state)
|
| 1386 |
+
CompleteOAuthSessionsByProvider("codex")
|
| 1387 |
+
}()
|
| 1388 |
+
|
| 1389 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
func (h *Handler) RequestAntigravityToken(c *gin.Context) {
|
| 1393 |
+
ctx := context.Background()
|
| 1394 |
+
|
| 1395 |
+
fmt.Println("Initializing Antigravity authentication...")
|
| 1396 |
+
|
| 1397 |
+
authSvc := antigravity.NewAntigravityAuth(h.cfg, nil)
|
| 1398 |
+
|
| 1399 |
+
state, errState := misc.GenerateRandomState()
|
| 1400 |
+
if errState != nil {
|
| 1401 |
+
log.Errorf("Failed to generate state parameter: %v", errState)
|
| 1402 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
|
| 1403 |
+
return
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", antigravity.CallbackPort)
|
| 1407 |
+
authURL := authSvc.BuildAuthURL(state, redirectURI)
|
| 1408 |
+
|
| 1409 |
+
RegisterOAuthSession(state, "antigravity")
|
| 1410 |
+
|
| 1411 |
+
isWebUI := isWebUIRequest(c)
|
| 1412 |
+
var forwarder *callbackForwarder
|
| 1413 |
+
if isWebUI {
|
| 1414 |
+
targetURL, errTarget := h.managementCallbackURL("/antigravity/callback")
|
| 1415 |
+
if errTarget != nil {
|
| 1416 |
+
log.WithError(errTarget).Error("failed to compute antigravity callback target")
|
| 1417 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
| 1418 |
+
return
|
| 1419 |
+
}
|
| 1420 |
+
var errStart error
|
| 1421 |
+
if forwarder, errStart = startCallbackForwarder(antigravity.CallbackPort, "antigravity", targetURL); errStart != nil {
|
| 1422 |
+
log.WithError(errStart).Error("failed to start antigravity callback forwarder")
|
| 1423 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
| 1424 |
+
return
|
| 1425 |
+
}
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
go func() {
|
| 1429 |
+
if isWebUI {
|
| 1430 |
+
defer stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder)
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state))
|
| 1434 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1435 |
+
var authCode string
|
| 1436 |
+
for {
|
| 1437 |
+
if !IsOAuthSessionPending(state, "antigravity") {
|
| 1438 |
+
return
|
| 1439 |
+
}
|
| 1440 |
+
if time.Now().After(deadline) {
|
| 1441 |
+
log.Error("oauth flow timed out")
|
| 1442 |
+
SetOAuthSessionError(state, "OAuth flow timed out")
|
| 1443 |
+
return
|
| 1444 |
+
}
|
| 1445 |
+
if data, errReadFile := os.ReadFile(waitFile); errReadFile == nil {
|
| 1446 |
+
var payload map[string]string
|
| 1447 |
+
_ = json.Unmarshal(data, &payload)
|
| 1448 |
+
_ = os.Remove(waitFile)
|
| 1449 |
+
if errStr := strings.TrimSpace(payload["error"]); errStr != "" {
|
| 1450 |
+
log.Errorf("Authentication failed: %s", errStr)
|
| 1451 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1452 |
+
return
|
| 1453 |
+
}
|
| 1454 |
+
if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state {
|
| 1455 |
+
log.Errorf("Authentication failed: state mismatch")
|
| 1456 |
+
SetOAuthSessionError(state, "Authentication failed: state mismatch")
|
| 1457 |
+
return
|
| 1458 |
+
}
|
| 1459 |
+
authCode = strings.TrimSpace(payload["code"])
|
| 1460 |
+
if authCode == "" {
|
| 1461 |
+
log.Error("Authentication failed: code not found")
|
| 1462 |
+
SetOAuthSessionError(state, "Authentication failed: code not found")
|
| 1463 |
+
return
|
| 1464 |
+
}
|
| 1465 |
+
break
|
| 1466 |
+
}
|
| 1467 |
+
time.Sleep(500 * time.Millisecond)
|
| 1468 |
+
}
|
| 1469 |
+
|
| 1470 |
+
tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI)
|
| 1471 |
+
if errToken != nil {
|
| 1472 |
+
log.Errorf("Failed to exchange token: %v", errToken)
|
| 1473 |
+
SetOAuthSessionError(state, "Failed to exchange token")
|
| 1474 |
+
return
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
accessToken := strings.TrimSpace(tokenResp.AccessToken)
|
| 1478 |
+
if accessToken == "" {
|
| 1479 |
+
log.Error("antigravity: token exchange returned empty access token")
|
| 1480 |
+
SetOAuthSessionError(state, "Failed to exchange token")
|
| 1481 |
+
return
|
| 1482 |
+
}
|
| 1483 |
+
|
| 1484 |
+
email, errInfo := authSvc.FetchUserInfo(ctx, accessToken)
|
| 1485 |
+
if errInfo != nil {
|
| 1486 |
+
log.Errorf("Failed to fetch user info: %v", errInfo)
|
| 1487 |
+
SetOAuthSessionError(state, "Failed to fetch user info")
|
| 1488 |
+
return
|
| 1489 |
+
}
|
| 1490 |
+
email = strings.TrimSpace(email)
|
| 1491 |
+
if email == "" {
|
| 1492 |
+
log.Error("antigravity: user info returned empty email")
|
| 1493 |
+
SetOAuthSessionError(state, "Failed to fetch user info")
|
| 1494 |
+
return
|
| 1495 |
+
}
|
| 1496 |
+
|
| 1497 |
+
projectID := ""
|
| 1498 |
+
if accessToken != "" {
|
| 1499 |
+
fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)
|
| 1500 |
+
if errProject != nil {
|
| 1501 |
+
log.Warnf("antigravity: failed to fetch project ID: %v", errProject)
|
| 1502 |
+
} else {
|
| 1503 |
+
projectID = fetchedProjectID
|
| 1504 |
+
log.Infof("antigravity: obtained project ID %s", projectID)
|
| 1505 |
+
}
|
| 1506 |
+
}
|
| 1507 |
+
|
| 1508 |
+
now := time.Now()
|
| 1509 |
+
metadata := map[string]any{
|
| 1510 |
+
"type": "antigravity",
|
| 1511 |
+
"access_token": tokenResp.AccessToken,
|
| 1512 |
+
"refresh_token": tokenResp.RefreshToken,
|
| 1513 |
+
"expires_in": tokenResp.ExpiresIn,
|
| 1514 |
+
"timestamp": now.UnixMilli(),
|
| 1515 |
+
"expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
| 1516 |
+
}
|
| 1517 |
+
if email != "" {
|
| 1518 |
+
metadata["email"] = email
|
| 1519 |
+
}
|
| 1520 |
+
if projectID != "" {
|
| 1521 |
+
metadata["project_id"] = projectID
|
| 1522 |
+
}
|
| 1523 |
+
|
| 1524 |
+
fileName := antigravity.CredentialFileName(email)
|
| 1525 |
+
label := strings.TrimSpace(email)
|
| 1526 |
+
if label == "" {
|
| 1527 |
+
label = "antigravity"
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
record := &coreauth.Auth{
|
| 1531 |
+
ID: fileName,
|
| 1532 |
+
Provider: "antigravity",
|
| 1533 |
+
FileName: fileName,
|
| 1534 |
+
Label: label,
|
| 1535 |
+
Metadata: metadata,
|
| 1536 |
+
}
|
| 1537 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1538 |
+
if errSave != nil {
|
| 1539 |
+
log.Errorf("Failed to save token to file: %v", errSave)
|
| 1540 |
+
SetOAuthSessionError(state, "Failed to save token to file")
|
| 1541 |
+
return
|
| 1542 |
+
}
|
| 1543 |
+
|
| 1544 |
+
CompleteOAuthSession(state)
|
| 1545 |
+
CompleteOAuthSessionsByProvider("antigravity")
|
| 1546 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1547 |
+
if projectID != "" {
|
| 1548 |
+
fmt.Printf("Using GCP project: %s\n", projectID)
|
| 1549 |
+
}
|
| 1550 |
+
fmt.Println("You can now use Antigravity services through this CLI")
|
| 1551 |
+
}()
|
| 1552 |
+
|
| 1553 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1554 |
+
}
|
| 1555 |
+
|
| 1556 |
+
func (h *Handler) RequestQwenToken(c *gin.Context) {
|
| 1557 |
+
ctx := context.Background()
|
| 1558 |
+
|
| 1559 |
+
fmt.Println("Initializing Qwen authentication...")
|
| 1560 |
+
|
| 1561 |
+
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
| 1562 |
+
// Initialize Qwen auth service
|
| 1563 |
+
qwenAuth := qwen.NewQwenAuth(h.cfg)
|
| 1564 |
+
|
| 1565 |
+
// Generate authorization URL
|
| 1566 |
+
deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
|
| 1567 |
+
if err != nil {
|
| 1568 |
+
log.Errorf("Failed to generate authorization URL: %v", err)
|
| 1569 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
| 1570 |
+
return
|
| 1571 |
+
}
|
| 1572 |
+
authURL := deviceFlow.VerificationURIComplete
|
| 1573 |
+
|
| 1574 |
+
RegisterOAuthSession(state, "qwen")
|
| 1575 |
+
|
| 1576 |
+
go func() {
|
| 1577 |
+
fmt.Println("Waiting for authentication...")
|
| 1578 |
+
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
| 1579 |
+
if errPollForToken != nil {
|
| 1580 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1581 |
+
fmt.Printf("Authentication failed: %v\n", errPollForToken)
|
| 1582 |
+
return
|
| 1583 |
+
}
|
| 1584 |
+
|
| 1585 |
+
// Create token storage
|
| 1586 |
+
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
|
| 1587 |
+
|
| 1588 |
+
tokenStorage.Email = fmt.Sprintf("%d", time.Now().UnixMilli())
|
| 1589 |
+
record := &coreauth.Auth{
|
| 1590 |
+
ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
| 1591 |
+
Provider: "qwen",
|
| 1592 |
+
FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
| 1593 |
+
Storage: tokenStorage,
|
| 1594 |
+
Metadata: map[string]any{"email": tokenStorage.Email},
|
| 1595 |
+
}
|
| 1596 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1597 |
+
if errSave != nil {
|
| 1598 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 1599 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 1600 |
+
return
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1604 |
+
fmt.Println("You can now use Qwen services through this CLI")
|
| 1605 |
+
CompleteOAuthSession(state)
|
| 1606 |
+
}()
|
| 1607 |
+
|
| 1608 |
+
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
| 1612 |
+
ctx := context.Background()
|
| 1613 |
+
|
| 1614 |
+
fmt.Println("Initializing iFlow authentication...")
|
| 1615 |
+
|
| 1616 |
+
state := fmt.Sprintf("ifl-%d", time.Now().UnixNano())
|
| 1617 |
+
authSvc := iflowauth.NewIFlowAuth(h.cfg)
|
| 1618 |
+
authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort)
|
| 1619 |
+
|
| 1620 |
+
RegisterOAuthSession(state, "iflow")
|
| 1621 |
+
|
| 1622 |
+
isWebUI := isWebUIRequest(c)
|
| 1623 |
+
var forwarder *callbackForwarder
|
| 1624 |
+
if isWebUI {
|
| 1625 |
+
targetURL, errTarget := h.managementCallbackURL("/iflow/callback")
|
| 1626 |
+
if errTarget != nil {
|
| 1627 |
+
log.WithError(errTarget).Error("failed to compute iflow callback target")
|
| 1628 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "callback server unavailable"})
|
| 1629 |
+
return
|
| 1630 |
+
}
|
| 1631 |
+
var errStart error
|
| 1632 |
+
if forwarder, errStart = startCallbackForwarder(iflowauth.CallbackPort, "iflow", targetURL); errStart != nil {
|
| 1633 |
+
log.WithError(errStart).Error("failed to start iflow callback forwarder")
|
| 1634 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start callback server"})
|
| 1635 |
+
return
|
| 1636 |
+
}
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
go func() {
|
| 1640 |
+
if isWebUI {
|
| 1641 |
+
defer stopCallbackForwarderInstance(iflowauth.CallbackPort, forwarder)
|
| 1642 |
+
}
|
| 1643 |
+
fmt.Println("Waiting for authentication...")
|
| 1644 |
+
|
| 1645 |
+
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state))
|
| 1646 |
+
deadline := time.Now().Add(5 * time.Minute)
|
| 1647 |
+
var resultMap map[string]string
|
| 1648 |
+
for {
|
| 1649 |
+
if !IsOAuthSessionPending(state, "iflow") {
|
| 1650 |
+
return
|
| 1651 |
+
}
|
| 1652 |
+
if time.Now().After(deadline) {
|
| 1653 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1654 |
+
fmt.Println("Authentication failed: timeout waiting for callback")
|
| 1655 |
+
return
|
| 1656 |
+
}
|
| 1657 |
+
if data, errR := os.ReadFile(waitFile); errR == nil {
|
| 1658 |
+
_ = os.Remove(waitFile)
|
| 1659 |
+
_ = json.Unmarshal(data, &resultMap)
|
| 1660 |
+
break
|
| 1661 |
+
}
|
| 1662 |
+
time.Sleep(500 * time.Millisecond)
|
| 1663 |
+
}
|
| 1664 |
+
|
| 1665 |
+
if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" {
|
| 1666 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1667 |
+
fmt.Printf("Authentication failed: %s\n", errStr)
|
| 1668 |
+
return
|
| 1669 |
+
}
|
| 1670 |
+
if resultState := strings.TrimSpace(resultMap["state"]); resultState != state {
|
| 1671 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1672 |
+
fmt.Println("Authentication failed: state mismatch")
|
| 1673 |
+
return
|
| 1674 |
+
}
|
| 1675 |
+
|
| 1676 |
+
code := strings.TrimSpace(resultMap["code"])
|
| 1677 |
+
if code == "" {
|
| 1678 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1679 |
+
fmt.Println("Authentication failed: code missing")
|
| 1680 |
+
return
|
| 1681 |
+
}
|
| 1682 |
+
|
| 1683 |
+
tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)
|
| 1684 |
+
if errExchange != nil {
|
| 1685 |
+
SetOAuthSessionError(state, "Authentication failed")
|
| 1686 |
+
fmt.Printf("Authentication failed: %v\n", errExchange)
|
| 1687 |
+
return
|
| 1688 |
+
}
|
| 1689 |
+
|
| 1690 |
+
tokenStorage := authSvc.CreateTokenStorage(tokenData)
|
| 1691 |
+
identifier := strings.TrimSpace(tokenStorage.Email)
|
| 1692 |
+
if identifier == "" {
|
| 1693 |
+
identifier = fmt.Sprintf("%d", time.Now().UnixMilli())
|
| 1694 |
+
tokenStorage.Email = identifier
|
| 1695 |
+
}
|
| 1696 |
+
record := &coreauth.Auth{
|
| 1697 |
+
ID: fmt.Sprintf("iflow-%s.json", identifier),
|
| 1698 |
+
Provider: "iflow",
|
| 1699 |
+
FileName: fmt.Sprintf("iflow-%s.json", identifier),
|
| 1700 |
+
Storage: tokenStorage,
|
| 1701 |
+
Metadata: map[string]any{"email": identifier, "api_key": tokenStorage.APIKey},
|
| 1702 |
+
Attributes: map[string]string{"api_key": tokenStorage.APIKey},
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1706 |
+
if errSave != nil {
|
| 1707 |
+
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
| 1708 |
+
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
| 1709 |
+
return
|
| 1710 |
+
}
|
| 1711 |
+
|
| 1712 |
+
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
| 1713 |
+
if tokenStorage.APIKey != "" {
|
| 1714 |
+
fmt.Println("API key obtained and saved")
|
| 1715 |
+
}
|
| 1716 |
+
fmt.Println("You can now use iFlow services through this CLI")
|
| 1717 |
+
CompleteOAuthSession(state)
|
| 1718 |
+
CompleteOAuthSessionsByProvider("iflow")
|
| 1719 |
+
}()
|
| 1720 |
+
|
| 1721 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
|
| 1722 |
+
}
|
| 1723 |
+
|
| 1724 |
+
func (h *Handler) RequestIFlowCookieToken(c *gin.Context) {
|
| 1725 |
+
ctx := context.Background()
|
| 1726 |
+
|
| 1727 |
+
var payload struct {
|
| 1728 |
+
Cookie string `json:"cookie"`
|
| 1729 |
+
}
|
| 1730 |
+
if err := c.ShouldBindJSON(&payload); err != nil {
|
| 1731 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"})
|
| 1732 |
+
return
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
cookieValue := strings.TrimSpace(payload.Cookie)
|
| 1736 |
+
|
| 1737 |
+
if cookieValue == "" {
|
| 1738 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"})
|
| 1739 |
+
return
|
| 1740 |
+
}
|
| 1741 |
+
|
| 1742 |
+
cookieValue, errNormalize := iflowauth.NormalizeCookie(cookieValue)
|
| 1743 |
+
if errNormalize != nil {
|
| 1744 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errNormalize.Error()})
|
| 1745 |
+
return
|
| 1746 |
+
}
|
| 1747 |
+
|
| 1748 |
+
// Check for duplicate BXAuth before authentication
|
| 1749 |
+
bxAuth := iflowauth.ExtractBXAuth(cookieValue)
|
| 1750 |
+
if existingFile, err := iflowauth.CheckDuplicateBXAuth(h.cfg.AuthDir, bxAuth); err != nil {
|
| 1751 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to check duplicate"})
|
| 1752 |
+
return
|
| 1753 |
+
} else if existingFile != "" {
|
| 1754 |
+
existingFileName := filepath.Base(existingFile)
|
| 1755 |
+
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "duplicate BXAuth found", "existing_file": existingFileName})
|
| 1756 |
+
return
|
| 1757 |
+
}
|
| 1758 |
+
|
| 1759 |
+
authSvc := iflowauth.NewIFlowAuth(h.cfg)
|
| 1760 |
+
tokenData, errAuth := authSvc.AuthenticateWithCookie(ctx, cookieValue)
|
| 1761 |
+
if errAuth != nil {
|
| 1762 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errAuth.Error()})
|
| 1763 |
+
return
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
tokenData.Cookie = cookieValue
|
| 1767 |
+
|
| 1768 |
+
tokenStorage := authSvc.CreateCookieTokenStorage(tokenData)
|
| 1769 |
+
email := strings.TrimSpace(tokenStorage.Email)
|
| 1770 |
+
if email == "" {
|
| 1771 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to extract email from token"})
|
| 1772 |
+
return
|
| 1773 |
+
}
|
| 1774 |
+
|
| 1775 |
+
fileName := iflowauth.SanitizeIFlowFileName(email)
|
| 1776 |
+
if fileName == "" {
|
| 1777 |
+
fileName = fmt.Sprintf("iflow-%d", time.Now().UnixMilli())
|
| 1778 |
+
} else {
|
| 1779 |
+
fileName = fmt.Sprintf("iflow-%s", fileName)
|
| 1780 |
+
}
|
| 1781 |
+
|
| 1782 |
+
tokenStorage.Email = email
|
| 1783 |
+
timestamp := time.Now().Unix()
|
| 1784 |
+
|
| 1785 |
+
record := &coreauth.Auth{
|
| 1786 |
+
ID: fmt.Sprintf("%s-%d.json", fileName, timestamp),
|
| 1787 |
+
Provider: "iflow",
|
| 1788 |
+
FileName: fmt.Sprintf("%s-%d.json", fileName, timestamp),
|
| 1789 |
+
Storage: tokenStorage,
|
| 1790 |
+
Metadata: map[string]any{
|
| 1791 |
+
"email": email,
|
| 1792 |
+
"api_key": tokenStorage.APIKey,
|
| 1793 |
+
"expired": tokenStorage.Expire,
|
| 1794 |
+
"cookie": tokenStorage.Cookie,
|
| 1795 |
+
"type": tokenStorage.Type,
|
| 1796 |
+
"last_refresh": tokenStorage.LastRefresh,
|
| 1797 |
+
},
|
| 1798 |
+
Attributes: map[string]string{
|
| 1799 |
+
"api_key": tokenStorage.APIKey,
|
| 1800 |
+
},
|
| 1801 |
+
}
|
| 1802 |
+
|
| 1803 |
+
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
| 1804 |
+
if errSave != nil {
|
| 1805 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to save authentication tokens"})
|
| 1806 |
+
return
|
| 1807 |
+
}
|
| 1808 |
+
|
| 1809 |
+
fmt.Printf("iFlow cookie authentication successful. Token saved to %s\n", savedPath)
|
| 1810 |
+
c.JSON(http.StatusOK, gin.H{
|
| 1811 |
+
"status": "ok",
|
| 1812 |
+
"saved_path": savedPath,
|
| 1813 |
+
"email": email,
|
| 1814 |
+
"expired": tokenStorage.Expire,
|
| 1815 |
+
"type": tokenStorage.Type,
|
| 1816 |
+
})
|
| 1817 |
+
}
|
| 1818 |
+
|
| 1819 |
+
type projectSelectionRequiredError struct{}
|
| 1820 |
+
|
| 1821 |
+
func (e *projectSelectionRequiredError) Error() string {
|
| 1822 |
+
return "gemini cli: project selection required"
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
+
func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
| 1826 |
+
if storage == nil {
|
| 1827 |
+
return fmt.Errorf("gemini storage is nil")
|
| 1828 |
+
}
|
| 1829 |
+
|
| 1830 |
+
trimmedRequest := strings.TrimSpace(requestedProject)
|
| 1831 |
+
if trimmedRequest == "" {
|
| 1832 |
+
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
| 1833 |
+
if errProjects != nil {
|
| 1834 |
+
return fmt.Errorf("fetch project list: %w", errProjects)
|
| 1835 |
+
}
|
| 1836 |
+
if len(projects) == 0 {
|
| 1837 |
+
return fmt.Errorf("no Google Cloud projects available for this account")
|
| 1838 |
+
}
|
| 1839 |
+
trimmedRequest = strings.TrimSpace(projects[0].ProjectID)
|
| 1840 |
+
if trimmedRequest == "" {
|
| 1841 |
+
return fmt.Errorf("resolved project id is empty")
|
| 1842 |
+
}
|
| 1843 |
+
storage.Auto = true
|
| 1844 |
+
} else {
|
| 1845 |
+
storage.Auto = false
|
| 1846 |
+
}
|
| 1847 |
+
|
| 1848 |
+
if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {
|
| 1849 |
+
return err
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
if strings.TrimSpace(storage.ProjectID) == "" {
|
| 1853 |
+
storage.ProjectID = trimmedRequest
|
| 1854 |
+
}
|
| 1855 |
+
|
| 1856 |
+
return nil
|
| 1857 |
+
}
|
| 1858 |
+
|
| 1859 |
+
func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) {
|
| 1860 |
+
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
| 1861 |
+
if errProjects != nil {
|
| 1862 |
+
return nil, fmt.Errorf("fetch project list: %w", errProjects)
|
| 1863 |
+
}
|
| 1864 |
+
if len(projects) == 0 {
|
| 1865 |
+
return nil, fmt.Errorf("no Google Cloud projects available for this account")
|
| 1866 |
+
}
|
| 1867 |
+
activated := make([]string, 0, len(projects))
|
| 1868 |
+
seen := make(map[string]struct{}, len(projects))
|
| 1869 |
+
for _, project := range projects {
|
| 1870 |
+
candidate := strings.TrimSpace(project.ProjectID)
|
| 1871 |
+
if candidate == "" {
|
| 1872 |
+
continue
|
| 1873 |
+
}
|
| 1874 |
+
if _, dup := seen[candidate]; dup {
|
| 1875 |
+
continue
|
| 1876 |
+
}
|
| 1877 |
+
if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil {
|
| 1878 |
+
return nil, fmt.Errorf("onboard project %s: %w", candidate, err)
|
| 1879 |
+
}
|
| 1880 |
+
finalID := strings.TrimSpace(storage.ProjectID)
|
| 1881 |
+
if finalID == "" {
|
| 1882 |
+
finalID = candidate
|
| 1883 |
+
}
|
| 1884 |
+
activated = append(activated, finalID)
|
| 1885 |
+
seen[candidate] = struct{}{}
|
| 1886 |
+
}
|
| 1887 |
+
if len(activated) == 0 {
|
| 1888 |
+
return nil, fmt.Errorf("no Google Cloud projects available for this account")
|
| 1889 |
+
}
|
| 1890 |
+
return activated, nil
|
| 1891 |
+
}
|
| 1892 |
+
|
| 1893 |
+
func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error {
|
| 1894 |
+
for _, pid := range projectIDs {
|
| 1895 |
+
trimmed := strings.TrimSpace(pid)
|
| 1896 |
+
if trimmed == "" {
|
| 1897 |
+
continue
|
| 1898 |
+
}
|
| 1899 |
+
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed)
|
| 1900 |
+
if errCheck != nil {
|
| 1901 |
+
return fmt.Errorf("project %s: %w", trimmed, errCheck)
|
| 1902 |
+
}
|
| 1903 |
+
if !isChecked {
|
| 1904 |
+
return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed)
|
| 1905 |
+
}
|
| 1906 |
+
}
|
| 1907 |
+
return nil
|
| 1908 |
+
}
|
| 1909 |
+
|
| 1910 |
+
func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
| 1911 |
+
metadata := map[string]string{
|
| 1912 |
+
"ideType": "IDE_UNSPECIFIED",
|
| 1913 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 1914 |
+
"pluginType": "GEMINI",
|
| 1915 |
+
}
|
| 1916 |
+
|
| 1917 |
+
trimmedRequest := strings.TrimSpace(requestedProject)
|
| 1918 |
+
explicitProject := trimmedRequest != ""
|
| 1919 |
+
|
| 1920 |
+
loadReqBody := map[string]any{
|
| 1921 |
+
"metadata": metadata,
|
| 1922 |
+
}
|
| 1923 |
+
if explicitProject {
|
| 1924 |
+
loadReqBody["cloudaicompanionProject"] = trimmedRequest
|
| 1925 |
+
}
|
| 1926 |
+
|
| 1927 |
+
var loadResp map[string]any
|
| 1928 |
+
if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
|
| 1929 |
+
return fmt.Errorf("load code assist: %w", errLoad)
|
| 1930 |
+
}
|
| 1931 |
+
|
| 1932 |
+
tierID := "legacy-tier"
|
| 1933 |
+
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
| 1934 |
+
for _, rawTier := range tiers {
|
| 1935 |
+
tier, okTier := rawTier.(map[string]any)
|
| 1936 |
+
if !okTier {
|
| 1937 |
+
continue
|
| 1938 |
+
}
|
| 1939 |
+
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
|
| 1940 |
+
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
|
| 1941 |
+
tierID = strings.TrimSpace(id)
|
| 1942 |
+
break
|
| 1943 |
+
}
|
| 1944 |
+
}
|
| 1945 |
+
}
|
| 1946 |
+
}
|
| 1947 |
+
|
| 1948 |
+
projectID := trimmedRequest
|
| 1949 |
+
if projectID == "" {
|
| 1950 |
+
if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
|
| 1951 |
+
projectID = strings.TrimSpace(id)
|
| 1952 |
+
}
|
| 1953 |
+
if projectID == "" {
|
| 1954 |
+
if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
|
| 1955 |
+
if id, okID := projectMap["id"].(string); okID {
|
| 1956 |
+
projectID = strings.TrimSpace(id)
|
| 1957 |
+
}
|
| 1958 |
+
}
|
| 1959 |
+
}
|
| 1960 |
+
}
|
| 1961 |
+
if projectID == "" {
|
| 1962 |
+
return &projectSelectionRequiredError{}
|
| 1963 |
+
}
|
| 1964 |
+
|
| 1965 |
+
onboardReqBody := map[string]any{
|
| 1966 |
+
"tierId": tierID,
|
| 1967 |
+
"metadata": metadata,
|
| 1968 |
+
"cloudaicompanionProject": projectID,
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
storage.ProjectID = projectID
|
| 1972 |
+
|
| 1973 |
+
for {
|
| 1974 |
+
var onboardResp map[string]any
|
| 1975 |
+
if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
|
| 1976 |
+
return fmt.Errorf("onboard user: %w", errOnboard)
|
| 1977 |
+
}
|
| 1978 |
+
|
| 1979 |
+
if done, okDone := onboardResp["done"].(bool); okDone && done {
|
| 1980 |
+
responseProjectID := ""
|
| 1981 |
+
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
|
| 1982 |
+
switch projectValue := resp["cloudaicompanionProject"].(type) {
|
| 1983 |
+
case map[string]any:
|
| 1984 |
+
if id, okID := projectValue["id"].(string); okID {
|
| 1985 |
+
responseProjectID = strings.TrimSpace(id)
|
| 1986 |
+
}
|
| 1987 |
+
case string:
|
| 1988 |
+
responseProjectID = strings.TrimSpace(projectValue)
|
| 1989 |
+
}
|
| 1990 |
+
}
|
| 1991 |
+
|
| 1992 |
+
finalProjectID := projectID
|
| 1993 |
+
if responseProjectID != "" {
|
| 1994 |
+
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
|
| 1995 |
+
// Check if this is a free user (gen-lang-client projects or free/legacy tier)
|
| 1996 |
+
isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
|
| 1997 |
+
strings.EqualFold(tierID, "FREE") ||
|
| 1998 |
+
strings.EqualFold(tierID, "LEGACY")
|
| 1999 |
+
|
| 2000 |
+
if isFreeUser {
|
| 2001 |
+
// For free users, use backend project ID for preview model access
|
| 2002 |
+
log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
|
| 2003 |
+
log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
|
| 2004 |
+
finalProjectID = responseProjectID
|
| 2005 |
+
} else {
|
| 2006 |
+
// Pro users: keep requested project ID (original behavior)
|
| 2007 |
+
log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
|
| 2008 |
+
}
|
| 2009 |
+
} else {
|
| 2010 |
+
finalProjectID = responseProjectID
|
| 2011 |
+
}
|
| 2012 |
+
}
|
| 2013 |
+
|
| 2014 |
+
storage.ProjectID = strings.TrimSpace(finalProjectID)
|
| 2015 |
+
if storage.ProjectID == "" {
|
| 2016 |
+
storage.ProjectID = strings.TrimSpace(projectID)
|
| 2017 |
+
}
|
| 2018 |
+
if storage.ProjectID == "" {
|
| 2019 |
+
return fmt.Errorf("onboard user completed without project id")
|
| 2020 |
+
}
|
| 2021 |
+
log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
|
| 2022 |
+
return nil
|
| 2023 |
+
}
|
| 2024 |
+
|
| 2025 |
+
log.Println("Onboarding in progress, waiting 5 seconds...")
|
| 2026 |
+
time.Sleep(5 * time.Second)
|
| 2027 |
+
}
|
| 2028 |
+
}
|
| 2029 |
+
|
| 2030 |
+
func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
|
| 2031 |
+
endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
|
| 2032 |
+
if strings.HasPrefix(endpoint, "operations/") {
|
| 2033 |
+
endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
|
| 2034 |
+
}
|
| 2035 |
+
|
| 2036 |
+
var reader io.Reader
|
| 2037 |
+
if body != nil {
|
| 2038 |
+
rawBody, errMarshal := json.Marshal(body)
|
| 2039 |
+
if errMarshal != nil {
|
| 2040 |
+
return fmt.Errorf("marshal request body: %w", errMarshal)
|
| 2041 |
+
}
|
| 2042 |
+
reader = bytes.NewReader(rawBody)
|
| 2043 |
+
}
|
| 2044 |
+
|
| 2045 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)
|
| 2046 |
+
if errRequest != nil {
|
| 2047 |
+
return fmt.Errorf("create request: %w", errRequest)
|
| 2048 |
+
}
|
| 2049 |
+
req.Header.Set("Content-Type", "application/json")
|
| 2050 |
+
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
| 2051 |
+
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
|
| 2052 |
+
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
|
| 2053 |
+
|
| 2054 |
+
resp, errDo := httpClient.Do(req)
|
| 2055 |
+
if errDo != nil {
|
| 2056 |
+
return fmt.Errorf("execute request: %w", errDo)
|
| 2057 |
+
}
|
| 2058 |
+
defer func() {
|
| 2059 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 2060 |
+
log.Errorf("response body close error: %v", errClose)
|
| 2061 |
+
}
|
| 2062 |
+
}()
|
| 2063 |
+
|
| 2064 |
+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
| 2065 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2066 |
+
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
| 2067 |
+
}
|
| 2068 |
+
|
| 2069 |
+
if result == nil {
|
| 2070 |
+
_, _ = io.Copy(io.Discard, resp.Body)
|
| 2071 |
+
return nil
|
| 2072 |
+
}
|
| 2073 |
+
|
| 2074 |
+
if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
|
| 2075 |
+
return fmt.Errorf("decode response body: %w", errDecode)
|
| 2076 |
+
}
|
| 2077 |
+
|
| 2078 |
+
return nil
|
| 2079 |
+
}
|
| 2080 |
+
|
| 2081 |
+
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
| 2082 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
| 2083 |
+
if errRequest != nil {
|
| 2084 |
+
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
| 2085 |
+
}
|
| 2086 |
+
|
| 2087 |
+
resp, errDo := httpClient.Do(req)
|
| 2088 |
+
if errDo != nil {
|
| 2089 |
+
return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
|
| 2090 |
+
}
|
| 2091 |
+
defer func() {
|
| 2092 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 2093 |
+
log.Errorf("response body close error: %v", errClose)
|
| 2094 |
+
}
|
| 2095 |
+
}()
|
| 2096 |
+
|
| 2097 |
+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
| 2098 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2099 |
+
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
| 2100 |
+
}
|
| 2101 |
+
|
| 2102 |
+
var projects interfaces.GCPProject
|
| 2103 |
+
if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
|
| 2104 |
+
return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
|
| 2105 |
+
}
|
| 2106 |
+
|
| 2107 |
+
return projects.Projects, nil
|
| 2108 |
+
}
|
| 2109 |
+
|
| 2110 |
+
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
|
| 2111 |
+
serviceUsageURL := "https://serviceusage.googleapis.com"
|
| 2112 |
+
requiredServices := []string{
|
| 2113 |
+
"cloudaicompanion.googleapis.com",
|
| 2114 |
+
}
|
| 2115 |
+
for _, service := range requiredServices {
|
| 2116 |
+
checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
| 2117 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
|
| 2118 |
+
if errRequest != nil {
|
| 2119 |
+
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 2120 |
+
}
|
| 2121 |
+
req.Header.Set("Content-Type", "application/json")
|
| 2122 |
+
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
| 2123 |
+
resp, errDo := httpClient.Do(req)
|
| 2124 |
+
if errDo != nil {
|
| 2125 |
+
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
| 2126 |
+
}
|
| 2127 |
+
|
| 2128 |
+
if resp.StatusCode == http.StatusOK {
|
| 2129 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2130 |
+
if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
|
| 2131 |
+
_ = resp.Body.Close()
|
| 2132 |
+
continue
|
| 2133 |
+
}
|
| 2134 |
+
}
|
| 2135 |
+
_ = resp.Body.Close()
|
| 2136 |
+
|
| 2137 |
+
enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
|
| 2138 |
+
req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}"))
|
| 2139 |
+
if errRequest != nil {
|
| 2140 |
+
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 2141 |
+
}
|
| 2142 |
+
req.Header.Set("Content-Type", "application/json")
|
| 2143 |
+
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
| 2144 |
+
resp, errDo = httpClient.Do(req)
|
| 2145 |
+
if errDo != nil {
|
| 2146 |
+
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
| 2147 |
+
}
|
| 2148 |
+
|
| 2149 |
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
| 2150 |
+
errMessage := string(bodyBytes)
|
| 2151 |
+
errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
|
| 2152 |
+
if errMessageResult.Exists() {
|
| 2153 |
+
errMessage = errMessageResult.String()
|
| 2154 |
+
}
|
| 2155 |
+
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
| 2156 |
+
_ = resp.Body.Close()
|
| 2157 |
+
continue
|
| 2158 |
+
} else if resp.StatusCode == http.StatusBadRequest {
|
| 2159 |
+
_ = resp.Body.Close()
|
| 2160 |
+
if strings.Contains(strings.ToLower(errMessage), "already enabled") {
|
| 2161 |
+
continue
|
| 2162 |
+
}
|
| 2163 |
+
}
|
| 2164 |
+
_ = resp.Body.Close()
|
| 2165 |
+
return false, fmt.Errorf("project activation required: %s", errMessage)
|
| 2166 |
+
}
|
| 2167 |
+
return true, nil
|
| 2168 |
+
}
|
| 2169 |
+
|
| 2170 |
+
func (h *Handler) GetAuthStatus(c *gin.Context) {
|
| 2171 |
+
state := strings.TrimSpace(c.Query("state"))
|
| 2172 |
+
if state == "" {
|
| 2173 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 2174 |
+
return
|
| 2175 |
+
}
|
| 2176 |
+
if err := ValidateOAuthState(state); err != nil {
|
| 2177 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
|
| 2178 |
+
return
|
| 2179 |
+
}
|
| 2180 |
+
|
| 2181 |
+
_, status, ok := GetOAuthSession(state)
|
| 2182 |
+
if !ok {
|
| 2183 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 2184 |
+
return
|
| 2185 |
+
}
|
| 2186 |
+
if status != "" {
|
| 2187 |
+
c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
|
| 2188 |
+
return
|
| 2189 |
+
}
|
| 2190 |
+
c.JSON(http.StatusOK, gin.H{"status": "wait"})
|
| 2191 |
+
}
|
internal/api/handlers/management/config_basic.go
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"io"
|
| 7 |
+
"net/http"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"strings"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 15 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
| 16 |
+
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
| 17 |
+
log "github.com/sirupsen/logrus"
|
| 18 |
+
"gopkg.in/yaml.v3"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
const (
|
| 22 |
+
latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
|
| 23 |
+
latestReleaseUserAgent = "CLIProxyAPI"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
func (h *Handler) GetConfig(c *gin.Context) {
|
| 27 |
+
if h == nil || h.cfg == nil {
|
| 28 |
+
c.JSON(200, gin.H{})
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
cfgCopy := *h.cfg
|
| 32 |
+
c.JSON(200, &cfgCopy)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
type releaseInfo struct {
|
| 36 |
+
TagName string `json:"tag_name"`
|
| 37 |
+
Name string `json:"name"`
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// GetLatestVersion returns the latest release version from GitHub without downloading assets.
|
| 41 |
+
func (h *Handler) GetLatestVersion(c *gin.Context) {
|
| 42 |
+
client := &http.Client{Timeout: 10 * time.Second}
|
| 43 |
+
proxyURL := ""
|
| 44 |
+
if h != nil && h.cfg != nil {
|
| 45 |
+
proxyURL = strings.TrimSpace(h.cfg.ProxyURL)
|
| 46 |
+
}
|
| 47 |
+
if proxyURL != "" {
|
| 48 |
+
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL}
|
| 49 |
+
util.SetProxy(sdkCfg, client)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
|
| 53 |
+
if err != nil {
|
| 54 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
req.Header.Set("Accept", "application/vnd.github+json")
|
| 58 |
+
req.Header.Set("User-Agent", latestReleaseUserAgent)
|
| 59 |
+
|
| 60 |
+
resp, err := client.Do(req)
|
| 61 |
+
if err != nil {
|
| 62 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "request_failed", "message": err.Error()})
|
| 63 |
+
return
|
| 64 |
+
}
|
| 65 |
+
defer func() {
|
| 66 |
+
if errClose := resp.Body.Close(); errClose != nil {
|
| 67 |
+
log.WithError(errClose).Debug("failed to close latest version response body")
|
| 68 |
+
}
|
| 69 |
+
}()
|
| 70 |
+
|
| 71 |
+
if resp.StatusCode != http.StatusOK {
|
| 72 |
+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
| 73 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected_status", "message": fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))})
|
| 74 |
+
return
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
var info releaseInfo
|
| 78 |
+
if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {
|
| 79 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "decode_failed", "message": errDecode.Error()})
|
| 80 |
+
return
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
version := strings.TrimSpace(info.TagName)
|
| 84 |
+
if version == "" {
|
| 85 |
+
version = strings.TrimSpace(info.Name)
|
| 86 |
+
}
|
| 87 |
+
if version == "" {
|
| 88 |
+
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid_response", "message": "missing release version"})
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
c.JSON(http.StatusOK, gin.H{"latest-version": version})
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
func WriteConfig(path string, data []byte) error {
|
| 96 |
+
data = config.NormalizeCommentIndentation(data)
|
| 97 |
+
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
| 98 |
+
if err != nil {
|
| 99 |
+
return err
|
| 100 |
+
}
|
| 101 |
+
if _, errWrite := f.Write(data); errWrite != nil {
|
| 102 |
+
_ = f.Close()
|
| 103 |
+
return errWrite
|
| 104 |
+
}
|
| 105 |
+
if errSync := f.Sync(); errSync != nil {
|
| 106 |
+
_ = f.Close()
|
| 107 |
+
return errSync
|
| 108 |
+
}
|
| 109 |
+
return f.Close()
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
func (h *Handler) PutConfigYAML(c *gin.Context) {
|
| 113 |
+
body, err := io.ReadAll(c.Request.Body)
|
| 114 |
+
if err != nil {
|
| 115 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": "cannot read request body"})
|
| 116 |
+
return
|
| 117 |
+
}
|
| 118 |
+
var cfg config.Config
|
| 119 |
+
if err = yaml.Unmarshal(body, &cfg); err != nil {
|
| 120 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()})
|
| 121 |
+
return
|
| 122 |
+
}
|
| 123 |
+
// Validate config using LoadConfigOptional with optional=false to enforce parsing
|
| 124 |
+
tmpDir := filepath.Dir(h.configFilePath)
|
| 125 |
+
tmpFile, err := os.CreateTemp(tmpDir, "config-validate-*.yaml")
|
| 126 |
+
if err != nil {
|
| 127 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()})
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
tempFile := tmpFile.Name()
|
| 131 |
+
if _, errWrite := tmpFile.Write(body); errWrite != nil {
|
| 132 |
+
_ = tmpFile.Close()
|
| 133 |
+
_ = os.Remove(tempFile)
|
| 134 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errWrite.Error()})
|
| 135 |
+
return
|
| 136 |
+
}
|
| 137 |
+
if errClose := tmpFile.Close(); errClose != nil {
|
| 138 |
+
_ = os.Remove(tempFile)
|
| 139 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errClose.Error()})
|
| 140 |
+
return
|
| 141 |
+
}
|
| 142 |
+
defer func() {
|
| 143 |
+
_ = os.Remove(tempFile)
|
| 144 |
+
}()
|
| 145 |
+
_, err = config.LoadConfigOptional(tempFile, false)
|
| 146 |
+
if err != nil {
|
| 147 |
+
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()})
|
| 148 |
+
return
|
| 149 |
+
}
|
| 150 |
+
h.mu.Lock()
|
| 151 |
+
defer h.mu.Unlock()
|
| 152 |
+
if WriteConfig(h.configFilePath, body) != nil {
|
| 153 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": "failed to write config"})
|
| 154 |
+
return
|
| 155 |
+
}
|
| 156 |
+
// Reload into handler to keep memory in sync
|
| 157 |
+
newCfg, err := config.LoadConfig(h.configFilePath)
|
| 158 |
+
if err != nil {
|
| 159 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "reload_failed", "message": err.Error()})
|
| 160 |
+
return
|
| 161 |
+
}
|
| 162 |
+
h.cfg = newCfg
|
| 163 |
+
c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// GetConfigYAML returns the raw config.yaml file bytes without re-encoding.
|
| 167 |
+
// It preserves comments and original formatting/styles.
|
| 168 |
+
func (h *Handler) GetConfigYAML(c *gin.Context) {
|
| 169 |
+
data, err := os.ReadFile(h.configFilePath)
|
| 170 |
+
if err != nil {
|
| 171 |
+
if os.IsNotExist(err) {
|
| 172 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "not_found", "message": "config file not found"})
|
| 173 |
+
return
|
| 174 |
+
}
|
| 175 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()})
|
| 176 |
+
return
|
| 177 |
+
}
|
| 178 |
+
c.Header("Content-Type", "application/yaml; charset=utf-8")
|
| 179 |
+
c.Header("Cache-Control", "no-store")
|
| 180 |
+
c.Header("X-Content-Type-Options", "nosniff")
|
| 181 |
+
// Write raw bytes as-is
|
| 182 |
+
_, _ = c.Writer.Write(data)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Debug
|
| 186 |
+
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
|
| 187 |
+
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
|
| 188 |
+
|
| 189 |
+
// UsageStatisticsEnabled
|
| 190 |
+
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
|
| 191 |
+
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
|
| 192 |
+
}
|
| 193 |
+
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
|
| 194 |
+
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// UsageStatisticsEnabled
|
| 198 |
+
func (h *Handler) GetLoggingToFile(c *gin.Context) {
|
| 199 |
+
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
|
| 200 |
+
}
|
| 201 |
+
func (h *Handler) PutLoggingToFile(c *gin.Context) {
|
| 202 |
+
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// LogsMaxTotalSizeMB
|
| 206 |
+
func (h *Handler) GetLogsMaxTotalSizeMB(c *gin.Context) {
|
| 207 |
+
c.JSON(200, gin.H{"logs-max-total-size-mb": h.cfg.LogsMaxTotalSizeMB})
|
| 208 |
+
}
|
| 209 |
+
func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {
|
| 210 |
+
var body struct {
|
| 211 |
+
Value *int `json:"value"`
|
| 212 |
+
}
|
| 213 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
|
| 214 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 215 |
+
return
|
| 216 |
+
}
|
| 217 |
+
value := *body.Value
|
| 218 |
+
if value < 0 {
|
| 219 |
+
value = 0
|
| 220 |
+
}
|
| 221 |
+
h.cfg.LogsMaxTotalSizeMB = value
|
| 222 |
+
h.persist(c)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Request log
|
| 226 |
+
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
|
| 227 |
+
func (h *Handler) PutRequestLog(c *gin.Context) {
|
| 228 |
+
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Websocket auth
|
| 232 |
+
func (h *Handler) GetWebsocketAuth(c *gin.Context) {
|
| 233 |
+
c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
|
| 234 |
+
}
|
| 235 |
+
func (h *Handler) PutWebsocketAuth(c *gin.Context) {
|
| 236 |
+
h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// Request retry
|
| 240 |
+
func (h *Handler) GetRequestRetry(c *gin.Context) {
|
| 241 |
+
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
|
| 242 |
+
}
|
| 243 |
+
func (h *Handler) PutRequestRetry(c *gin.Context) {
|
| 244 |
+
h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Max retry interval
|
| 248 |
+
func (h *Handler) GetMaxRetryInterval(c *gin.Context) {
|
| 249 |
+
c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval})
|
| 250 |
+
}
|
| 251 |
+
func (h *Handler) PutMaxRetryInterval(c *gin.Context) {
|
| 252 |
+
h.updateIntField(c, func(v int) { h.cfg.MaxRetryInterval = v })
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// ForceModelPrefix
|
| 256 |
+
func (h *Handler) GetForceModelPrefix(c *gin.Context) {
|
| 257 |
+
c.JSON(200, gin.H{"force-model-prefix": h.cfg.ForceModelPrefix})
|
| 258 |
+
}
|
| 259 |
+
func (h *Handler) PutForceModelPrefix(c *gin.Context) {
|
| 260 |
+
h.updateBoolField(c, func(v bool) { h.cfg.ForceModelPrefix = v })
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
func normalizeRoutingStrategy(strategy string) (string, bool) {
|
| 264 |
+
normalized := strings.ToLower(strings.TrimSpace(strategy))
|
| 265 |
+
switch normalized {
|
| 266 |
+
case "", "round-robin", "roundrobin", "rr":
|
| 267 |
+
return "round-robin", true
|
| 268 |
+
case "fill-first", "fillfirst", "ff":
|
| 269 |
+
return "fill-first", true
|
| 270 |
+
default:
|
| 271 |
+
return "", false
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// RoutingStrategy
|
| 276 |
+
func (h *Handler) GetRoutingStrategy(c *gin.Context) {
|
| 277 |
+
strategy, ok := normalizeRoutingStrategy(h.cfg.Routing.Strategy)
|
| 278 |
+
if !ok {
|
| 279 |
+
c.JSON(200, gin.H{"strategy": strings.TrimSpace(h.cfg.Routing.Strategy)})
|
| 280 |
+
return
|
| 281 |
+
}
|
| 282 |
+
c.JSON(200, gin.H{"strategy": strategy})
|
| 283 |
+
}
|
| 284 |
+
func (h *Handler) PutRoutingStrategy(c *gin.Context) {
|
| 285 |
+
var body struct {
|
| 286 |
+
Value *string `json:"value"`
|
| 287 |
+
}
|
| 288 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
|
| 289 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 290 |
+
return
|
| 291 |
+
}
|
| 292 |
+
normalized, ok := normalizeRoutingStrategy(*body.Value)
|
| 293 |
+
if !ok {
|
| 294 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid strategy"})
|
| 295 |
+
return
|
| 296 |
+
}
|
| 297 |
+
h.cfg.Routing.Strategy = normalized
|
| 298 |
+
h.persist(c)
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Proxy URL
|
| 302 |
+
func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
|
| 303 |
+
func (h *Handler) PutProxyURL(c *gin.Context) {
|
| 304 |
+
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
|
| 305 |
+
}
|
| 306 |
+
func (h *Handler) DeleteProxyURL(c *gin.Context) {
|
| 307 |
+
h.cfg.ProxyURL = ""
|
| 308 |
+
h.persist(c)
|
| 309 |
+
}
|
internal/api/handlers/management/config_lists.go
ADDED
|
@@ -0,0 +1,1522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"strings"
|
| 7 |
+
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
// Generic helpers for list[string]
|
| 13 |
+
func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
|
| 14 |
+
data, err := c.GetRawData()
|
| 15 |
+
if err != nil {
|
| 16 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 17 |
+
return
|
| 18 |
+
}
|
| 19 |
+
var arr []string
|
| 20 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 21 |
+
var obj struct {
|
| 22 |
+
Items []string `json:"items"`
|
| 23 |
+
}
|
| 24 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 25 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 26 |
+
return
|
| 27 |
+
}
|
| 28 |
+
arr = obj.Items
|
| 29 |
+
}
|
| 30 |
+
set(arr)
|
| 31 |
+
if after != nil {
|
| 32 |
+
after()
|
| 33 |
+
}
|
| 34 |
+
h.persist(c)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
|
| 38 |
+
var body struct {
|
| 39 |
+
Old *string `json:"old"`
|
| 40 |
+
New *string `json:"new"`
|
| 41 |
+
Index *int `json:"index"`
|
| 42 |
+
Value *string `json:"value"`
|
| 43 |
+
}
|
| 44 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 45 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
|
| 49 |
+
(*target)[*body.Index] = *body.Value
|
| 50 |
+
if after != nil {
|
| 51 |
+
after()
|
| 52 |
+
}
|
| 53 |
+
h.persist(c)
|
| 54 |
+
return
|
| 55 |
+
}
|
| 56 |
+
if body.Old != nil && body.New != nil {
|
| 57 |
+
for i := range *target {
|
| 58 |
+
if (*target)[i] == *body.Old {
|
| 59 |
+
(*target)[i] = *body.New
|
| 60 |
+
if after != nil {
|
| 61 |
+
after()
|
| 62 |
+
}
|
| 63 |
+
h.persist(c)
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
*target = append(*target, *body.New)
|
| 68 |
+
if after != nil {
|
| 69 |
+
after()
|
| 70 |
+
}
|
| 71 |
+
h.persist(c)
|
| 72 |
+
return
|
| 73 |
+
}
|
| 74 |
+
c.JSON(400, gin.H{"error": "missing fields"})
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
|
| 78 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 79 |
+
var idx int
|
| 80 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 81 |
+
if err == nil && idx >= 0 && idx < len(*target) {
|
| 82 |
+
*target = append((*target)[:idx], (*target)[idx+1:]...)
|
| 83 |
+
if after != nil {
|
| 84 |
+
after()
|
| 85 |
+
}
|
| 86 |
+
h.persist(c)
|
| 87 |
+
return
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
if val := strings.TrimSpace(c.Query("value")); val != "" {
|
| 91 |
+
out := make([]string, 0, len(*target))
|
| 92 |
+
for _, v := range *target {
|
| 93 |
+
if strings.TrimSpace(v) != val {
|
| 94 |
+
out = append(out, v)
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
*target = out
|
| 98 |
+
if after != nil {
|
| 99 |
+
after()
|
| 100 |
+
}
|
| 101 |
+
h.persist(c)
|
| 102 |
+
return
|
| 103 |
+
}
|
| 104 |
+
c.JSON(400, gin.H{"error": "missing index or value"})
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// api-keys
|
| 108 |
+
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
| 109 |
+
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
| 110 |
+
h.putStringList(c, func(v []string) {
|
| 111 |
+
h.cfg.APIKeys = append([]string(nil), v...)
|
| 112 |
+
h.cfg.Access.Providers = nil
|
| 113 |
+
}, nil)
|
| 114 |
+
}
|
| 115 |
+
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
| 116 |
+
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
| 117 |
+
}
|
| 118 |
+
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
| 119 |
+
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// gemini-api-key: []GeminiKey
|
| 123 |
+
func (h *Handler) GetGeminiKeys(c *gin.Context) {
|
| 124 |
+
c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
|
| 125 |
+
}
|
| 126 |
+
func (h *Handler) PutGeminiKeys(c *gin.Context) {
|
| 127 |
+
data, err := c.GetRawData()
|
| 128 |
+
if err != nil {
|
| 129 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 130 |
+
return
|
| 131 |
+
}
|
| 132 |
+
var arr []config.GeminiKey
|
| 133 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 134 |
+
var obj struct {
|
| 135 |
+
Items []config.GeminiKey `json:"items"`
|
| 136 |
+
}
|
| 137 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 138 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 139 |
+
return
|
| 140 |
+
}
|
| 141 |
+
arr = obj.Items
|
| 142 |
+
}
|
| 143 |
+
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
|
| 144 |
+
h.cfg.SanitizeGeminiKeys()
|
| 145 |
+
h.persist(c)
|
| 146 |
+
}
|
| 147 |
+
func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
| 148 |
+
type geminiKeyPatch struct {
|
| 149 |
+
APIKey *string `json:"api-key"`
|
| 150 |
+
Prefix *string `json:"prefix"`
|
| 151 |
+
BaseURL *string `json:"base-url"`
|
| 152 |
+
ProxyURL *string `json:"proxy-url"`
|
| 153 |
+
Headers *map[string]string `json:"headers"`
|
| 154 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 155 |
+
}
|
| 156 |
+
var body struct {
|
| 157 |
+
Index *int `json:"index"`
|
| 158 |
+
Match *string `json:"match"`
|
| 159 |
+
Value *geminiKeyPatch `json:"value"`
|
| 160 |
+
}
|
| 161 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 162 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 163 |
+
return
|
| 164 |
+
}
|
| 165 |
+
targetIndex := -1
|
| 166 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
| 167 |
+
targetIndex = *body.Index
|
| 168 |
+
}
|
| 169 |
+
if targetIndex == -1 && body.Match != nil {
|
| 170 |
+
match := strings.TrimSpace(*body.Match)
|
| 171 |
+
if match != "" {
|
| 172 |
+
for i := range h.cfg.GeminiKey {
|
| 173 |
+
if h.cfg.GeminiKey[i].APIKey == match {
|
| 174 |
+
targetIndex = i
|
| 175 |
+
break
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
if targetIndex == -1 {
|
| 181 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 182 |
+
return
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
entry := h.cfg.GeminiKey[targetIndex]
|
| 186 |
+
if body.Value.APIKey != nil {
|
| 187 |
+
trimmed := strings.TrimSpace(*body.Value.APIKey)
|
| 188 |
+
if trimmed == "" {
|
| 189 |
+
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
|
| 190 |
+
h.cfg.SanitizeGeminiKeys()
|
| 191 |
+
h.persist(c)
|
| 192 |
+
return
|
| 193 |
+
}
|
| 194 |
+
entry.APIKey = trimmed
|
| 195 |
+
}
|
| 196 |
+
if body.Value.Prefix != nil {
|
| 197 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 198 |
+
}
|
| 199 |
+
if body.Value.BaseURL != nil {
|
| 200 |
+
entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
|
| 201 |
+
}
|
| 202 |
+
if body.Value.ProxyURL != nil {
|
| 203 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 204 |
+
}
|
| 205 |
+
if body.Value.Headers != nil {
|
| 206 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 207 |
+
}
|
| 208 |
+
if body.Value.ExcludedModels != nil {
|
| 209 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 210 |
+
}
|
| 211 |
+
h.cfg.GeminiKey[targetIndex] = entry
|
| 212 |
+
h.cfg.SanitizeGeminiKeys()
|
| 213 |
+
h.persist(c)
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
| 217 |
+
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
| 218 |
+
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
| 219 |
+
for _, v := range h.cfg.GeminiKey {
|
| 220 |
+
if v.APIKey != val {
|
| 221 |
+
out = append(out, v)
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
if len(out) != len(h.cfg.GeminiKey) {
|
| 225 |
+
h.cfg.GeminiKey = out
|
| 226 |
+
h.cfg.SanitizeGeminiKeys()
|
| 227 |
+
h.persist(c)
|
| 228 |
+
} else {
|
| 229 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 230 |
+
}
|
| 231 |
+
return
|
| 232 |
+
}
|
| 233 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 234 |
+
var idx int
|
| 235 |
+
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
|
| 236 |
+
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
|
| 237 |
+
h.cfg.SanitizeGeminiKeys()
|
| 238 |
+
h.persist(c)
|
| 239 |
+
return
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// kiro-api-key: []KiroKey
|
| 246 |
+
func (h *Handler) GetKiroKeys(c *gin.Context) {
|
| 247 |
+
c.JSON(200, gin.H{"kiro-api-key": h.cfg.KiroKey})
|
| 248 |
+
}
|
| 249 |
+
func (h *Handler) PutKiroKeys(c *gin.Context) {
|
| 250 |
+
data, err := c.GetRawData()
|
| 251 |
+
if err != nil {
|
| 252 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 253 |
+
return
|
| 254 |
+
}
|
| 255 |
+
var arr []config.KiroKey
|
| 256 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 257 |
+
var obj struct {
|
| 258 |
+
Items []config.KiroKey `json:"items"`
|
| 259 |
+
}
|
| 260 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 261 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 262 |
+
return
|
| 263 |
+
}
|
| 264 |
+
arr = obj.Items
|
| 265 |
+
}
|
| 266 |
+
for i := range arr {
|
| 267 |
+
normalizeKiroKey(&arr[i])
|
| 268 |
+
}
|
| 269 |
+
h.cfg.KiroKey = arr
|
| 270 |
+
// h.cfg.SanitizeKiroKeys() // Add sanitizer if needed
|
| 271 |
+
h.persist(c)
|
| 272 |
+
}
|
| 273 |
+
func (h *Handler) PatchKiroKey(c *gin.Context) {
|
| 274 |
+
type kiroKeyPatch struct {
|
| 275 |
+
RefreshToken *string `json:"refresh-token"`
|
| 276 |
+
ProfileARN *string `json:"profile-arn"`
|
| 277 |
+
Region *string `json:"region"`
|
| 278 |
+
Prefix *string `json:"prefix"`
|
| 279 |
+
ProxyURL *string `json:"proxy-url"`
|
| 280 |
+
CredentialsFile *string `json:"credentials-file"`
|
| 281 |
+
KiroCliDBFile *string `json:"kiro-cli-db-file"`
|
| 282 |
+
Models *[]config.KiroModel `json:"models"`
|
| 283 |
+
Headers *map[string]string `json:"headers"`
|
| 284 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 285 |
+
}
|
| 286 |
+
var body struct {
|
| 287 |
+
Index *int `json:"index"`
|
| 288 |
+
Match *string `json:"match"`
|
| 289 |
+
Value *kiroKeyPatch `json:"value"`
|
| 290 |
+
}
|
| 291 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 292 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 293 |
+
return
|
| 294 |
+
}
|
| 295 |
+
targetIndex := -1
|
| 296 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.KiroKey) {
|
| 297 |
+
targetIndex = *body.Index
|
| 298 |
+
}
|
| 299 |
+
if targetIndex == -1 && body.Match != nil {
|
| 300 |
+
match := strings.TrimSpace(*body.Match)
|
| 301 |
+
for i := range h.cfg.KiroKey {
|
| 302 |
+
if h.cfg.KiroKey[i].RefreshToken == match {
|
| 303 |
+
targetIndex = i
|
| 304 |
+
break
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
if targetIndex == -1 {
|
| 309 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 310 |
+
return
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
entry := h.cfg.KiroKey[targetIndex]
|
| 314 |
+
if body.Value.RefreshToken != nil {
|
| 315 |
+
entry.RefreshToken = strings.TrimSpace(*body.Value.RefreshToken)
|
| 316 |
+
}
|
| 317 |
+
if body.Value.ProfileARN != nil {
|
| 318 |
+
entry.ProfileARN = strings.TrimSpace(*body.Value.ProfileARN)
|
| 319 |
+
}
|
| 320 |
+
if body.Value.Region != nil {
|
| 321 |
+
entry.Region = strings.TrimSpace(*body.Value.Region)
|
| 322 |
+
}
|
| 323 |
+
if body.Value.Prefix != nil {
|
| 324 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 325 |
+
}
|
| 326 |
+
if body.Value.ProxyURL != nil {
|
| 327 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 328 |
+
}
|
| 329 |
+
if body.Value.CredentialsFile != nil {
|
| 330 |
+
entry.CredentialsFile = strings.TrimSpace(*body.Value.CredentialsFile)
|
| 331 |
+
}
|
| 332 |
+
if body.Value.KiroCliDBFile != nil {
|
| 333 |
+
entry.KiroCliDBFile = strings.TrimSpace(*body.Value.KiroCliDBFile)
|
| 334 |
+
}
|
| 335 |
+
if body.Value.Models != nil {
|
| 336 |
+
entry.Models = append([]config.KiroModel(nil), (*body.Value.Models)...)
|
| 337 |
+
}
|
| 338 |
+
if body.Value.Headers != nil {
|
| 339 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 340 |
+
}
|
| 341 |
+
if body.Value.ExcludedModels != nil {
|
| 342 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 343 |
+
}
|
| 344 |
+
normalizeKiroKey(&entry)
|
| 345 |
+
h.cfg.KiroKey[targetIndex] = entry
|
| 346 |
+
h.persist(c)
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
func (h *Handler) DeleteKiroKey(c *gin.Context) {
|
| 350 |
+
if val := c.Query("refresh-token"); val != "" {
|
| 351 |
+
out := make([]config.KiroKey, 0, len(h.cfg.KiroKey))
|
| 352 |
+
for _, v := range h.cfg.KiroKey {
|
| 353 |
+
if v.RefreshToken != val {
|
| 354 |
+
out = append(out, v)
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
h.cfg.KiroKey = out
|
| 358 |
+
h.persist(c)
|
| 359 |
+
return
|
| 360 |
+
}
|
| 361 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 362 |
+
var idx int
|
| 363 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 364 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.KiroKey) {
|
| 365 |
+
h.cfg.KiroKey = append(h.cfg.KiroKey[:idx], h.cfg.KiroKey[idx+1:]...)
|
| 366 |
+
h.persist(c)
|
| 367 |
+
return
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
c.JSON(400, gin.H{"error": "missing refresh-token or index"})
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// claude-api-key: []ClaudeKey
|
| 374 |
+
func (h *Handler) GetClaudeKeys(c *gin.Context) {
|
| 375 |
+
c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
|
| 376 |
+
}
|
| 377 |
+
func (h *Handler) PutClaudeKeys(c *gin.Context) {
|
| 378 |
+
data, err := c.GetRawData()
|
| 379 |
+
if err != nil {
|
| 380 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 381 |
+
return
|
| 382 |
+
}
|
| 383 |
+
var arr []config.ClaudeKey
|
| 384 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 385 |
+
var obj struct {
|
| 386 |
+
Items []config.ClaudeKey `json:"items"`
|
| 387 |
+
}
|
| 388 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 389 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 390 |
+
return
|
| 391 |
+
}
|
| 392 |
+
arr = obj.Items
|
| 393 |
+
}
|
| 394 |
+
for i := range arr {
|
| 395 |
+
normalizeClaudeKey(&arr[i])
|
| 396 |
+
}
|
| 397 |
+
h.cfg.ClaudeKey = arr
|
| 398 |
+
h.cfg.SanitizeClaudeKeys()
|
| 399 |
+
h.persist(c)
|
| 400 |
+
}
|
| 401 |
+
func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
| 402 |
+
type claudeKeyPatch struct {
|
| 403 |
+
APIKey *string `json:"api-key"`
|
| 404 |
+
Prefix *string `json:"prefix"`
|
| 405 |
+
BaseURL *string `json:"base-url"`
|
| 406 |
+
ProxyURL *string `json:"proxy-url"`
|
| 407 |
+
Models *[]config.ClaudeModel `json:"models"`
|
| 408 |
+
Headers *map[string]string `json:"headers"`
|
| 409 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 410 |
+
}
|
| 411 |
+
var body struct {
|
| 412 |
+
Index *int `json:"index"`
|
| 413 |
+
Match *string `json:"match"`
|
| 414 |
+
Value *claudeKeyPatch `json:"value"`
|
| 415 |
+
}
|
| 416 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 417 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 418 |
+
return
|
| 419 |
+
}
|
| 420 |
+
targetIndex := -1
|
| 421 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
|
| 422 |
+
targetIndex = *body.Index
|
| 423 |
+
}
|
| 424 |
+
if targetIndex == -1 && body.Match != nil {
|
| 425 |
+
match := strings.TrimSpace(*body.Match)
|
| 426 |
+
for i := range h.cfg.ClaudeKey {
|
| 427 |
+
if h.cfg.ClaudeKey[i].APIKey == match {
|
| 428 |
+
targetIndex = i
|
| 429 |
+
break
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
if targetIndex == -1 {
|
| 434 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 435 |
+
return
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
entry := h.cfg.ClaudeKey[targetIndex]
|
| 439 |
+
if body.Value.APIKey != nil {
|
| 440 |
+
entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
|
| 441 |
+
}
|
| 442 |
+
if body.Value.Prefix != nil {
|
| 443 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 444 |
+
}
|
| 445 |
+
if body.Value.BaseURL != nil {
|
| 446 |
+
entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
|
| 447 |
+
}
|
| 448 |
+
if body.Value.ProxyURL != nil {
|
| 449 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 450 |
+
}
|
| 451 |
+
if body.Value.Models != nil {
|
| 452 |
+
entry.Models = append([]config.ClaudeModel(nil), (*body.Value.Models)...)
|
| 453 |
+
}
|
| 454 |
+
if body.Value.Headers != nil {
|
| 455 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 456 |
+
}
|
| 457 |
+
if body.Value.ExcludedModels != nil {
|
| 458 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 459 |
+
}
|
| 460 |
+
normalizeClaudeKey(&entry)
|
| 461 |
+
h.cfg.ClaudeKey[targetIndex] = entry
|
| 462 |
+
h.cfg.SanitizeClaudeKeys()
|
| 463 |
+
h.persist(c)
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
| 467 |
+
if val := c.Query("api-key"); val != "" {
|
| 468 |
+
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
| 469 |
+
for _, v := range h.cfg.ClaudeKey {
|
| 470 |
+
if v.APIKey != val {
|
| 471 |
+
out = append(out, v)
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
h.cfg.ClaudeKey = out
|
| 475 |
+
h.cfg.SanitizeClaudeKeys()
|
| 476 |
+
h.persist(c)
|
| 477 |
+
return
|
| 478 |
+
}
|
| 479 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 480 |
+
var idx int
|
| 481 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 482 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
|
| 483 |
+
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
|
| 484 |
+
h.cfg.SanitizeClaudeKeys()
|
| 485 |
+
h.persist(c)
|
| 486 |
+
return
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// openai-compatibility: []OpenAICompatibility
|
| 493 |
+
func (h *Handler) GetOpenAICompat(c *gin.Context) {
|
| 494 |
+
c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
|
| 495 |
+
}
|
| 496 |
+
func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
| 497 |
+
data, err := c.GetRawData()
|
| 498 |
+
if err != nil {
|
| 499 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 500 |
+
return
|
| 501 |
+
}
|
| 502 |
+
var arr []config.OpenAICompatibility
|
| 503 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 504 |
+
var obj struct {
|
| 505 |
+
Items []config.OpenAICompatibility `json:"items"`
|
| 506 |
+
}
|
| 507 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 508 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 509 |
+
return
|
| 510 |
+
}
|
| 511 |
+
arr = obj.Items
|
| 512 |
+
}
|
| 513 |
+
filtered := make([]config.OpenAICompatibility, 0, len(arr))
|
| 514 |
+
for i := range arr {
|
| 515 |
+
normalizeOpenAICompatibilityEntry(&arr[i])
|
| 516 |
+
if strings.TrimSpace(arr[i].BaseURL) != "" {
|
| 517 |
+
filtered = append(filtered, arr[i])
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
h.cfg.OpenAICompatibility = filtered
|
| 521 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 522 |
+
h.persist(c)
|
| 523 |
+
}
|
| 524 |
+
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
| 525 |
+
type openAICompatPatch struct {
|
| 526 |
+
Name *string `json:"name"`
|
| 527 |
+
Prefix *string `json:"prefix"`
|
| 528 |
+
BaseURL *string `json:"base-url"`
|
| 529 |
+
APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
|
| 530 |
+
Models *[]config.OpenAICompatibilityModel `json:"models"`
|
| 531 |
+
Headers *map[string]string `json:"headers"`
|
| 532 |
+
}
|
| 533 |
+
var body struct {
|
| 534 |
+
Name *string `json:"name"`
|
| 535 |
+
Index *int `json:"index"`
|
| 536 |
+
Value *openAICompatPatch `json:"value"`
|
| 537 |
+
}
|
| 538 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 539 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 540 |
+
return
|
| 541 |
+
}
|
| 542 |
+
targetIndex := -1
|
| 543 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
| 544 |
+
targetIndex = *body.Index
|
| 545 |
+
}
|
| 546 |
+
if targetIndex == -1 && body.Name != nil {
|
| 547 |
+
match := strings.TrimSpace(*body.Name)
|
| 548 |
+
for i := range h.cfg.OpenAICompatibility {
|
| 549 |
+
if h.cfg.OpenAICompatibility[i].Name == match {
|
| 550 |
+
targetIndex = i
|
| 551 |
+
break
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
if targetIndex == -1 {
|
| 556 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 557 |
+
return
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
entry := h.cfg.OpenAICompatibility[targetIndex]
|
| 561 |
+
if body.Value.Name != nil {
|
| 562 |
+
entry.Name = strings.TrimSpace(*body.Value.Name)
|
| 563 |
+
}
|
| 564 |
+
if body.Value.Prefix != nil {
|
| 565 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 566 |
+
}
|
| 567 |
+
if body.Value.BaseURL != nil {
|
| 568 |
+
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
| 569 |
+
if trimmed == "" {
|
| 570 |
+
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
|
| 571 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 572 |
+
h.persist(c)
|
| 573 |
+
return
|
| 574 |
+
}
|
| 575 |
+
entry.BaseURL = trimmed
|
| 576 |
+
}
|
| 577 |
+
if body.Value.APIKeyEntries != nil {
|
| 578 |
+
entry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*body.Value.APIKeyEntries)...)
|
| 579 |
+
}
|
| 580 |
+
if body.Value.Models != nil {
|
| 581 |
+
entry.Models = append([]config.OpenAICompatibilityModel(nil), (*body.Value.Models)...)
|
| 582 |
+
}
|
| 583 |
+
if body.Value.Headers != nil {
|
| 584 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 585 |
+
}
|
| 586 |
+
normalizeOpenAICompatibilityEntry(&entry)
|
| 587 |
+
h.cfg.OpenAICompatibility[targetIndex] = entry
|
| 588 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 589 |
+
h.persist(c)
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
| 593 |
+
if name := c.Query("name"); name != "" {
|
| 594 |
+
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
| 595 |
+
for _, v := range h.cfg.OpenAICompatibility {
|
| 596 |
+
if v.Name != name {
|
| 597 |
+
out = append(out, v)
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
h.cfg.OpenAICompatibility = out
|
| 601 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 602 |
+
h.persist(c)
|
| 603 |
+
return
|
| 604 |
+
}
|
| 605 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 606 |
+
var idx int
|
| 607 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 608 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
|
| 609 |
+
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
|
| 610 |
+
h.cfg.SanitizeOpenAICompatibility()
|
| 611 |
+
h.persist(c)
|
| 612 |
+
return
|
| 613 |
+
}
|
| 614 |
+
}
|
| 615 |
+
c.JSON(400, gin.H{"error": "missing name or index"})
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
// vertex-api-key: []VertexCompatKey
|
| 619 |
+
func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
|
| 620 |
+
c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
|
| 621 |
+
}
|
| 622 |
+
func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
|
| 623 |
+
data, err := c.GetRawData()
|
| 624 |
+
if err != nil {
|
| 625 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 626 |
+
return
|
| 627 |
+
}
|
| 628 |
+
var arr []config.VertexCompatKey
|
| 629 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 630 |
+
var obj struct {
|
| 631 |
+
Items []config.VertexCompatKey `json:"items"`
|
| 632 |
+
}
|
| 633 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 634 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 635 |
+
return
|
| 636 |
+
}
|
| 637 |
+
arr = obj.Items
|
| 638 |
+
}
|
| 639 |
+
for i := range arr {
|
| 640 |
+
normalizeVertexCompatKey(&arr[i])
|
| 641 |
+
}
|
| 642 |
+
h.cfg.VertexCompatAPIKey = arr
|
| 643 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 644 |
+
h.persist(c)
|
| 645 |
+
}
|
| 646 |
+
func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
| 647 |
+
type vertexCompatPatch struct {
|
| 648 |
+
APIKey *string `json:"api-key"`
|
| 649 |
+
Prefix *string `json:"prefix"`
|
| 650 |
+
BaseURL *string `json:"base-url"`
|
| 651 |
+
ProxyURL *string `json:"proxy-url"`
|
| 652 |
+
Headers *map[string]string `json:"headers"`
|
| 653 |
+
Models *[]config.VertexCompatModel `json:"models"`
|
| 654 |
+
}
|
| 655 |
+
var body struct {
|
| 656 |
+
Index *int `json:"index"`
|
| 657 |
+
Match *string `json:"match"`
|
| 658 |
+
Value *vertexCompatPatch `json:"value"`
|
| 659 |
+
}
|
| 660 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
|
| 661 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 662 |
+
return
|
| 663 |
+
}
|
| 664 |
+
targetIndex := -1
|
| 665 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
|
| 666 |
+
targetIndex = *body.Index
|
| 667 |
+
}
|
| 668 |
+
if targetIndex == -1 && body.Match != nil {
|
| 669 |
+
match := strings.TrimSpace(*body.Match)
|
| 670 |
+
if match != "" {
|
| 671 |
+
for i := range h.cfg.VertexCompatAPIKey {
|
| 672 |
+
if h.cfg.VertexCompatAPIKey[i].APIKey == match {
|
| 673 |
+
targetIndex = i
|
| 674 |
+
break
|
| 675 |
+
}
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
}
|
| 679 |
+
if targetIndex == -1 {
|
| 680 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 681 |
+
return
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
entry := h.cfg.VertexCompatAPIKey[targetIndex]
|
| 685 |
+
if body.Value.APIKey != nil {
|
| 686 |
+
trimmed := strings.TrimSpace(*body.Value.APIKey)
|
| 687 |
+
if trimmed == "" {
|
| 688 |
+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
|
| 689 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 690 |
+
h.persist(c)
|
| 691 |
+
return
|
| 692 |
+
}
|
| 693 |
+
entry.APIKey = trimmed
|
| 694 |
+
}
|
| 695 |
+
if body.Value.Prefix != nil {
|
| 696 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 697 |
+
}
|
| 698 |
+
if body.Value.BaseURL != nil {
|
| 699 |
+
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
| 700 |
+
if trimmed == "" {
|
| 701 |
+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
|
| 702 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 703 |
+
h.persist(c)
|
| 704 |
+
return
|
| 705 |
+
}
|
| 706 |
+
entry.BaseURL = trimmed
|
| 707 |
+
}
|
| 708 |
+
if body.Value.ProxyURL != nil {
|
| 709 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 710 |
+
}
|
| 711 |
+
if body.Value.Headers != nil {
|
| 712 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 713 |
+
}
|
| 714 |
+
if body.Value.Models != nil {
|
| 715 |
+
entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
|
| 716 |
+
}
|
| 717 |
+
normalizeVertexCompatKey(&entry)
|
| 718 |
+
h.cfg.VertexCompatAPIKey[targetIndex] = entry
|
| 719 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 720 |
+
h.persist(c)
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
|
| 724 |
+
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
| 725 |
+
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
|
| 726 |
+
for _, v := range h.cfg.VertexCompatAPIKey {
|
| 727 |
+
if v.APIKey != val {
|
| 728 |
+
out = append(out, v)
|
| 729 |
+
}
|
| 730 |
+
}
|
| 731 |
+
h.cfg.VertexCompatAPIKey = out
|
| 732 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 733 |
+
h.persist(c)
|
| 734 |
+
return
|
| 735 |
+
}
|
| 736 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 737 |
+
var idx int
|
| 738 |
+
_, errScan := fmt.Sscanf(idxStr, "%d", &idx)
|
| 739 |
+
if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
|
| 740 |
+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
|
| 741 |
+
h.cfg.SanitizeVertexCompatKeys()
|
| 742 |
+
h.persist(c)
|
| 743 |
+
return
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
// oauth-excluded-models: map[string][]string
|
| 750 |
+
func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
|
| 751 |
+
c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
func (h *Handler) PutOAuthExcludedModels(c *gin.Context) {
|
| 755 |
+
data, err := c.GetRawData()
|
| 756 |
+
if err != nil {
|
| 757 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 758 |
+
return
|
| 759 |
+
}
|
| 760 |
+
var entries map[string][]string
|
| 761 |
+
if err = json.Unmarshal(data, &entries); err != nil {
|
| 762 |
+
var wrapper struct {
|
| 763 |
+
Items map[string][]string `json:"items"`
|
| 764 |
+
}
|
| 765 |
+
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
| 766 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 767 |
+
return
|
| 768 |
+
}
|
| 769 |
+
entries = wrapper.Items
|
| 770 |
+
}
|
| 771 |
+
h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)
|
| 772 |
+
h.persist(c)
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
|
| 776 |
+
var body struct {
|
| 777 |
+
Provider *string `json:"provider"`
|
| 778 |
+
Models []string `json:"models"`
|
| 779 |
+
}
|
| 780 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil {
|
| 781 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 782 |
+
return
|
| 783 |
+
}
|
| 784 |
+
provider := strings.ToLower(strings.TrimSpace(*body.Provider))
|
| 785 |
+
if provider == "" {
|
| 786 |
+
c.JSON(400, gin.H{"error": "invalid provider"})
|
| 787 |
+
return
|
| 788 |
+
}
|
| 789 |
+
normalized := config.NormalizeExcludedModels(body.Models)
|
| 790 |
+
if len(normalized) == 0 {
|
| 791 |
+
if h.cfg.OAuthExcludedModels == nil {
|
| 792 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 793 |
+
return
|
| 794 |
+
}
|
| 795 |
+
if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
|
| 796 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 797 |
+
return
|
| 798 |
+
}
|
| 799 |
+
delete(h.cfg.OAuthExcludedModels, provider)
|
| 800 |
+
if len(h.cfg.OAuthExcludedModels) == 0 {
|
| 801 |
+
h.cfg.OAuthExcludedModels = nil
|
| 802 |
+
}
|
| 803 |
+
h.persist(c)
|
| 804 |
+
return
|
| 805 |
+
}
|
| 806 |
+
if h.cfg.OAuthExcludedModels == nil {
|
| 807 |
+
h.cfg.OAuthExcludedModels = make(map[string][]string)
|
| 808 |
+
}
|
| 809 |
+
h.cfg.OAuthExcludedModels[provider] = normalized
|
| 810 |
+
h.persist(c)
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
|
| 814 |
+
provider := strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
| 815 |
+
if provider == "" {
|
| 816 |
+
c.JSON(400, gin.H{"error": "missing provider"})
|
| 817 |
+
return
|
| 818 |
+
}
|
| 819 |
+
if h.cfg.OAuthExcludedModels == nil {
|
| 820 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 821 |
+
return
|
| 822 |
+
}
|
| 823 |
+
if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
|
| 824 |
+
c.JSON(404, gin.H{"error": "provider not found"})
|
| 825 |
+
return
|
| 826 |
+
}
|
| 827 |
+
delete(h.cfg.OAuthExcludedModels, provider)
|
| 828 |
+
if len(h.cfg.OAuthExcludedModels) == 0 {
|
| 829 |
+
h.cfg.OAuthExcludedModels = nil
|
| 830 |
+
}
|
| 831 |
+
h.persist(c)
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
// oauth-model-alias: map[string][]OAuthModelAlias
|
| 835 |
+
func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
|
| 836 |
+
c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
func (h *Handler) PutOAuthModelAlias(c *gin.Context) {
|
| 840 |
+
data, err := c.GetRawData()
|
| 841 |
+
if err != nil {
|
| 842 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 843 |
+
return
|
| 844 |
+
}
|
| 845 |
+
var entries map[string][]config.OAuthModelAlias
|
| 846 |
+
if err = json.Unmarshal(data, &entries); err != nil {
|
| 847 |
+
var wrapper struct {
|
| 848 |
+
Items map[string][]config.OAuthModelAlias `json:"items"`
|
| 849 |
+
}
|
| 850 |
+
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
| 851 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 852 |
+
return
|
| 853 |
+
}
|
| 854 |
+
entries = wrapper.Items
|
| 855 |
+
}
|
| 856 |
+
h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)
|
| 857 |
+
h.persist(c)
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
|
| 861 |
+
var body struct {
|
| 862 |
+
Provider *string `json:"provider"`
|
| 863 |
+
Channel *string `json:"channel"`
|
| 864 |
+
Aliases []config.OAuthModelAlias `json:"aliases"`
|
| 865 |
+
}
|
| 866 |
+
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
| 867 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 868 |
+
return
|
| 869 |
+
}
|
| 870 |
+
channelRaw := ""
|
| 871 |
+
if body.Channel != nil {
|
| 872 |
+
channelRaw = *body.Channel
|
| 873 |
+
} else if body.Provider != nil {
|
| 874 |
+
channelRaw = *body.Provider
|
| 875 |
+
}
|
| 876 |
+
channel := strings.ToLower(strings.TrimSpace(channelRaw))
|
| 877 |
+
if channel == "" {
|
| 878 |
+
c.JSON(400, gin.H{"error": "invalid channel"})
|
| 879 |
+
return
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
|
| 883 |
+
normalized := normalizedMap[channel]
|
| 884 |
+
if len(normalized) == 0 {
|
| 885 |
+
if h.cfg.OAuthModelAlias == nil {
|
| 886 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 887 |
+
return
|
| 888 |
+
}
|
| 889 |
+
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
|
| 890 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 891 |
+
return
|
| 892 |
+
}
|
| 893 |
+
delete(h.cfg.OAuthModelAlias, channel)
|
| 894 |
+
if len(h.cfg.OAuthModelAlias) == 0 {
|
| 895 |
+
h.cfg.OAuthModelAlias = nil
|
| 896 |
+
}
|
| 897 |
+
h.persist(c)
|
| 898 |
+
return
|
| 899 |
+
}
|
| 900 |
+
if h.cfg.OAuthModelAlias == nil {
|
| 901 |
+
h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
|
| 902 |
+
}
|
| 903 |
+
h.cfg.OAuthModelAlias[channel] = normalized
|
| 904 |
+
h.persist(c)
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
|
| 908 |
+
channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
|
| 909 |
+
if channel == "" {
|
| 910 |
+
channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
| 911 |
+
}
|
| 912 |
+
if channel == "" {
|
| 913 |
+
c.JSON(400, gin.H{"error": "missing channel"})
|
| 914 |
+
return
|
| 915 |
+
}
|
| 916 |
+
if h.cfg.OAuthModelAlias == nil {
|
| 917 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 918 |
+
return
|
| 919 |
+
}
|
| 920 |
+
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
|
| 921 |
+
c.JSON(404, gin.H{"error": "channel not found"})
|
| 922 |
+
return
|
| 923 |
+
}
|
| 924 |
+
delete(h.cfg.OAuthModelAlias, channel)
|
| 925 |
+
if len(h.cfg.OAuthModelAlias) == 0 {
|
| 926 |
+
h.cfg.OAuthModelAlias = nil
|
| 927 |
+
}
|
| 928 |
+
h.persist(c)
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
// codex-api-key: []CodexKey
|
| 932 |
+
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
| 933 |
+
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
| 934 |
+
}
|
| 935 |
+
func (h *Handler) PutCodexKeys(c *gin.Context) {
|
| 936 |
+
data, err := c.GetRawData()
|
| 937 |
+
if err != nil {
|
| 938 |
+
c.JSON(400, gin.H{"error": "failed to read body"})
|
| 939 |
+
return
|
| 940 |
+
}
|
| 941 |
+
var arr []config.CodexKey
|
| 942 |
+
if err = json.Unmarshal(data, &arr); err != nil {
|
| 943 |
+
var obj struct {
|
| 944 |
+
Items []config.CodexKey `json:"items"`
|
| 945 |
+
}
|
| 946 |
+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
| 947 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 948 |
+
return
|
| 949 |
+
}
|
| 950 |
+
arr = obj.Items
|
| 951 |
+
}
|
| 952 |
+
// Filter out codex entries with empty base-url (treat as removed)
|
| 953 |
+
filtered := make([]config.CodexKey, 0, len(arr))
|
| 954 |
+
for i := range arr {
|
| 955 |
+
entry := arr[i]
|
| 956 |
+
normalizeCodexKey(&entry)
|
| 957 |
+
if entry.BaseURL == "" {
|
| 958 |
+
continue
|
| 959 |
+
}
|
| 960 |
+
filtered = append(filtered, entry)
|
| 961 |
+
}
|
| 962 |
+
h.cfg.CodexKey = filtered
|
| 963 |
+
h.cfg.SanitizeCodexKeys()
|
| 964 |
+
h.persist(c)
|
| 965 |
+
}
|
| 966 |
+
func (h *Handler) PatchCodexKey(c *gin.Context) {
|
| 967 |
+
type codexKeyPatch struct {
|
| 968 |
+
APIKey *string `json:"api-key"`
|
| 969 |
+
Prefix *string `json:"prefix"`
|
| 970 |
+
BaseURL *string `json:"base-url"`
|
| 971 |
+
ProxyURL *string `json:"proxy-url"`
|
| 972 |
+
Models *[]config.CodexModel `json:"models"`
|
| 973 |
+
Headers *map[string]string `json:"headers"`
|
| 974 |
+
ExcludedModels *[]string `json:"excluded-models"`
|
| 975 |
+
}
|
| 976 |
+
var body struct {
|
| 977 |
+
Index *int `json:"index"`
|
| 978 |
+
Match *string `json:"match"`
|
| 979 |
+
Value *codexKeyPatch `json:"value"`
|
| 980 |
+
}
|
| 981 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 982 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 983 |
+
return
|
| 984 |
+
}
|
| 985 |
+
targetIndex := -1
|
| 986 |
+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
| 987 |
+
targetIndex = *body.Index
|
| 988 |
+
}
|
| 989 |
+
if targetIndex == -1 && body.Match != nil {
|
| 990 |
+
match := strings.TrimSpace(*body.Match)
|
| 991 |
+
for i := range h.cfg.CodexKey {
|
| 992 |
+
if h.cfg.CodexKey[i].APIKey == match {
|
| 993 |
+
targetIndex = i
|
| 994 |
+
break
|
| 995 |
+
}
|
| 996 |
+
}
|
| 997 |
+
}
|
| 998 |
+
if targetIndex == -1 {
|
| 999 |
+
c.JSON(404, gin.H{"error": "item not found"})
|
| 1000 |
+
return
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
entry := h.cfg.CodexKey[targetIndex]
|
| 1004 |
+
if body.Value.APIKey != nil {
|
| 1005 |
+
entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
|
| 1006 |
+
}
|
| 1007 |
+
if body.Value.Prefix != nil {
|
| 1008 |
+
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
| 1009 |
+
}
|
| 1010 |
+
if body.Value.BaseURL != nil {
|
| 1011 |
+
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
| 1012 |
+
if trimmed == "" {
|
| 1013 |
+
h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
|
| 1014 |
+
h.cfg.SanitizeCodexKeys()
|
| 1015 |
+
h.persist(c)
|
| 1016 |
+
return
|
| 1017 |
+
}
|
| 1018 |
+
entry.BaseURL = trimmed
|
| 1019 |
+
}
|
| 1020 |
+
if body.Value.ProxyURL != nil {
|
| 1021 |
+
entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
|
| 1022 |
+
}
|
| 1023 |
+
if body.Value.Models != nil {
|
| 1024 |
+
entry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...)
|
| 1025 |
+
}
|
| 1026 |
+
if body.Value.Headers != nil {
|
| 1027 |
+
entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
|
| 1028 |
+
}
|
| 1029 |
+
if body.Value.ExcludedModels != nil {
|
| 1030 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
| 1031 |
+
}
|
| 1032 |
+
normalizeCodexKey(&entry)
|
| 1033 |
+
h.cfg.CodexKey[targetIndex] = entry
|
| 1034 |
+
h.cfg.SanitizeCodexKeys()
|
| 1035 |
+
h.persist(c)
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
| 1039 |
+
if val := c.Query("api-key"); val != "" {
|
| 1040 |
+
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
| 1041 |
+
for _, v := range h.cfg.CodexKey {
|
| 1042 |
+
if v.APIKey != val {
|
| 1043 |
+
out = append(out, v)
|
| 1044 |
+
}
|
| 1045 |
+
}
|
| 1046 |
+
h.cfg.CodexKey = out
|
| 1047 |
+
h.cfg.SanitizeCodexKeys()
|
| 1048 |
+
h.persist(c)
|
| 1049 |
+
return
|
| 1050 |
+
}
|
| 1051 |
+
if idxStr := c.Query("index"); idxStr != "" {
|
| 1052 |
+
var idx int
|
| 1053 |
+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
| 1054 |
+
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
|
| 1055 |
+
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
|
| 1056 |
+
h.cfg.SanitizeCodexKeys()
|
| 1057 |
+
h.persist(c)
|
| 1058 |
+
return
|
| 1059 |
+
}
|
| 1060 |
+
}
|
| 1061 |
+
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
|
| 1065 |
+
if entry == nil {
|
| 1066 |
+
return
|
| 1067 |
+
}
|
| 1068 |
+
// Trim base-url; empty base-url indicates provider should be removed by sanitization
|
| 1069 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 1070 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1071 |
+
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
|
| 1072 |
+
for i := range entry.APIKeyEntries {
|
| 1073 |
+
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
|
| 1074 |
+
entry.APIKeyEntries[i].APIKey = trimmed
|
| 1075 |
+
if trimmed != "" {
|
| 1076 |
+
existing[trimmed] = struct{}{}
|
| 1077 |
+
}
|
| 1078 |
+
}
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
|
| 1082 |
+
if len(entries) == 0 {
|
| 1083 |
+
return nil
|
| 1084 |
+
}
|
| 1085 |
+
out := make([]config.OpenAICompatibility, len(entries))
|
| 1086 |
+
for i := range entries {
|
| 1087 |
+
copyEntry := entries[i]
|
| 1088 |
+
if len(copyEntry.APIKeyEntries) > 0 {
|
| 1089 |
+
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
|
| 1090 |
+
}
|
| 1091 |
+
normalizeOpenAICompatibilityEntry(©Entry)
|
| 1092 |
+
out[i] = copyEntry
|
| 1093 |
+
}
|
| 1094 |
+
return out
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
func normalizeKiroKey(entry *config.KiroKey) {
|
| 1098 |
+
if entry == nil {
|
| 1099 |
+
return
|
| 1100 |
+
}
|
| 1101 |
+
entry.RefreshToken = strings.TrimSpace(entry.RefreshToken)
|
| 1102 |
+
entry.ProfileARN = strings.TrimSpace(entry.ProfileARN)
|
| 1103 |
+
entry.Region = strings.TrimSpace(entry.Region)
|
| 1104 |
+
entry.Prefix = strings.TrimSpace(entry.Prefix)
|
| 1105 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 1106 |
+
entry.CredentialsFile = strings.TrimSpace(entry.CredentialsFile)
|
| 1107 |
+
entry.KiroCliDBFile = strings.TrimSpace(entry.KiroCliDBFile)
|
| 1108 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1109 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
| 1110 |
+
if len(entry.Models) == 0 {
|
| 1111 |
+
return
|
| 1112 |
+
}
|
| 1113 |
+
normalized := make([]config.KiroModel, 0, len(entry.Models))
|
| 1114 |
+
for i := range entry.Models {
|
| 1115 |
+
model := entry.Models[i]
|
| 1116 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 1117 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 1118 |
+
if model.Name == "" && model.Alias == "" {
|
| 1119 |
+
continue
|
| 1120 |
+
}
|
| 1121 |
+
normalized = append(normalized, model)
|
| 1122 |
+
}
|
| 1123 |
+
entry.Models = normalized
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
func normalizeClaudeKey(entry *config.ClaudeKey) {
|
| 1127 |
+
if entry == nil {
|
| 1128 |
+
return
|
| 1129 |
+
}
|
| 1130 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 1131 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 1132 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 1133 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1134 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
| 1135 |
+
if len(entry.Models) == 0 {
|
| 1136 |
+
return
|
| 1137 |
+
}
|
| 1138 |
+
normalized := make([]config.ClaudeModel, 0, len(entry.Models))
|
| 1139 |
+
for i := range entry.Models {
|
| 1140 |
+
model := entry.Models[i]
|
| 1141 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 1142 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 1143 |
+
if model.Name == "" && model.Alias == "" {
|
| 1144 |
+
continue
|
| 1145 |
+
}
|
| 1146 |
+
normalized = append(normalized, model)
|
| 1147 |
+
}
|
| 1148 |
+
entry.Models = normalized
|
| 1149 |
+
}
|
| 1150 |
+
|
| 1151 |
+
func normalizeCodexKey(entry *config.CodexKey) {
|
| 1152 |
+
if entry == nil {
|
| 1153 |
+
return
|
| 1154 |
+
}
|
| 1155 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 1156 |
+
entry.Prefix = strings.TrimSpace(entry.Prefix)
|
| 1157 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 1158 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 1159 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1160 |
+
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
| 1161 |
+
if len(entry.Models) == 0 {
|
| 1162 |
+
return
|
| 1163 |
+
}
|
| 1164 |
+
normalized := make([]config.CodexModel, 0, len(entry.Models))
|
| 1165 |
+
for i := range entry.Models {
|
| 1166 |
+
model := entry.Models[i]
|
| 1167 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 1168 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 1169 |
+
if model.Name == "" && model.Alias == "" {
|
| 1170 |
+
continue
|
| 1171 |
+
}
|
| 1172 |
+
normalized = append(normalized, model)
|
| 1173 |
+
}
|
| 1174 |
+
entry.Models = normalized
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
|
| 1178 |
+
if entry == nil {
|
| 1179 |
+
return
|
| 1180 |
+
}
|
| 1181 |
+
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
| 1182 |
+
entry.Prefix = strings.TrimSpace(entry.Prefix)
|
| 1183 |
+
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
| 1184 |
+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
| 1185 |
+
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
| 1186 |
+
if len(entry.Models) == 0 {
|
| 1187 |
+
return
|
| 1188 |
+
}
|
| 1189 |
+
normalized := make([]config.VertexCompatModel, 0, len(entry.Models))
|
| 1190 |
+
for i := range entry.Models {
|
| 1191 |
+
model := entry.Models[i]
|
| 1192 |
+
model.Name = strings.TrimSpace(model.Name)
|
| 1193 |
+
model.Alias = strings.TrimSpace(model.Alias)
|
| 1194 |
+
if model.Name == "" || model.Alias == "" {
|
| 1195 |
+
continue
|
| 1196 |
+
}
|
| 1197 |
+
normalized = append(normalized, model)
|
| 1198 |
+
}
|
| 1199 |
+
entry.Models = normalized
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
func sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias {
|
| 1203 |
+
if len(entries) == 0 {
|
| 1204 |
+
return nil
|
| 1205 |
+
}
|
| 1206 |
+
copied := make(map[string][]config.OAuthModelAlias, len(entries))
|
| 1207 |
+
for channel, aliases := range entries {
|
| 1208 |
+
if len(aliases) == 0 {
|
| 1209 |
+
continue
|
| 1210 |
+
}
|
| 1211 |
+
copied[channel] = append([]config.OAuthModelAlias(nil), aliases...)
|
| 1212 |
+
}
|
| 1213 |
+
if len(copied) == 0 {
|
| 1214 |
+
return nil
|
| 1215 |
+
}
|
| 1216 |
+
cfg := config.Config{OAuthModelAlias: copied}
|
| 1217 |
+
cfg.SanitizeOAuthModelAlias()
|
| 1218 |
+
if len(cfg.OAuthModelAlias) == 0 {
|
| 1219 |
+
return nil
|
| 1220 |
+
}
|
| 1221 |
+
return cfg.OAuthModelAlias
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
// GetAmpCode returns the complete ampcode configuration.
|
| 1225 |
+
func (h *Handler) GetAmpCode(c *gin.Context) {
|
| 1226 |
+
if h == nil || h.cfg == nil {
|
| 1227 |
+
c.JSON(200, gin.H{"ampcode": config.AmpCode{}})
|
| 1228 |
+
return
|
| 1229 |
+
}
|
| 1230 |
+
c.JSON(200, gin.H{"ampcode": h.cfg.AmpCode})
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
// GetAmpUpstreamURL returns the ampcode upstream URL.
|
| 1234 |
+
func (h *Handler) GetAmpUpstreamURL(c *gin.Context) {
|
| 1235 |
+
if h == nil || h.cfg == nil {
|
| 1236 |
+
c.JSON(200, gin.H{"upstream-url": ""})
|
| 1237 |
+
return
|
| 1238 |
+
}
|
| 1239 |
+
c.JSON(200, gin.H{"upstream-url": h.cfg.AmpCode.UpstreamURL})
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
// PutAmpUpstreamURL updates the ampcode upstream URL.
|
| 1243 |
+
func (h *Handler) PutAmpUpstreamURL(c *gin.Context) {
|
| 1244 |
+
h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
// DeleteAmpUpstreamURL clears the ampcode upstream URL.
|
| 1248 |
+
func (h *Handler) DeleteAmpUpstreamURL(c *gin.Context) {
|
| 1249 |
+
h.cfg.AmpCode.UpstreamURL = ""
|
| 1250 |
+
h.persist(c)
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
// GetAmpUpstreamAPIKey returns the ampcode upstream API key.
|
| 1254 |
+
func (h *Handler) GetAmpUpstreamAPIKey(c *gin.Context) {
|
| 1255 |
+
if h == nil || h.cfg == nil {
|
| 1256 |
+
c.JSON(200, gin.H{"upstream-api-key": ""})
|
| 1257 |
+
return
|
| 1258 |
+
}
|
| 1259 |
+
c.JSON(200, gin.H{"upstream-api-key": h.cfg.AmpCode.UpstreamAPIKey})
|
| 1260 |
+
}
|
| 1261 |
+
|
| 1262 |
+
// PutAmpUpstreamAPIKey updates the ampcode upstream API key.
|
| 1263 |
+
func (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {
|
| 1264 |
+
h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
// DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.
|
| 1268 |
+
func (h *Handler) DeleteAmpUpstreamAPIKey(c *gin.Context) {
|
| 1269 |
+
h.cfg.AmpCode.UpstreamAPIKey = ""
|
| 1270 |
+
h.persist(c)
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
// GetAmpRestrictManagementToLocalhost returns the localhost restriction setting.
|
| 1274 |
+
func (h *Handler) GetAmpRestrictManagementToLocalhost(c *gin.Context) {
|
| 1275 |
+
if h == nil || h.cfg == nil {
|
| 1276 |
+
c.JSON(200, gin.H{"restrict-management-to-localhost": true})
|
| 1277 |
+
return
|
| 1278 |
+
}
|
| 1279 |
+
c.JSON(200, gin.H{"restrict-management-to-localhost": h.cfg.AmpCode.RestrictManagementToLocalhost})
|
| 1280 |
+
}
|
| 1281 |
+
|
| 1282 |
+
// PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.
|
| 1283 |
+
func (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {
|
| 1284 |
+
h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
// GetAmpModelMappings returns the ampcode model mappings.
|
| 1288 |
+
func (h *Handler) GetAmpModelMappings(c *gin.Context) {
|
| 1289 |
+
if h == nil || h.cfg == nil {
|
| 1290 |
+
c.JSON(200, gin.H{"model-mappings": []config.AmpModelMapping{}})
|
| 1291 |
+
return
|
| 1292 |
+
}
|
| 1293 |
+
c.JSON(200, gin.H{"model-mappings": h.cfg.AmpCode.ModelMappings})
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
// PutAmpModelMappings replaces all ampcode model mappings.
|
| 1297 |
+
func (h *Handler) PutAmpModelMappings(c *gin.Context) {
|
| 1298 |
+
var body struct {
|
| 1299 |
+
Value []config.AmpModelMapping `json:"value"`
|
| 1300 |
+
}
|
| 1301 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1302 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1303 |
+
return
|
| 1304 |
+
}
|
| 1305 |
+
h.cfg.AmpCode.ModelMappings = body.Value
|
| 1306 |
+
h.persist(c)
|
| 1307 |
+
}
|
| 1308 |
+
|
| 1309 |
+
// PatchAmpModelMappings adds or updates model mappings.
|
| 1310 |
+
func (h *Handler) PatchAmpModelMappings(c *gin.Context) {
|
| 1311 |
+
var body struct {
|
| 1312 |
+
Value []config.AmpModelMapping `json:"value"`
|
| 1313 |
+
}
|
| 1314 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1315 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1316 |
+
return
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
existing := make(map[string]int)
|
| 1320 |
+
for i, m := range h.cfg.AmpCode.ModelMappings {
|
| 1321 |
+
existing[strings.TrimSpace(m.From)] = i
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
for _, newMapping := range body.Value {
|
| 1325 |
+
from := strings.TrimSpace(newMapping.From)
|
| 1326 |
+
if idx, ok := existing[from]; ok {
|
| 1327 |
+
h.cfg.AmpCode.ModelMappings[idx] = newMapping
|
| 1328 |
+
} else {
|
| 1329 |
+
h.cfg.AmpCode.ModelMappings = append(h.cfg.AmpCode.ModelMappings, newMapping)
|
| 1330 |
+
existing[from] = len(h.cfg.AmpCode.ModelMappings) - 1
|
| 1331 |
+
}
|
| 1332 |
+
}
|
| 1333 |
+
h.persist(c)
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
// DeleteAmpModelMappings removes specified model mappings by "from" field.
|
| 1337 |
+
func (h *Handler) DeleteAmpModelMappings(c *gin.Context) {
|
| 1338 |
+
var body struct {
|
| 1339 |
+
Value []string `json:"value"`
|
| 1340 |
+
}
|
| 1341 |
+
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
| 1342 |
+
h.cfg.AmpCode.ModelMappings = nil
|
| 1343 |
+
h.persist(c)
|
| 1344 |
+
return
|
| 1345 |
+
}
|
| 1346 |
+
|
| 1347 |
+
toRemove := make(map[string]bool)
|
| 1348 |
+
for _, from := range body.Value {
|
| 1349 |
+
toRemove[strings.TrimSpace(from)] = true
|
| 1350 |
+
}
|
| 1351 |
+
|
| 1352 |
+
newMappings := make([]config.AmpModelMapping, 0, len(h.cfg.AmpCode.ModelMappings))
|
| 1353 |
+
for _, m := range h.cfg.AmpCode.ModelMappings {
|
| 1354 |
+
if !toRemove[strings.TrimSpace(m.From)] {
|
| 1355 |
+
newMappings = append(newMappings, m)
|
| 1356 |
+
}
|
| 1357 |
+
}
|
| 1358 |
+
h.cfg.AmpCode.ModelMappings = newMappings
|
| 1359 |
+
h.persist(c)
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
// GetAmpForceModelMappings returns whether model mappings are forced.
|
| 1363 |
+
func (h *Handler) GetAmpForceModelMappings(c *gin.Context) {
|
| 1364 |
+
if h == nil || h.cfg == nil {
|
| 1365 |
+
c.JSON(200, gin.H{"force-model-mappings": false})
|
| 1366 |
+
return
|
| 1367 |
+
}
|
| 1368 |
+
c.JSON(200, gin.H{"force-model-mappings": h.cfg.AmpCode.ForceModelMappings})
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
// PutAmpForceModelMappings updates the force model mappings setting.
|
| 1372 |
+
func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
|
| 1373 |
+
h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
|
| 1374 |
+
}
|
| 1375 |
+
|
| 1376 |
+
// GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
|
| 1377 |
+
func (h *Handler) GetAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1378 |
+
if h == nil || h.cfg == nil {
|
| 1379 |
+
c.JSON(200, gin.H{"upstream-api-keys": []config.AmpUpstreamAPIKeyEntry{}})
|
| 1380 |
+
return
|
| 1381 |
+
}
|
| 1382 |
+
c.JSON(200, gin.H{"upstream-api-keys": h.cfg.AmpCode.UpstreamAPIKeys})
|
| 1383 |
+
}
|
| 1384 |
+
|
| 1385 |
+
// PutAmpUpstreamAPIKeys replaces all ampcode upstream API keys mappings.
|
| 1386 |
+
func (h *Handler) PutAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1387 |
+
var body struct {
|
| 1388 |
+
Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
|
| 1389 |
+
}
|
| 1390 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1391 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1392 |
+
return
|
| 1393 |
+
}
|
| 1394 |
+
// Normalize entries: trim whitespace, filter empty
|
| 1395 |
+
normalized := normalizeAmpUpstreamAPIKeyEntries(body.Value)
|
| 1396 |
+
h.cfg.AmpCode.UpstreamAPIKeys = normalized
|
| 1397 |
+
h.persist(c)
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
+
// PatchAmpUpstreamAPIKeys adds or updates upstream API keys entries.
|
| 1401 |
+
// Matching is done by upstream-api-key value.
|
| 1402 |
+
func (h *Handler) PatchAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1403 |
+
var body struct {
|
| 1404 |
+
Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
|
| 1405 |
+
}
|
| 1406 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1407 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1408 |
+
return
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
existing := make(map[string]int)
|
| 1412 |
+
for i, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
|
| 1413 |
+
existing[strings.TrimSpace(entry.UpstreamAPIKey)] = i
|
| 1414 |
+
}
|
| 1415 |
+
|
| 1416 |
+
for _, newEntry := range body.Value {
|
| 1417 |
+
upstreamKey := strings.TrimSpace(newEntry.UpstreamAPIKey)
|
| 1418 |
+
if upstreamKey == "" {
|
| 1419 |
+
continue
|
| 1420 |
+
}
|
| 1421 |
+
normalizedEntry := config.AmpUpstreamAPIKeyEntry{
|
| 1422 |
+
UpstreamAPIKey: upstreamKey,
|
| 1423 |
+
APIKeys: normalizeAPIKeysList(newEntry.APIKeys),
|
| 1424 |
+
}
|
| 1425 |
+
if idx, ok := existing[upstreamKey]; ok {
|
| 1426 |
+
h.cfg.AmpCode.UpstreamAPIKeys[idx] = normalizedEntry
|
| 1427 |
+
} else {
|
| 1428 |
+
h.cfg.AmpCode.UpstreamAPIKeys = append(h.cfg.AmpCode.UpstreamAPIKeys, normalizedEntry)
|
| 1429 |
+
existing[upstreamKey] = len(h.cfg.AmpCode.UpstreamAPIKeys) - 1
|
| 1430 |
+
}
|
| 1431 |
+
}
|
| 1432 |
+
h.persist(c)
|
| 1433 |
+
}
|
| 1434 |
+
|
| 1435 |
+
// DeleteAmpUpstreamAPIKeys removes specified upstream API keys entries.
|
| 1436 |
+
// Body must be JSON: {"value": ["<upstream-api-key>", ...]}.
|
| 1437 |
+
// If "value" is an empty array, clears all entries.
|
| 1438 |
+
// If JSON is invalid or "value" is missing/null, returns 400 and does not persist any change.
|
| 1439 |
+
func (h *Handler) DeleteAmpUpstreamAPIKeys(c *gin.Context) {
|
| 1440 |
+
var body struct {
|
| 1441 |
+
Value []string `json:"value"`
|
| 1442 |
+
}
|
| 1443 |
+
if err := c.ShouldBindJSON(&body); err != nil {
|
| 1444 |
+
c.JSON(400, gin.H{"error": "invalid body"})
|
| 1445 |
+
return
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
if body.Value == nil {
|
| 1449 |
+
c.JSON(400, gin.H{"error": "missing value"})
|
| 1450 |
+
return
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
// Empty array means clear all
|
| 1454 |
+
if len(body.Value) == 0 {
|
| 1455 |
+
h.cfg.AmpCode.UpstreamAPIKeys = nil
|
| 1456 |
+
h.persist(c)
|
| 1457 |
+
return
|
| 1458 |
+
}
|
| 1459 |
+
|
| 1460 |
+
toRemove := make(map[string]bool)
|
| 1461 |
+
for _, key := range body.Value {
|
| 1462 |
+
trimmed := strings.TrimSpace(key)
|
| 1463 |
+
if trimmed == "" {
|
| 1464 |
+
continue
|
| 1465 |
+
}
|
| 1466 |
+
toRemove[trimmed] = true
|
| 1467 |
+
}
|
| 1468 |
+
if len(toRemove) == 0 {
|
| 1469 |
+
c.JSON(400, gin.H{"error": "empty value"})
|
| 1470 |
+
return
|
| 1471 |
+
}
|
| 1472 |
+
|
| 1473 |
+
newEntries := make([]config.AmpUpstreamAPIKeyEntry, 0, len(h.cfg.AmpCode.UpstreamAPIKeys))
|
| 1474 |
+
for _, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
|
| 1475 |
+
if !toRemove[strings.TrimSpace(entry.UpstreamAPIKey)] {
|
| 1476 |
+
newEntries = append(newEntries, entry)
|
| 1477 |
+
}
|
| 1478 |
+
}
|
| 1479 |
+
h.cfg.AmpCode.UpstreamAPIKeys = newEntries
|
| 1480 |
+
h.persist(c)
|
| 1481 |
+
}
|
| 1482 |
+
|
| 1483 |
+
// normalizeAmpUpstreamAPIKeyEntries normalizes a list of upstream API key entries.
|
| 1484 |
+
func normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry {
|
| 1485 |
+
if len(entries) == 0 {
|
| 1486 |
+
return nil
|
| 1487 |
+
}
|
| 1488 |
+
out := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries))
|
| 1489 |
+
for _, entry := range entries {
|
| 1490 |
+
upstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)
|
| 1491 |
+
if upstreamKey == "" {
|
| 1492 |
+
continue
|
| 1493 |
+
}
|
| 1494 |
+
apiKeys := normalizeAPIKeysList(entry.APIKeys)
|
| 1495 |
+
out = append(out, config.AmpUpstreamAPIKeyEntry{
|
| 1496 |
+
UpstreamAPIKey: upstreamKey,
|
| 1497 |
+
APIKeys: apiKeys,
|
| 1498 |
+
})
|
| 1499 |
+
}
|
| 1500 |
+
if len(out) == 0 {
|
| 1501 |
+
return nil
|
| 1502 |
+
}
|
| 1503 |
+
return out
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
// normalizeAPIKeysList trims and filters empty strings from a list of API keys.
|
| 1507 |
+
func normalizeAPIKeysList(keys []string) []string {
|
| 1508 |
+
if len(keys) == 0 {
|
| 1509 |
+
return nil
|
| 1510 |
+
}
|
| 1511 |
+
out := make([]string, 0, len(keys))
|
| 1512 |
+
for _, k := range keys {
|
| 1513 |
+
trimmed := strings.TrimSpace(k)
|
| 1514 |
+
if trimmed != "" {
|
| 1515 |
+
out = append(out, trimmed)
|
| 1516 |
+
}
|
| 1517 |
+
}
|
| 1518 |
+
if len(out) == 0 {
|
| 1519 |
+
return nil
|
| 1520 |
+
}
|
| 1521 |
+
return out
|
| 1522 |
+
}
|
internal/api/handlers/management/handler.go
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package management provides the management API handlers and middleware
|
| 2 |
+
// for configuring the server and managing auth files.
|
| 3 |
+
package management
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"crypto/subtle"
|
| 7 |
+
"fmt"
|
| 8 |
+
"net/http"
|
| 9 |
+
"os"
|
| 10 |
+
"path/filepath"
|
| 11 |
+
"strings"
|
| 12 |
+
"sync"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"github.com/gin-gonic/gin"
|
| 16 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
| 17 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 18 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
| 19 |
+
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
| 20 |
+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
| 21 |
+
"golang.org/x/crypto/bcrypt"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
type attemptInfo struct {
|
| 25 |
+
count int
|
| 26 |
+
blockedUntil time.Time
|
| 27 |
+
lastActivity time.Time // track last activity for cleanup
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// attemptCleanupInterval controls how often stale IP entries are purged
|
| 31 |
+
const attemptCleanupInterval = 1 * time.Hour
|
| 32 |
+
|
| 33 |
+
// attemptMaxIdleTime controls how long an IP can be idle before cleanup
|
| 34 |
+
const attemptMaxIdleTime = 2 * time.Hour
|
| 35 |
+
|
| 36 |
+
// Handler aggregates config reference, persistence path and helpers.
|
| 37 |
+
type Handler struct {
|
| 38 |
+
cfg *config.Config
|
| 39 |
+
configFilePath string
|
| 40 |
+
mu sync.Mutex
|
| 41 |
+
attemptsMu sync.Mutex
|
| 42 |
+
failedAttempts map[string]*attemptInfo // keyed by client IP
|
| 43 |
+
authManager *coreauth.Manager
|
| 44 |
+
usageStats *usage.RequestStatistics
|
| 45 |
+
tokenStore coreauth.Store
|
| 46 |
+
localPassword string
|
| 47 |
+
allowRemoteOverride bool
|
| 48 |
+
envSecret string
|
| 49 |
+
logDir string
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// NewHandler creates a new management handler instance.
|
| 53 |
+
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
|
| 54 |
+
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
|
| 55 |
+
envSecret = strings.TrimSpace(envSecret)
|
| 56 |
+
|
| 57 |
+
h := &Handler{
|
| 58 |
+
cfg: cfg,
|
| 59 |
+
configFilePath: configFilePath,
|
| 60 |
+
failedAttempts: make(map[string]*attemptInfo),
|
| 61 |
+
authManager: manager,
|
| 62 |
+
usageStats: usage.GetRequestStatistics(),
|
| 63 |
+
tokenStore: sdkAuth.GetTokenStore(),
|
| 64 |
+
allowRemoteOverride: envSecret != "",
|
| 65 |
+
envSecret: envSecret,
|
| 66 |
+
}
|
| 67 |
+
h.startAttemptCleanup()
|
| 68 |
+
return h
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// startAttemptCleanup launches a background goroutine that periodically
|
| 72 |
+
// removes stale IP entries from failedAttempts to prevent memory leaks.
|
| 73 |
+
func (h *Handler) startAttemptCleanup() {
|
| 74 |
+
go func() {
|
| 75 |
+
ticker := time.NewTicker(attemptCleanupInterval)
|
| 76 |
+
defer ticker.Stop()
|
| 77 |
+
for range ticker.C {
|
| 78 |
+
h.purgeStaleAttempts()
|
| 79 |
+
}
|
| 80 |
+
}()
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// purgeStaleAttempts removes IP entries that have been idle beyond attemptMaxIdleTime
|
| 84 |
+
// and whose ban (if any) has expired.
|
| 85 |
+
func (h *Handler) purgeStaleAttempts() {
|
| 86 |
+
now := time.Now()
|
| 87 |
+
h.attemptsMu.Lock()
|
| 88 |
+
defer h.attemptsMu.Unlock()
|
| 89 |
+
for ip, ai := range h.failedAttempts {
|
| 90 |
+
// Skip if still banned
|
| 91 |
+
if !ai.blockedUntil.IsZero() && now.Before(ai.blockedUntil) {
|
| 92 |
+
continue
|
| 93 |
+
}
|
| 94 |
+
// Remove if idle too long
|
| 95 |
+
if now.Sub(ai.lastActivity) > attemptMaxIdleTime {
|
| 96 |
+
delete(h.failedAttempts, ip)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// NewHandler creates a new management handler instance.
|
| 102 |
+
func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler {
|
| 103 |
+
return NewHandler(cfg, "", manager)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// SetConfig updates the in-memory config reference when the server hot-reloads.
|
| 107 |
+
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
|
| 108 |
+
|
| 109 |
+
// SetAuthManager updates the auth manager reference used by management endpoints.
|
| 110 |
+
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
|
| 111 |
+
|
| 112 |
+
// SetUsageStatistics allows replacing the usage statistics reference.
|
| 113 |
+
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
|
| 114 |
+
|
| 115 |
+
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
| 116 |
+
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
| 117 |
+
|
| 118 |
+
// SetLogDirectory updates the directory where main.log should be looked up.
|
| 119 |
+
func (h *Handler) SetLogDirectory(dir string) {
|
| 120 |
+
if dir == "" {
|
| 121 |
+
return
|
| 122 |
+
}
|
| 123 |
+
if !filepath.IsAbs(dir) {
|
| 124 |
+
if abs, err := filepath.Abs(dir); err == nil {
|
| 125 |
+
dir = abs
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
h.logDir = dir
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// Middleware enforces access control for management endpoints.
|
| 132 |
+
// All requests (local and remote) require a valid management key.
|
| 133 |
+
// Additionally, remote access requires allow-remote-management=true.
|
| 134 |
+
func (h *Handler) Middleware() gin.HandlerFunc {
|
| 135 |
+
const maxFailures = 5
|
| 136 |
+
const banDuration = 30 * time.Minute
|
| 137 |
+
|
| 138 |
+
return func(c *gin.Context) {
|
| 139 |
+
c.Header("X-CPA-VERSION", buildinfo.Version)
|
| 140 |
+
c.Header("X-CPA-COMMIT", buildinfo.Commit)
|
| 141 |
+
c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate)
|
| 142 |
+
|
| 143 |
+
clientIP := c.ClientIP()
|
| 144 |
+
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
|
| 145 |
+
cfg := h.cfg
|
| 146 |
+
var (
|
| 147 |
+
allowRemote bool
|
| 148 |
+
secretHash string
|
| 149 |
+
)
|
| 150 |
+
if cfg != nil {
|
| 151 |
+
allowRemote = cfg.RemoteManagement.AllowRemote
|
| 152 |
+
secretHash = cfg.RemoteManagement.SecretKey
|
| 153 |
+
}
|
| 154 |
+
if h.allowRemoteOverride {
|
| 155 |
+
allowRemote = true
|
| 156 |
+
}
|
| 157 |
+
envSecret := h.envSecret
|
| 158 |
+
|
| 159 |
+
fail := func() {}
|
| 160 |
+
if !localClient {
|
| 161 |
+
h.attemptsMu.Lock()
|
| 162 |
+
ai := h.failedAttempts[clientIP]
|
| 163 |
+
if ai != nil {
|
| 164 |
+
if !ai.blockedUntil.IsZero() {
|
| 165 |
+
if time.Now().Before(ai.blockedUntil) {
|
| 166 |
+
remaining := time.Until(ai.blockedUntil).Round(time.Second)
|
| 167 |
+
h.attemptsMu.Unlock()
|
| 168 |
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
|
| 169 |
+
return
|
| 170 |
+
}
|
| 171 |
+
// Ban expired, reset state
|
| 172 |
+
ai.blockedUntil = time.Time{}
|
| 173 |
+
ai.count = 0
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
h.attemptsMu.Unlock()
|
| 177 |
+
|
| 178 |
+
if !allowRemote {
|
| 179 |
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
|
| 180 |
+
return
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
fail = func() {
|
| 184 |
+
h.attemptsMu.Lock()
|
| 185 |
+
aip := h.failedAttempts[clientIP]
|
| 186 |
+
if aip == nil {
|
| 187 |
+
aip = &attemptInfo{}
|
| 188 |
+
h.failedAttempts[clientIP] = aip
|
| 189 |
+
}
|
| 190 |
+
aip.count++
|
| 191 |
+
aip.lastActivity = time.Now()
|
| 192 |
+
if aip.count >= maxFailures {
|
| 193 |
+
aip.blockedUntil = time.Now().Add(banDuration)
|
| 194 |
+
aip.count = 0
|
| 195 |
+
}
|
| 196 |
+
h.attemptsMu.Unlock()
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
if secretHash == "" && envSecret == "" {
|
| 200 |
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
|
| 201 |
+
return
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Accept either Authorization: Bearer <key> or X-Management-Key
|
| 205 |
+
var provided string
|
| 206 |
+
if ah := c.GetHeader("Authorization"); ah != "" {
|
| 207 |
+
parts := strings.SplitN(ah, " ", 2)
|
| 208 |
+
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
| 209 |
+
provided = parts[1]
|
| 210 |
+
} else {
|
| 211 |
+
provided = ah
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
if provided == "" {
|
| 215 |
+
provided = c.GetHeader("X-Management-Key")
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
if provided == "" {
|
| 219 |
+
if !localClient {
|
| 220 |
+
fail()
|
| 221 |
+
}
|
| 222 |
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
| 223 |
+
return
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if localClient {
|
| 227 |
+
if lp := h.localPassword; lp != "" {
|
| 228 |
+
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
|
| 229 |
+
c.Next()
|
| 230 |
+
return
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
|
| 236 |
+
if !localClient {
|
| 237 |
+
h.attemptsMu.Lock()
|
| 238 |
+
if ai := h.failedAttempts[clientIP]; ai != nil {
|
| 239 |
+
ai.count = 0
|
| 240 |
+
ai.blockedUntil = time.Time{}
|
| 241 |
+
}
|
| 242 |
+
h.attemptsMu.Unlock()
|
| 243 |
+
}
|
| 244 |
+
c.Next()
|
| 245 |
+
return
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
|
| 249 |
+
if !localClient {
|
| 250 |
+
fail()
|
| 251 |
+
}
|
| 252 |
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
|
| 253 |
+
return
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
if !localClient {
|
| 257 |
+
h.attemptsMu.Lock()
|
| 258 |
+
if ai := h.failedAttempts[clientIP]; ai != nil {
|
| 259 |
+
ai.count = 0
|
| 260 |
+
ai.blockedUntil = time.Time{}
|
| 261 |
+
}
|
| 262 |
+
h.attemptsMu.Unlock()
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
c.Next()
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// persist saves the current in-memory config to disk.
|
| 270 |
+
func (h *Handler) persist(c *gin.Context) bool {
|
| 271 |
+
h.mu.Lock()
|
| 272 |
+
defer h.mu.Unlock()
|
| 273 |
+
// Preserve comments when writing
|
| 274 |
+
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
|
| 275 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
|
| 276 |
+
return false
|
| 277 |
+
}
|
| 278 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 279 |
+
return true
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// Helper methods for simple types
|
| 283 |
+
func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
|
| 284 |
+
var body struct {
|
| 285 |
+
Value *bool `json:"value"`
|
| 286 |
+
}
|
| 287 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 288 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 289 |
+
return
|
| 290 |
+
}
|
| 291 |
+
set(*body.Value)
|
| 292 |
+
h.persist(c)
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
|
| 296 |
+
var body struct {
|
| 297 |
+
Value *int `json:"value"`
|
| 298 |
+
}
|
| 299 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 300 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 301 |
+
return
|
| 302 |
+
}
|
| 303 |
+
set(*body.Value)
|
| 304 |
+
h.persist(c)
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
|
| 308 |
+
var body struct {
|
| 309 |
+
Value *string `json:"value"`
|
| 310 |
+
}
|
| 311 |
+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
| 312 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
| 313 |
+
return
|
| 314 |
+
}
|
| 315 |
+
set(*body.Value)
|
| 316 |
+
h.persist(c)
|
| 317 |
+
}
|
internal/api/handlers/management/logs.go
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bufio"
|
| 5 |
+
"fmt"
|
| 6 |
+
"math"
|
| 7 |
+
"net/http"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"sort"
|
| 11 |
+
"strconv"
|
| 12 |
+
"strings"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"github.com/gin-gonic/gin"
|
| 16 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
const (
|
| 20 |
+
defaultLogFileName = "main.log"
|
| 21 |
+
logScannerInitialBuffer = 64 * 1024
|
| 22 |
+
logScannerMaxBuffer = 8 * 1024 * 1024
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
// GetLogs returns log lines with optional incremental loading.
|
| 26 |
+
func (h *Handler) GetLogs(c *gin.Context) {
|
| 27 |
+
if h == nil {
|
| 28 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
if h.cfg == nil {
|
| 32 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 33 |
+
return
|
| 34 |
+
}
|
| 35 |
+
if !h.cfg.LoggingToFile {
|
| 36 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
logDir := h.logDirectory()
|
| 41 |
+
if strings.TrimSpace(logDir) == "" {
|
| 42 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
files, err := h.collectLogFiles(logDir)
|
| 47 |
+
if err != nil {
|
| 48 |
+
if os.IsNotExist(err) {
|
| 49 |
+
cutoff := parseCutoff(c.Query("after"))
|
| 50 |
+
c.JSON(http.StatusOK, gin.H{
|
| 51 |
+
"lines": []string{},
|
| 52 |
+
"line-count": 0,
|
| 53 |
+
"latest-timestamp": cutoff,
|
| 54 |
+
})
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)})
|
| 58 |
+
return
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
limit, errLimit := parseLimit(c.Query("limit"))
|
| 62 |
+
if errLimit != nil {
|
| 63 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid limit: %v", errLimit)})
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
cutoff := parseCutoff(c.Query("after"))
|
| 68 |
+
acc := newLogAccumulator(cutoff, limit)
|
| 69 |
+
for i := range files {
|
| 70 |
+
if errProcess := acc.consumeFile(files[i]); errProcess != nil {
|
| 71 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)})
|
| 72 |
+
return
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
lines, total, latest := acc.result()
|
| 77 |
+
if latest == 0 || latest < cutoff {
|
| 78 |
+
latest = cutoff
|
| 79 |
+
}
|
| 80 |
+
c.JSON(http.StatusOK, gin.H{
|
| 81 |
+
"lines": lines,
|
| 82 |
+
"line-count": total,
|
| 83 |
+
"latest-timestamp": latest,
|
| 84 |
+
})
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// DeleteLogs removes all rotated log files and truncates the active log.
|
| 88 |
+
func (h *Handler) DeleteLogs(c *gin.Context) {
|
| 89 |
+
if h == nil {
|
| 90 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 91 |
+
return
|
| 92 |
+
}
|
| 93 |
+
if h.cfg == nil {
|
| 94 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 95 |
+
return
|
| 96 |
+
}
|
| 97 |
+
if !h.cfg.LoggingToFile {
|
| 98 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
| 99 |
+
return
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
dir := h.logDirectory()
|
| 103 |
+
if strings.TrimSpace(dir) == "" {
|
| 104 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 105 |
+
return
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
entries, err := os.ReadDir(dir)
|
| 109 |
+
if err != nil {
|
| 110 |
+
if os.IsNotExist(err) {
|
| 111 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
|
| 112 |
+
return
|
| 113 |
+
}
|
| 114 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
|
| 115 |
+
return
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
removed := 0
|
| 119 |
+
for _, entry := range entries {
|
| 120 |
+
if entry.IsDir() {
|
| 121 |
+
continue
|
| 122 |
+
}
|
| 123 |
+
name := entry.Name()
|
| 124 |
+
fullPath := filepath.Join(dir, name)
|
| 125 |
+
if name == defaultLogFileName {
|
| 126 |
+
if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {
|
| 127 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)})
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
continue
|
| 131 |
+
}
|
| 132 |
+
if isRotatedLogFile(name) {
|
| 133 |
+
if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {
|
| 134 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)})
|
| 135 |
+
return
|
| 136 |
+
}
|
| 137 |
+
removed++
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
c.JSON(http.StatusOK, gin.H{
|
| 142 |
+
"success": true,
|
| 143 |
+
"message": "Logs cleared successfully",
|
| 144 |
+
"removed": removed,
|
| 145 |
+
})
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// GetRequestErrorLogs lists error request log files when RequestLog is disabled.
|
| 149 |
+
// It returns an empty list when RequestLog is enabled.
|
| 150 |
+
func (h *Handler) GetRequestErrorLogs(c *gin.Context) {
|
| 151 |
+
if h == nil {
|
| 152 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 153 |
+
return
|
| 154 |
+
}
|
| 155 |
+
if h.cfg == nil {
|
| 156 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 157 |
+
return
|
| 158 |
+
}
|
| 159 |
+
if h.cfg.RequestLog {
|
| 160 |
+
c.JSON(http.StatusOK, gin.H{"files": []any{}})
|
| 161 |
+
return
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
dir := h.logDirectory()
|
| 165 |
+
if strings.TrimSpace(dir) == "" {
|
| 166 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 167 |
+
return
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
entries, err := os.ReadDir(dir)
|
| 171 |
+
if err != nil {
|
| 172 |
+
if os.IsNotExist(err) {
|
| 173 |
+
c.JSON(http.StatusOK, gin.H{"files": []any{}})
|
| 174 |
+
return
|
| 175 |
+
}
|
| 176 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list request error logs: %v", err)})
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
type errorLog struct {
|
| 181 |
+
Name string `json:"name"`
|
| 182 |
+
Size int64 `json:"size"`
|
| 183 |
+
Modified int64 `json:"modified"`
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
files := make([]errorLog, 0, len(entries))
|
| 187 |
+
for _, entry := range entries {
|
| 188 |
+
if entry.IsDir() {
|
| 189 |
+
continue
|
| 190 |
+
}
|
| 191 |
+
name := entry.Name()
|
| 192 |
+
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
|
| 193 |
+
continue
|
| 194 |
+
}
|
| 195 |
+
info, errInfo := entry.Info()
|
| 196 |
+
if errInfo != nil {
|
| 197 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log info for %s: %v", name, errInfo)})
|
| 198 |
+
return
|
| 199 |
+
}
|
| 200 |
+
files = append(files, errorLog{
|
| 201 |
+
Name: name,
|
| 202 |
+
Size: info.Size(),
|
| 203 |
+
Modified: info.ModTime().Unix(),
|
| 204 |
+
})
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
|
| 208 |
+
|
| 209 |
+
c.JSON(http.StatusOK, gin.H{"files": files})
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// GetRequestLogByID finds and downloads a request log file by its request ID.
|
| 213 |
+
// The ID is matched against the suffix of log file names (format: *-{requestID}.log).
|
| 214 |
+
func (h *Handler) GetRequestLogByID(c *gin.Context) {
|
| 215 |
+
if h == nil {
|
| 216 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 217 |
+
return
|
| 218 |
+
}
|
| 219 |
+
if h.cfg == nil {
|
| 220 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 221 |
+
return
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
dir := h.logDirectory()
|
| 225 |
+
if strings.TrimSpace(dir) == "" {
|
| 226 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 227 |
+
return
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
requestID := strings.TrimSpace(c.Param("id"))
|
| 231 |
+
if requestID == "" {
|
| 232 |
+
requestID = strings.TrimSpace(c.Query("id"))
|
| 233 |
+
}
|
| 234 |
+
if requestID == "" {
|
| 235 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing request ID"})
|
| 236 |
+
return
|
| 237 |
+
}
|
| 238 |
+
if strings.ContainsAny(requestID, "/\\") {
|
| 239 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request ID"})
|
| 240 |
+
return
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
entries, err := os.ReadDir(dir)
|
| 244 |
+
if err != nil {
|
| 245 |
+
if os.IsNotExist(err) {
|
| 246 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
|
| 250 |
+
return
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
suffix := "-" + requestID + ".log"
|
| 254 |
+
var matchedFile string
|
| 255 |
+
for _, entry := range entries {
|
| 256 |
+
if entry.IsDir() {
|
| 257 |
+
continue
|
| 258 |
+
}
|
| 259 |
+
name := entry.Name()
|
| 260 |
+
if strings.HasSuffix(name, suffix) {
|
| 261 |
+
matchedFile = name
|
| 262 |
+
break
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
if matchedFile == "" {
|
| 267 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found for the given request ID"})
|
| 268 |
+
return
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
dirAbs, errAbs := filepath.Abs(dir)
|
| 272 |
+
if errAbs != nil {
|
| 273 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
|
| 274 |
+
return
|
| 275 |
+
}
|
| 276 |
+
fullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile))
|
| 277 |
+
prefix := dirAbs + string(os.PathSeparator)
|
| 278 |
+
if !strings.HasPrefix(fullPath, prefix) {
|
| 279 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
|
| 280 |
+
return
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
info, errStat := os.Stat(fullPath)
|
| 284 |
+
if errStat != nil {
|
| 285 |
+
if os.IsNotExist(errStat) {
|
| 286 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
|
| 287 |
+
return
|
| 288 |
+
}
|
| 289 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
|
| 290 |
+
return
|
| 291 |
+
}
|
| 292 |
+
if info.IsDir() {
|
| 293 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
|
| 294 |
+
return
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
c.FileAttachment(fullPath, matchedFile)
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// DownloadRequestErrorLog downloads a specific error request log file by name.
|
| 301 |
+
func (h *Handler) DownloadRequestErrorLog(c *gin.Context) {
|
| 302 |
+
if h == nil {
|
| 303 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
| 304 |
+
return
|
| 305 |
+
}
|
| 306 |
+
if h.cfg == nil {
|
| 307 |
+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
| 308 |
+
return
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
dir := h.logDirectory()
|
| 312 |
+
if strings.TrimSpace(dir) == "" {
|
| 313 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
| 314 |
+
return
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
name := strings.TrimSpace(c.Param("name"))
|
| 318 |
+
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
| 319 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file name"})
|
| 320 |
+
return
|
| 321 |
+
}
|
| 322 |
+
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
|
| 323 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
|
| 324 |
+
return
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
dirAbs, errAbs := filepath.Abs(dir)
|
| 328 |
+
if errAbs != nil {
|
| 329 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
|
| 330 |
+
return
|
| 331 |
+
}
|
| 332 |
+
fullPath := filepath.Clean(filepath.Join(dirAbs, name))
|
| 333 |
+
prefix := dirAbs + string(os.PathSeparator)
|
| 334 |
+
if !strings.HasPrefix(fullPath, prefix) {
|
| 335 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
|
| 336 |
+
return
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
info, errStat := os.Stat(fullPath)
|
| 340 |
+
if errStat != nil {
|
| 341 |
+
if os.IsNotExist(errStat) {
|
| 342 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
|
| 343 |
+
return
|
| 344 |
+
}
|
| 345 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
|
| 346 |
+
return
|
| 347 |
+
}
|
| 348 |
+
if info.IsDir() {
|
| 349 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
|
| 350 |
+
return
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
c.FileAttachment(fullPath, name)
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
func (h *Handler) logDirectory() string {
|
| 357 |
+
if h == nil {
|
| 358 |
+
return ""
|
| 359 |
+
}
|
| 360 |
+
if h.logDir != "" {
|
| 361 |
+
return h.logDir
|
| 362 |
+
}
|
| 363 |
+
return logging.ResolveLogDirectory(h.cfg)
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
func (h *Handler) collectLogFiles(dir string) ([]string, error) {
|
| 367 |
+
entries, err := os.ReadDir(dir)
|
| 368 |
+
if err != nil {
|
| 369 |
+
return nil, err
|
| 370 |
+
}
|
| 371 |
+
type candidate struct {
|
| 372 |
+
path string
|
| 373 |
+
order int64
|
| 374 |
+
}
|
| 375 |
+
cands := make([]candidate, 0, len(entries))
|
| 376 |
+
for _, entry := range entries {
|
| 377 |
+
if entry.IsDir() {
|
| 378 |
+
continue
|
| 379 |
+
}
|
| 380 |
+
name := entry.Name()
|
| 381 |
+
if name == defaultLogFileName {
|
| 382 |
+
cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})
|
| 383 |
+
continue
|
| 384 |
+
}
|
| 385 |
+
if order, ok := rotationOrder(name); ok {
|
| 386 |
+
cands = append(cands, candidate{path: filepath.Join(dir, name), order: order})
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
if len(cands) == 0 {
|
| 390 |
+
return []string{}, nil
|
| 391 |
+
}
|
| 392 |
+
sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })
|
| 393 |
+
paths := make([]string, 0, len(cands))
|
| 394 |
+
for i := len(cands) - 1; i >= 0; i-- {
|
| 395 |
+
paths = append(paths, cands[i].path)
|
| 396 |
+
}
|
| 397 |
+
return paths, nil
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
type logAccumulator struct {
|
| 401 |
+
cutoff int64
|
| 402 |
+
limit int
|
| 403 |
+
lines []string
|
| 404 |
+
total int
|
| 405 |
+
latest int64
|
| 406 |
+
include bool
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
func newLogAccumulator(cutoff int64, limit int) *logAccumulator {
|
| 410 |
+
capacity := 256
|
| 411 |
+
if limit > 0 && limit < capacity {
|
| 412 |
+
capacity = limit
|
| 413 |
+
}
|
| 414 |
+
return &logAccumulator{
|
| 415 |
+
cutoff: cutoff,
|
| 416 |
+
limit: limit,
|
| 417 |
+
lines: make([]string, 0, capacity),
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
func (acc *logAccumulator) consumeFile(path string) error {
|
| 422 |
+
file, err := os.Open(path)
|
| 423 |
+
if err != nil {
|
| 424 |
+
if os.IsNotExist(err) {
|
| 425 |
+
return nil
|
| 426 |
+
}
|
| 427 |
+
return err
|
| 428 |
+
}
|
| 429 |
+
defer func() {
|
| 430 |
+
_ = file.Close()
|
| 431 |
+
}()
|
| 432 |
+
|
| 433 |
+
scanner := bufio.NewScanner(file)
|
| 434 |
+
buf := make([]byte, 0, logScannerInitialBuffer)
|
| 435 |
+
scanner.Buffer(buf, logScannerMaxBuffer)
|
| 436 |
+
for scanner.Scan() {
|
| 437 |
+
acc.addLine(scanner.Text())
|
| 438 |
+
}
|
| 439 |
+
if errScan := scanner.Err(); errScan != nil {
|
| 440 |
+
return errScan
|
| 441 |
+
}
|
| 442 |
+
return nil
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
func (acc *logAccumulator) addLine(raw string) {
|
| 446 |
+
line := strings.TrimRight(raw, "\r")
|
| 447 |
+
acc.total++
|
| 448 |
+
ts := parseTimestamp(line)
|
| 449 |
+
if ts > acc.latest {
|
| 450 |
+
acc.latest = ts
|
| 451 |
+
}
|
| 452 |
+
if ts > 0 {
|
| 453 |
+
acc.include = acc.cutoff == 0 || ts > acc.cutoff
|
| 454 |
+
if acc.cutoff == 0 || acc.include {
|
| 455 |
+
acc.append(line)
|
| 456 |
+
}
|
| 457 |
+
return
|
| 458 |
+
}
|
| 459 |
+
if acc.cutoff == 0 || acc.include {
|
| 460 |
+
acc.append(line)
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
func (acc *logAccumulator) append(line string) {
|
| 465 |
+
acc.lines = append(acc.lines, line)
|
| 466 |
+
if acc.limit > 0 && len(acc.lines) > acc.limit {
|
| 467 |
+
acc.lines = acc.lines[len(acc.lines)-acc.limit:]
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
func (acc *logAccumulator) result() ([]string, int, int64) {
|
| 472 |
+
if acc.lines == nil {
|
| 473 |
+
acc.lines = []string{}
|
| 474 |
+
}
|
| 475 |
+
return acc.lines, acc.total, acc.latest
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
func parseCutoff(raw string) int64 {
|
| 479 |
+
value := strings.TrimSpace(raw)
|
| 480 |
+
if value == "" {
|
| 481 |
+
return 0
|
| 482 |
+
}
|
| 483 |
+
ts, err := strconv.ParseInt(value, 10, 64)
|
| 484 |
+
if err != nil || ts <= 0 {
|
| 485 |
+
return 0
|
| 486 |
+
}
|
| 487 |
+
return ts
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
func parseLimit(raw string) (int, error) {
|
| 491 |
+
value := strings.TrimSpace(raw)
|
| 492 |
+
if value == "" {
|
| 493 |
+
return 0, nil
|
| 494 |
+
}
|
| 495 |
+
limit, err := strconv.Atoi(value)
|
| 496 |
+
if err != nil {
|
| 497 |
+
return 0, fmt.Errorf("must be a positive integer")
|
| 498 |
+
}
|
| 499 |
+
if limit <= 0 {
|
| 500 |
+
return 0, fmt.Errorf("must be greater than zero")
|
| 501 |
+
}
|
| 502 |
+
return limit, nil
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
func parseTimestamp(line string) int64 {
|
| 506 |
+
if strings.HasPrefix(line, "[") {
|
| 507 |
+
line = line[1:]
|
| 508 |
+
}
|
| 509 |
+
if len(line) < 19 {
|
| 510 |
+
return 0
|
| 511 |
+
}
|
| 512 |
+
candidate := line[:19]
|
| 513 |
+
t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local)
|
| 514 |
+
if err != nil {
|
| 515 |
+
return 0
|
| 516 |
+
}
|
| 517 |
+
return t.Unix()
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
func isRotatedLogFile(name string) bool {
|
| 521 |
+
if _, ok := rotationOrder(name); ok {
|
| 522 |
+
return true
|
| 523 |
+
}
|
| 524 |
+
return false
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
func rotationOrder(name string) (int64, bool) {
|
| 528 |
+
if order, ok := numericRotationOrder(name); ok {
|
| 529 |
+
return order, true
|
| 530 |
+
}
|
| 531 |
+
if order, ok := timestampRotationOrder(name); ok {
|
| 532 |
+
return order, true
|
| 533 |
+
}
|
| 534 |
+
return 0, false
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
func numericRotationOrder(name string) (int64, bool) {
|
| 538 |
+
if !strings.HasPrefix(name, defaultLogFileName+".") {
|
| 539 |
+
return 0, false
|
| 540 |
+
}
|
| 541 |
+
suffix := strings.TrimPrefix(name, defaultLogFileName+".")
|
| 542 |
+
if suffix == "" {
|
| 543 |
+
return 0, false
|
| 544 |
+
}
|
| 545 |
+
n, err := strconv.Atoi(suffix)
|
| 546 |
+
if err != nil {
|
| 547 |
+
return 0, false
|
| 548 |
+
}
|
| 549 |
+
return int64(n), true
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
func timestampRotationOrder(name string) (int64, bool) {
|
| 553 |
+
ext := filepath.Ext(defaultLogFileName)
|
| 554 |
+
base := strings.TrimSuffix(defaultLogFileName, ext)
|
| 555 |
+
if base == "" {
|
| 556 |
+
return 0, false
|
| 557 |
+
}
|
| 558 |
+
prefix := base + "-"
|
| 559 |
+
if !strings.HasPrefix(name, prefix) {
|
| 560 |
+
return 0, false
|
| 561 |
+
}
|
| 562 |
+
clean := strings.TrimPrefix(name, prefix)
|
| 563 |
+
if strings.HasSuffix(clean, ".gz") {
|
| 564 |
+
clean = strings.TrimSuffix(clean, ".gz")
|
| 565 |
+
}
|
| 566 |
+
if ext != "" {
|
| 567 |
+
if !strings.HasSuffix(clean, ext) {
|
| 568 |
+
return 0, false
|
| 569 |
+
}
|
| 570 |
+
clean = strings.TrimSuffix(clean, ext)
|
| 571 |
+
}
|
| 572 |
+
if clean == "" {
|
| 573 |
+
return 0, false
|
| 574 |
+
}
|
| 575 |
+
if idx := strings.IndexByte(clean, '.'); idx != -1 {
|
| 576 |
+
clean = clean[:idx]
|
| 577 |
+
}
|
| 578 |
+
parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local)
|
| 579 |
+
if err != nil {
|
| 580 |
+
return 0, false
|
| 581 |
+
}
|
| 582 |
+
return math.MaxInt64 - parsed.Unix(), true
|
| 583 |
+
}
|
internal/api/handlers/management/model_definitions.go
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"strings"
|
| 6 |
+
|
| 7 |
+
"github.com/gin-gonic/gin"
|
| 8 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// GetStaticModelDefinitions returns static model metadata for a given channel.
|
| 12 |
+
// Channel is provided via path param (:channel) or query param (?channel=...).
|
| 13 |
+
func (h *Handler) GetStaticModelDefinitions(c *gin.Context) {
|
| 14 |
+
channel := strings.TrimSpace(c.Param("channel"))
|
| 15 |
+
if channel == "" {
|
| 16 |
+
channel = strings.TrimSpace(c.Query("channel"))
|
| 17 |
+
}
|
| 18 |
+
if channel == "" {
|
| 19 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "channel is required"})
|
| 20 |
+
return
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
models := registry.GetStaticModelDefinitionsByChannel(channel)
|
| 24 |
+
if models == nil {
|
| 25 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown channel", "channel": channel})
|
| 26 |
+
return
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
c.JSON(http.StatusOK, gin.H{
|
| 30 |
+
"channel": strings.ToLower(strings.TrimSpace(channel)),
|
| 31 |
+
"models": models,
|
| 32 |
+
})
|
| 33 |
+
}
|
internal/api/handlers/management/oauth_callback.go
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/url"
|
| 7 |
+
"strings"
|
| 8 |
+
|
| 9 |
+
"github.com/gin-gonic/gin"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type oauthCallbackRequest struct {
|
| 13 |
+
Provider string `json:"provider"`
|
| 14 |
+
RedirectURL string `json:"redirect_url"`
|
| 15 |
+
Code string `json:"code"`
|
| 16 |
+
State string `json:"state"`
|
| 17 |
+
Error string `json:"error"`
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func (h *Handler) PostOAuthCallback(c *gin.Context) {
|
| 21 |
+
if h == nil || h.cfg == nil {
|
| 22 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "handler not initialized"})
|
| 23 |
+
return
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
var req oauthCallbackRequest
|
| 27 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 28 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"})
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
canonicalProvider, err := NormalizeOAuthProvider(req.Provider)
|
| 33 |
+
if err != nil {
|
| 34 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unsupported provider"})
|
| 35 |
+
return
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
state := strings.TrimSpace(req.State)
|
| 39 |
+
code := strings.TrimSpace(req.Code)
|
| 40 |
+
errMsg := strings.TrimSpace(req.Error)
|
| 41 |
+
|
| 42 |
+
if rawRedirect := strings.TrimSpace(req.RedirectURL); rawRedirect != "" {
|
| 43 |
+
u, errParse := url.Parse(rawRedirect)
|
| 44 |
+
if errParse != nil {
|
| 45 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid redirect_url"})
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
q := u.Query()
|
| 49 |
+
if state == "" {
|
| 50 |
+
state = strings.TrimSpace(q.Get("state"))
|
| 51 |
+
}
|
| 52 |
+
if code == "" {
|
| 53 |
+
code = strings.TrimSpace(q.Get("code"))
|
| 54 |
+
}
|
| 55 |
+
if errMsg == "" {
|
| 56 |
+
errMsg = strings.TrimSpace(q.Get("error"))
|
| 57 |
+
if errMsg == "" {
|
| 58 |
+
errMsg = strings.TrimSpace(q.Get("error_description"))
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if state == "" {
|
| 64 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "state is required"})
|
| 65 |
+
return
|
| 66 |
+
}
|
| 67 |
+
if err := ValidateOAuthState(state); err != nil {
|
| 68 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
if code == "" && errMsg == "" {
|
| 72 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "code or error is required"})
|
| 73 |
+
return
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
sessionProvider, sessionStatus, ok := GetOAuthSession(state)
|
| 77 |
+
if !ok {
|
| 78 |
+
c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
|
| 79 |
+
return
|
| 80 |
+
}
|
| 81 |
+
if sessionStatus != "" {
|
| 82 |
+
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
|
| 83 |
+
return
|
| 84 |
+
}
|
| 85 |
+
if !strings.EqualFold(sessionProvider, canonicalProvider) {
|
| 86 |
+
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "provider does not match state"})
|
| 87 |
+
return
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if _, errWrite := WriteOAuthCallbackFileForPendingSession(h.cfg.AuthDir, canonicalProvider, state, code, errMsg); errWrite != nil {
|
| 91 |
+
if errors.Is(errWrite, errOAuthSessionNotPending) {
|
| 92 |
+
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to persist oauth callback"})
|
| 96 |
+
return
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 100 |
+
}
|
internal/api/handlers/management/oauth_sessions.go
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"os"
|
| 8 |
+
"path/filepath"
|
| 9 |
+
"strings"
|
| 10 |
+
"sync"
|
| 11 |
+
"time"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
const (
|
| 15 |
+
oauthSessionTTL = 10 * time.Minute
|
| 16 |
+
maxOAuthStateLength = 128
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
var (
|
| 20 |
+
errInvalidOAuthState = errors.New("invalid oauth state")
|
| 21 |
+
errUnsupportedOAuthFlow = errors.New("unsupported oauth provider")
|
| 22 |
+
errOAuthSessionNotPending = errors.New("oauth session is not pending")
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
type oauthSession struct {
|
| 26 |
+
Provider string
|
| 27 |
+
Status string
|
| 28 |
+
CreatedAt time.Time
|
| 29 |
+
ExpiresAt time.Time
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
type oauthSessionStore struct {
|
| 33 |
+
mu sync.RWMutex
|
| 34 |
+
ttl time.Duration
|
| 35 |
+
sessions map[string]oauthSession
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
|
| 39 |
+
if ttl <= 0 {
|
| 40 |
+
ttl = oauthSessionTTL
|
| 41 |
+
}
|
| 42 |
+
return &oauthSessionStore{
|
| 43 |
+
ttl: ttl,
|
| 44 |
+
sessions: make(map[string]oauthSession),
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func (s *oauthSessionStore) purgeExpiredLocked(now time.Time) {
|
| 49 |
+
for state, session := range s.sessions {
|
| 50 |
+
if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
|
| 51 |
+
delete(s.sessions, state)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func (s *oauthSessionStore) Register(state, provider string) {
|
| 57 |
+
state = strings.TrimSpace(state)
|
| 58 |
+
provider = strings.ToLower(strings.TrimSpace(provider))
|
| 59 |
+
if state == "" || provider == "" {
|
| 60 |
+
return
|
| 61 |
+
}
|
| 62 |
+
now := time.Now()
|
| 63 |
+
|
| 64 |
+
s.mu.Lock()
|
| 65 |
+
defer s.mu.Unlock()
|
| 66 |
+
|
| 67 |
+
s.purgeExpiredLocked(now)
|
| 68 |
+
s.sessions[state] = oauthSession{
|
| 69 |
+
Provider: provider,
|
| 70 |
+
Status: "",
|
| 71 |
+
CreatedAt: now,
|
| 72 |
+
ExpiresAt: now.Add(s.ttl),
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func (s *oauthSessionStore) SetError(state, message string) {
|
| 77 |
+
state = strings.TrimSpace(state)
|
| 78 |
+
message = strings.TrimSpace(message)
|
| 79 |
+
if state == "" {
|
| 80 |
+
return
|
| 81 |
+
}
|
| 82 |
+
if message == "" {
|
| 83 |
+
message = "Authentication failed"
|
| 84 |
+
}
|
| 85 |
+
now := time.Now()
|
| 86 |
+
|
| 87 |
+
s.mu.Lock()
|
| 88 |
+
defer s.mu.Unlock()
|
| 89 |
+
|
| 90 |
+
s.purgeExpiredLocked(now)
|
| 91 |
+
session, ok := s.sessions[state]
|
| 92 |
+
if !ok {
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
session.Status = message
|
| 96 |
+
session.ExpiresAt = now.Add(s.ttl)
|
| 97 |
+
s.sessions[state] = session
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
func (s *oauthSessionStore) Complete(state string) {
|
| 101 |
+
state = strings.TrimSpace(state)
|
| 102 |
+
if state == "" {
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
now := time.Now()
|
| 106 |
+
|
| 107 |
+
s.mu.Lock()
|
| 108 |
+
defer s.mu.Unlock()
|
| 109 |
+
|
| 110 |
+
s.purgeExpiredLocked(now)
|
| 111 |
+
delete(s.sessions, state)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
func (s *oauthSessionStore) CompleteProvider(provider string) int {
|
| 115 |
+
provider = strings.ToLower(strings.TrimSpace(provider))
|
| 116 |
+
if provider == "" {
|
| 117 |
+
return 0
|
| 118 |
+
}
|
| 119 |
+
now := time.Now()
|
| 120 |
+
|
| 121 |
+
s.mu.Lock()
|
| 122 |
+
defer s.mu.Unlock()
|
| 123 |
+
|
| 124 |
+
s.purgeExpiredLocked(now)
|
| 125 |
+
removed := 0
|
| 126 |
+
for state, session := range s.sessions {
|
| 127 |
+
if strings.EqualFold(session.Provider, provider) {
|
| 128 |
+
delete(s.sessions, state)
|
| 129 |
+
removed++
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
return removed
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
func (s *oauthSessionStore) Get(state string) (oauthSession, bool) {
|
| 136 |
+
state = strings.TrimSpace(state)
|
| 137 |
+
now := time.Now()
|
| 138 |
+
|
| 139 |
+
s.mu.Lock()
|
| 140 |
+
defer s.mu.Unlock()
|
| 141 |
+
|
| 142 |
+
s.purgeExpiredLocked(now)
|
| 143 |
+
session, ok := s.sessions[state]
|
| 144 |
+
return session, ok
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
func (s *oauthSessionStore) IsPending(state, provider string) bool {
|
| 148 |
+
state = strings.TrimSpace(state)
|
| 149 |
+
provider = strings.ToLower(strings.TrimSpace(provider))
|
| 150 |
+
now := time.Now()
|
| 151 |
+
|
| 152 |
+
s.mu.Lock()
|
| 153 |
+
defer s.mu.Unlock()
|
| 154 |
+
|
| 155 |
+
s.purgeExpiredLocked(now)
|
| 156 |
+
session, ok := s.sessions[state]
|
| 157 |
+
if !ok {
|
| 158 |
+
return false
|
| 159 |
+
}
|
| 160 |
+
if session.Status != "" {
|
| 161 |
+
return false
|
| 162 |
+
}
|
| 163 |
+
if provider == "" {
|
| 164 |
+
return true
|
| 165 |
+
}
|
| 166 |
+
return strings.EqualFold(session.Provider, provider)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
var oauthSessions = newOAuthSessionStore(oauthSessionTTL)
|
| 170 |
+
|
| 171 |
+
func RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) }
|
| 172 |
+
|
| 173 |
+
func SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) }
|
| 174 |
+
|
| 175 |
+
func CompleteOAuthSession(state string) { oauthSessions.Complete(state) }
|
| 176 |
+
|
| 177 |
+
func CompleteOAuthSessionsByProvider(provider string) int {
|
| 178 |
+
return oauthSessions.CompleteProvider(provider)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
func GetOAuthSession(state string) (provider string, status string, ok bool) {
|
| 182 |
+
session, ok := oauthSessions.Get(state)
|
| 183 |
+
if !ok {
|
| 184 |
+
return "", "", false
|
| 185 |
+
}
|
| 186 |
+
return session.Provider, session.Status, true
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
func IsOAuthSessionPending(state, provider string) bool {
|
| 190 |
+
return oauthSessions.IsPending(state, provider)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
func ValidateOAuthState(state string) error {
|
| 194 |
+
trimmed := strings.TrimSpace(state)
|
| 195 |
+
if trimmed == "" {
|
| 196 |
+
return fmt.Errorf("%w: empty", errInvalidOAuthState)
|
| 197 |
+
}
|
| 198 |
+
if len(trimmed) > maxOAuthStateLength {
|
| 199 |
+
return fmt.Errorf("%w: too long", errInvalidOAuthState)
|
| 200 |
+
}
|
| 201 |
+
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") {
|
| 202 |
+
return fmt.Errorf("%w: contains path separator", errInvalidOAuthState)
|
| 203 |
+
}
|
| 204 |
+
if strings.Contains(trimmed, "..") {
|
| 205 |
+
return fmt.Errorf("%w: contains '..'", errInvalidOAuthState)
|
| 206 |
+
}
|
| 207 |
+
for _, r := range trimmed {
|
| 208 |
+
switch {
|
| 209 |
+
case r >= 'a' && r <= 'z':
|
| 210 |
+
case r >= 'A' && r <= 'Z':
|
| 211 |
+
case r >= '0' && r <= '9':
|
| 212 |
+
case r == '-' || r == '_' || r == '.':
|
| 213 |
+
default:
|
| 214 |
+
return fmt.Errorf("%w: invalid character", errInvalidOAuthState)
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
return nil
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
func NormalizeOAuthProvider(provider string) (string, error) {
|
| 221 |
+
switch strings.ToLower(strings.TrimSpace(provider)) {
|
| 222 |
+
case "anthropic", "claude":
|
| 223 |
+
return "anthropic", nil
|
| 224 |
+
case "codex", "openai":
|
| 225 |
+
return "codex", nil
|
| 226 |
+
case "gemini", "google":
|
| 227 |
+
return "gemini", nil
|
| 228 |
+
case "iflow", "i-flow":
|
| 229 |
+
return "iflow", nil
|
| 230 |
+
case "antigravity", "anti-gravity":
|
| 231 |
+
return "antigravity", nil
|
| 232 |
+
case "qwen":
|
| 233 |
+
return "qwen", nil
|
| 234 |
+
default:
|
| 235 |
+
return "", errUnsupportedOAuthFlow
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
type oauthCallbackFilePayload struct {
|
| 240 |
+
Code string `json:"code"`
|
| 241 |
+
State string `json:"state"`
|
| 242 |
+
Error string `json:"error"`
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {
|
| 246 |
+
if strings.TrimSpace(authDir) == "" {
|
| 247 |
+
return "", fmt.Errorf("auth dir is empty")
|
| 248 |
+
}
|
| 249 |
+
canonicalProvider, err := NormalizeOAuthProvider(provider)
|
| 250 |
+
if err != nil {
|
| 251 |
+
return "", err
|
| 252 |
+
}
|
| 253 |
+
if err := ValidateOAuthState(state); err != nil {
|
| 254 |
+
return "", err
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
fileName := fmt.Sprintf(".oauth-%s-%s.oauth", canonicalProvider, state)
|
| 258 |
+
filePath := filepath.Join(authDir, fileName)
|
| 259 |
+
payload := oauthCallbackFilePayload{
|
| 260 |
+
Code: strings.TrimSpace(code),
|
| 261 |
+
State: strings.TrimSpace(state),
|
| 262 |
+
Error: strings.TrimSpace(errorMessage),
|
| 263 |
+
}
|
| 264 |
+
data, err := json.Marshal(payload)
|
| 265 |
+
if err != nil {
|
| 266 |
+
return "", fmt.Errorf("marshal oauth callback payload: %w", err)
|
| 267 |
+
}
|
| 268 |
+
if err := os.WriteFile(filePath, data, 0o600); err != nil {
|
| 269 |
+
return "", fmt.Errorf("write oauth callback file: %w", err)
|
| 270 |
+
}
|
| 271 |
+
return filePath, nil
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {
|
| 275 |
+
canonicalProvider, err := NormalizeOAuthProvider(provider)
|
| 276 |
+
if err != nil {
|
| 277 |
+
return "", err
|
| 278 |
+
}
|
| 279 |
+
if !IsOAuthSessionPending(state, canonicalProvider) {
|
| 280 |
+
return "", errOAuthSessionNotPending
|
| 281 |
+
}
|
| 282 |
+
return WriteOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage)
|
| 283 |
+
}
|
internal/api/handlers/management/quota.go
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package management
|
| 2 |
+
|
| 3 |
+
import "github.com/gin-gonic/gin"
|
| 4 |
+
|
| 5 |
+
// Quota exceeded toggles
|
| 6 |
+
func (h *Handler) GetSwitchProject(c *gin.Context) {
|
| 7 |
+
c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
|
| 8 |
+
}
|
| 9 |
+
func (h *Handler) PutSwitchProject(c *gin.Context) {
|
| 10 |
+
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
|
| 14 |
+
c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
|
| 15 |
+
}
|
| 16 |
+
func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
|
| 17 |
+
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
|
| 18 |
+
}
|