mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Compare commits
317 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40255b128e | ||
|
|
6524d3a51e | ||
|
|
92c8cd7c72 | ||
|
|
c678ca21d5 | ||
|
|
6d4b43dd7a | ||
|
|
b0f2ad7cfe | ||
|
|
cd0b1be46c | ||
|
|
08856a97fb | ||
|
|
b6d5ce2d4d | ||
|
|
0f55e550cf | ||
|
|
e1de04230f | ||
|
|
a887a337a5 | ||
|
|
2717ba3e50 | ||
|
|
63af4c551d | ||
|
|
c675cf5e72 | ||
|
|
4fd95ead3b | ||
|
|
514add4b85 | ||
|
|
3ca01b60a5 | ||
|
|
39e398ae02 | ||
|
|
9bbe64489f | ||
|
|
7e54156f2f | ||
|
|
9b80820b17 | ||
|
|
e836b4ac10 | ||
|
|
f228a4dcca | ||
|
|
3297f75edd | ||
|
|
25ba042493 | ||
|
|
483229779c | ||
|
|
5a50856fc1 | ||
|
|
cf734f7e7b | ||
|
|
72325f792c | ||
|
|
9761ac5045 | ||
|
|
8fa52e9d31 | ||
|
|
80b6a95eba | ||
|
|
96cebd2a35 | ||
|
|
fc103f6c17 | ||
|
|
a45d2109f3 | ||
|
|
7a30e65175 | ||
|
|
c63dc7fe2f | ||
|
|
830b51c75b | ||
|
|
cc8c46d5de | ||
|
|
a4767fdd8e | ||
|
|
2a274d4a08 | ||
|
|
2175a10932 | ||
|
|
20f3e62529 | ||
|
|
7f2e2fee56 | ||
|
|
9810834f20 | ||
|
|
0d4cb9e9fb | ||
|
|
f5dc380b63 | ||
|
|
3f69254f43 | ||
|
|
84248b6ec2 | ||
|
|
688547b063 | ||
|
|
ac93641946 | ||
|
|
58f74ebad1 | ||
|
|
e3be548e8d | ||
|
|
2724630430 | ||
|
|
bb8f93146f | ||
|
|
8fc73874de | ||
|
|
19609db13c | ||
|
|
0db0b03db9 | ||
|
|
3c5390a87e | ||
|
|
e9d2905a82 | ||
|
|
48bbd9e214 | ||
|
|
4ecc798b1b | ||
|
|
68be2f023f | ||
|
|
c76b8785f8 | ||
|
|
d4f5ec2492 | ||
|
|
06a3e9792d | ||
|
|
e9707c2f9e | ||
|
|
ab55373bc5 | ||
|
|
a2c5fdaf66 | ||
|
|
b86ed46845 | ||
|
|
3dd5095792 | ||
|
|
582677d067 | ||
|
|
3ade03f3b3 | ||
|
|
5090d9853b | ||
|
|
d41ff2076f | ||
|
|
b018072914 | ||
|
|
361a69f4de | ||
|
|
73cf491478 | ||
|
|
9df04d71e2 | ||
|
|
c159180589 | ||
|
|
8e485e5868 | ||
|
|
11b0efc38f | ||
|
|
45d382f344 | ||
|
|
5bf7a9575c | ||
|
|
50c8f7f96f | ||
|
|
e8e00d4cb8 | ||
|
|
49232372a7 | ||
|
|
72ffeb73d3 | ||
|
|
e68a6037e2 | ||
|
|
ec08500924 | ||
|
|
6046a8c95b | ||
|
|
792ec49e5b | ||
|
|
3ffd87d8de | ||
|
|
e313d39be8 | ||
|
|
ac59023abb | ||
|
|
d32fc0400e | ||
|
|
7ea88358f0 | ||
|
|
c5df806ad2 | ||
|
|
c6b391304d | ||
|
|
2e836cee88 | ||
|
|
e41d127732 | ||
|
|
f1c4caf14a | ||
|
|
c9ce3a5464 | ||
|
|
22a69333a0 | ||
|
|
ed87dda0a6 | ||
|
|
053134f66e | ||
|
|
837ae1b1b3 | ||
|
|
4008be19f4 | ||
|
|
c28a5d24f8 | ||
|
|
314125e7ec | ||
|
|
759bb88a90 | ||
|
|
0607e52767 | ||
|
|
d6bb143978 | ||
|
|
f81898c906 | ||
|
|
d5ad5fab87 | ||
|
|
d9ad65622a | ||
|
|
4999fce7f4 | ||
|
|
e5a6fd2d4f | ||
|
|
83a1fa618d | ||
|
|
9253bdbf77 | ||
|
|
41effa5aeb | ||
|
|
b07ed71de2 | ||
|
|
deaa64b080 | ||
|
|
d42384cdb7 | ||
|
|
24f243a1bc | ||
|
|
67a4fe703c | ||
|
|
c16a989287 | ||
|
|
bc376ad419 | ||
|
|
aba719f5fe | ||
|
|
1d7abc95b8 | ||
|
|
1dccdb7ff2 | ||
|
|
395164e2d4 | ||
|
|
b449d17124 | ||
|
|
6ad5e0709c | ||
|
|
4bfafbe3aa | ||
|
|
2274d7488b | ||
|
|
39518ec633 | ||
|
|
6bd37b2a2b | ||
|
|
f17ec7ffd8 | ||
|
|
d9f8129a32 | ||
|
|
8f0a345e2a | ||
|
|
56b2dabcca | ||
|
|
7632204966 | ||
|
|
c0fbc1979e | ||
|
|
d00604dd28 | ||
|
|
869a3dfbb4 | ||
|
|
df66046b14 | ||
|
|
9ec8478b41 | ||
|
|
bb6ec7ca81 | ||
|
|
1b2e3dc7af | ||
|
|
580ec737d3 | ||
|
|
e4dd22b260 | ||
|
|
172f282e9e | ||
|
|
7f0c9b1942 | ||
|
|
8c2063aeea | ||
|
|
ed6e7750e2 | ||
|
|
a0c389a854 | ||
|
|
e9037fceb0 | ||
|
|
2406cc775e | ||
|
|
b84cbee77a | ||
|
|
fa762e69a4 | ||
|
|
7e0fd1e260 | ||
|
|
d6037e5549 | ||
|
|
9fce13fe03 | ||
|
|
4375822cbb | ||
|
|
e0d13148ef | ||
|
|
bd68472d3c | ||
|
|
b3c534bae5 | ||
|
|
b7d6ae1b48 | ||
|
|
aacfcae382 | ||
|
|
1c92034191 | ||
|
|
ef8820e4e4 | ||
|
|
35daffdb2f | ||
|
|
0983119ae2 | ||
|
|
0371062e86 | ||
|
|
74bae32c83 | ||
|
|
4e67cd4baf | ||
|
|
0449fefa60 | ||
|
|
156e3b017d | ||
|
|
d4dc7b0a34 | ||
|
|
ebf2a26e72 | ||
|
|
545dff8b64 | ||
|
|
7353bc0b2b | ||
|
|
99c9f3069c | ||
|
|
f9f2333997 | ||
|
|
179b8aa88f | ||
|
|
040d66f0bb | ||
|
|
c875088be2 | ||
|
|
46fa32f087 | ||
|
|
551bc1a4a8 | ||
|
|
1305f2f6dc | ||
|
|
2a2a276e3b | ||
|
|
5aba4ca1b1 | ||
|
|
47b5ebfc43 | ||
|
|
1bb0d11f62 | ||
|
|
6164f5c35b | ||
|
|
c263398423 | ||
|
|
ef922b29c2 | ||
|
|
d10ef7b58a | ||
|
|
e074e957d1 | ||
|
|
7b546ea2ee | ||
|
|
506e2e12a6 | ||
|
|
c52255e2a4 | ||
|
|
b05d00ede9 | ||
|
|
8d05489973 | ||
|
|
4f18809500 | ||
|
|
28218ec550 | ||
|
|
f97954c811 | ||
|
|
798f65b35e | ||
|
|
57484b97bb | ||
|
|
0e0602c553 | ||
|
|
54ffb52838 | ||
|
|
c62e45ee88 | ||
|
|
56a05d2cce | ||
|
|
3e09bc9470 | ||
|
|
5ed79e5aa3 | ||
|
|
f38b78dbe6 | ||
|
|
f1d6f01585 | ||
|
|
9b627a93ac | ||
|
|
d4709ffcf9 | ||
|
|
ad943b2d4d | ||
|
|
7209fa233f | ||
|
|
7b9cfbc3f7 | ||
|
|
70e916942e | ||
|
|
f60ef0b2e7 | ||
|
|
6d2f7e3ce0 | ||
|
|
caf386c877 | ||
|
|
c4a42eb1f0 | ||
|
|
b6f8677b01 | ||
|
|
36ee21ea8f | ||
|
|
30d5d87ca6 | ||
|
|
67e0b71c18 | ||
|
|
b0f72736b0 | ||
|
|
ae06f13e0e | ||
|
|
0652241519 | ||
|
|
edf9d9b747 | ||
|
|
3acdec51bd | ||
|
|
ce5d2bad97 | ||
|
|
34855bc647 | ||
|
|
56c8297f6b | ||
|
|
e11637dc62 | ||
|
|
e0bff9f212 | ||
|
|
bff6f6679b | ||
|
|
305916f5a9 | ||
|
|
1f46dc2715 | ||
|
|
e3994ace33 | ||
|
|
bdac24bb4e | ||
|
|
6d30faf9c9 | ||
|
|
c0eaa41c7a | ||
|
|
8a2285e706 | ||
|
|
db43930b98 | ||
|
|
b1254106ee | ||
|
|
9c9ea99380 | ||
|
|
ba4c11428c | ||
|
|
0331660fe2 | ||
|
|
3f7840188e | ||
|
|
512c8b600a | ||
|
|
1aad033fec | ||
|
|
f1d9364ef4 | ||
|
|
c2b2c9eafe | ||
|
|
09b9d3b3fa | ||
|
|
e9e0016a63 | ||
|
|
3704dae342 | ||
|
|
bea5f97cbf | ||
|
|
7a6adfa97e | ||
|
|
1c4183d943 | ||
|
|
dff31a7a4c | ||
|
|
ed8873fbb0 | ||
|
|
9102ff031d | ||
|
|
8c555c4e69 | ||
|
|
2b1762be16 | ||
|
|
aa2f37d54d | ||
|
|
d58cc55cb2 | ||
|
|
c5cc238308 | ||
|
|
6bbdf67f96 | ||
|
|
fcadf08921 | ||
|
|
4155805ad6 | ||
|
|
de7b8501cc | ||
|
|
d2394b0be9 | ||
|
|
ebcd4dbf3d | ||
|
|
1483c31c73 | ||
|
|
00f33f5f3a | ||
|
|
3c4dc07980 | ||
|
|
3b4634e2dc | ||
|
|
00bd6a3e46 | ||
|
|
5812229d9b | ||
|
|
0b026933a7 | ||
|
|
3b2ab0d7bd | ||
|
|
e64fa48823 | ||
|
|
beff9282f6 | ||
|
|
31a9e2d11f | ||
|
|
423faae3da | ||
|
|
ead71fb7ef | ||
|
|
58b7afdf1e | ||
|
|
c86545d7e1 | ||
|
|
f49a530c1a | ||
|
|
368796349e | ||
|
|
c601542f6f | ||
|
|
3c0c61aaf1 | ||
|
|
edeadfc389 | ||
|
|
aa9fd057fe | ||
|
|
b3607d3981 | ||
|
|
fa8d94971f | ||
|
|
ef68a97526 | ||
|
|
d880d1a1ea | ||
|
|
d4104214ed | ||
|
|
273e1d9cbe | ||
|
|
65f47c196a | ||
|
|
9be56fe8e0 | ||
|
|
589ae6d3aa | ||
|
|
7cb76ae1a5 | ||
|
|
e73f165070 | ||
|
|
512f2d5247 | ||
|
|
bf086464dd | ||
|
|
5ec6450c50 | ||
|
|
8dd7f8e82f |
33
.dockerignore
Normal file
33
.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Git and GitHub folders
|
||||||
|
.git/*
|
||||||
|
.github/*
|
||||||
|
|
||||||
|
# Docker and CI/CD related files
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
.goreleaser.yml
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
# Documentation and license
|
||||||
|
docs/*
|
||||||
|
README.md
|
||||||
|
README_CN.md
|
||||||
|
MANAGEMENT_API.md
|
||||||
|
MANAGEMENT_API_CN.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Example configuration
|
||||||
|
config.example.yaml
|
||||||
|
|
||||||
|
# Runtime data folders (should be mounted as volumes)
|
||||||
|
auths/*
|
||||||
|
logs/*
|
||||||
|
conv/*
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Development/editor
|
||||||
|
bin/*
|
||||||
|
.claude/*
|
||||||
|
.vscode/*
|
||||||
|
.serena/*
|
||||||
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**CLI Type**
|
||||||
|
What type of CLI account do you use? (gemini-cli, gemini, codex, claude code or openai-compatibility)
|
||||||
|
|
||||||
|
**Model Name**
|
||||||
|
What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
|
||||||
|
|
||||||
|
**LLM Client**
|
||||||
|
What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
|
||||||
|
|
||||||
|
**Request Information**
|
||||||
|
The best way is to paste the cURL command of the HTTP request here.
|
||||||
|
Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**OS Type**
|
||||||
|
- OS: [e.g. macOS]
|
||||||
|
- Version [e.g. 15.6.0]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
46
.github/workflows/docker-image.yml
vendored
Normal file
46
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: docker-image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_NAME: CLIProxyAPI
|
||||||
|
DOCKERHUB_REPO: eceasy/cli-proxy-api
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Generate Build Metadata
|
||||||
|
run: |
|
||||||
|
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
||||||
|
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||||
|
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ env.VERSION }}
|
||||||
|
COMMIT=${{ env.COMMIT }}
|
||||||
|
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||||
|
tags: |
|
||||||
|
${{ env.DOCKERHUB_REPO }}:latest
|
||||||
|
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
|
||||||
14
.github/workflows/release.yaml
vendored
14
.github/workflows/release.yaml
vendored
@@ -13,18 +13,26 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: git fetch --force --tags
|
- run: git fetch --force --tags
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.24.0'
|
go-version: '>=1.24.0'
|
||||||
cache: true
|
cache: true
|
||||||
- uses: goreleaser/goreleaser-action@v3
|
- name: Generate Build Metadata
|
||||||
|
run: |
|
||||||
|
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
||||||
|
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||||
|
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||||
|
- uses: goreleaser/goreleaser-action@v4
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
|
COMMIT: ${{ env.COMMIT }}
|
||||||
|
BUILD_DATE: ${{ env.BUILD_DATE }}
|
||||||
|
|||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
config.yaml
|
||||||
|
bin/*
|
||||||
|
docs/*
|
||||||
|
logs/*
|
||||||
|
conv/*
|
||||||
|
auths/*
|
||||||
|
!auths/.gitkeep
|
||||||
|
.vscode/*
|
||||||
|
.claude/*
|
||||||
|
.serena/*
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
*.exe
|
||||||
|
temp/*
|
||||||
@@ -9,3 +9,29 @@ builds:
|
|||||||
- arm64
|
- arm64
|
||||||
main: ./cmd/server/
|
main: ./cmd/server/
|
||||||
binary: cli-proxy-api
|
binary: cli-proxy-api
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
|
||||||
|
archives:
|
||||||
|
- id: "cli-proxy-api"
|
||||||
|
format: tar.gz
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
- README_CN.md
|
||||||
|
- config.example.yaml
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- '^docs:'
|
||||||
|
- '^test:'
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG COMMIT=none
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
|
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/
|
||||||
|
|
||||||
|
FROM alpine:3.22.0
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
|
RUN mkdir /CLIProxyAPI
|
||||||
|
|
||||||
|
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
|
||||||
|
|
||||||
|
WORKDIR /CLIProxyAPI
|
||||||
|
|
||||||
|
EXPOSE 8317
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
|
||||||
|
|
||||||
|
CMD ["./CLIProxyAPI"]
|
||||||
688
MANAGEMENT_API.md
Normal file
688
MANAGEMENT_API.md
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
# Management API
|
||||||
|
|
||||||
|
Base path: `http://localhost:8317/v0/management`
|
||||||
|
|
||||||
|
This API manages the CLI Proxy API’s runtime configuration and authentication files. All changes are persisted to the YAML config file and hot‑reloaded by the service.
|
||||||
|
|
||||||
|
Note: The following options cannot be modified via API and must be set in the config file (restart if needed):
|
||||||
|
- `allow-remote-management`
|
||||||
|
- `remote-management-key` (if plaintext is detected at startup, it is automatically bcrypt‑hashed and written back to the config)
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
- All requests (including localhost) must provide a valid management key.
|
||||||
|
- Remote access requires enabling remote management in the config: `allow-remote-management: true`.
|
||||||
|
- Provide the management key (in plaintext) via either:
|
||||||
|
- `Authorization: Bearer <plaintext-key>`
|
||||||
|
- `X-Management-Key: <plaintext-key>`
|
||||||
|
|
||||||
|
Additional notes:
|
||||||
|
- If `remote-management.secret-key` is empty, the entire Management API is disabled (all `/v0/management` routes return 404).
|
||||||
|
- For remote IPs, 5 consecutive authentication failures trigger a temporary ban (~30 minutes) before further attempts are allowed.
|
||||||
|
|
||||||
|
If a plaintext key is detected in the config at startup, it will be bcrypt‑hashed and written back to the config file automatically.
|
||||||
|
|
||||||
|
## Request/Response Conventions
|
||||||
|
|
||||||
|
- Content-Type: `application/json` (unless otherwise noted).
|
||||||
|
- Boolean/int/string updates: request body is `{ "value": <type> }`.
|
||||||
|
- Array PUT: either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`.
|
||||||
|
- Array PATCH: supports `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`.
|
||||||
|
- Object-array PATCH: supports matching by index or by key field (specified per endpoint).
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Usage Statistics
|
||||||
|
- GET `/usage` — Retrieve aggregated in-memory request metrics
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"usage": {
|
||||||
|
"total_requests": 24,
|
||||||
|
"success_count": 22,
|
||||||
|
"failure_count": 2,
|
||||||
|
"total_tokens": 13890,
|
||||||
|
"requests_by_day": {
|
||||||
|
"2024-05-20": 12
|
||||||
|
},
|
||||||
|
"requests_by_hour": {
|
||||||
|
"09": 4,
|
||||||
|
"18": 8
|
||||||
|
},
|
||||||
|
"tokens_by_day": {
|
||||||
|
"2024-05-20": 9876
|
||||||
|
},
|
||||||
|
"tokens_by_hour": {
|
||||||
|
"09": 1234,
|
||||||
|
"18": 865
|
||||||
|
},
|
||||||
|
"apis": {
|
||||||
|
"POST /v1/chat/completions": {
|
||||||
|
"total_requests": 12,
|
||||||
|
"total_tokens": 9021,
|
||||||
|
"models": {
|
||||||
|
"gpt-4o-mini": {
|
||||||
|
"total_requests": 8,
|
||||||
|
"total_tokens": 7123,
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"timestamp": "2024-05-20T09:15:04.123456Z",
|
||||||
|
"tokens": {
|
||||||
|
"input_tokens": 523,
|
||||||
|
"output_tokens": 308,
|
||||||
|
"reasoning_tokens": 0,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"total_tokens": 831
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Notes:
|
||||||
|
- Statistics are recalculated for every request that reports token usage; data resets when the server restarts.
|
||||||
|
- Hourly counters fold all days into the same hour bucket (`00`–`23`).
|
||||||
|
|
||||||
|
### Config
|
||||||
|
- GET `/config` — Get the full config
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/config
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug
|
||||||
|
- GET `/debug` — Get the current debug state
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/debug
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "debug": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/debug` — Set debug (boolean)
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/debug
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force GPT-5 Codex
|
||||||
|
- GET `/force-gpt-5-codex` — Get current flag
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/force-gpt-5-codex
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "gpt-5-codex": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/force-gpt-5-codex` — Set boolean
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/force-gpt-5-codex
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxy Server URL
|
||||||
|
- GET `/proxy-url` — Get the proxy URL string
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "proxy-url": "socks5://user:pass@127.0.0.1:1080/" }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/proxy-url` — Set the proxy URL string
|
||||||
|
- Request (PUT):
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \
|
||||||
|
http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- Request (PATCH):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":"http://127.0.0.1:8080"}' \
|
||||||
|
http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/proxy-url` — Clear the proxy URL
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quota Exceeded Behavior
|
||||||
|
- GET `/quota-exceeded/switch-project`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-project
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "switch-project": true }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/quota-exceeded/switch-project` — Boolean
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":false}' \
|
||||||
|
http://localhost:8317/v0/management/quota-exceeded/switch-project
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- GET `/quota-exceeded/switch-preview-model`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "switch-preview-model": true }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/quota-exceeded/switch-preview-model` — Boolean
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Keys (proxy service auth)
|
||||||
|
These endpoints update the inline `config-api-key` provider inside the `auth.providers` section of the configuration. Legacy top-level `api-keys` remain in sync automatically.
|
||||||
|
- GET `/api-keys` — Return the full list
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "api-keys": ["k1","k2","k3"] }
|
||||||
|
```
|
||||||
|
- PUT `/api-keys` — Replace the full list
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '["k1","k2","k3"]' \
|
||||||
|
http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/api-keys` — Modify one item (`old/new` or `index/value`)
|
||||||
|
- Request (by old/new):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"old":"k2","new":"k2b"}' \
|
||||||
|
http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- Request (by index/value):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":0,"value":"k1b"}' \
|
||||||
|
http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/api-keys` — Delete one (`?value=` or `?index=`)
|
||||||
|
- Request (by value):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
|
||||||
|
```
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini API Key (Generative Language)
|
||||||
|
- GET `/generative-language-api-key`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/generative-language-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "generative-language-api-key": ["AIzaSy...01","AIzaSy...02"] }
|
||||||
|
```
|
||||||
|
- PUT `/generative-language-api-key`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '["AIzaSy-1","AIzaSy-2"]' \
|
||||||
|
http://localhost:8317/v0/management/generative-language-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/generative-language-api-key`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"old":"AIzaSy-1","new":"AIzaSy-1b"}' \
|
||||||
|
http://localhost:8317/v0/management/generative-language-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/generative-language-api-key`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/generative-language-api-key?value=AIzaSy-2'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex API KEY (object array)
|
||||||
|
- GET `/codex-api-key` — List all
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||||
|
```
|
||||||
|
- PUT `/codex-api-key` — Replace the list
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||||
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/codex-api-key` — Modify one (by `index` or `match`)
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||||
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- Request (by match):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||||
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/codex-api-key` — Delete one (`?api-key=` or `?index=`)
|
||||||
|
- Request (by api-key):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?api-key=sk-b2'
|
||||||
|
```
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Retry Count
|
||||||
|
- GET `/request-retry` — Get integer
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-retry
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "request-retry": 3 }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/request-retry` — Set integer
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":5}' \
|
||||||
|
http://localhost:8317/v0/management/request-retry
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Log
|
||||||
|
- GET `/request-log` — Get boolean
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-log
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "request-log": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/request-log` — Set boolean
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/request-log
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude API KEY (object array)
|
||||||
|
- GET `/claude-api-key` — List all
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||||
|
```
|
||||||
|
- PUT `/claude-api-key` — Replace the list
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||||
|
http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/claude-api-key` — Modify one (by `index` or `match`)
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||||
|
http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- Request (by match):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||||
|
http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/claude-api-key` — Delete one (`?api-key=` or `?index=`)
|
||||||
|
- Request (by api-key):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2'
|
||||||
|
```
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAI Compatibility Providers (object array)
|
||||||
|
- GET `/openai-compatibility` — List all
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "models": [] } ] }
|
||||||
|
```
|
||||||
|
- PUT `/openai-compatibility` — Replace the list
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk"],"models":[{"name":"m","alias":"a"}]}]' \
|
||||||
|
http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/openai-compatibility` — Modify one (by `index` or `name`)
|
||||||
|
- Request (by name):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||||
|
http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||||
|
http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/openai-compatibility` — Delete (`?name=` or `?index=`)
|
||||||
|
- Request (by name):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?name=openrouter'
|
||||||
|
```
|
||||||
|
- Request (by index):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth File Management
|
||||||
|
|
||||||
|
Manage JSON token files under `auth-dir`: list, download, upload, delete.
|
||||||
|
|
||||||
|
- GET `/auth-files` — List
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/auth-files
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/auth-files/download?name=<file.json>` — Download a single file
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -OJ 'http://localhost:8317/v0/management/auth-files/download?name=acc1.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/auth-files` — Upload
|
||||||
|
- Request (multipart):
|
||||||
|
```bash
|
||||||
|
curl -X POST -F 'file=@/path/to/acc1.json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/auth-files
|
||||||
|
```
|
||||||
|
- Request (raw JSON):
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d @/path/to/acc1.json \
|
||||||
|
'http://localhost:8317/v0/management/auth-files?name=acc1.json'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- DELETE `/auth-files?name=<file.json>` — Delete a single file
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?name=acc1.json'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- DELETE `/auth-files?all=true` — Delete all `.json` files under `auth-dir`
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?all=true'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "deleted": 3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login/OAuth URLs
|
||||||
|
|
||||||
|
These endpoints initiate provider login flows and return a URL to open in a browser. Tokens are saved under `auths/` once the flow completes.
|
||||||
|
|
||||||
|
- GET `/anthropic-auth-url` — Start Anthropic (Claude) login
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/anthropic-auth-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/codex-auth-url` — Start Codex login
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/codex-auth-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/gemini-cli-auth-url` — Start Google (Gemini CLI) login
|
||||||
|
- Query params:
|
||||||
|
- `project_id` (optional): Google Cloud project ID.
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/gemini-web-token` — Save Gemini Web cookies directly
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
|
||||||
|
http://localhost:8317/v0/management/gemini-web-token
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "file": "gemini-web-<hash>.json" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/qwen-auth-url` — Start Qwen login (device flow)
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/qwen-auth-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/get-auth-status?state=<state>` — Poll OAuth flow status
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
'http://localhost:8317/v0/management/get-auth-status?state=<STATE_FROM_AUTH_URL>'
|
||||||
|
```
|
||||||
|
- Response examples:
|
||||||
|
```json
|
||||||
|
{ "status": "wait" }
|
||||||
|
{ "status": "ok" }
|
||||||
|
{ "status": "error", "error": "Authentication failed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
Generic error format:
|
||||||
|
- 400 Bad Request: `{ "error": "invalid body" }`
|
||||||
|
- 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }`
|
||||||
|
- 403 Forbidden: `{ "error": "remote management disabled" }`
|
||||||
|
- 404 Not Found: `{ "error": "item not found" }` or `{ "error": "file not found" }`
|
||||||
|
- 500 Internal Server Error: `{ "error": "failed to save config: ..." }`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients.
|
||||||
|
- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.
|
||||||
688
MANAGEMENT_API_CN.md
Normal file
688
MANAGEMENT_API_CN.md
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
# 管理 API
|
||||||
|
|
||||||
|
基础路径:`http://localhost:8317/v0/management`
|
||||||
|
|
||||||
|
该 API 用于管理 CLI Proxy API 的运行时配置与认证文件。所有变更会持久化写入 YAML 配置文件,并由服务自动热重载。
|
||||||
|
|
||||||
|
注意:以下选项不能通过 API 修改,需在配置文件中设置(如有必要可重启):
|
||||||
|
- `allow-remote-management`
|
||||||
|
- `remote-management-key`(若在启动时检测到明文,会自动进行 bcrypt 加密并写回配置)
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
- 所有请求(包括本地访问)都必须提供有效的管理密钥.
|
||||||
|
- 远程访问需要在配置文件中开启远程访问: `allow-remote-management: true`
|
||||||
|
- 通过以下任意方式提供管理密钥(明文):
|
||||||
|
- `Authorization: Bearer <plaintext-key>`
|
||||||
|
- `X-Management-Key: <plaintext-key>`
|
||||||
|
|
||||||
|
若在启动时检测到配置中的管理密钥为明文,会自动使用 bcrypt 加密并回写到配置文件中。
|
||||||
|
|
||||||
|
其它说明:
|
||||||
|
- 若 `remote-management.secret-key` 为空,则管理 API 整体被禁用(所有 `/v0/management` 路由均返回 404)。
|
||||||
|
- 对于远程 IP,连续 5 次认证失败会触发临时封禁(约 30 分钟)。
|
||||||
|
|
||||||
|
## 请求/响应约定
|
||||||
|
|
||||||
|
- Content-Type:`application/json`(除非另有说明)。
|
||||||
|
- 布尔/整数/字符串更新:请求体为 `{ "value": <type> }`。
|
||||||
|
- 数组 PUT:既可使用原始数组(如 `["a","b"]`),也可使用 `{ "items": [ ... ] }`。
|
||||||
|
- 数组 PATCH:支持 `{ "old": "k1", "new": "k2" }` 或 `{ "index": 0, "value": "k2" }`。
|
||||||
|
- 对象数组 PATCH:支持按索引或按关键字段匹配(各端点中单独说明)。
|
||||||
|
|
||||||
|
## 端点说明
|
||||||
|
|
||||||
|
### Usage(请求统计)
|
||||||
|
- GET `/usage` — 获取内存中的请求统计
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"usage": {
|
||||||
|
"total_requests": 24,
|
||||||
|
"success_count": 22,
|
||||||
|
"failure_count": 2,
|
||||||
|
"total_tokens": 13890,
|
||||||
|
"requests_by_day": {
|
||||||
|
"2024-05-20": 12
|
||||||
|
},
|
||||||
|
"requests_by_hour": {
|
||||||
|
"09": 4,
|
||||||
|
"18": 8
|
||||||
|
},
|
||||||
|
"tokens_by_day": {
|
||||||
|
"2024-05-20": 9876
|
||||||
|
},
|
||||||
|
"tokens_by_hour": {
|
||||||
|
"09": 1234,
|
||||||
|
"18": 865
|
||||||
|
},
|
||||||
|
"apis": {
|
||||||
|
"POST /v1/chat/completions": {
|
||||||
|
"total_requests": 12,
|
||||||
|
"total_tokens": 9021,
|
||||||
|
"models": {
|
||||||
|
"gpt-4o-mini": {
|
||||||
|
"total_requests": 8,
|
||||||
|
"total_tokens": 7123,
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"timestamp": "2024-05-20T09:15:04.123456Z",
|
||||||
|
"tokens": {
|
||||||
|
"input_tokens": 523,
|
||||||
|
"output_tokens": 308,
|
||||||
|
"reasoning_tokens": 0,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"total_tokens": 831
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 说明:
|
||||||
|
- 仅统计带有 token 使用信息的请求,服务重启后数据会被清空。
|
||||||
|
- 小时维度会将所有日期折叠到 `00`–`23` 的统一小时桶中。
|
||||||
|
|
||||||
|
### Config
|
||||||
|
- GET `/config` — 获取完整的配置
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/config
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug
|
||||||
|
- GET `/debug` — 获取当前 debug 状态
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/debug
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "debug": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/debug` — 设置 debug(布尔值)
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/debug
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 强制 GPT-5 Codex
|
||||||
|
- GET `/force-gpt-5-codex` — 获取当前标志
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/force-gpt-5-codex
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "gpt-5-codex": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/force-gpt-5-codex` — 设置布尔值
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/force-gpt-5-codex
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代理服务器 URL
|
||||||
|
- GET `/proxy-url` — 获取代理 URL 字符串
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "proxy-url": "socks5://user:pass@127.0.0.1:1080/" }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/proxy-url` — 设置代理 URL 字符串
|
||||||
|
- 请求(PUT):
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \
|
||||||
|
http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- 请求(PATCH):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":"http://127.0.0.1:8080"}' \
|
||||||
|
http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/proxy-url` — 清空代理 URL
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE http://localhost:8317/v0/management/proxy-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 超出配额行为
|
||||||
|
- GET `/quota-exceeded/switch-project`
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-project
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "switch-project": true }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/quota-exceeded/switch-project` — 布尔值
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":false}' \
|
||||||
|
http://localhost:8317/v0/management/quota-exceeded/switch-project
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- GET `/quota-exceeded/switch-preview-model`
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "switch-preview-model": true }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/quota-exceeded/switch-preview-model` — 布尔值
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Keys(代理服务认证)
|
||||||
|
这些接口会更新配置中 `auth.providers` 内置的 `config-api-key` 提供方,旧版顶层 `api-keys` 会自动保持同步。
|
||||||
|
- GET `/api-keys` — 返回完整列表
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "api-keys": ["k1","k2","k3"] }
|
||||||
|
```
|
||||||
|
- PUT `/api-keys` — 完整改写列表
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '["k1","k2","k3"]' \
|
||||||
|
http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/api-keys` — 修改其中一个(`old/new` 或 `index/value`)
|
||||||
|
- 请求(按 old/new):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"old":"k2","new":"k2b"}' \
|
||||||
|
http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- 请求(按 index/value):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":0,"value":"k1b"}' \
|
||||||
|
http://localhost:8317/v0/management/api-keys
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/api-keys` — 删除其中一个(`?value=` 或 `?index=`)
|
||||||
|
- 请求(按值删除):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
|
||||||
|
```
|
||||||
|
- 请求(按索引删除):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini API Key(生成式语言)
|
||||||
|
- GET `/generative-language-api-key`
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/generative-language-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "generative-language-api-key": ["AIzaSy...01","AIzaSy...02"] }
|
||||||
|
```
|
||||||
|
- PUT `/generative-language-api-key`
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '["AIzaSy-1","AIzaSy-2"]' \
|
||||||
|
http://localhost:8317/v0/management/generative-language-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/generative-language-api-key`
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"old":"AIzaSy-1","new":"AIzaSy-1b"}' \
|
||||||
|
http://localhost:8317/v0/management/generative-language-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/generative-language-api-key`
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/generative-language-api-key?value=AIzaSy-2'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex API KEY(对象数组)
|
||||||
|
- GET `/codex-api-key` — 列出全部
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||||
|
```
|
||||||
|
- PUT `/codex-api-key` — 完整改写列表
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||||
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/codex-api-key` — 修改其中一个(按 `index` 或 `match`)
|
||||||
|
- 请求(按索引):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||||
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- 请求(按匹配):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||||
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/codex-api-key` — 删除其中一个(`?api-key=` 或 `?index=`)
|
||||||
|
- 请求(按 api-key):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?api-key=sk-b2'
|
||||||
|
```
|
||||||
|
- 请求(按索引):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求重试次数
|
||||||
|
- GET `/request-retry` — 获取整数
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-retry
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "request-retry": 3 }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/request-retry` — 设置整数
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":5}' \
|
||||||
|
http://localhost:8317/v0/management/request-retry
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求日志开关
|
||||||
|
- GET `/request-log` — 获取布尔值
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-log
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "request-log": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/request-log` — 设置布尔值
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"value":true}' \
|
||||||
|
http://localhost:8317/v0/management/request-log
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude API KEY(对象数组)
|
||||||
|
- GET `/claude-api-key` — 列出全部
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||||
|
```
|
||||||
|
- PUT `/claude-api-key` — 完整改写列表
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||||
|
http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/claude-api-key` — 修改其中一个(按 `index` 或 `match`)
|
||||||
|
- 请求(按索引):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||||
|
http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- 请求(按匹配):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||||
|
http://localhost:8317/v0/management/claude-api-key
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/claude-api-key` — 删除其中一个(`?api-key=` 或 `?index=`)
|
||||||
|
- 请求(按 api-key):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2'
|
||||||
|
```
|
||||||
|
- 请求(按索引):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAI 兼容提供商(对象数组)
|
||||||
|
- GET `/openai-compatibility` — 列出全部
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "models": [] } ] }
|
||||||
|
```
|
||||||
|
- PUT `/openai-compatibility` — 完整改写列表
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk"],"models":[{"name":"m","alias":"a"}]}]' \
|
||||||
|
http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- PATCH `/openai-compatibility` — 修改其中一个(按 `index` 或 `name`)
|
||||||
|
- 请求(按名称):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||||
|
http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- 请求(按索引):
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||||
|
http://localhost:8317/v0/management/openai-compatibility
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
- DELETE `/openai-compatibility` — 删除(`?name=` 或 `?index=`)
|
||||||
|
- 请求(按名称):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?name=openrouter'
|
||||||
|
```
|
||||||
|
- 请求(按索引):
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 认证文件管理
|
||||||
|
|
||||||
|
管理 `auth-dir` 下的 JSON 令牌文件:列出、下载、上传、删除。
|
||||||
|
|
||||||
|
- GET `/auth-files` — 列表
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/auth-files
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/auth-files/download?name=<file.json>` — 下载单个文件
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -OJ 'http://localhost:8317/v0/management/auth-files/download?name=acc1.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/auth-files` — 上传
|
||||||
|
- 请求(multipart):
|
||||||
|
```bash
|
||||||
|
curl -X POST -F 'file=@/path/to/acc1.json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/auth-files
|
||||||
|
```
|
||||||
|
- 请求(原始 JSON):
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-d @/path/to/acc1.json \
|
||||||
|
'http://localhost:8317/v0/management/auth-files?name=acc1.json'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- DELETE `/auth-files?name=<file.json>` — 删除单个文件
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?name=acc1.json'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- DELETE `/auth-files?all=true` — 删除 `auth-dir` 下所有 `.json` 文件
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?all=true'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "deleted": 3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 登录/授权 URL
|
||||||
|
|
||||||
|
以下端点用于发起各提供商的登录流程,并返回需要在浏览器中打开的 URL。流程完成后,令牌会保存到 `auths/` 目录。
|
||||||
|
|
||||||
|
- GET `/anthropic-auth-url` — 开始 Anthropic(Claude)登录
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/anthropic-auth-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/codex-auth-url` — 开始 Codex 登录
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/codex-auth-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/gemini-cli-auth-url` — 开始 Google(Gemini CLI)登录
|
||||||
|
- 查询参数:
|
||||||
|
- `project_id`(可选):Google Cloud 项目 ID。
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/gemini-web-token` — 直接保存 Gemini Web Cookie
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
|
||||||
|
http://localhost:8317/v0/management/gemini-web-token
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "file": "gemini-web-<hash>.json" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/qwen-auth-url` — 开始 Qwen 登录(设备授权流程)
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/qwen-auth-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/get-auth-status?state=<state>` — 轮询 OAuth 流程状态
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
'http://localhost:8317/v0/management/get-auth-status?state=<STATE_FROM_AUTH_URL>'
|
||||||
|
```
|
||||||
|
- 响应示例:
|
||||||
|
```json
|
||||||
|
{ "status": "wait" }
|
||||||
|
{ "status": "ok" }
|
||||||
|
{ "status": "error", "error": "Authentication failed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误响应
|
||||||
|
|
||||||
|
通用错误格式:
|
||||||
|
- 400 Bad Request: `{ "error": "invalid body" }`
|
||||||
|
- 401 Unauthorized: `{ "error": "missing management key" }` 或 `{ "error": "invalid management key" }`
|
||||||
|
- 403 Forbidden: `{ "error": "remote management disabled" }`
|
||||||
|
- 404 Not Found: `{ "error": "item not found" }` 或 `{ "error": "file not found" }`
|
||||||
|
- 500 Internal Server Error: `{ "error": "failed to save config: ..." }`
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。
|
||||||
|
- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。
|
||||||
538
README.md
538
README.md
@@ -1,22 +1,44 @@
|
|||||||
# CLI Proxy API
|
# CLI Proxy API
|
||||||
|
|
||||||
A proxy server that provides an OpenAI-compatible API interface for CLI. This allows you to use CLI models with tools and libraries designed for the OpenAI API.
|
English | [中文](README_CN.md)
|
||||||
|
|
||||||
|
A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
|
||||||
|
|
||||||
|
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
|
||||||
|
|
||||||
|
So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
|
||||||
|
|
||||||
|
The first Chinese provider has now been added: [Qwen Code](https://github.com/QwenLM/qwen-code).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- OpenAI-compatible API endpoints for CLI models
|
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
|
||||||
- Support for both streaming and non-streaming responses
|
- OpenAI Codex support (GPT models) via OAuth login
|
||||||
|
- Claude Code support via OAuth login
|
||||||
|
- Qwen Code support via OAuth login
|
||||||
|
- Gemini Web support via cookie-based login
|
||||||
|
- Streaming and non-streaming responses
|
||||||
- Function calling/tools support
|
- Function calling/tools support
|
||||||
- Multimodal input support (text and images)
|
- Multimodal input support (text and images)
|
||||||
- Multiple account support with load balancing
|
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude and Qwen)
|
||||||
- Simple CLI authentication flow
|
- Simple CLI authentication flows (Gemini, OpenAI, Claude and Qwen)
|
||||||
|
- Generative Language API Key support
|
||||||
|
- Gemini CLI multi-account load balancing
|
||||||
|
- Claude Code multi-account load balancing
|
||||||
|
- Qwen Code multi-account load balancing
|
||||||
|
- OpenAI Codex multi-account load balancing
|
||||||
|
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
||||||
|
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`, 中文: `docs/sdk-usage_CN.md`)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.24 or higher
|
- Go 1.24 or higher
|
||||||
- A Google account with access to CLI models
|
- A Google account with access to Gemini CLI models (optional)
|
||||||
|
- An OpenAI account for Codex/GPT access (optional)
|
||||||
|
- An Anthropic account for Claude Code access (optional)
|
||||||
|
- A Qwen Chat account for Qwen Code access (optional)
|
||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
@@ -27,25 +49,70 @@ A proxy server that provides an OpenAI-compatible API interface for CLI. This al
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. Build the application:
|
2. Build the application:
|
||||||
|
|
||||||
|
Linux, macOS:
|
||||||
```bash
|
```bash
|
||||||
go build -o cli-proxy-api ./cmd/server
|
go build -o cli-proxy-api ./cmd/server
|
||||||
```
|
```
|
||||||
|
Windows:
|
||||||
|
```bash
|
||||||
|
go build -o cli-proxy-api.exe ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### GUI Client & Official WebUI
|
||||||
|
|
||||||
|
#### [EasyCLI](https://github.com/router-for-me/EasyCLI)
|
||||||
|
|
||||||
|
A cross-platform desktop GUI client for CLIProxyAPI.
|
||||||
|
|
||||||
|
#### [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
|
||||||
|
|
||||||
|
A web-based management center for CLIProxyAPI.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Before using the API, you need to authenticate with your Google account:
|
You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the same `auth-dir` and will be load balanced.
|
||||||
|
|
||||||
|
- Gemini (Google):
|
||||||
```bash
|
```bash
|
||||||
./cli-proxy-api --login
|
./cli-proxy-api --login
|
||||||
```
|
```
|
||||||
|
If you are an existing Gemini Code user, you may need to specify a project ID:
|
||||||
If you are an old gemini code user, you may need to specify a project ID:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./cli-proxy-api --login --project_id <your_project_id>
|
./cli-proxy-api --login --project_id <your_project_id>
|
||||||
```
|
```
|
||||||
|
The local OAuth callback uses port `8085`.
|
||||||
|
|
||||||
|
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `8085`.
|
||||||
|
|
||||||
|
- Gemini Web (via Cookies):
|
||||||
|
This method authenticates by simulating a browser, using cookies obtained from the Gemini website.
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --gemini-web-auth
|
||||||
|
```
|
||||||
|
You will be prompted to enter your `__Secure-1PSID` and `__Secure-1PSIDTS` values. Please retrieve these cookies from your browser's developer tools.
|
||||||
|
|
||||||
|
- OpenAI (Codex/GPT via OAuth):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --codex-login
|
||||||
|
```
|
||||||
|
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `1455`.
|
||||||
|
|
||||||
|
- Claude (Anthropic via OAuth):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --claude-login
|
||||||
|
```
|
||||||
|
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `54545`.
|
||||||
|
|
||||||
|
- Qwen (Qwen Chat via OAuth):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --qwen-login
|
||||||
|
```
|
||||||
|
Options: add `--no-browser` to print the login URL instead of opening a browser. Use the Qwen Chat's OAuth device flow.
|
||||||
|
|
||||||
|
|
||||||
### Starting the Server
|
### Starting the Server
|
||||||
|
|
||||||
@@ -86,6 +153,15 @@ Request body example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Use a `gemini-*` model for Gemini (e.g., "gemini-2.5-pro"), a `gpt-*` model for OpenAI (e.g., "gpt-5"), a `claude-*` model for Claude (e.g., "claude-3-5-sonnet-20241022"), or a `qwen-*` model for Qwen (e.g., "qwen3-coder-plus"). The proxy will route to the correct provider automatically.
|
||||||
|
|
||||||
|
#### Claude Messages (SSE-compatible)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://localhost:8317/v1/messages
|
||||||
|
```
|
||||||
|
|
||||||
### Using with OpenAI Libraries
|
### Using with OpenAI Libraries
|
||||||
|
|
||||||
You can use this proxy with any OpenAI-compatible library by setting the base URL to your local server:
|
You can use this proxy with any OpenAI-compatible library by setting the base URL to your local server:
|
||||||
@@ -100,14 +176,32 @@ client = OpenAI(
|
|||||||
base_url="http://localhost:8317/v1"
|
base_url="http://localhost:8317/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
# Gemini example
|
||||||
|
gemini = client.chat.completions.create(
|
||||||
model="gemini-2.5-pro",
|
model="gemini-2.5-pro",
|
||||||
messages=[
|
messages=[{"role": "user", "content": "Hello, how are you?"}]
|
||||||
{"role": "user", "content": "Hello, how are you?"}
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print(response.choices[0].message.content)
|
# Codex/GPT example
|
||||||
|
gpt = client.chat.completions.create(
|
||||||
|
model="gpt-5",
|
||||||
|
messages=[{"role": "user", "content": "Summarize this project in one sentence."}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Claude example (using messages endpoint)
|
||||||
|
import requests
|
||||||
|
claude_response = requests.post(
|
||||||
|
"http://localhost:8317/v1/messages",
|
||||||
|
json={
|
||||||
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
|
"messages": [{"role": "user", "content": "Summarize this project in one sentence."}],
|
||||||
|
"max_tokens": 1000
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(gemini.choices[0].message.content)
|
||||||
|
print(gpt.choices[0].message.content)
|
||||||
|
print(claude_response.json())
|
||||||
```
|
```
|
||||||
|
|
||||||
#### JavaScript/TypeScript
|
#### JavaScript/TypeScript
|
||||||
@@ -120,39 +214,99 @@ const openai = new OpenAI({
|
|||||||
baseURL: 'http://localhost:8317/v1',
|
baseURL: 'http://localhost:8317/v1',
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
// Gemini
|
||||||
|
const gemini = await openai.chat.completions.create({
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
messages: [
|
messages: [{ role: 'user', content: 'Hello, how are you?' }],
|
||||||
{ role: 'user', content: 'Hello, how are you?' }
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(response.choices[0].message.content);
|
// Codex/GPT
|
||||||
|
const gpt = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'Summarize this project in one sentence.' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude example (using messages endpoint)
|
||||||
|
const claudeResponse = await fetch('http://localhost:8317/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-3-5-sonnet-20241022',
|
||||||
|
messages: [{ role: 'user', content: 'Summarize this project in one sentence.' }],
|
||||||
|
max_tokens: 1000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(gemini.choices[0].message.content);
|
||||||
|
console.log(gpt.choices[0].message.content);
|
||||||
|
console.log(await claudeResponse.json());
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Models
|
## Supported Models
|
||||||
|
|
||||||
- gemini-2.5-pro
|
- gemini-2.5-pro
|
||||||
- gemini-2.5-flash
|
- gemini-2.5-flash
|
||||||
- And various preview versions
|
- gemini-2.5-flash-lite
|
||||||
|
- gpt-5
|
||||||
|
- gpt-5-codex
|
||||||
|
- claude-opus-4-1-20250805
|
||||||
|
- claude-opus-4-20250514
|
||||||
|
- claude-sonnet-4-20250514
|
||||||
|
- claude-3-7-sonnet-20250219
|
||||||
|
- claude-3-5-haiku-20241022
|
||||||
|
- qwen3-coder-plus
|
||||||
|
- qwen3-coder-flash
|
||||||
|
- Gemini models auto-switch to preview variants when needed
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The server uses a YAML configuration file (`config.yaml`) located in the project root directory by default. You can specify a different configuration file path using the `--config` flag:
|
The server uses a YAML configuration file (`config.yaml`) located in the project root directory by default. You can specify a different configuration file path using the `--config` flag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./cli-proxy --config /path/to/your/config.yaml
|
./cli-proxy-api --config /path/to/your/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration Options
|
### Configuration Options
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|-------------|----------|--------------------|----------------------------------------------------------------------------------------------|
|
|-----------------------------------------|----------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `port` | integer | 8317 | The port number on which the server will listen |
|
| `port` | integer | 8317 | The port number on which the server will listen. |
|
||||||
| `auth_dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for home directory |
|
| `auth-dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for the home directory. If you use Windows, please set the directory like this: `C:/cli-proxy-api/` |
|
||||||
| `proxy-url` | string | "" | Proxy url, support socks5/http/https protocol, example: socks5://user:pass@192.168.1.1:1080/ |
|
| `proxy-url` | string | "" | Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ |
|
||||||
| `debug` | boolean | false | Enable debug mode for verbose logging |
|
| `request-retry` | integer | 0 | Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. |
|
||||||
| `api_keys` | string[] | [] | List of API keys that can be used to authenticate requests |
|
| `remote-management.allow-remote` | boolean | false | Whether to allow remote (non-localhost) access to the management API. If false, only localhost can access. A management key is still required for localhost. |
|
||||||
|
| `remote-management.secret-key` | string | "" | Management key. If a plaintext value is provided, it will be hashed on startup using bcrypt and persisted back to the config file. If empty, the entire management API is disabled (404). |
|
||||||
|
| `quota-exceeded` | object | {} | Configuration for handling quota exceeded. |
|
||||||
|
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. |
|
||||||
|
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. |
|
||||||
|
| `debug` | boolean | false | Enable debug mode for verbose logging. |
|
||||||
|
| `logging-to-file` | boolean | true | Write application logs to rotating files instead of stdout. Set to `false` to log to stdout/stderr. |
|
||||||
|
| `usage-statistics-enabled` | boolean | true | Enable in-memory usage aggregation for management APIs. Disable to drop all collected usage metrics. |
|
||||||
|
| `auth` | object | {} | Request authentication configuration. |
|
||||||
|
| `auth.providers` | object[] | [] | Authentication providers. Includes built-in `config-api-key` for inline keys. |
|
||||||
|
| `auth.providers.*.name` | string | "" | Provider instance name. |
|
||||||
|
| `auth.providers.*.type` | string | "" | Provider implementation identifier (for example `config-api-key`). |
|
||||||
|
| `auth.providers.*.api-keys` | string[] | [] | Inline API keys consumed by the `config-api-key` provider. |
|
||||||
|
| `api-keys` | string[] | [] | Legacy shorthand for inline API keys. Values are mirrored into the `config-api-key` provider for backwards compatibility. |
|
||||||
|
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. |
|
||||||
|
| `codex-api-key` | object | {} | List of Codex API keys. |
|
||||||
|
| `codex-api-key.api-key` | string | "" | Codex API key. |
|
||||||
|
| `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. |
|
||||||
|
| `claude-api-key` | object | {} | List of Claude API keys. |
|
||||||
|
| `claude-api-key.api-key` | string | "" | Claude API key. |
|
||||||
|
| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. |
|
||||||
|
| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). |
|
||||||
|
| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. |
|
||||||
|
| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. |
|
||||||
|
| `openai-compatibility.*.api-keys` | string[] | [] | The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. |
|
||||||
|
| `openai-compatibility.*.models` | object[] | [] | The actual model name. |
|
||||||
|
| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. |
|
||||||
|
| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. |
|
||||||
|
| `gemini-web` | object | {} | Configuration specific to the Gemini Web client. |
|
||||||
|
| `gemini-web.context` | boolean | true | Enables conversation context reuse for continuous dialogue. |
|
||||||
|
| `gemini-web.code-mode` | boolean | false | Enables code mode for optimized responses in coding-related tasks. |
|
||||||
|
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | The maximum number of characters to send to Gemini Web in a single request. |
|
||||||
|
| `gemini-web.disable-continuation-hint` | boolean | false | Disables the continuation hint for split prompts. |
|
||||||
|
|
||||||
### Example Configuration File
|
### Example Configuration File
|
||||||
|
|
||||||
@@ -160,30 +314,342 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
|
|||||||
# Server port
|
# Server port
|
||||||
port: 8317
|
port: 8317
|
||||||
|
|
||||||
# Authentication directory (supports ~ for home directory)
|
# Management API settings
|
||||||
auth_dir: "~/.cli-proxy-api"
|
remote-management:
|
||||||
|
# Whether to allow remote (non-localhost) management access.
|
||||||
|
# When false, only localhost can access management endpoints (a key is still required).
|
||||||
|
allow-remote: false
|
||||||
|
|
||||||
|
# Management key. If a plaintext value is provided here, it will be hashed on startup.
|
||||||
|
# All management requests (even from localhost) require this key.
|
||||||
|
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
|
||||||
|
secret-key: ""
|
||||||
|
|
||||||
|
# Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/`
|
||||||
|
auth-dir: "~/.cli-proxy-api"
|
||||||
|
|
||||||
# Enable debug logging
|
# Enable debug logging
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
# API keys for authentication
|
# When true, write application logs to rotating files instead of stdout
|
||||||
api_keys:
|
logging-to-file: true
|
||||||
|
|
||||||
|
# When false, disable in-memory usage statistics aggregation
|
||||||
|
usage-statistics-enabled: true
|
||||||
|
|
||||||
|
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||||
|
proxy-url: ""
|
||||||
|
|
||||||
|
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
|
||||||
|
request-retry: 3
|
||||||
|
|
||||||
|
# Quota exceeded behavior
|
||||||
|
quota-exceeded:
|
||||||
|
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
|
||||||
|
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
|
||||||
|
|
||||||
|
# Gemini Web client configuration
|
||||||
|
gemini-web:
|
||||||
|
context: true # Enable conversation context reuse
|
||||||
|
code-mode: false # Enable code mode
|
||||||
|
max-chars-per-request: 1000000 # Max characters per request
|
||||||
|
|
||||||
|
# Request authentication providers
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: "default"
|
||||||
|
type: "config-api-key"
|
||||||
|
api-keys:
|
||||||
- "your-api-key-1"
|
- "your-api-key-1"
|
||||||
- "your-api-key-2"
|
- "your-api-key-2"
|
||||||
|
|
||||||
|
# API keys for official Generative Language API
|
||||||
|
generative-language-api-key:
|
||||||
|
- "AIzaSy...01"
|
||||||
|
- "AIzaSy...02"
|
||||||
|
- "AIzaSy...03"
|
||||||
|
- "AIzaSy...04"
|
||||||
|
|
||||||
|
# Codex API keys
|
||||||
|
codex-api-key:
|
||||||
|
- api-key: "sk-atSM..."
|
||||||
|
base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||||
|
|
||||||
|
# Claude API keys
|
||||||
|
claude-api-key:
|
||||||
|
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||||
|
- api-key: "sk-atSM..."
|
||||||
|
base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||||
|
|
||||||
|
# OpenAI compatibility providers
|
||||||
|
openai-compatibility:
|
||||||
|
- name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||||
|
base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||||
|
api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed.
|
||||||
|
- "sk-or-v1-...b780"
|
||||||
|
- "sk-or-v1-...b781"
|
||||||
|
models: # The models supported by the provider.
|
||||||
|
- name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||||
|
alias: "kimi-k2" # The alias used in the API.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### OpenAI Compatibility Providers
|
||||||
|
|
||||||
|
Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-compatibility`.
|
||||||
|
|
||||||
|
- name: provider identifier used internally
|
||||||
|
- base-url: provider base URL
|
||||||
|
- api-keys: optional list of API keys (omit if provider allows unauthenticated requests)
|
||||||
|
- models: list of mappings from upstream model `name` to local `alias`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
openai-compatibility:
|
||||||
|
- name: "openrouter"
|
||||||
|
base-url: "https://openrouter.ai/api/v1"
|
||||||
|
api-keys:
|
||||||
|
- "sk-or-v1-...b780"
|
||||||
|
- "sk-or-v1-...b781"
|
||||||
|
models:
|
||||||
|
- name: "moonshotai/kimi-k2:free"
|
||||||
|
alias: "kimi-k2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
Call OpenAI's endpoint `/v1/chat/completions` with `model` set to the alias (e.g., `kimi-k2`). The proxy routes to the configured provider/model automatically.
|
||||||
|
|
||||||
|
Also, you may call Claude's endpoint `/v1/messages`, Gemini's `/v1beta/models/model-name:streamGenerateContent` or `/v1beta/models/model-name:generateContent`.
|
||||||
|
|
||||||
|
And you can always use Gemini CLI with `CODE_ASSIST_ENDPOINT` set to `http://127.0.0.1:8317` for these OpenAI-compatible provider's models.
|
||||||
|
|
||||||
|
|
||||||
### Authentication Directory
|
### Authentication Directory
|
||||||
|
|
||||||
The `auth_dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing.
|
The `auth-dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing.
|
||||||
|
|
||||||
### API Keys
|
### Request Authentication Providers
|
||||||
|
|
||||||
The `api_keys` parameter allows you to define a list of API keys that can be used to authenticate requests to your proxy server. When making requests to the API, you can include one of these keys in the `Authorization` header:
|
Configure inbound authentication through the `auth.providers` section. The built-in `config-api-key` provider works with inline keys:
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer your-api-key-1
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: default
|
||||||
|
type: config-api-key
|
||||||
|
api-keys:
|
||||||
|
- your-api-key-1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Clients should send requests with an `Authorization: Bearer your-api-key-1` header (or `X-Goog-Api-Key`, `X-Api-Key`, or `?key=` as before). The legacy top-level `api-keys` array is still accepted and automatically synced to the default provider for backwards compatibility.
|
||||||
|
|
||||||
|
### Official Generative Language API
|
||||||
|
|
||||||
|
The `generative-language-api-key` parameter allows you to define a list of API keys that can be used to authenticate requests to the official Generative Language API.
|
||||||
|
|
||||||
|
## Hot Reloading
|
||||||
|
|
||||||
|
The server watches the config file and the `auth-dir` for changes and reloads clients and settings automatically. You can add or remove Gemini/OpenAI token JSON files while the server is running; no restart is required.
|
||||||
|
|
||||||
|
## Gemini CLI with multiple account load balancing
|
||||||
|
|
||||||
|
Start CLI Proxy API server, and then set the `CODE_ASSIST_ENDPOINT` environment variable to the URL of the CLI Proxy API server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:8317"
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will relay the `loadCodeAssist`, `onboardUser`, and `countTokens` requests. And automatically load balance the text generation requests between the multiple accounts.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This feature only allows local access because there is currently no way to authenticate the requests.
|
||||||
|
> 127.0.0.1 is hardcoded for load balancing.
|
||||||
|
|
||||||
|
## Claude Code with multiple account load balancing
|
||||||
|
|
||||||
|
Start CLI Proxy API server, and then set the `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL` environment variables.
|
||||||
|
|
||||||
|
Using Gemini models:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=gemini-2.5-pro
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
Using OpenAI GPT 5 models:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=gpt-5
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
Using OpenAI GPT 5 Codex models:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=gpt-5-codex
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-codex-low
|
||||||
|
```
|
||||||
|
|
||||||
|
Using Claude models:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=claude-3-5-haiku-20241022
|
||||||
|
```
|
||||||
|
|
||||||
|
Using Qwen models:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=qwen3-coder-plus
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Codex with multiple account load balancing
|
||||||
|
|
||||||
|
Start CLI Proxy API server, and then edit the `~/.codex/config.toml` and `~/.codex/auth.json` files.
|
||||||
|
|
||||||
|
config.toml:
|
||||||
|
```toml
|
||||||
|
model_provider = "cliproxyapi"
|
||||||
|
model = "gpt-5-codex" # Or gpt-5, you can also use any of the models that we support.
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
|
||||||
|
[model_providers.cliproxyapi]
|
||||||
|
name = "cliproxyapi"
|
||||||
|
base_url = "http://127.0.0.1:8317/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
|
||||||
|
auth.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "sk-dummy"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run with Docker
|
||||||
|
|
||||||
|
Run the following command to login (Gemini OAuth on port 8085):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8085:8085 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --login
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the following command to login (Gemini Web Cookies):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the following command to login (OpenAI OAuth on port 1455):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 1455:1455 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --codex-login
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the following command to logi (Claude OAuth on port 54545):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -rm -p 54545:54545 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --claude-login
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the following command to login (Qwen OAuth):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it -rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --qwen-login
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the following command to start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run with Docker Compose
|
||||||
|
|
||||||
|
1. Clone the repository and navigate into the directory:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/luispater/CLIProxyAPI.git
|
||||||
|
cd CLIProxyAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Prepare the configuration file:
|
||||||
|
Create a `config.yaml` file by copying the example and customize it to your needs.
|
||||||
|
```bash
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
```
|
||||||
|
*(Note for Windows users: You can use `copy config.example.yaml config.yaml` in CMD or PowerShell.)*
|
||||||
|
|
||||||
|
3. Start the service:
|
||||||
|
- **For most users (recommended):**
|
||||||
|
Run the following command to start the service using the pre-built image from Docker Hub. The service will run in the background.
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
- **For advanced users:**
|
||||||
|
If you have modified the source code and need to build a new image, use the interactive helper scripts:
|
||||||
|
- For Windows (PowerShell):
|
||||||
|
```powershell
|
||||||
|
.\docker-build.ps1
|
||||||
|
```
|
||||||
|
- For Linux/macOS:
|
||||||
|
```bash
|
||||||
|
bash docker-build.sh
|
||||||
|
```
|
||||||
|
The script will prompt you to choose how to run the application:
|
||||||
|
- **Option 1: Run using Pre-built Image (Recommended)**: Pulls the latest official image from the registry and starts the container. This is the easiest way to get started.
|
||||||
|
- **Option 2: Build from Source and Run (For Developers)**: Builds the image from the local source code, tags it as `cli-proxy-api:local`, and then starts the container. This is useful if you are making changes to the source code.
|
||||||
|
|
||||||
|
4. To authenticate with providers, run the login command inside the container:
|
||||||
|
- **Gemini**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login
|
||||||
|
```
|
||||||
|
- **Gemini Web**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||||
|
```
|
||||||
|
- **OpenAI (Codex)**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --codex-login
|
||||||
|
```
|
||||||
|
- **Claude**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login
|
||||||
|
```
|
||||||
|
- **Qwen**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login
|
||||||
|
```
|
||||||
|
|
||||||
|
5. To view the server logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
6. To stop the application:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Management API
|
||||||
|
|
||||||
|
see [MANAGEMENT_API.md](MANAGEMENT_API.md)
|
||||||
|
|
||||||
|
## SDK Docs
|
||||||
|
|
||||||
|
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
|
||||||
|
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
|
||||||
|
- Access: [docs/sdk-access.md](docs/sdk-access.md)
|
||||||
|
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
|
||||||
|
- Custom Provider Example: `examples/custom-provider`
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|||||||
674
README_CN.md
Normal file
674
README_CN.md
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
# 写给所有中国网友的
|
||||||
|
|
||||||
|
对于项目前期的确有很多用户使用上遇到各种各样的奇怪问题,大部分是因为配置或我说明文档不全导致的。
|
||||||
|
|
||||||
|
对说明文档我已经尽可能的修补,有些重要的地方我甚至已经写到了打包的配置文件里。
|
||||||
|
|
||||||
|
已经写在 README 中的功能,都是**可用**的,经过**验证**的,并且我自己**每天**都在使用的。
|
||||||
|
|
||||||
|
可能在某些场景中使用上效果并不是很出色,但那基本上是模型和工具的原因,比如用 Claude Code 的时候,有的模型就无法正确使用工具,比如 Gemini,就在 Claude Code 和 Codex 的下使用的相当扭捏,有时能完成大部分工作,但有时候却只说不做。
|
||||||
|
|
||||||
|
目前来说 Claude 和 GPT-5 是目前使用各种第三方CLI工具运用的最好的模型,我自己也是多个账号做均衡负载使用。
|
||||||
|
|
||||||
|
实事求是的说,最初的几个版本我根本就没有中文文档,我至今所有文档也都是使用英文更新让后让 Gemini 翻译成中文的。但是无论如何都不会出现中文文档无法理解的问题。因为所有的中英文文档我都是再三校对,并且发现未及时更改的更新的地方都快速更新掉了。
|
||||||
|
|
||||||
|
最后,烦请在发 Issue 之前请认真阅读这篇文档。
|
||||||
|
|
||||||
|
另外中文需要交流的用户可以加 QQ 群:188637136
|
||||||
|
|
||||||
|
或 Telegram 群:https://t.me/CLIProxyAPI
|
||||||
|
|
||||||
|
# CLI 代理 API
|
||||||
|
|
||||||
|
[English](README.md) | 中文
|
||||||
|
|
||||||
|
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
|
||||||
|
|
||||||
|
现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
|
||||||
|
|
||||||
|
您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
|
||||||
|
|
||||||
|
现已新增首个中国提供商:[Qwen Code](https://github.com/QwenLM/qwen-code)。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
||||||
|
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
||||||
|
- 新增 Claude Code 支持(OAuth 登录)
|
||||||
|
- 新增 Qwen Code 支持(OAuth 登录)
|
||||||
|
- 新增 Gemini Web 支持(通过 Cookie 登录)
|
||||||
|
- 支持流式与非流式响应
|
||||||
|
- 函数调用/工具支持
|
||||||
|
- 多模态输入(文本、图片)
|
||||||
|
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude 与 Qwen)
|
||||||
|
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude 与 Qwen)
|
||||||
|
- 支持 Gemini AIStudio API 密钥
|
||||||
|
- 支持 Gemini CLI 多账户轮询
|
||||||
|
- 支持 Claude Code 多账户轮询
|
||||||
|
- 支持 Qwen Code 多账户轮询
|
||||||
|
- 支持 OpenAI Codex 多账户轮询
|
||||||
|
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
||||||
|
- 可复用的 Go SDK(见 `docs/sdk-usage.md`)
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Go 1.24 或更高版本
|
||||||
|
- 有权访问 Gemini CLI 模型的 Google 账户(可选)
|
||||||
|
- 有权访问 OpenAI Codex/GPT 的 OpenAI 账户(可选)
|
||||||
|
- 有权访问 Claude Code 的 Anthropic 账户(可选)
|
||||||
|
- 有权访问 Qwen Code 的 Qwen Chat 账户(可选)
|
||||||
|
|
||||||
|
### 从源码构建
|
||||||
|
|
||||||
|
1. 克隆仓库:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/luispater/CLIProxyAPI.git
|
||||||
|
cd CLIProxyAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 构建应用程序:
|
||||||
|
```bash
|
||||||
|
go build -o cli-proxy-api ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 图形客户端与官方 WebUI
|
||||||
|
|
||||||
|
#### [EasyCLI](https://github.com/router-for-me/EasyCLI)
|
||||||
|
|
||||||
|
CLIProxyAPI 的跨平台桌面图形客户端。
|
||||||
|
|
||||||
|
#### [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
|
||||||
|
|
||||||
|
CLIProxyAPI 的基于 Web 的管理中心。
|
||||||
|
|
||||||
|
### 身份验证
|
||||||
|
|
||||||
|
您可以分别为 Gemini、OpenAI 和 Claude 进行身份验证,三者可同时存在于同一个 `auth-dir` 中并参与负载均衡。
|
||||||
|
|
||||||
|
- Gemini(Google):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --login
|
||||||
|
```
|
||||||
|
如果您是现有的 Gemini Code 用户,可能需要指定一个项目ID:
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --login --project_id <your_project_id>
|
||||||
|
```
|
||||||
|
本地 OAuth 回调端口为 `8085`。
|
||||||
|
|
||||||
|
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `8085`。
|
||||||
|
|
||||||
|
- Gemini Web (通过 Cookie):
|
||||||
|
此方法通过模拟浏览器行为,使用从 Gemini 网站获取的 Cookie 进行身份验证。
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --gemini-web-auth
|
||||||
|
```
|
||||||
|
程序将提示您输入 `__Secure-1PSID` 和 `__Secure-1PSIDTS` 的值。请从您的浏览器开发者工具中获取这些 Cookie。
|
||||||
|
|
||||||
|
- OpenAI(Codex/GPT,OAuth):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --codex-login
|
||||||
|
```
|
||||||
|
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `1455`。
|
||||||
|
|
||||||
|
- Claude(Anthropic,OAuth):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --claude-login
|
||||||
|
```
|
||||||
|
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `54545`。
|
||||||
|
|
||||||
|
- Qwen(Qwen Chat,OAuth):
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --qwen-login
|
||||||
|
```
|
||||||
|
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。使用 Qwen Chat 的 OAuth 设备登录流程。
|
||||||
|
|
||||||
|
### 启动服务器
|
||||||
|
|
||||||
|
身份验证完成后,启动服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api
|
||||||
|
```
|
||||||
|
|
||||||
|
默认情况下,服务器在端口 8317 上运行。
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
|
||||||
|
#### 列出模型
|
||||||
|
|
||||||
|
```
|
||||||
|
GET http://localhost:8317/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 聊天补全
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://localhost:8317/v1/chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "gemini-2.5-pro",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "你好,你好吗?"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stream": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 使用 "gemini-*" 模型(例如 "gemini-2.5-pro")来调用 Gemini,使用 "gpt-*" 模型(例如 "gpt-5")来调用 OpenAI,使用 "claude-*" 模型(例如 "claude-3-5-sonnet-20241022")来调用 Claude,或者使用 "qwen-*" 模型(例如 "qwen3-coder-plus")来调用 Qwen。代理服务会自动将请求路由到相应的提供商。
|
||||||
|
|
||||||
|
#### Claude 消息(SSE 兼容)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://localhost:8317/v1/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 与 OpenAI 库一起使用
|
||||||
|
|
||||||
|
您可以通过将基础 URL 设置为本地服务器来将此代理与任何 OpenAI 兼容的库一起使用:
|
||||||
|
|
||||||
|
#### Python(使用 OpenAI 库)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
api_key="dummy", # 不使用但必需
|
||||||
|
base_url="http://localhost:8317/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gemini 示例
|
||||||
|
gemini = client.chat.completions.create(
|
||||||
|
model="gemini-2.5-pro",
|
||||||
|
messages=[{"role": "user", "content": "你好,你好吗?"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Codex/GPT 示例
|
||||||
|
gpt = client.chat.completions.create(
|
||||||
|
model="gpt-5",
|
||||||
|
messages=[{"role": "user", "content": "用一句话总结这个项目"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Claude 示例(使用 messages 端点)
|
||||||
|
import requests
|
||||||
|
claude_response = requests.post(
|
||||||
|
"http://localhost:8317/v1/messages",
|
||||||
|
json={
|
||||||
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
|
"messages": [{"role": "user", "content": "用一句话总结这个项目"}],
|
||||||
|
"max_tokens": 1000
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(gemini.choices[0].message.content)
|
||||||
|
print(gpt.choices[0].message.content)
|
||||||
|
print(claude_response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript/TypeScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: 'dummy', // 不使用但必需
|
||||||
|
baseURL: 'http://localhost:8317/v1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gemini
|
||||||
|
const gemini = await openai.chat.completions.create({
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
messages: [{ role: 'user', content: '你好,你好吗?' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Codex/GPT
|
||||||
|
const gpt = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: '用一句话总结这个项目' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude 示例(使用 messages 端点)
|
||||||
|
const claudeResponse = await fetch('http://localhost:8317/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-3-5-sonnet-20241022',
|
||||||
|
messages: [{ role: 'user', content: '用一句话总结这个项目' }],
|
||||||
|
max_tokens: 1000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(gemini.choices[0].message.content);
|
||||||
|
console.log(gpt.choices[0].message.content);
|
||||||
|
console.log(await claudeResponse.json());
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的模型
|
||||||
|
|
||||||
|
- gemini-2.5-pro
|
||||||
|
- gemini-2.5-flash
|
||||||
|
- gemini-2.5-flash-lite
|
||||||
|
- gpt-5
|
||||||
|
- gpt-5-codex
|
||||||
|
- claude-opus-4-1-20250805
|
||||||
|
- claude-opus-4-20250514
|
||||||
|
- claude-sonnet-4-20250514
|
||||||
|
- claude-3-7-sonnet-20250219
|
||||||
|
- claude-3-5-haiku-20241022
|
||||||
|
- qwen3-coder-plus
|
||||||
|
- qwen3-coder-flash
|
||||||
|
- Gemini 模型在需要时自动切换到对应的 preview 版本
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
服务器默认使用位于项目根目录的 YAML 配置文件(`config.yaml`)。您可以使用 `--config` 标志指定不同的配置文件路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./cli-proxy-api --config /path/to/your/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置选项
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 描述 |
|
||||||
|
|-----------------------------------------|----------|--------------------|---------------------------------------------------------------------|
|
||||||
|
| `port` | integer | 8317 | 服务器将监听的端口号。 |
|
||||||
|
| `auth-dir` | string | "~/.cli-proxy-api" | 存储身份验证令牌的目录。支持使用 `~` 来表示主目录。如果你使用Windows,建议设置成`C:/cli-proxy-api/`。 |
|
||||||
|
| `proxy-url` | string | "" | 代理URL。支持socks5/http/https协议。例如:socks5://user:pass@192.168.1.1:1080/ |
|
||||||
|
| `request-retry` | integer | 0 | 请求重试次数。如果HTTP响应码为403、408、500、502、503或504,将会触发重试。 |
|
||||||
|
| `remote-management.allow-remote` | boolean | false | 是否允许远程(非localhost)访问管理接口。为false时仅允许本地访问;本地访问同样需要管理密钥。 |
|
||||||
|
| `remote-management.secret-key` | string | "" | 管理密钥。若配置为明文,启动时会自动进行bcrypt加密并写回配置文件。若为空,管理接口整体不可用(404)。 |
|
||||||
|
| `quota-exceeded` | object | {} | 用于处理配额超限的配置。 |
|
||||||
|
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 |
|
||||||
|
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 |
|
||||||
|
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
|
||||||
|
| `logging-to-file` | boolean | true | 是否将应用日志写入滚动文件;设为 false 时输出到 stdout/stderr。 |
|
||||||
|
| `usage-statistics-enabled` | boolean | true | 是否启用内存中的使用统计;设为 false 时直接丢弃所有统计数据。 |
|
||||||
|
| `auth` | object | {} | 请求鉴权配置。 |
|
||||||
|
| `auth.providers` | object[] | [] | 鉴权提供方列表,内置 `config-api-key` 支持内联密钥。 |
|
||||||
|
| `auth.providers.*.name` | string | "" | 提供方实例名称。 |
|
||||||
|
| `auth.providers.*.type` | string | "" | 提供方实现标识(例如 `config-api-key`)。 |
|
||||||
|
| `auth.providers.*.api-keys` | string[] | [] | `config-api-key` 提供方使用的内联密钥。 |
|
||||||
|
| `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 |
|
||||||
|
| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
|
||||||
|
| `codex-api-key` | object | {} | Codex API密钥列表。 |
|
||||||
|
| `codex-api-key.api-key` | string | "" | Codex API密钥。 |
|
||||||
|
| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 |
|
||||||
|
| `claude-api-key` | object | {} | Claude API密钥列表。 |
|
||||||
|
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
|
||||||
|
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 |
|
||||||
|
| `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置(名称、基础URL、API密钥、模型)。 |
|
||||||
|
| `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理(User Agent)和其他地方。 |
|
||||||
|
| `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 |
|
||||||
|
| `openai-compatibility.*.api-keys` | string[] | [] | 提供商的API密钥。如果需要,可以添加多个密钥。如果允许未经身份验证的访问,则可以省略。 |
|
||||||
|
| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 |
|
||||||
|
| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 |
|
||||||
|
| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 |
|
||||||
|
| `gemini-web` | object | {} | Gemini Web 客户端的特定配置。 |
|
||||||
|
| `gemini-web.context` | boolean | true | 是否启用会话上下文重用,以实现连续对话。 |
|
||||||
|
| `gemini-web.code-mode` | boolean | false | 是否启用代码模式,优化代码相关任务的响应。 |
|
||||||
|
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | 单次请求发送给 Gemini Web 的最大字符数。 |
|
||||||
|
| `gemini-web.disable-continuation-hint` | boolean | false | 当提示被拆分时,是否禁用连续提示的暗示。 |
|
||||||
|
|
||||||
|
### 配置文件示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 服务器端口
|
||||||
|
port: 8317
|
||||||
|
|
||||||
|
# 管理 API 设置
|
||||||
|
remote-management:
|
||||||
|
# 是否允许远程(非localhost)访问管理接口。为false时仅允许本地访问(但本地访问同样需要管理密钥)。
|
||||||
|
allow-remote: false
|
||||||
|
|
||||||
|
# 管理密钥。若配置为明文,启动时会自动进行bcrypt加密并写回配置文件。
|
||||||
|
# 所有管理请求(包括本地)都需要该密钥。
|
||||||
|
# 若为空,/v0/management 整体处于 404(禁用)。
|
||||||
|
secret-key: ""
|
||||||
|
|
||||||
|
# 身份验证目录(支持 ~ 表示主目录)。如果你使用Windows,建议设置成`C:/cli-proxy-api/`。
|
||||||
|
auth-dir: "~/.cli-proxy-api"
|
||||||
|
|
||||||
|
# 启用调试日志
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# 为 true 时将应用日志写入滚动文件而不是 stdout
|
||||||
|
logging-to-file: true
|
||||||
|
|
||||||
|
# 为 false 时禁用内存中的使用统计并直接丢弃所有数据
|
||||||
|
usage-statistics-enabled: true
|
||||||
|
|
||||||
|
# 代理URL。支持socks5/http/https协议。例如:socks5://user:pass@192.168.1.1:1080/
|
||||||
|
proxy-url: ""
|
||||||
|
|
||||||
|
# 请求重试次数。如果HTTP响应码为403、408、500、502、503或504,将会触发重试。
|
||||||
|
request-retry: 3
|
||||||
|
|
||||||
|
|
||||||
|
# 配额超限行为
|
||||||
|
quota-exceeded:
|
||||||
|
switch-project: true # 当配额超限时是否自动切换到另一个项目
|
||||||
|
switch-preview-model: true # 当配额超限时是否自动切换到预览模型
|
||||||
|
|
||||||
|
# Gemini Web 客户端配置
|
||||||
|
gemini-web:
|
||||||
|
context: true # 启用会话上下文重用
|
||||||
|
code-mode: false # 启用代码模式
|
||||||
|
max-chars-per-request: 1000000 # 单次请求最大字符数
|
||||||
|
|
||||||
|
# 请求鉴权提供方
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: "default"
|
||||||
|
type: "config-api-key"
|
||||||
|
api-keys:
|
||||||
|
- "your-api-key-1"
|
||||||
|
- "your-api-key-2"
|
||||||
|
|
||||||
|
# AIStduio Gemini API 的 API 密钥
|
||||||
|
generative-language-api-key:
|
||||||
|
- "AIzaSy...01"
|
||||||
|
- "AIzaSy...02"
|
||||||
|
- "AIzaSy...03"
|
||||||
|
- "AIzaSy...04"
|
||||||
|
|
||||||
|
# Codex API 密钥
|
||||||
|
codex-api-key:
|
||||||
|
- api-key: "sk-atSM..."
|
||||||
|
base-url: "https://www.example.com" # 第三方 Codex API 中转服务端点
|
||||||
|
|
||||||
|
# Claude API 密钥
|
||||||
|
claude-api-key:
|
||||||
|
- api-key: "sk-atSM..." # 如果使用官方 Claude API,无需设置 base-url
|
||||||
|
- api-key: "sk-atSM..."
|
||||||
|
base-url: "https://www.example.com" # 第三方 Claude API 中转服务端点
|
||||||
|
|
||||||
|
# OpenAI 兼容提供商
|
||||||
|
openai-compatibility:
|
||||||
|
- name: "openrouter" # 提供商的名称;它将被用于用户代理和其它地方。
|
||||||
|
base-url: "https://openrouter.ai/api/v1" # 提供商的基础URL。
|
||||||
|
api-keys: # 提供商的API密钥。如果需要,可以添加多个密钥。如果允许未经身份验证的访问,则可以省略。
|
||||||
|
- "sk-or-v1-...b780"
|
||||||
|
- "sk-or-v1-...b781"
|
||||||
|
models: # 提供商支持的模型。
|
||||||
|
- name: "moonshotai/kimi-k2:free" # 实际的模型名称。
|
||||||
|
alias: "kimi-k2" # 在API中使用的别名。
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAI 兼容上游提供商
|
||||||
|
|
||||||
|
通过 `openai-compatibility` 配置上游 OpenAI 兼容提供商(例如 OpenRouter)。
|
||||||
|
|
||||||
|
- name:内部识别名
|
||||||
|
- base-url:提供商基础地址
|
||||||
|
- api-keys:可选,多密钥轮询(若提供商支持无鉴权可省略)
|
||||||
|
- models:将上游模型 `name` 映射为本地可用 `alias`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
openai-compatibility:
|
||||||
|
- name: "openrouter"
|
||||||
|
base-url: "https://openrouter.ai/api/v1"
|
||||||
|
api-keys:
|
||||||
|
- "sk-or-v1-...b780"
|
||||||
|
- "sk-or-v1-...b781"
|
||||||
|
models:
|
||||||
|
- name: "moonshotai/kimi-k2:free"
|
||||||
|
alias: "kimi-k2"
|
||||||
|
```
|
||||||
|
|
||||||
|
使用方式:在 `/v1/chat/completions` 中将 `model` 设为别名(如 `kimi-k2`),代理将自动路由到对应提供商与模型。
|
||||||
|
|
||||||
|
并且,对于这些与OpenAI兼容的提供商模型,您始终可以通过将CODE_ASSIST_ENDPOINT设置为 http://127.0.0.1:8317 来使用Gemini CLI。
|
||||||
|
|
||||||
|
### 身份验证目录
|
||||||
|
|
||||||
|
`auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。
|
||||||
|
|
||||||
|
### 请求鉴权提供方
|
||||||
|
|
||||||
|
通过 `auth.providers` 配置接入请求鉴权。内置的 `config-api-key` 提供方支持内联密钥:
|
||||||
|
|
||||||
|
```
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: default
|
||||||
|
type: config-api-key
|
||||||
|
api-keys:
|
||||||
|
- your-api-key-1
|
||||||
|
```
|
||||||
|
|
||||||
|
调用时可在 `Authorization` 标头中携带密钥(或继续使用 `X-Goog-Api-Key`、`X-Api-Key`、查询参数 `key`)。为了兼容旧版本,顶层的 `api-keys` 字段仍然可用,并会自动同步到默认的 `config-api-key` 提供方。
|
||||||
|
|
||||||
|
### 官方生成式语言 API
|
||||||
|
|
||||||
|
`generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。
|
||||||
|
|
||||||
|
## 热更新
|
||||||
|
|
||||||
|
服务会监听配置文件与 `auth-dir` 目录的变化并自动重新加载客户端与配置。您可以在运行中新增/移除 Gemini/OpenAI 的令牌 JSON 文件,无需重启服务。
|
||||||
|
|
||||||
|
## Gemini CLI 多账户负载均衡
|
||||||
|
|
||||||
|
启动 CLI 代理 API 服务器,然后将 `CODE_ASSIST_ENDPOINT` 环境变量设置为 CLI 代理 API 服务器的 URL。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:8317"
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器将中继 `loadCodeAssist`、`onboardUser` 和 `countTokens` 请求。并自动在多个账户之间轮询文本生成请求。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 此功能仅允许本地访问,因为找不到一个可以验证请求的方法。
|
||||||
|
> 所以只能强制只有 `127.0.0.1` 可以访问。
|
||||||
|
|
||||||
|
## Claude Code 的使用方法
|
||||||
|
|
||||||
|
启动 CLI Proxy API 服务器, 设置如下系统环境变量 `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`
|
||||||
|
|
||||||
|
使用 Gemini 模型:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=gemini-2.5-pro
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 OpenAI GPT 5 模型:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=gpt-5
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 OpenAI GPT 5 Codex 模型:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=gpt-5-codex
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-codex-low
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
使用 Claude 模型:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=claude-3-5-haiku-20241022
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 Qwen 模型:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||||
|
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||||
|
export ANTHROPIC_MODEL=qwen3-coder-plus
|
||||||
|
export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Codex 多账户负载均衡
|
||||||
|
|
||||||
|
启动 CLI Proxy API 服务器, 修改 `~/.codex/config.toml` 和 `~/.codex/auth.json` 文件。
|
||||||
|
|
||||||
|
config.toml:
|
||||||
|
```toml
|
||||||
|
model_provider = "cliproxyapi"
|
||||||
|
model = "gpt-5-codex" # 或者是gpt-5,你也可以使用任何我们支持的模型
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
|
||||||
|
[model_providers.cliproxyapi]
|
||||||
|
name = "cliproxyapi"
|
||||||
|
base_url = "http://127.0.0.1:8317/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
|
||||||
|
auth.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "sk-dummy"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用 Docker 运行
|
||||||
|
|
||||||
|
运行以下命令进行登录(Gemini OAuth,端口 8085):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8085:8085 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --login
|
||||||
|
```
|
||||||
|
|
||||||
|
运行以下命令进行登录(Gemini Web Cookie):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
运行以下命令进行登录(OpenAI OAuth,端口 1455):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 1455:1455 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --codex-login
|
||||||
|
```
|
||||||
|
|
||||||
|
运行以下命令进行登录(Claude OAuth,端口 54545):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 54545:54545 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --claude-login
|
||||||
|
```
|
||||||
|
|
||||||
|
运行以下命令进行登录(Qwen OAuth):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it -rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --qwen-login
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
运行以下命令启动服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用 Docker Compose 运行
|
||||||
|
|
||||||
|
1. 克隆仓库并进入目录:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/luispater/CLIProxyAPI.git
|
||||||
|
cd CLIProxyAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 准备配置文件:
|
||||||
|
通过复制示例文件来创建 `config.yaml` 文件,并根据您的需求进行自定义。
|
||||||
|
```bash
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
```
|
||||||
|
*(Windows 用户请注意:您可以在 CMD 或 PowerShell 中使用 `copy config.example.yaml config.yaml`。)*
|
||||||
|
|
||||||
|
3. 启动服务:
|
||||||
|
- **适用于大多数用户(推荐):**
|
||||||
|
运行以下命令,使用 Docker Hub 上的预构建镜像启动服务。服务将在后台运行。
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
- **适用于进阶用户:**
|
||||||
|
如果您修改了源代码并需要构建新镜像,请使用交互式辅助脚本:
|
||||||
|
- 对于 Windows (PowerShell):
|
||||||
|
```powershell
|
||||||
|
.\docker-build.ps1
|
||||||
|
```
|
||||||
|
- 对于 Linux/macOS:
|
||||||
|
```bash
|
||||||
|
bash docker-build.sh
|
||||||
|
```
|
||||||
|
脚本将提示您选择运行方式:
|
||||||
|
- **选项 1:使用预构建的镜像运行 (推荐)**:从镜像仓库拉取最新的官方镜像并启动容器。这是最简单的开始方式。
|
||||||
|
- **选项 2:从源码构建并运行 (适用于开发者)**:从本地源代码构建镜像,将其标记为 `cli-proxy-api:local`,然后启动容器。如果您需要修改源代码,此选项很有用。
|
||||||
|
|
||||||
|
4. 要在容器内运行登录命令进行身份验证:
|
||||||
|
- **Gemini**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login
|
||||||
|
```
|
||||||
|
- **Gemini Web**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||||
|
```
|
||||||
|
- **OpenAI (Codex)**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --codex-login
|
||||||
|
```
|
||||||
|
- **Claude**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login
|
||||||
|
```
|
||||||
|
- **Qwen**:
|
||||||
|
```bash
|
||||||
|
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 查看服务器日志:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 停止应用程序:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 管理 API 文档
|
||||||
|
|
||||||
|
请参见 [MANAGEMENT_API_CN.md](MANAGEMENT_API_CN.md)
|
||||||
|
|
||||||
|
## SDK 文档
|
||||||
|
|
||||||
|
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
|
||||||
|
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
|
||||||
|
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
|
||||||
|
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
|
||||||
|
- 自定义 Provider 示例:`examples/custom-provider`
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎贡献!请随时提交 Pull Request。
|
||||||
|
|
||||||
|
1. Fork 仓库
|
||||||
|
2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
|
||||||
|
3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. 推送到分支(`git push origin feature/amazing-feature`)
|
||||||
|
5. 打开 Pull Request
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
||||||
0
auths/.gitkeep
Normal file
0
auths/.gitkeep
Normal file
@@ -1,93 +1,171 @@
|
|||||||
|
// Package main provides the entry point for the CLI Proxy API server.
|
||||||
|
// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
|
||||||
|
// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/cmd"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LogFormatter struct {
|
var (
|
||||||
}
|
Version = "dev"
|
||||||
|
Commit = "none"
|
||||||
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
BuildDate = "unknown"
|
||||||
var b *bytes.Buffer
|
)
|
||||||
if entry.Buffer != nil {
|
|
||||||
b = entry.Buffer
|
|
||||||
} else {
|
|
||||||
b = &bytes.Buffer{}
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
|
||||||
var newLog string
|
|
||||||
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, path.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
|
|
||||||
|
|
||||||
b.WriteString(newLog)
|
|
||||||
return b.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// init initializes the shared logger setup.
|
||||||
func init() {
|
func init() {
|
||||||
log.SetOutput(os.Stdout)
|
logging.SetupBaseLogger()
|
||||||
log.SetReportCaller(true)
|
|
||||||
log.SetFormatter(&LogFormatter{})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// main is the entry point of the application.
|
||||||
|
// It parses command-line flags, loads configuration, and starts the appropriate
|
||||||
|
// service based on the provided flags (login, codex-login, or server mode).
|
||||||
func main() {
|
func main() {
|
||||||
|
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", Version, Commit, BuildDate)
|
||||||
|
|
||||||
|
// Command-line flags to control the application's behavior.
|
||||||
var login bool
|
var login bool
|
||||||
|
var codexLogin bool
|
||||||
|
var claudeLogin bool
|
||||||
|
var qwenLogin bool
|
||||||
|
var geminiWebAuth bool
|
||||||
|
var noBrowser bool
|
||||||
var projectID string
|
var projectID string
|
||||||
var configPath string
|
var configPath string
|
||||||
|
var password string
|
||||||
|
|
||||||
|
// Define command-line flags for different operation modes.
|
||||||
flag.BoolVar(&login, "login", false, "Login Google Account")
|
flag.BoolVar(&login, "login", false, "Login Google Account")
|
||||||
flag.StringVar(&projectID, "project_id", "", "Project ID")
|
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
||||||
|
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
||||||
|
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
|
||||||
|
flag.BoolVar(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies")
|
||||||
|
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
||||||
|
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||||
flag.StringVar(&configPath, "config", "", "Configure File Path")
|
flag.StringVar(&configPath, "config", "", "Configure File Path")
|
||||||
|
flag.StringVar(&password, "password", "", "")
|
||||||
|
|
||||||
|
flag.CommandLine.Usage = func() {
|
||||||
|
out := flag.CommandLine.Output()
|
||||||
|
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
|
||||||
|
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||||
|
if f.Name == "password" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf(" -%s", f.Name)
|
||||||
|
name, usage := flag.UnquoteUsage(f)
|
||||||
|
if name != "" {
|
||||||
|
s += " " + name
|
||||||
|
}
|
||||||
|
if len(s) <= 4 {
|
||||||
|
s += " "
|
||||||
|
} else {
|
||||||
|
s += "\n "
|
||||||
|
}
|
||||||
|
if usage != "" {
|
||||||
|
s += usage
|
||||||
|
}
|
||||||
|
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
|
||||||
|
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(out, s+"\n")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the command-line flags.
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// Core application variables.
|
||||||
var err error
|
var err error
|
||||||
var cfg *config.Config
|
var cfg *config.Config
|
||||||
var wd string
|
var wd string
|
||||||
|
|
||||||
|
// Determine and load the configuration file.
|
||||||
|
// If a config path is provided via flags, it is used directly.
|
||||||
|
// Otherwise, it defaults to "config.yaml" in the current working directory.
|
||||||
|
var configFilePath string
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
|
configFilePath = configPath
|
||||||
cfg, err = config.LoadConfig(configPath)
|
cfg, err = config.LoadConfig(configPath)
|
||||||
} else {
|
} else {
|
||||||
wd, err = os.Getwd()
|
wd, err = os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get working directory: %v", err)
|
log.Fatalf("failed to get working directory: %v", err)
|
||||||
}
|
}
|
||||||
cfg, err = config.LoadConfig(path.Join(wd, "config.yaml"))
|
configFilePath = filepath.Join(wd, "config.yaml")
|
||||||
|
cfg, err = config.LoadConfig(configFilePath)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to load config: %v", err)
|
log.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||||
|
|
||||||
if cfg.Debug {
|
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.Fatalf("failed to configure log output: %v", err)
|
||||||
} else {
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate)
|
||||||
|
|
||||||
|
// Set the log level based on the configuration.
|
||||||
|
util.SetLogLevel(cfg)
|
||||||
|
|
||||||
|
// Expand the tilde (~) in the auth directory path to the user's home directory.
|
||||||
if strings.HasPrefix(cfg.AuthDir, "~") {
|
if strings.HasPrefix(cfg.AuthDir, "~") {
|
||||||
home, errUserHomeDir := os.UserHomeDir()
|
home, errUserHomeDir := os.UserHomeDir()
|
||||||
if errUserHomeDir != nil {
|
if errUserHomeDir != nil {
|
||||||
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
|
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
|
||||||
}
|
}
|
||||||
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
|
// Reconstruct the path by replacing the tilde with the user's home directory.
|
||||||
if len(parts) > 1 {
|
remainder := strings.TrimPrefix(cfg.AuthDir, "~")
|
||||||
parts[0] = home
|
remainder = strings.TrimLeft(remainder, "/\\")
|
||||||
cfg.AuthDir = path.Join(parts...)
|
if remainder == "" {
|
||||||
} else {
|
|
||||||
cfg.AuthDir = home
|
cfg.AuthDir = home
|
||||||
|
} else {
|
||||||
|
// Normalize any slash style in the remainder so Windows paths keep nested directories.
|
||||||
|
normalized := strings.ReplaceAll(remainder, "\\", "/")
|
||||||
|
cfg.AuthDir = filepath.Join(home, filepath.FromSlash(normalized))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create login options to be used in authentication flows.
|
||||||
|
options := &cmd.LoginOptions{
|
||||||
|
NoBrowser: noBrowser,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the shared token store once so all components use the same persistence backend.
|
||||||
|
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
||||||
|
|
||||||
|
// Handle different command modes based on the provided flags.
|
||||||
|
|
||||||
if login {
|
if login {
|
||||||
cmd.DoLogin(cfg, projectID)
|
// Handle Google/Gemini login
|
||||||
|
cmd.DoLogin(cfg, projectID, options)
|
||||||
|
} else if codexLogin {
|
||||||
|
// Handle Codex login
|
||||||
|
cmd.DoCodexLogin(cfg, options)
|
||||||
|
} else if claudeLogin {
|
||||||
|
// Handle Claude login
|
||||||
|
cmd.DoClaudeLogin(cfg, options)
|
||||||
|
} else if qwenLogin {
|
||||||
|
cmd.DoQwenLogin(cfg, options)
|
||||||
|
} else if geminiWebAuth {
|
||||||
|
cmd.DoGeminiWebAuth(cfg)
|
||||||
} else {
|
} else {
|
||||||
cmd.StartService(cfg)
|
// Start the main proxy service
|
||||||
|
cmd.StartService(cfg, configFilePath, password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
config.example.yaml
Normal file
92
config.example.yaml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Server port
|
||||||
|
port: 8317
|
||||||
|
|
||||||
|
# Management API settings
|
||||||
|
remote-management:
|
||||||
|
# Whether to allow remote (non-localhost) management access.
|
||||||
|
# When false, only localhost can access management endpoints (a key is still required).
|
||||||
|
allow-remote: false
|
||||||
|
|
||||||
|
# Management key. If a plaintext value is provided here, it will be hashed on startup.
|
||||||
|
# All management requests (even from localhost) require this key.
|
||||||
|
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
|
||||||
|
secret-key: ""
|
||||||
|
|
||||||
|
# Authentication directory (supports ~ for home directory)
|
||||||
|
auth-dir: "~/.cli-proxy-api"
|
||||||
|
|
||||||
|
# Enable debug logging
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# When true, write application logs to rotating files instead of stdout
|
||||||
|
logging-to-file: true
|
||||||
|
|
||||||
|
# When false, disable in-memory usage statistics aggregation
|
||||||
|
usage-statistics-enabled: true
|
||||||
|
|
||||||
|
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||||
|
proxy-url: ""
|
||||||
|
|
||||||
|
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
|
||||||
|
request-retry: 3
|
||||||
|
|
||||||
|
# Quota exceeded behavior
|
||||||
|
quota-exceeded:
|
||||||
|
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
|
||||||
|
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
|
||||||
|
|
||||||
|
# Request authentication providers
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: "default"
|
||||||
|
type: "config-api-key"
|
||||||
|
api-keys:
|
||||||
|
- "your-api-key-1"
|
||||||
|
- "your-api-key-2"
|
||||||
|
|
||||||
|
# API keys for official Generative Language API
|
||||||
|
generative-language-api-key:
|
||||||
|
- "AIzaSy...01"
|
||||||
|
- "AIzaSy...02"
|
||||||
|
- "AIzaSy...03"
|
||||||
|
- "AIzaSy...04"
|
||||||
|
|
||||||
|
# Codex API keys
|
||||||
|
codex-api-key:
|
||||||
|
- api-key: "sk-atSM..."
|
||||||
|
base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||||
|
|
||||||
|
# Claude API keys
|
||||||
|
claude-api-key:
|
||||||
|
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||||
|
- api-key: "sk-atSM..."
|
||||||
|
base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||||
|
|
||||||
|
# OpenAI compatibility providers
|
||||||
|
openai-compatibility:
|
||||||
|
- name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||||
|
base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||||
|
api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed.
|
||||||
|
- "sk-or-v1-...b780"
|
||||||
|
- "sk-or-v1-...b781"
|
||||||
|
models: # The models supported by the provider.
|
||||||
|
- name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||||
|
alias: "kimi-k2" # The alias used in the API.
|
||||||
|
|
||||||
|
# Gemini Web settings
|
||||||
|
gemini-web:
|
||||||
|
# Conversation reuse: set to true to enable (default), false to disable.
|
||||||
|
context: true
|
||||||
|
# Maximum characters per single request to Gemini Web. Requests exceeding this
|
||||||
|
# size split into chunks. Only the last chunk carries files and yields the final answer.
|
||||||
|
max-chars-per-request: 1000000
|
||||||
|
# Disable the short continuation hint appended to intermediate chunks
|
||||||
|
# when splitting long prompts. Default is false (hint enabled by default).
|
||||||
|
disable-continuation-hint: false
|
||||||
|
# Code mode:
|
||||||
|
# - true: enable XML wrapping hint and attach the coding-partner Gem.
|
||||||
|
# Thought merging (<think> into visible content) applies to STREAMING only;
|
||||||
|
# non-stream responses keep reasoning/thought parts separate for clients
|
||||||
|
# that expect explicit reasoning fields.
|
||||||
|
# - false: disable XML hint and keep <think> separate
|
||||||
|
code-mode: false
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
port: 8317
|
|
||||||
auth_dir: "~/.cli-proxy-api"
|
|
||||||
debug: false
|
|
||||||
proxy-url: ""
|
|
||||||
api_keys:
|
|
||||||
- "12345"
|
|
||||||
- "23456"
|
|
||||||
53
docker-build.ps1
Normal file
53
docker-build.ps1
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# build.ps1 - Windows PowerShell Build Script
|
||||||
|
#
|
||||||
|
# This script automates the process of building and running the Docker container
|
||||||
|
# with version information dynamically injected at build time.
|
||||||
|
|
||||||
|
# Stop script execution on any error
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Step 1: Choose Environment ---
|
||||||
|
Write-Host "Please select an option:"
|
||||||
|
Write-Host "1) Run using Pre-built Image (Recommended)"
|
||||||
|
Write-Host "2) Build from Source and Run (For Developers)"
|
||||||
|
$choice = Read-Host -Prompt "Enter choice [1-2]"
|
||||||
|
|
||||||
|
# --- Step 2: Execute based on choice ---
|
||||||
|
switch ($choice) {
|
||||||
|
"1" {
|
||||||
|
Write-Host "--- Running with Pre-built Image ---"
|
||||||
|
docker compose up -d --remove-orphans --no-build
|
||||||
|
Write-Host "Services are starting from remote image."
|
||||||
|
Write-Host "Run 'docker compose logs -f' to see the logs."
|
||||||
|
}
|
||||||
|
"2" {
|
||||||
|
Write-Host "--- Building from Source and Running ---"
|
||||||
|
|
||||||
|
# Get Version Information
|
||||||
|
$VERSION = (git describe --tags --always --dirty)
|
||||||
|
$COMMIT = (git rev-parse --short HEAD)
|
||||||
|
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
|
||||||
|
Write-Host "Building with the following info:"
|
||||||
|
Write-Host " Version: $VERSION"
|
||||||
|
Write-Host " Commit: $COMMIT"
|
||||||
|
Write-Host " Build Date: $BUILD_DATE"
|
||||||
|
Write-Host "----------------------------------------"
|
||||||
|
|
||||||
|
# Build and start the services with a local-only image tag
|
||||||
|
$env:CLI_PROXY_IMAGE = "cli-proxy-api:local"
|
||||||
|
|
||||||
|
Write-Host "Building the Docker image..."
|
||||||
|
docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
|
||||||
|
|
||||||
|
Write-Host "Starting the services..."
|
||||||
|
docker compose up -d --remove-orphans --pull never
|
||||||
|
|
||||||
|
Write-Host "Build complete. Services are starting."
|
||||||
|
Write-Host "Run 'docker compose logs -f' to see the logs."
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
Write-Host "Invalid choice. Please enter 1 or 2."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
58
docker-build.sh
Normal file
58
docker-build.sh
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# build.sh - Linux/macOS Build Script
|
||||||
|
#
|
||||||
|
# This script automates the process of building and running the Docker container
|
||||||
|
# with version information dynamically injected at build time.
|
||||||
|
|
||||||
|
# Exit immediately if a command exits with a non-zero status.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Step 1: Choose Environment ---
|
||||||
|
echo "Please select an option:"
|
||||||
|
echo "1) Run using Pre-built Image (Recommended)"
|
||||||
|
echo "2) Build from Source and Run (For Developers)"
|
||||||
|
read -r -p "Enter choice [1-2]: " choice
|
||||||
|
|
||||||
|
# --- Step 2: Execute based on choice ---
|
||||||
|
case "$choice" in
|
||||||
|
1)
|
||||||
|
echo "--- Running with Pre-built Image ---"
|
||||||
|
docker compose up -d --remove-orphans --no-build
|
||||||
|
echo "Services are starting from remote image."
|
||||||
|
echo "Run 'docker compose logs -f' to see the logs."
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "--- Building from Source and Running ---"
|
||||||
|
|
||||||
|
# Get Version Information
|
||||||
|
VERSION="$(git describe --tags --always --dirty)"
|
||||||
|
COMMIT="$(git rev-parse --short HEAD)"
|
||||||
|
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
|
echo "Building with the following info:"
|
||||||
|
echo " Version: ${VERSION}"
|
||||||
|
echo " Commit: ${COMMIT}"
|
||||||
|
echo " Build Date: ${BUILD_DATE}"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
|
||||||
|
# Build and start the services with a local-only image tag
|
||||||
|
export CLI_PROXY_IMAGE="cli-proxy-api:local"
|
||||||
|
|
||||||
|
echo "Building the Docker image..."
|
||||||
|
docker compose build \
|
||||||
|
--build-arg VERSION="${VERSION}" \
|
||||||
|
--build-arg COMMIT="${COMMIT}" \
|
||||||
|
--build-arg BUILD_DATE="${BUILD_DATE}"
|
||||||
|
|
||||||
|
echo "Starting the services..."
|
||||||
|
docker compose up -d --remove-orphans --pull never
|
||||||
|
|
||||||
|
echo "Build complete. Services are starting."
|
||||||
|
echo "Run 'docker compose logs -f' to see the logs."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Please enter 1 or 2."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
cli-proxy-api:
|
||||||
|
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
|
||||||
|
pull_policy: always
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${VERSION:-dev}
|
||||||
|
COMMIT: ${COMMIT:-none}
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-unknown}
|
||||||
|
container_name: cli-proxy-api
|
||||||
|
ports:
|
||||||
|
- "8317:8317"
|
||||||
|
- "8085:8085"
|
||||||
|
- "1455:1455"
|
||||||
|
- "54545:54545"
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/CLIProxyAPI/config.yaml
|
||||||
|
- ./auths:/root/.cli-proxy-api
|
||||||
|
- ./logs:/CLIProxyAPI/logs
|
||||||
|
- ./conv:/CLIProxyAPI/conv
|
||||||
|
restart: unless-stopped
|
||||||
176
docs/sdk-access.md
Normal file
176
docs/sdk-access.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# @sdk/access SDK Reference
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
|
||||||
|
|
||||||
|
## Manager Lifecycle
|
||||||
|
|
||||||
|
```go
|
||||||
|
manager := sdkaccess.NewManager()
|
||||||
|
providers, err := sdkaccess.BuildProviders(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
manager.SetProviders(providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
* `NewManager` constructs an empty manager.
|
||||||
|
* `SetProviders` replaces the provider slice using a defensive copy.
|
||||||
|
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
|
||||||
|
* `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.
|
||||||
|
|
||||||
|
## Authenticating Requests
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := manager.Authenticate(ctx, req)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
// Authentication succeeded; result describes the provider and principal.
|
||||||
|
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
||||||
|
// No recognizable credentials were supplied.
|
||||||
|
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
||||||
|
// Supplied credentials were present but rejected.
|
||||||
|
default:
|
||||||
|
// Transport-level failure was returned by a provider.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
|
||||||
|
|
||||||
|
## Configuration Layout
|
||||||
|
|
||||||
|
The manager expects access providers under the `auth.providers` key inside `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: inline-api
|
||||||
|
type: config-api-key
|
||||||
|
api-keys:
|
||||||
|
- sk-test-123
|
||||||
|
- sk-prod-456
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Loading providers from external SDK modules
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: partner-auth
|
||||||
|
type: partner-token
|
||||||
|
sdk: github.com/acme/xplatform/sdk/access/providers/partner
|
||||||
|
config:
|
||||||
|
region: us-west-2
|
||||||
|
audience: cli-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
|
||||||
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
|
||||||
|
|
||||||
|
## Built-in Providers
|
||||||
|
|
||||||
|
The SDK ships with one provider out of the box:
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
|
||||||
|
|
||||||
|
### Metadata and auditing
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Writing Custom Providers
|
||||||
|
|
||||||
|
```go
|
||||||
|
type customProvider struct{}
|
||||||
|
|
||||||
|
func (p *customProvider) Identifier() string { return "my-provider" }
|
||||||
|
|
||||||
|
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
||||||
|
token := r.Header.Get("X-Custom")
|
||||||
|
if token == "" {
|
||||||
|
return nil, sdkaccess.ErrNoCredentials
|
||||||
|
}
|
||||||
|
if token != "expected" {
|
||||||
|
return nil, sdkaccess.ErrInvalidCredential
|
||||||
|
}
|
||||||
|
return &sdkaccess.Result{
|
||||||
|
Provider: p.Identifier(),
|
||||||
|
Principal: "service-user",
|
||||||
|
Metadata: map[string]string{"source": "x-custom"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
||||||
|
return &customProvider{}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Error Semantics
|
||||||
|
|
||||||
|
- `ErrNoCredentials`: no credentials were present or recognized by any provider.
|
||||||
|
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
|
||||||
|
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
|
||||||
|
|
||||||
|
Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked.
|
||||||
|
|
||||||
|
## Integration with cliproxy Service
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
```go
|
||||||
|
coreCfg, _ := config.LoadConfig("config.yaml")
|
||||||
|
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
||||||
|
manager := sdkaccess.NewManager()
|
||||||
|
manager.SetProviders(providers)
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(coreCfg).
|
||||||
|
WithAccessManager(manager).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary.
|
||||||
|
|
||||||
|
### Hot reloading providers
|
||||||
|
|
||||||
|
When configuration changes, rebuild providers and swap them into the manager:
|
||||||
|
|
||||||
|
```go
|
||||||
|
providers, err := sdkaccess.BuildProviders(newCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("reload auth providers failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessManager.SetProviders(providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process.
|
||||||
176
docs/sdk-access_CN.md
Normal file
176
docs/sdk-access_CN.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# @sdk/access 开发指引
|
||||||
|
|
||||||
|
`github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。
|
||||||
|
|
||||||
|
## 引用方式
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
|
||||||
|
|
||||||
|
## 管理器生命周期
|
||||||
|
|
||||||
|
```go
|
||||||
|
manager := sdkaccess.NewManager()
|
||||||
|
providers, err := sdkaccess.BuildProviders(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
manager.SetProviders(providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `NewManager` 创建空管理器。
|
||||||
|
- `SetProviders` 替换提供者切片并做防御性拷贝。
|
||||||
|
- `Providers` 返回适合并发读取的快照。
|
||||||
|
- `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
|
||||||
|
|
||||||
|
## 认证请求
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := manager.Authenticate(ctx, req)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
// Authentication succeeded; result carries provider and principal.
|
||||||
|
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
||||||
|
// No recognizable credentials were supplied.
|
||||||
|
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
||||||
|
// Credentials were present but rejected.
|
||||||
|
default:
|
||||||
|
// Provider surfaced a transport-level failure.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。
|
||||||
|
|
||||||
|
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
|
||||||
|
|
||||||
|
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
|
||||||
|
|
||||||
|
## 配置结构
|
||||||
|
|
||||||
|
在 `config.yaml` 的 `auth.providers` 下定义访问提供者:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: inline-api
|
||||||
|
type: config-api-key
|
||||||
|
api-keys:
|
||||||
|
- sk-test-123
|
||||||
|
- sk-prod-456
|
||||||
|
```
|
||||||
|
|
||||||
|
条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
|
||||||
|
|
||||||
|
### 引入外部 SDK 提供者
|
||||||
|
|
||||||
|
若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
providers:
|
||||||
|
- name: partner-auth
|
||||||
|
type: partner-token
|
||||||
|
sdk: github.com/acme/xplatform/sdk/access/providers/partner
|
||||||
|
config:
|
||||||
|
region: us-west-2
|
||||||
|
audience: cli-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
|
||||||
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。
|
||||||
|
|
||||||
|
## 内建提供者
|
||||||
|
|
||||||
|
当前 SDK 默认内置:
|
||||||
|
|
||||||
|
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。
|
||||||
|
|
||||||
|
导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
|
||||||
|
|
||||||
|
### 元数据与审计
|
||||||
|
|
||||||
|
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key` 或 `query-key`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。
|
||||||
|
|
||||||
|
## 编写自定义提供者
|
||||||
|
|
||||||
|
```go
|
||||||
|
type customProvider struct{}
|
||||||
|
|
||||||
|
func (p *customProvider) Identifier() string { return "my-provider" }
|
||||||
|
|
||||||
|
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
||||||
|
token := r.Header.Get("X-Custom")
|
||||||
|
if token == "" {
|
||||||
|
return nil, sdkaccess.ErrNoCredentials
|
||||||
|
}
|
||||||
|
if token != "expected" {
|
||||||
|
return nil, sdkaccess.ErrInvalidCredential
|
||||||
|
}
|
||||||
|
return &sdkaccess.Result{
|
||||||
|
Provider: p.Identifier(),
|
||||||
|
Principal: "service-user",
|
||||||
|
Metadata: map[string]string{"source": "x-custom"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
||||||
|
return &customProvider{}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。
|
||||||
|
|
||||||
|
## 错误语义
|
||||||
|
|
||||||
|
- `ErrNoCredentials`:任何提供者都未识别到凭证。
|
||||||
|
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
|
||||||
|
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。
|
||||||
|
|
||||||
|
自定义错误(例如网络异常)会马上冒泡返回。
|
||||||
|
|
||||||
|
## 与 cliproxy 集成
|
||||||
|
|
||||||
|
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
|
||||||
|
|
||||||
|
```go
|
||||||
|
coreCfg, _ := config.LoadConfig("config.yaml")
|
||||||
|
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
||||||
|
manager := sdkaccess.NewManager()
|
||||||
|
manager.SetProviders(providers)
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(coreCfg).
|
||||||
|
WithAccessManager(manager).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。
|
||||||
|
|
||||||
|
### 动态热更新提供者
|
||||||
|
|
||||||
|
当配置发生变化时,可以重新构建提供者并替换当前列表:
|
||||||
|
|
||||||
|
```go
|
||||||
|
providers, err := sdkaccess.BuildProviders(newCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("reload auth providers failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessManager.SetProviders(providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。
|
||||||
138
docs/sdk-advanced.md
Normal file
138
docs/sdk-advanced.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# SDK Advanced: Executors & Translators
|
||||||
|
|
||||||
|
This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
|
||||||
|
- Implement a provider executor that talks to your upstream API
|
||||||
|
- Register request/response translators for schema conversion
|
||||||
|
- Register models so they appear in `/v1/models`
|
||||||
|
|
||||||
|
The examples use Go 1.24+ and the v6 module path.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
|
||||||
|
|
||||||
|
## 1) Implement a Provider Executor
|
||||||
|
|
||||||
|
Create a type that satisfies `auth.ProviderExecutor`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct{}
|
||||||
|
|
||||||
|
func (Executor) Identifier() string { return "myprov" }
|
||||||
|
|
||||||
|
// Optional: mutate outbound HTTP requests with credentials
|
||||||
|
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||||
|
// Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||||
|
// Build HTTP request based on req.Payload (already translated into provider format)
|
||||||
|
// Use per‑auth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
|
||||||
|
// Perform call and return provider JSON payload
|
||||||
|
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||||
|
ch := make(chan clipexec.StreamChunk, 1)
|
||||||
|
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
||||||
|
// Optionally refresh tokens and return updated auth
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register the executor with the core manager before starting the service:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.RegisterExecutor(myprov.Executor{})
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
|
||||||
|
|
||||||
|
## 2) Register Translators
|
||||||
|
|
||||||
|
The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`’s default registry.
|
||||||
|
|
||||||
|
Direction matters:
|
||||||
|
- Request: register from inbound schema to provider schema
|
||||||
|
- Response: register from provider schema back to inbound schema
|
||||||
|
|
||||||
|
Example: Convert OpenAI Chat → MyProv Chat and back.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FOpenAI = sdktr.Format("openai.chat")
|
||||||
|
FMyProv = sdktr.Format("myprov.chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sdktr.Register(FOpenAI, FMyProv,
|
||||||
|
// Request transform (model, rawJSON, stream)
|
||||||
|
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
||||||
|
// Response transform (stream & non‑stream)
|
||||||
|
sdktr.ResponseTransform{
|
||||||
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||||
|
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||||
|
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
|
||||||
|
|
||||||
|
## 3) Register Models
|
||||||
|
|
||||||
|
Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
|
||||||
|
|
||||||
|
```go
|
||||||
|
models := []*cliproxy.ModelInfo{
|
||||||
|
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
||||||
|
}
|
||||||
|
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Credentials & Transports
|
||||||
|
|
||||||
|
- Use `Manager.SetRoundTripperProvider` to inject per‑auth `*http.Transport` (e.g., proxy):
|
||||||
|
```go
|
||||||
|
core.SetRoundTripperProvider(myProvider) // returns transport per auth
|
||||||
|
```
|
||||||
|
- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
|
||||||
|
|
||||||
|
## Testing Tips
|
||||||
|
|
||||||
|
- Enable request logging: Management API GET/PUT `/v0/management/request-log`
|
||||||
|
- Toggle debug logs: Management API GET/PUT `/v0/management/debug`
|
||||||
|
- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher
|
||||||
|
|
||||||
131
docs/sdk-advanced_CN.md
Normal file
131
docs/sdk-advanced_CN.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# SDK 高级指南:执行器与翻译器
|
||||||
|
|
||||||
|
本文介绍如何使用 SDK 扩展内嵌代理:
|
||||||
|
- 实现自定义 Provider 执行器以调用你的上游 API
|
||||||
|
- 注册请求/响应翻译器进行协议转换
|
||||||
|
- 注册模型以出现在 `/v1/models`
|
||||||
|
|
||||||
|
示例基于 Go 1.24+ 与 v6 模块路径。
|
||||||
|
|
||||||
|
## 概念
|
||||||
|
|
||||||
|
- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key(如 `gemini`、`claude`、`codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
|
||||||
|
- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
|
||||||
|
- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
|
||||||
|
|
||||||
|
## 1) 实现 Provider 执行器
|
||||||
|
|
||||||
|
创建类型满足 `auth.ProviderExecutor` 接口。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct{}
|
||||||
|
|
||||||
|
func (Executor) Identifier() string { return "myprov" }
|
||||||
|
|
||||||
|
// 可选:在原始 HTTP 请求上注入凭据
|
||||||
|
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||||
|
// 例如:req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||||
|
// 基于 req.Payload 构造上游请求,返回上游 JSON 负载
|
||||||
|
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||||
|
ch := make(chan clipexec.StreamChunk, 1)
|
||||||
|
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
|
||||||
|
```
|
||||||
|
|
||||||
|
在启动服务前将执行器注册到核心管理器:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.RegisterExecutor(myprov.Executor{})
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
当凭据的 `Provider` 为 `"myprov"` 时,管理器会将请求路由到你的执行器。
|
||||||
|
|
||||||
|
## 2) 注册翻译器
|
||||||
|
|
||||||
|
内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
|
||||||
|
|
||||||
|
方向很重要:
|
||||||
|
- 请求:从“入站格式”转换为“provider 格式”
|
||||||
|
- 响应:从“provider 格式”转换回“入站格式”
|
||||||
|
|
||||||
|
示例:OpenAI Chat → MyProv Chat 及其反向。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FOpenAI = sdktr.Format("openai.chat")
|
||||||
|
FMyProv = sdktr.Format("myprov.chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sdktr.Register(FOpenAI, FMyProv,
|
||||||
|
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
||||||
|
sdktr.ResponseTransform{
|
||||||
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||||
|
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||||
|
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
|
||||||
|
|
||||||
|
## 3) 注册模型
|
||||||
|
|
||||||
|
通过全局模型注册表将模型暴露到 `/v1/models`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
models := []*cliproxy.ModelInfo{
|
||||||
|
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
||||||
|
}
|
||||||
|
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
||||||
|
```
|
||||||
|
|
||||||
|
内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
|
||||||
|
|
||||||
|
## 凭据与传输
|
||||||
|
|
||||||
|
- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
|
||||||
|
```go
|
||||||
|
core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
|
||||||
|
```
|
||||||
|
- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
- 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
|
||||||
|
- 切换调试日志:管理 API GET/PUT `/v0/management/debug`
|
||||||
|
- 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用
|
||||||
|
|
||||||
163
docs/sdk-usage.md
Normal file
163
docs/sdk-usage.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# CLI Proxy SDK Guide
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Install & Import
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `/v6` module path.
|
||||||
|
|
||||||
|
## Minimal Embed
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
svc, err := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml"). // absolute or working-dir relative
|
||||||
|
Build()
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
|
||||||
|
|
||||||
|
## Server Options (middleware, routes, logs)
|
||||||
|
|
||||||
|
The server accepts options via `WithServerOptions`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithServerOptions(
|
||||||
|
// Add global middleware
|
||||||
|
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
||||||
|
// Tweak gin engine early (CORS, trusted proxies, etc.)
|
||||||
|
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
||||||
|
// Add your own routes after defaults
|
||||||
|
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
||||||
|
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
||||||
|
}),
|
||||||
|
// Override request log writer/dir
|
||||||
|
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||||
|
}),
|
||||||
|
).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
These options mirror the internals used by the CLI server.
|
||||||
|
|
||||||
|
## Management API (when embedded)
|
||||||
|
|
||||||
|
- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
|
||||||
|
- Remote access additionally requires `remote-management.allow-remote: true`.
|
||||||
|
- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
|
||||||
|
|
||||||
|
## Using the Core Auth Manager
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.SetRoundTripperProvider(myRTProvider) // per‑auth *http.Transport
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithCoreAuthManager(core).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement a custom per‑auth transport:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type myRTProvider struct{}
|
||||||
|
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
||||||
|
if a == nil || a.ProxyURL == "" { return nil }
|
||||||
|
u, _ := url.Parse(a.ProxyURL)
|
||||||
|
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Programmatic execution is available on the manager:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Non‑streaming
|
||||||
|
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
||||||
|
|
||||||
|
// Streaming
|
||||||
|
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
||||||
|
for ch := range chunks { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Custom Client Sources
|
||||||
|
|
||||||
|
Replace the default loaders if your creds live outside the local filesystem:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type memoryTokenProvider struct{}
|
||||||
|
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
||||||
|
// Populate from memory/remote store and return counts
|
||||||
|
return &cliproxy.TokenClientResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithTokenClientProvider(&memoryTokenProvider{}).
|
||||||
|
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
Observe lifecycle without patching internals:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hooks := cliproxy.Hooks{
|
||||||
|
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
||||||
|
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
||||||
|
}
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shutdown
|
||||||
|
|
||||||
|
`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = svc.Shutdown(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
|
||||||
|
- Request logging can be toggled at runtime via the Management API.
|
||||||
|
- Gemini Web features (`gemini-web.*`) are honored in the embedded server.
|
||||||
164
docs/sdk-usage_CN.md
Normal file
164
docs/sdk-usage_CN.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# CLI Proxy SDK 使用指南
|
||||||
|
|
||||||
|
`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
|
||||||
|
|
||||||
|
## 安装与导入
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意模块路径包含 `/v6`。
|
||||||
|
|
||||||
|
## 最小可用示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
svc, err := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
|
||||||
|
Build()
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
|
||||||
|
|
||||||
|
## 服务器可选项(中间件、路由、日志)
|
||||||
|
|
||||||
|
通过 `WithServerOptions` 自定义:
|
||||||
|
|
||||||
|
```go
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithServerOptions(
|
||||||
|
// 追加全局中间件
|
||||||
|
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
||||||
|
// 提前调整 gin 引擎(如 CORS、trusted proxies)
|
||||||
|
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
||||||
|
// 在默认路由之后追加自定义路由
|
||||||
|
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
||||||
|
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
||||||
|
}),
|
||||||
|
// 覆盖请求日志的创建(启用/目录)
|
||||||
|
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||||
|
}),
|
||||||
|
).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
这些选项与 CLI 服务器内部用法保持一致。
|
||||||
|
|
||||||
|
## 管理 API(内嵌时)
|
||||||
|
|
||||||
|
- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
|
||||||
|
- 远程访问还需要 `remote-management.allow-remote: true`。
|
||||||
|
- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`。
|
||||||
|
|
||||||
|
## 使用核心鉴权管理器
|
||||||
|
|
||||||
|
服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithCoreAuthManager(core).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
实现每个账户的自定义传输:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type myRTProvider struct{}
|
||||||
|
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
||||||
|
if a == nil || a.ProxyURL == "" { return nil }
|
||||||
|
u, _ := url.Parse(a.ProxyURL)
|
||||||
|
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
管理器提供编程式执行接口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 非流式
|
||||||
|
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
||||||
|
|
||||||
|
// 流式
|
||||||
|
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
||||||
|
for ch := range chunks { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
|
||||||
|
|
||||||
|
## 自定义凭据来源
|
||||||
|
|
||||||
|
当凭据不在本地文件系统时,替换默认加载器:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type memoryTokenProvider struct{}
|
||||||
|
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
||||||
|
// 从内存/远端加载并返回数量统计
|
||||||
|
return &cliproxy.TokenClientResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithTokenClientProvider(&memoryTokenProvider{}).
|
||||||
|
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动钩子
|
||||||
|
|
||||||
|
无需修改内部代码即可观察生命周期:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hooks := cliproxy.Hooks{
|
||||||
|
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
||||||
|
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
||||||
|
}
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关闭
|
||||||
|
|
||||||
|
`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = svc.Shutdown(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 热更新:`config.yaml` 与 `auths/` 变化会被自动侦测并应用。
|
||||||
|
- 请求日志可通过管理 API 在运行时开关。
|
||||||
|
- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。
|
||||||
|
|
||||||
32
docs/sdk-watcher.md
Normal file
32
docs/sdk-watcher.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# SDK Watcher Integration
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Update Queue Contract
|
||||||
|
|
||||||
|
- `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`.
|
||||||
|
- `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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Watcher Behaviour
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## High-Frequency Change Handling
|
||||||
|
|
||||||
|
- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
|
||||||
|
- Back-pressure is absorbed in two places:
|
||||||
|
- The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
|
||||||
|
- The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Usage Checklist
|
||||||
|
|
||||||
|
1. Instantiate the SDK service (builder or manual construction).
|
||||||
|
2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
|
||||||
|
3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
|
||||||
|
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`.
|
||||||
|
|
||||||
|
Following this flow keeps auth changes responsive while avoiding full reloads for every edit.
|
||||||
32
docs/sdk-watcher_CN.md
Normal file
32
docs/sdk-watcher_CN.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# SDK Watcher集成说明
|
||||||
|
|
||||||
|
本文档介绍SDK服务与文件监控器之间的增量更新队列,包括接口契约、高频变更下的处理策略以及接入步骤。
|
||||||
|
|
||||||
|
## 更新队列契约
|
||||||
|
|
||||||
|
- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add`、`modify`或`delete`,`ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`。
|
||||||
|
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher,必须在watcher启动前完成。
|
||||||
|
- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道,并在`consumeAuthUpdates`中使用专职goroutine消费;消费侧会主动“抽干”积压事件,降低切换开销。
|
||||||
|
|
||||||
|
## Watcher行为
|
||||||
|
|
||||||
|
- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
|
||||||
|
- 以凭据ID为维度对更新进行合并,同一凭据在短时间内的多次变更只会保留最新状态(例如先写后删只会下发`delete`)。
|
||||||
|
- watcher内部运行异步分发循环:生产者只向内存缓冲追加事件并唤醒分发协程,即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环,确保协程正常退出。
|
||||||
|
|
||||||
|
## 高频变更处理
|
||||||
|
|
||||||
|
- 分发循环与服务消费协程相互独立,因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
|
||||||
|
- 背压通过两级缓冲吸收:
|
||||||
|
- 分发缓冲(map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
|
||||||
|
- 服务端通道的256容量加上消费侧的“抽干”逻辑,可平稳处理多个突发批次。
|
||||||
|
- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
|
||||||
|
|
||||||
|
## 接入步骤
|
||||||
|
|
||||||
|
1. 实例化SDK Service(构建器或手工创建)。
|
||||||
|
2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
|
||||||
|
3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道,然后再启动watcher。
|
||||||
|
4. Reload回调专注于配置更新;认证增量会通过队列送达,并由`handleAuthUpdate`自动应用。
|
||||||
|
|
||||||
|
遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。
|
||||||
207
examples/custom-provider/main.go
Normal file
207
examples/custom-provider/main.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// Package main demonstrates how to create a custom AI provider executor
|
||||||
|
// and integrate it with the CLI Proxy API server. This example shows how to:
|
||||||
|
// - Create a custom executor that implements the Executor interface
|
||||||
|
// - Register custom translators for request/response transformation
|
||||||
|
// - Integrate the custom provider with the SDK server
|
||||||
|
// - Register custom models in the model registry
|
||||||
|
//
|
||||||
|
// This example uses a simple echo service (httpbin.org) as the upstream API
|
||||||
|
// for demonstration purposes. In a real implementation, you would replace
|
||||||
|
// this with your actual AI service provider.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// providerKey is the identifier for our custom provider.
|
||||||
|
providerKey = "myprov"
|
||||||
|
|
||||||
|
// fOpenAI represents the OpenAI chat format.
|
||||||
|
fOpenAI = sdktr.Format("openai.chat")
|
||||||
|
|
||||||
|
// fMyProv represents our custom provider's chat format.
|
||||||
|
fMyProv = sdktr.Format("myprov.chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
// init registers trivial translators for demonstration purposes.
|
||||||
|
// In a real implementation, you would implement proper request/response
|
||||||
|
// transformation logic between OpenAI format and your provider's format.
|
||||||
|
func init() {
|
||||||
|
sdktr.Register(fOpenAI, fMyProv,
|
||||||
|
func(model string, raw []byte, stream bool) []byte { return raw },
|
||||||
|
sdktr.ResponseTransform{
|
||||||
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||||
|
return []string{string(raw)}
|
||||||
|
},
|
||||||
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||||
|
return string(raw)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyExecutor is a minimal provider implementation for demonstration purposes.
|
||||||
|
// It implements the Executor interface to handle requests to a custom AI provider.
|
||||||
|
type MyExecutor struct{}
|
||||||
|
|
||||||
|
// Identifier returns the unique identifier for this executor.
|
||||||
|
func (MyExecutor) Identifier() string { return providerKey }
|
||||||
|
|
||||||
|
// PrepareRequest optionally injects credentials to raw HTTP requests.
|
||||||
|
// This method is called before each request to allow the executor to modify
|
||||||
|
// the HTTP request with authentication headers or other necessary modifications.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - req: The HTTP request to prepare
|
||||||
|
// - a: The authentication information
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if request preparation fails
|
||||||
|
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||||
|
if req == nil || a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if a.Attributes != nil {
|
||||||
|
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHTTPClient(a *coreauth.Auth) *http.Client {
|
||||||
|
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
u, err := url.Parse(a.ProxyURL)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upstreamEndpoint(a *coreauth.Auth) string {
|
||||||
|
if a != nil && a.Attributes != nil {
|
||||||
|
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Demo echo endpoint; replace with your upstream.
|
||||||
|
return "https://httpbin.org/post"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||||
|
client := buildHTTPClient(a)
|
||||||
|
endpoint := upstreamEndpoint(a)
|
||||||
|
|
||||||
|
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
|
||||||
|
if errNew != nil {
|
||||||
|
return clipexec.Response{}, errNew
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Inject credentials via PrepareRequest hook.
|
||||||
|
_ = (MyExecutor{}).PrepareRequest(httpReq, a)
|
||||||
|
|
||||||
|
resp, errDo := client.Do(httpReq)
|
||||||
|
if errDo != nil {
|
||||||
|
return clipexec.Response{}, errDo
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
// Best-effort close; log if needed in real projects.
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return clipexec.Response{Payload: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||||
|
ch := make(chan clipexec.StreamChunk, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
|
||||||
|
}()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStore := sdkAuth.GetTokenStore()
|
||||||
|
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
||||||
|
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||||
|
}
|
||||||
|
store, ok := tokenStore.(coreauth.Store)
|
||||||
|
if !ok {
|
||||||
|
panic("token store does not implement coreauth.Store")
|
||||||
|
}
|
||||||
|
core := coreauth.NewManager(store, nil, nil)
|
||||||
|
core.RegisterExecutor(MyExecutor{})
|
||||||
|
|
||||||
|
hooks := cliproxy.Hooks{
|
||||||
|
OnAfterStart: func(s *cliproxy.Service) {
|
||||||
|
// Register demo models for the custom provider so they appear in /v1/models.
|
||||||
|
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
|
||||||
|
for _, a := range core.List() {
|
||||||
|
if strings.EqualFold(a.Provider, providerKey) {
|
||||||
|
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithCoreAuthManager(core).
|
||||||
|
WithServerOptions(
|
||||||
|
// Optional: add a simple middleware + custom request logger
|
||||||
|
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
|
||||||
|
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||||
|
}),
|
||||||
|
).
|
||||||
|
WithHooks(hooks).
|
||||||
|
Build()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
_ = os.Stderr // keep os import used (demo only)
|
||||||
|
_ = time.Second
|
||||||
|
}
|
||||||
11
go.mod
11
go.mod
@@ -1,13 +1,18 @@
|
|||||||
module github.com/luispater/CLIProxyAPI
|
module github.com/router-for-me/CLIProxyAPI/v6
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
|
go.etcd.io/bbolt v1.3.8
|
||||||
|
golang.org/x/crypto v0.36.0
|
||||||
|
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -25,6 +30,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.3 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -36,9 +42,8 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.36.0 // indirect
|
|
||||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 // indirect
|
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -11,6 +11,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
@@ -30,8 +32,12 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
|
|||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
||||||
|
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
@@ -78,6 +84,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
|
||||||
|
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
@@ -100,6 +108,8 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW
|
|||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -1,730 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
mutex = &sync.Mutex{}
|
|
||||||
lastUsedClientIndex = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// APIHandlers contains the handlers for API endpoints
|
|
||||||
type APIHandlers struct {
|
|
||||||
cliClients []*client.Client
|
|
||||||
debug bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAPIHandlers creates a new API handlers instance
|
|
||||||
func NewAPIHandlers(cliClients []*client.Client, debug bool) *APIHandlers {
|
|
||||||
return &APIHandlers{
|
|
||||||
cliClients: cliClients,
|
|
||||||
debug: debug,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *APIHandlers) Models(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"data": []map[string]any{
|
|
||||||
{
|
|
||||||
"id": "gemini-2.5-pro-preview-05-06",
|
|
||||||
"object": "model",
|
|
||||||
"version": "2.5-preview-05-06",
|
|
||||||
"name": "Gemini 2.5 Pro Preview 05-06",
|
|
||||||
"description": "Preview release (May 6th, 2025) of Gemini 2.5 Pro",
|
|
||||||
"context_length": 1048576,
|
|
||||||
"max_completion_tokens": 65536,
|
|
||||||
"supported_parameters": []string{
|
|
||||||
"tools",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"top_k",
|
|
||||||
},
|
|
||||||
"temperature": 1,
|
|
||||||
"topP": 0.95,
|
|
||||||
"topK": 64,
|
|
||||||
"maxTemperature": 2,
|
|
||||||
"thinking": true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-2.5-pro-preview-06-05",
|
|
||||||
"object": "model",
|
|
||||||
"version": "2.5-preview-06-05",
|
|
||||||
"name": "Gemini 2.5 Pro Preview",
|
|
||||||
"description": "Preview release (June 5th, 2025) of Gemini 2.5 Pro",
|
|
||||||
"context_length": 1048576,
|
|
||||||
"max_completion_tokens": 65536,
|
|
||||||
"supported_parameters": []string{
|
|
||||||
"tools",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"top_k",
|
|
||||||
},
|
|
||||||
"temperature": 1,
|
|
||||||
"topP": 0.95,
|
|
||||||
"topK": 64,
|
|
||||||
"maxTemperature": 2,
|
|
||||||
"thinking": true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-2.5-pro",
|
|
||||||
"object": "model",
|
|
||||||
"version": "2.5",
|
|
||||||
"name": "Gemini 2.5 Pro",
|
|
||||||
"description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
|
|
||||||
"context_length": 1048576,
|
|
||||||
"max_completion_tokens": 65536,
|
|
||||||
"supported_parameters": []string{
|
|
||||||
"tools",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"top_k",
|
|
||||||
},
|
|
||||||
"temperature": 1,
|
|
||||||
"topP": 0.95,
|
|
||||||
"topK": 64,
|
|
||||||
"maxTemperature": 2,
|
|
||||||
"thinking": true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-2.5-flash-preview-04-17",
|
|
||||||
"object": "model",
|
|
||||||
"version": "2.5-preview-04-17",
|
|
||||||
"name": "Gemini 2.5 Flash Preview 04-17",
|
|
||||||
"description": "Preview release (April 17th, 2025) of Gemini 2.5 Flash",
|
|
||||||
"context_length": 1048576,
|
|
||||||
"max_completion_tokens": 65536,
|
|
||||||
"supported_parameters": []string{
|
|
||||||
"tools",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"top_k",
|
|
||||||
},
|
|
||||||
"temperature": 1,
|
|
||||||
"topP": 0.95,
|
|
||||||
"topK": 64,
|
|
||||||
"maxTemperature": 2,
|
|
||||||
"thinking": true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-2.5-flash-preview-05-20",
|
|
||||||
"object": "model",
|
|
||||||
"version": "2.5-preview-05-20",
|
|
||||||
"name": "Gemini 2.5 Flash Preview 05-20",
|
|
||||||
"description": "Preview release (April 17th, 2025) of Gemini 2.5 Flash",
|
|
||||||
"context_length": 1048576,
|
|
||||||
"max_completion_tokens": 65536,
|
|
||||||
"supported_parameters": []string{
|
|
||||||
"tools",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"top_k",
|
|
||||||
},
|
|
||||||
"temperature": 1,
|
|
||||||
"topP": 0.95,
|
|
||||||
"topK": 64,
|
|
||||||
"maxTemperature": 2,
|
|
||||||
"thinking": true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-2.5-flash",
|
|
||||||
"object": "model",
|
|
||||||
"version": "001",
|
|
||||||
"name": "Gemini 2.5 Flash",
|
|
||||||
"description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
|
|
||||||
"context_length": 1048576,
|
|
||||||
"max_completion_tokens": 65536,
|
|
||||||
"supported_parameters": []string{
|
|
||||||
"tools",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"top_k",
|
|
||||||
},
|
|
||||||
"temperature": 1,
|
|
||||||
"topP": 0.95,
|
|
||||||
"topK": 64,
|
|
||||||
"maxTemperature": 2,
|
|
||||||
"thinking": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatCompletions handles the /v1/chat/completions endpoint
|
|
||||||
func (h *APIHandlers) ChatCompletions(c *gin.Context) {
|
|
||||||
rawJson, err := c.GetRawData()
|
|
||||||
// If data retrieval fails, return 400 error
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request: %v", err), "code": 400})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
streamResult := gjson.GetBytes(rawJson, "stream")
|
|
||||||
if streamResult.Type == gjson.True {
|
|
||||||
h.handleStreamingResponse(c, rawJson)
|
|
||||||
} else {
|
|
||||||
h.handleNonStreamingResponse(c, rawJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *APIHandlers) prepareRequest(rawJson []byte) (string, []client.Content, []client.ToolDeclaration) {
|
|
||||||
// log.Debug(string(rawJson))
|
|
||||||
modelName := "gemini-2.5-pro"
|
|
||||||
modelResult := gjson.GetBytes(rawJson, "model")
|
|
||||||
if modelResult.Type == gjson.String {
|
|
||||||
modelName = modelResult.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
contents := make([]client.Content, 0)
|
|
||||||
messagesResult := gjson.GetBytes(rawJson, "messages")
|
|
||||||
if messagesResult.IsArray() {
|
|
||||||
messagesResults := messagesResult.Array()
|
|
||||||
for i := 0; i < len(messagesResults); i++ {
|
|
||||||
messageResult := messagesResults[i]
|
|
||||||
roleResult := messageResult.Get("role")
|
|
||||||
contentResult := messageResult.Get("content")
|
|
||||||
if roleResult.Type == gjson.String {
|
|
||||||
if roleResult.String() == "system" {
|
|
||||||
if contentResult.Type == gjson.String {
|
|
||||||
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
|
|
||||||
} else if contentResult.IsObject() {
|
|
||||||
contentTypeResult := contentResult.Get("type")
|
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
|
||||||
contentTextResult := contentResult.Get("text")
|
|
||||||
if contentTextResult.Type == gjson.String {
|
|
||||||
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentTextResult.String()}}})
|
|
||||||
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: "Understood. I will follow these instructions and use my tools to assist you."}}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if roleResult.String() == "user" {
|
|
||||||
if contentResult.Type == gjson.String {
|
|
||||||
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
|
|
||||||
} else if contentResult.IsObject() {
|
|
||||||
contentTypeResult := contentResult.Get("type")
|
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
|
||||||
contentTextResult := contentResult.Get("text")
|
|
||||||
if contentTextResult.Type == gjson.String {
|
|
||||||
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentTextResult.String()}}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if contentResult.IsArray() {
|
|
||||||
contentItemResults := contentResult.Array()
|
|
||||||
parts := make([]client.Part, 0)
|
|
||||||
for j := 0; j < len(contentItemResults); j++ {
|
|
||||||
contentItemResult := contentItemResults[j]
|
|
||||||
contentTypeResult := contentItemResult.Get("type")
|
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
|
||||||
contentTextResult := contentItemResult.Get("text")
|
|
||||||
if contentTextResult.Type == gjson.String {
|
|
||||||
parts = append(parts, client.Part{Text: contentTextResult.String()})
|
|
||||||
}
|
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "image_url" {
|
|
||||||
imageURLResult := contentItemResult.Get("image_url.url")
|
|
||||||
if imageURLResult.Type == gjson.String {
|
|
||||||
imageURL := imageURLResult.String()
|
|
||||||
if len(imageURL) > 5 {
|
|
||||||
imageURLs := strings.SplitN(imageURL[5:], ";", 2)
|
|
||||||
if len(imageURLs) == 2 {
|
|
||||||
if len(imageURLs[1]) > 7 {
|
|
||||||
parts = append(parts, client.Part{InlineData: &client.InlineData{
|
|
||||||
MimeType: imageURLs[0],
|
|
||||||
Data: imageURLs[1][7:],
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "file" {
|
|
||||||
filenameResult := contentItemResult.Get("file.filename")
|
|
||||||
fileDataResult := contentItemResult.Get("file.file_data")
|
|
||||||
if filenameResult.Type == gjson.String && fileDataResult.Type == gjson.String {
|
|
||||||
filename := filenameResult.String()
|
|
||||||
splitFilename := strings.Split(filename, ".")
|
|
||||||
ext := splitFilename[len(splitFilename)-1]
|
|
||||||
|
|
||||||
mimeType, ok := MimeTypes[ext]
|
|
||||||
if !ok {
|
|
||||||
log.Warnf("Unknown file name extension '%s' at index %d, skipping file", ext, j)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts = append(parts, client.Part{InlineData: &client.InlineData{
|
|
||||||
MimeType: mimeType,
|
|
||||||
Data: fileDataResult.String(),
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contents = append(contents, client.Content{Role: "user", Parts: parts})
|
|
||||||
}
|
|
||||||
} else if roleResult.String() == "assistant" {
|
|
||||||
if contentResult.Type == gjson.String {
|
|
||||||
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: contentResult.String()}}})
|
|
||||||
} else if contentResult.IsObject() {
|
|
||||||
contentTypeResult := contentResult.Get("type")
|
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
|
||||||
contentTextResult := contentResult.Get("text")
|
|
||||||
if contentTextResult.Type == gjson.String {
|
|
||||||
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentTextResult.String()}}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !contentResult.Exists() || contentResult.Type == gjson.Null {
|
|
||||||
toolCallsResult := messageResult.Get("tool_calls")
|
|
||||||
if toolCallsResult.IsArray() {
|
|
||||||
tcsResult := toolCallsResult.Array()
|
|
||||||
for j := 0; j < len(tcsResult); j++ {
|
|
||||||
tcResult := tcsResult[j]
|
|
||||||
functionNameResult := tcResult.Get("function.name")
|
|
||||||
functionArguments := tcResult.Get("function.arguments")
|
|
||||||
if functionNameResult.Exists() && functionNameResult.Type == gjson.String && functionArguments.Exists() && functionArguments.Type == gjson.String {
|
|
||||||
var args map[string]any
|
|
||||||
err := json.Unmarshal([]byte(functionArguments.String()), &args)
|
|
||||||
if err == nil {
|
|
||||||
contents = append(contents, client.Content{
|
|
||||||
Role: "model", Parts: []client.Part{
|
|
||||||
{
|
|
||||||
FunctionCall: &client.FunctionCall{
|
|
||||||
Name: functionNameResult.String(),
|
|
||||||
Args: args,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if roleResult.String() == "tool" {
|
|
||||||
toolCallIDResult := messageResult.Get("tool_call_id")
|
|
||||||
if toolCallIDResult.Exists() && toolCallIDResult.Type == gjson.String {
|
|
||||||
if contentResult.Type == gjson.String {
|
|
||||||
functionResponse := client.FunctionResponse{Name: toolCallIDResult.String(), Response: map[string]interface{}{"result": contentResult.String()}}
|
|
||||||
contents = append(contents, client.Content{Role: "tool", Parts: []client.Part{{FunctionResponse: &functionResponse}}})
|
|
||||||
} else if contentResult.IsObject() {
|
|
||||||
contentTypeResult := contentResult.Get("type")
|
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
|
||||||
contentTextResult := contentResult.Get("text")
|
|
||||||
if contentTextResult.Type == gjson.String {
|
|
||||||
functionResponse := client.FunctionResponse{Name: toolCallIDResult.String(), Response: map[string]interface{}{"result": contentResult.String()}}
|
|
||||||
contents = append(contents, client.Content{Role: "tool", Parts: []client.Part{{FunctionResponse: &functionResponse}}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var tools []client.ToolDeclaration
|
|
||||||
toolsResult := gjson.GetBytes(rawJson, "tools")
|
|
||||||
if toolsResult.IsArray() {
|
|
||||||
tools = make([]client.ToolDeclaration, 1)
|
|
||||||
tools[0].FunctionDeclarations = make([]any, 0)
|
|
||||||
toolsResults := toolsResult.Array()
|
|
||||||
for i := 0; i < len(toolsResults); i++ {
|
|
||||||
toolTypeResult := toolsResults[i].Get("type")
|
|
||||||
if toolTypeResult.Type != gjson.String || toolTypeResult.String() != "function" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
functionTypeResult := toolsResults[i].Get("function")
|
|
||||||
if functionTypeResult.Exists() && functionTypeResult.IsObject() {
|
|
||||||
var functionDeclaration any
|
|
||||||
err := json.Unmarshal([]byte(functionTypeResult.Raw), &functionDeclaration)
|
|
||||||
if err == nil {
|
|
||||||
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, functionDeclaration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tools = make([]client.ToolDeclaration, 0)
|
|
||||||
}
|
|
||||||
return modelName, contents, tools
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleNonStreamingResponse handles non-streaming responses
|
|
||||||
func (h *APIHandlers) handleNonStreamingResponse(c *gin.Context, rawJson []byte) {
|
|
||||||
c.Header("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// Handle streaming manually
|
|
||||||
flusher, ok := c.Writer.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
|
||||||
Error: ErrorDetail{
|
|
||||||
Message: "Streaming not supported",
|
|
||||||
Type: "server_error",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
modelName, contents, tools := h.prepareRequest(rawJson)
|
|
||||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
|
||||||
var cliClient *client.Client
|
|
||||||
defer func() {
|
|
||||||
if cliClient != nil {
|
|
||||||
cliClient.RequestMutex.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Lock the mutex to update the last used page index
|
|
||||||
mutex.Lock()
|
|
||||||
startIndex := lastUsedClientIndex
|
|
||||||
currentIndex := (startIndex + 1) % len(h.cliClients)
|
|
||||||
lastUsedClientIndex = currentIndex
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
// Reorder the pages to start from the last used index
|
|
||||||
reorderedPages := make([]*client.Client, len(h.cliClients))
|
|
||||||
for i := 0; i < len(h.cliClients); i++ {
|
|
||||||
reorderedPages[i] = h.cliClients[(startIndex+1+i)%len(h.cliClients)]
|
|
||||||
}
|
|
||||||
|
|
||||||
locked := false
|
|
||||||
for i := 0; i < len(reorderedPages); i++ {
|
|
||||||
cliClient = reorderedPages[i]
|
|
||||||
if cliClient.RequestMutex.TryLock() {
|
|
||||||
locked = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !locked {
|
|
||||||
cliClient = h.cliClients[0]
|
|
||||||
cliClient.RequestMutex.Lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
|
|
||||||
jsonTemplate := `{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
|
|
||||||
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJson, modelName, contents, tools)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.Request.Context().Done():
|
|
||||||
if c.Request.Context().Err().Error() == "context canceled" {
|
|
||||||
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
|
|
||||||
cliCancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case chunk, okStream := <-respChan:
|
|
||||||
if !okStream {
|
|
||||||
_, _ = fmt.Fprint(c.Writer, jsonTemplate)
|
|
||||||
flusher.Flush()
|
|
||||||
cliCancel()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
jsonTemplate = h.convertCliToOpenAINonStream(jsonTemplate, chunk)
|
|
||||||
}
|
|
||||||
case err, okError := <-errChan:
|
|
||||||
if okError {
|
|
||||||
c.Status(err.StatusCode)
|
|
||||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
|
||||||
flusher.Flush()
|
|
||||||
// c.JSON(http.StatusInternalServerError, ErrorResponse{
|
|
||||||
// Error: ErrorDetail{
|
|
||||||
// Message: err.Error(),
|
|
||||||
// Type: "server_error",
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
cliCancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
_, _ = c.Writer.Write([]byte("\n"))
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStreamingResponse handles streaming responses
|
|
||||||
func (h *APIHandlers) handleStreamingResponse(c *gin.Context, rawJson []byte) {
|
|
||||||
c.Header("Content-Type", "text/event-stream")
|
|
||||||
c.Header("Cache-Control", "no-cache")
|
|
||||||
c.Header("Connection", "keep-alive")
|
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
|
||||||
|
|
||||||
// Handle streaming manually
|
|
||||||
flusher, ok := c.Writer.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
|
||||||
Error: ErrorDetail{
|
|
||||||
Message: "Streaming not supported",
|
|
||||||
Type: "server_error",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelName, contents, tools := h.prepareRequest(rawJson)
|
|
||||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
|
||||||
var cliClient *client.Client
|
|
||||||
defer func() {
|
|
||||||
if cliClient != nil {
|
|
||||||
cliClient.RequestMutex.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Lock the mutex to update the last used page index
|
|
||||||
mutex.Lock()
|
|
||||||
startIndex := lastUsedClientIndex
|
|
||||||
currentIndex := (startIndex + 1) % len(h.cliClients)
|
|
||||||
lastUsedClientIndex = currentIndex
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
// Reorder the pages to start from the last used index
|
|
||||||
reorderedPages := make([]*client.Client, len(h.cliClients))
|
|
||||||
for i := 0; i < len(h.cliClients); i++ {
|
|
||||||
reorderedPages[i] = h.cliClients[(startIndex+1+i)%len(h.cliClients)]
|
|
||||||
}
|
|
||||||
|
|
||||||
locked := false
|
|
||||||
for i := 0; i < len(reorderedPages); i++ {
|
|
||||||
cliClient = reorderedPages[i]
|
|
||||||
if cliClient.RequestMutex.TryLock() {
|
|
||||||
locked = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !locked {
|
|
||||||
cliClient = h.cliClients[0]
|
|
||||||
cliClient.RequestMutex.Lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
|
|
||||||
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJson, modelName, contents, tools)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.Request.Context().Done():
|
|
||||||
if c.Request.Context().Err().Error() == "context canceled" {
|
|
||||||
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
|
|
||||||
cliCancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case chunk, okStream := <-respChan:
|
|
||||||
if !okStream {
|
|
||||||
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
|
||||||
flusher.Flush()
|
|
||||||
cliCancel()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
openAIFormat := h.convertCliToOpenAI(chunk)
|
|
||||||
if openAIFormat != "" {
|
|
||||||
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", openAIFormat)
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case err, okError := <-errChan:
|
|
||||||
if okError {
|
|
||||||
c.Status(err.StatusCode)
|
|
||||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
|
||||||
flusher.Flush()
|
|
||||||
// c.JSON(http.StatusInternalServerError, ErrorResponse{
|
|
||||||
// Error: ErrorDetail{
|
|
||||||
// Message: err.Error(),
|
|
||||||
// Type: "server_error",
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
cliCancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
_, _ = c.Writer.Write([]byte(": CLI-PROXY-API PROCESSING\n\n"))
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *APIHandlers) convertCliToOpenAI(rawJson []byte) string {
|
|
||||||
// log.Debugf(string(rawJson))
|
|
||||||
template := `{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
|
|
||||||
|
|
||||||
modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion")
|
|
||||||
if modelVersionResult.Exists() && modelVersionResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "model", modelVersionResult.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
createTimeResult := gjson.GetBytes(rawJson, "response.createTime")
|
|
||||||
if createTimeResult.Exists() && createTimeResult.Type == gjson.String {
|
|
||||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
|
||||||
var unixTimestamp int64
|
|
||||||
if err == nil {
|
|
||||||
unixTimestamp = t.Unix()
|
|
||||||
} else {
|
|
||||||
unixTimestamp = time.Now().Unix()
|
|
||||||
}
|
|
||||||
template, _ = sjson.Set(template, "created", unixTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
responseIdResult := gjson.GetBytes(rawJson, "response.responseId")
|
|
||||||
if responseIdResult.Exists() && responseIdResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "id", responseIdResult.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason")
|
|
||||||
if finishReasonResult.Exists() && finishReasonResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
|
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
usageResult := gjson.GetBytes(rawJson, "response.usageMetadata")
|
|
||||||
candidatesTokenCountResult := usageResult.Get("candidatesTokenCount")
|
|
||||||
if candidatesTokenCountResult.Exists() && candidatesTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
totalTokenCountResult := usageResult.Get("totalTokenCount")
|
|
||||||
if totalTokenCountResult.Exists() && totalTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
thoughtsTokenCountResult := usageResult.Get("thoughtsTokenCount")
|
|
||||||
promptTokenCountResult := usageResult.Get("promptTokenCount")
|
|
||||||
if promptTokenCountResult.Exists() && promptTokenCountResult.Type == gjson.Number {
|
|
||||||
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int()+thoughtsTokenCountResult.Int())
|
|
||||||
} else {
|
|
||||||
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
|
|
||||||
partResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts.0")
|
|
||||||
partTextResult := partResult.Get("text")
|
|
||||||
functionCallResult := partResult.Get("functionCall")
|
|
||||||
|
|
||||||
if partTextResult.Exists() && partTextResult.Type == gjson.String {
|
|
||||||
partThoughtResult := partResult.Get("thought")
|
|
||||||
if partThoughtResult.Exists() && partThoughtResult.Type == gjson.True {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", partTextResult.String())
|
|
||||||
} else {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.content", partTextResult.String())
|
|
||||||
}
|
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
|
||||||
} else if functionCallResult.Exists() {
|
|
||||||
functionCallTemplate := `[{"id": "","type": "function","function": {"name": "","arguments": ""}}]`
|
|
||||||
fcNameResult := functionCallResult.Get("name")
|
|
||||||
if fcNameResult.Exists() && fcNameResult.Type == gjson.String {
|
|
||||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.id", fcNameResult.String())
|
|
||||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.function.name", fcNameResult.String())
|
|
||||||
}
|
|
||||||
fcArgsResult := functionCallResult.Get("args")
|
|
||||||
if fcArgsResult.Exists() && fcArgsResult.IsObject() {
|
|
||||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.function.arguments", fcArgsResult.Raw)
|
|
||||||
}
|
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", functionCallTemplate)
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return template
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *APIHandlers) convertCliToOpenAINonStream(template string, rawJson []byte) string {
|
|
||||||
modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion")
|
|
||||||
if modelVersionResult.Exists() && modelVersionResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "model", modelVersionResult.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
createTimeResult := gjson.GetBytes(rawJson, "response.createTime")
|
|
||||||
if createTimeResult.Exists() && createTimeResult.Type == gjson.String {
|
|
||||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
|
||||||
var unixTimestamp int64
|
|
||||||
if err == nil {
|
|
||||||
unixTimestamp = t.Unix()
|
|
||||||
} else {
|
|
||||||
unixTimestamp = time.Now().Unix()
|
|
||||||
}
|
|
||||||
template, _ = sjson.Set(template, "created", unixTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
responseIdResult := gjson.GetBytes(rawJson, "response.responseId")
|
|
||||||
if responseIdResult.Exists() && responseIdResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "id", responseIdResult.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason")
|
|
||||||
if finishReasonResult.Exists() && finishReasonResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
|
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
usageResult := gjson.GetBytes(rawJson, "response.usageMetadata")
|
|
||||||
candidatesTokenCountResult := usageResult.Get("candidatesTokenCount")
|
|
||||||
if candidatesTokenCountResult.Exists() && candidatesTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
totalTokenCountResult := usageResult.Get("totalTokenCount")
|
|
||||||
if totalTokenCountResult.Exists() && totalTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
thoughtsTokenCountResult := usageResult.Get("thoughtsTokenCount")
|
|
||||||
promptTokenCountResult := usageResult.Get("promptTokenCount")
|
|
||||||
if promptTokenCountResult.Exists() && promptTokenCountResult.Type == gjson.Number {
|
|
||||||
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int()+thoughtsTokenCountResult.Int())
|
|
||||||
} else {
|
|
||||||
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
|
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCountResult.Int())
|
|
||||||
}
|
|
||||||
|
|
||||||
partResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts.0")
|
|
||||||
partTextResult := partResult.Get("text")
|
|
||||||
functionCallResult := partResult.Get("functionCall")
|
|
||||||
|
|
||||||
if partTextResult.Exists() && partTextResult.Type == gjson.String {
|
|
||||||
partThoughtResult := partResult.Get("thought")
|
|
||||||
if partThoughtResult.Exists() && partThoughtResult.Type == gjson.True {
|
|
||||||
reasoningContentResult := gjson.Get(template, "choices.0.message.reasoning_content")
|
|
||||||
if reasoningContentResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", reasoningContentResult.String()+partTextResult.String())
|
|
||||||
} else {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", partTextResult.String())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reasoningContentResult := gjson.Get(template, "choices.0.message.content")
|
|
||||||
if reasoningContentResult.Type == gjson.String {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.message.content", reasoningContentResult.String()+partTextResult.String())
|
|
||||||
} else {
|
|
||||||
template, _ = sjson.Set(template, "choices.0.message.content", partTextResult.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
|
||||||
} else if functionCallResult.Exists() {
|
|
||||||
toolCallsResult := gjson.Get(template, "choices.0.message.tool_calls")
|
|
||||||
if !toolCallsResult.Exists() || toolCallsResult.Type == gjson.Null {
|
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
|
|
||||||
fcNameResult := functionCallResult.Get("name")
|
|
||||||
if fcNameResult.Exists() && fcNameResult.Type == gjson.String {
|
|
||||||
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fcNameResult.String())
|
|
||||||
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", fcNameResult.String())
|
|
||||||
}
|
|
||||||
fcArgsResult := functionCallResult.Get("args")
|
|
||||||
if fcArgsResult.Exists() && fcArgsResult.IsObject() {
|
|
||||||
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
|
|
||||||
}
|
|
||||||
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallItemTemplate)
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return template
|
|
||||||
}
|
|
||||||
237
internal/api/handlers/claude/code_handlers.go
Normal file
237
internal/api/handlers/claude/code_handlers.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// Package claude provides HTTP handlers for Claude API code-related functionality.
|
||||||
|
// This package implements Claude-compatible streaming chat completions with sophisticated
|
||||||
|
// client rotation and quota management systems to ensure high availability and optimal
|
||||||
|
// resource utilization across multiple backend clients. It handles request translation
|
||||||
|
// between Claude API format and the underlying Gemini backend, providing seamless
|
||||||
|
// API compatibility while maintaining robust error handling and connection management.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||||
|
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaudeCodeAPIHandler contains the handlers for Claude API endpoints.
|
||||||
|
// It holds a pool of clients to interact with the backend service.
|
||||||
|
type ClaudeCodeAPIHandler struct {
|
||||||
|
*handlers.BaseAPIHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClaudeCodeAPIHandler creates a new Claude API handlers instance.
|
||||||
|
// It takes an BaseAPIHandler instance as input and returns a ClaudeCodeAPIHandler.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - apiHandlers: The base API handler instance.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ClaudeCodeAPIHandler: A new Claude code API handler instance.
|
||||||
|
func NewClaudeCodeAPIHandler(apiHandlers *handlers.BaseAPIHandler) *ClaudeCodeAPIHandler {
|
||||||
|
return &ClaudeCodeAPIHandler{
|
||||||
|
BaseAPIHandler: apiHandlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerType returns the identifier for this handler implementation.
|
||||||
|
func (h *ClaudeCodeAPIHandler) HandlerType() string {
|
||||||
|
return Claude
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models returns a list of models supported by this handler.
|
||||||
|
func (h *ClaudeCodeAPIHandler) Models() []map[string]any {
|
||||||
|
// Get dynamic models from the global registry
|
||||||
|
modelRegistry := registry.GetGlobalRegistry()
|
||||||
|
return modelRegistry.GetAvailableModels("claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeMessages handles Claude-compatible streaming chat completions.
|
||||||
|
// This function implements a sophisticated client rotation and quota management system
|
||||||
|
// to ensure high availability and optimal resource utilization across multiple backend clients.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request.
|
||||||
|
func (h *ClaudeCodeAPIHandler) ClaudeMessages(c *gin.Context) {
|
||||||
|
// Extract raw JSON data from the incoming request
|
||||||
|
rawJSON, err := c.GetRawData()
|
||||||
|
// If data retrieval fails, return a 400 Bad Request error.
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client requested a streaming response.
|
||||||
|
streamResult := gjson.GetBytes(rawJSON, "stream")
|
||||||
|
if !streamResult.Exists() || streamResult.Type == gjson.False {
|
||||||
|
h.handleNonStreamingResponse(c, rawJSON)
|
||||||
|
} else {
|
||||||
|
h.handleStreamingResponse(c, rawJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeMessages handles Claude-compatible streaming chat completions.
|
||||||
|
// This function implements a sophisticated client rotation and quota management system
|
||||||
|
// to ensure high availability and optimal resource utilization across multiple backend clients.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request.
|
||||||
|
func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) {
|
||||||
|
// Extract raw JSON data from the incoming request
|
||||||
|
rawJSON, err := c.GetRawData()
|
||||||
|
// If data retrieval fails, return a 400 Bad Request error.
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
alt := h.GetAlt(c)
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
|
||||||
|
resp, errMsg := h.ExecuteCountWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeModels handles the Claude models listing endpoint.
|
||||||
|
// It returns a JSON response containing available Claude models and their specifications.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request.
|
||||||
|
func (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": h.Models(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamingResponse handles non-streaming content generation requests for Claude models.
|
||||||
|
// This function processes the request synchronously and returns the complete generated
|
||||||
|
// response in a single API call. It supports various generation parameters and
|
||||||
|
// response formats.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request
|
||||||
|
// - modelName: The name of the Gemini model to use for content generation
|
||||||
|
// - rawJSON: The raw JSON request body containing generation parameters and content
|
||||||
|
func (h *ClaudeCodeAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
alt := h.GetAlt(c)
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
|
||||||
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamingResponse streams Claude-compatible responses backed by Gemini.
|
||||||
|
// It sets up SSE, selects a backend client with rotation/quota logic,
|
||||||
|
// forwards chunks, and translates them to Claude CLI format.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request.
|
||||||
|
// - rawJSON: The raw JSON request body.
|
||||||
|
func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
// Set up Server-Sent Events (SSE) headers for streaming response
|
||||||
|
// These headers are essential for maintaining a persistent connection
|
||||||
|
// and enabling real-time streaming of chat completions
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
// Get the http.Flusher interface to manually flush the response.
|
||||||
|
// This is crucial for streaming as it allows immediate sending of data chunks
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Streaming not supported",
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
|
||||||
|
// Create a cancellable context for the backend client request
|
||||||
|
// This allows proper cleanup and cancellation of ongoing requests
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
|
||||||
|
dataChan, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
||||||
|
h.forwardClaudeStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
cancel(c.Request.Context().Err())
|
||||||
|
return
|
||||||
|
case chunk, ok := <-data:
|
||||||
|
if !ok {
|
||||||
|
flusher.Flush()
|
||||||
|
cancel(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(chunk, []byte("event:")) {
|
||||||
|
_, _ = c.Writer.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = c.Writer.Write(chunk)
|
||||||
|
_, _ = c.Writer.Write([]byte("\n"))
|
||||||
|
|
||||||
|
flusher.Flush()
|
||||||
|
case errMsg, ok := <-errs:
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
var execErr error
|
||||||
|
if errMsg != nil {
|
||||||
|
execErr = errMsg.Error
|
||||||
|
}
|
||||||
|
cancel(execErr)
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
internal/api/handlers/gemini/gemini-cli_handlers.go
Normal file
227
internal/api/handlers/gemini/gemini-cli_handlers.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// Package gemini provides HTTP handlers for Gemini CLI API functionality.
|
||||||
|
// This package implements handlers that process CLI-specific requests for Gemini API operations,
|
||||||
|
// including content generation and streaming content generation endpoints.
|
||||||
|
// The handlers restrict access to localhost only and manage communication with the backend service.
|
||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||||
|
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiCLIAPIHandler contains the handlers for Gemini CLI API endpoints.
|
||||||
|
// It holds a pool of clients to interact with the backend service.
|
||||||
|
type GeminiCLIAPIHandler struct {
|
||||||
|
*handlers.BaseAPIHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeminiCLIAPIHandler creates a new Gemini CLI API handlers instance.
|
||||||
|
// It takes an BaseAPIHandler instance as input and returns a GeminiCLIAPIHandler.
|
||||||
|
func NewGeminiCLIAPIHandler(apiHandlers *handlers.BaseAPIHandler) *GeminiCLIAPIHandler {
|
||||||
|
return &GeminiCLIAPIHandler{
|
||||||
|
BaseAPIHandler: apiHandlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerType returns the type of this handler.
|
||||||
|
func (h *GeminiCLIAPIHandler) HandlerType() string {
|
||||||
|
return GeminiCLI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models returns a list of models supported by this handler.
|
||||||
|
func (h *GeminiCLIAPIHandler) Models() []map[string]any {
|
||||||
|
return make([]map[string]any, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIHandler handles CLI-specific requests for Gemini API operations.
|
||||||
|
// It restricts access to localhost only and routes requests to appropriate internal handlers.
|
||||||
|
func (h *GeminiCLIAPIHandler) CLIHandler(c *gin.Context) {
|
||||||
|
if !strings.HasPrefix(c.Request.RemoteAddr, "127.0.0.1:") {
|
||||||
|
c.JSON(http.StatusForbidden, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "CLI reply only allow local access",
|
||||||
|
Type: "forbidden",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawJSON, _ := c.GetRawData()
|
||||||
|
requestRawURI := c.Request.URL.Path
|
||||||
|
|
||||||
|
if requestRawURI == "/v1internal:generateContent" {
|
||||||
|
h.handleInternalGenerateContent(c, rawJSON)
|
||||||
|
} else if requestRawURI == "/v1internal:streamGenerateContent" {
|
||||||
|
h.handleInternalStreamGenerateContent(c, rawJSON)
|
||||||
|
} else {
|
||||||
|
reqBody := bytes.NewBuffer(rawJSON)
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("https://cloudcode-pa.googleapis.com%s", c.Request.URL.RequestURI()), reqBody)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range c.Request.Header {
|
||||||
|
req.Header[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := util.SetProxy(h.Cfg, &http.Client{})
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
defer func() {
|
||||||
|
if err = resp.Body.Close(); err != nil {
|
||||||
|
log.Printf("warn: failed to close response body: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: string(bodyBytes),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for key, value := range resp.Header {
|
||||||
|
c.Header(key, value[0])
|
||||||
|
}
|
||||||
|
output, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to read response body: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(output)
|
||||||
|
c.Set("API_RESPONSE", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInternalStreamGenerateContent handles streaming content generation requests.
|
||||||
|
// It sets up a server-sent event stream and forwards the request to the backend client.
|
||||||
|
// The function continuously proxies response chunks from the backend to the client.
|
||||||
|
func (h *GeminiCLIAPIHandler) handleInternalStreamGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||||
|
alt := h.GetAlt(c)
|
||||||
|
|
||||||
|
if alt == "" {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the http.Flusher interface to manually flush the response.
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Streaming not supported",
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||||
|
modelName := modelResult.String()
|
||||||
|
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
dataChan, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
||||||
|
h.forwardCLIStream(c, flusher, "", func(err error) { cliCancel(err) }, dataChan, errChan)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInternalGenerateContent handles non-streaming content generation requests.
|
||||||
|
// It sends a request to the backend client and proxies the entire response back to the client at once.
|
||||||
|
func (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||||
|
modelName := modelResult.String()
|
||||||
|
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *GeminiCLIAPIHandler) forwardCLIStream(c *gin.Context, flusher http.Flusher, alt string, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
cancel(c.Request.Context().Err())
|
||||||
|
return
|
||||||
|
case chunk, ok := <-data:
|
||||||
|
if !ok {
|
||||||
|
cancel(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alt == "" {
|
||||||
|
if bytes.Equal(chunk, []byte("data: [DONE]")) || bytes.Equal(chunk, []byte("[DONE]")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.HasPrefix(chunk, []byte("data:")) {
|
||||||
|
_, _ = c.Writer.Write([]byte("data: "))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = c.Writer.Write(chunk)
|
||||||
|
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = c.Writer.Write(chunk)
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case errMsg, ok := <-errs:
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
var execErr error
|
||||||
|
if errMsg != nil {
|
||||||
|
execErr = errMsg.Error
|
||||||
|
}
|
||||||
|
cancel(execErr)
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
internal/api/handlers/gemini/gemini_handlers.go
Normal file
297
internal/api/handlers/gemini/gemini_handlers.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// Package gemini provides HTTP handlers for Gemini API endpoints.
|
||||||
|
// This package implements handlers for managing Gemini model operations including
|
||||||
|
// model listing, content generation, streaming content generation, and token counting.
|
||||||
|
// It serves as a proxy layer between clients and the Gemini backend service,
|
||||||
|
// handling request translation, client management, and response processing.
|
||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||||
|
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiAPIHandler contains the handlers for Gemini API endpoints.
|
||||||
|
// It holds a pool of clients to interact with the backend service.
|
||||||
|
type GeminiAPIHandler struct {
|
||||||
|
*handlers.BaseAPIHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeminiAPIHandler creates a new Gemini API handlers instance.
|
||||||
|
// It takes an BaseAPIHandler instance as input and returns a GeminiAPIHandler.
|
||||||
|
func NewGeminiAPIHandler(apiHandlers *handlers.BaseAPIHandler) *GeminiAPIHandler {
|
||||||
|
return &GeminiAPIHandler{
|
||||||
|
BaseAPIHandler: apiHandlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerType returns the identifier for this handler implementation.
|
||||||
|
func (h *GeminiAPIHandler) HandlerType() string {
|
||||||
|
return Gemini
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models returns the Gemini-compatible model metadata supported by this handler.
|
||||||
|
func (h *GeminiAPIHandler) Models() []map[string]any {
|
||||||
|
// Get dynamic models from the global registry
|
||||||
|
modelRegistry := registry.GetGlobalRegistry()
|
||||||
|
return modelRegistry.GetAvailableModels("gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeminiModels handles the Gemini models listing endpoint.
|
||||||
|
// It returns a JSON response containing available Gemini models and their specifications.
|
||||||
|
func (h *GeminiAPIHandler) GeminiModels(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"models": h.Models(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeminiGetHandler handles GET requests for specific Gemini model information.
|
||||||
|
// It returns detailed information about a specific Gemini model based on the action parameter.
|
||||||
|
func (h *GeminiAPIHandler) GeminiGetHandler(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Action string `uri:"action" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindUri(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch request.Action {
|
||||||
|
case "gemini-2.5-pro":
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"name": "models/gemini-2.5-pro",
|
||||||
|
"version": "2.5",
|
||||||
|
"displayName": "Gemini 2.5 Pro",
|
||||||
|
"description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
|
||||||
|
"inputTokenLimit": 1048576,
|
||||||
|
"outputTokenLimit": 65536,
|
||||||
|
"supportedGenerationMethods": []string{
|
||||||
|
"generateContent",
|
||||||
|
"countTokens",
|
||||||
|
"createCachedContent",
|
||||||
|
"batchGenerateContent",
|
||||||
|
},
|
||||||
|
"temperature": 1,
|
||||||
|
"topP": 0.95,
|
||||||
|
"topK": 64,
|
||||||
|
"maxTemperature": 2,
|
||||||
|
"thinking": true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
case "gemini-2.5-flash":
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"name": "models/gemini-2.5-flash",
|
||||||
|
"version": "001",
|
||||||
|
"displayName": "Gemini 2.5 Flash",
|
||||||
|
"description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
|
||||||
|
"inputTokenLimit": 1048576,
|
||||||
|
"outputTokenLimit": 65536,
|
||||||
|
"supportedGenerationMethods": []string{
|
||||||
|
"generateContent",
|
||||||
|
"countTokens",
|
||||||
|
"createCachedContent",
|
||||||
|
"batchGenerateContent",
|
||||||
|
},
|
||||||
|
"temperature": 1,
|
||||||
|
"topP": 0.95,
|
||||||
|
"topK": 64,
|
||||||
|
"maxTemperature": 2,
|
||||||
|
"thinking": true,
|
||||||
|
})
|
||||||
|
case "gpt-5":
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"name": "gpt-5",
|
||||||
|
"version": "001",
|
||||||
|
"displayName": "GPT 5",
|
||||||
|
"description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.",
|
||||||
|
"inputTokenLimit": 400000,
|
||||||
|
"outputTokenLimit": 128000,
|
||||||
|
"supportedGenerationMethods": []string{
|
||||||
|
"generateContent",
|
||||||
|
},
|
||||||
|
"temperature": 1,
|
||||||
|
"topP": 0.95,
|
||||||
|
"topK": 64,
|
||||||
|
"maxTemperature": 2,
|
||||||
|
"thinking": true,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusNotFound, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Not Found",
|
||||||
|
Type: "not_found",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeminiHandler handles POST requests for Gemini API operations.
|
||||||
|
// It routes requests to appropriate handlers based on the action parameter (model:method format).
|
||||||
|
func (h *GeminiAPIHandler) GeminiHandler(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Action string `uri:"action" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindUri(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := strings.Split(request.Action, ":")
|
||||||
|
if len(action) != 2 {
|
||||||
|
c.JSON(http.StatusNotFound, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("%s not found.", c.Request.URL.Path),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
method := action[1]
|
||||||
|
rawJSON, _ := c.GetRawData()
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "generateContent":
|
||||||
|
h.handleGenerateContent(c, action[0], rawJSON)
|
||||||
|
case "streamGenerateContent":
|
||||||
|
h.handleStreamGenerateContent(c, action[0], rawJSON)
|
||||||
|
case "countTokens":
|
||||||
|
h.handleCountTokens(c, action[0], rawJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamGenerateContent handles streaming content generation requests for Gemini models.
|
||||||
|
// This function establishes a Server-Sent Events connection and streams the generated content
|
||||||
|
// back to the client in real-time. It supports both SSE format and direct streaming based
|
||||||
|
// on the 'alt' query parameter.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request
|
||||||
|
// - modelName: The name of the Gemini model to use for content generation
|
||||||
|
// - rawJSON: The raw JSON request body containing generation parameters
|
||||||
|
func (h *GeminiAPIHandler) handleStreamGenerateContent(c *gin.Context, modelName string, rawJSON []byte) {
|
||||||
|
alt := h.GetAlt(c)
|
||||||
|
|
||||||
|
if alt == "" {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the http.Flusher interface to manually flush the response.
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Streaming not supported",
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
dataChan, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
h.forwardGeminiStream(c, flusher, alt, func(err error) { cliCancel(err) }, dataChan, errChan)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCountTokens handles token counting requests for Gemini models.
|
||||||
|
// This function counts the number of tokens in the provided content without
|
||||||
|
// generating a response. It's useful for quota management and content validation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request
|
||||||
|
// - modelName: The name of the Gemini model to use for token counting
|
||||||
|
// - rawJSON: The raw JSON request body containing the content to count
|
||||||
|
func (h *GeminiAPIHandler) handleCountTokens(c *gin.Context, modelName string, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
alt := h.GetAlt(c)
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
resp, errMsg := h.ExecuteCountWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateContent handles non-streaming content generation requests for Gemini models.
|
||||||
|
// This function processes the request synchronously and returns the complete generated
|
||||||
|
// response in a single API call. It supports various generation parameters and
|
||||||
|
// response formats.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context for the request
|
||||||
|
// - modelName: The name of the Gemini model to use for content generation
|
||||||
|
// - rawJSON: The raw JSON request body containing generation parameters and content
|
||||||
|
func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName string, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
alt := h.GetAlt(c)
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *GeminiAPIHandler) forwardGeminiStream(c *gin.Context, flusher http.Flusher, alt string, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
cancel(c.Request.Context().Err())
|
||||||
|
return
|
||||||
|
case chunk, ok := <-data:
|
||||||
|
if !ok {
|
||||||
|
cancel(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alt == "" {
|
||||||
|
_, _ = c.Writer.Write([]byte("data: "))
|
||||||
|
_, _ = c.Writer.Write(chunk)
|
||||||
|
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = c.Writer.Write(chunk)
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case errMsg, ok := <-errs:
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
var execErr error
|
||||||
|
if errMsg != nil {
|
||||||
|
execErr = errMsg.Error
|
||||||
|
}
|
||||||
|
cancel(execErr)
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
267
internal/api/handlers/handlers.go
Normal file
267
internal/api/handlers/handlers.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// Package handlers provides core API handler functionality for the CLI Proxy API server.
|
||||||
|
// It includes common types, client management, load balancing, and error handling
|
||||||
|
// shared across all API endpoint handlers (OpenAI, Claude, Gemini).
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorResponse represents a standard error response format for the API.
|
||||||
|
// It contains a single ErrorDetail field.
|
||||||
|
type ErrorResponse struct {
|
||||||
|
// Error contains detailed information about the error that occurred.
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail provides specific information about an error that occurred.
|
||||||
|
// It includes a human-readable message, an error type, and an optional error code.
|
||||||
|
type ErrorDetail struct {
|
||||||
|
// Message is a human-readable message providing more details about the error.
|
||||||
|
Message string `json:"message"`
|
||||||
|
|
||||||
|
// Type is the category of error that occurred (e.g., "invalid_request_error").
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
// Code is a short code identifying the error, if applicable.
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseAPIHandler contains the handlers for API endpoints.
|
||||||
|
// It holds a pool of clients to interact with the backend service and manages
|
||||||
|
// load balancing, client selection, and configuration.
|
||||||
|
type BaseAPIHandler struct {
|
||||||
|
// AuthManager manages auth lifecycle and execution in the new architecture.
|
||||||
|
AuthManager *coreauth.Manager
|
||||||
|
|
||||||
|
// Cfg holds the current application configuration.
|
||||||
|
Cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBaseAPIHandlers creates a new API handlers instance.
|
||||||
|
// It takes a slice of clients and configuration as input.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cliClients: A slice of AI service clients
|
||||||
|
// - cfg: The application configuration
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *BaseAPIHandler: A new API handlers instance
|
||||||
|
func NewBaseAPIHandlers(cfg *config.Config, authManager *coreauth.Manager) *BaseAPIHandler {
|
||||||
|
return &BaseAPIHandler{
|
||||||
|
Cfg: cfg,
|
||||||
|
AuthManager: authManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateClients updates the handlers' client list and configuration.
|
||||||
|
// This method is called when the configuration or authentication tokens change.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - clients: The new slice of AI service clients
|
||||||
|
// - cfg: The new application configuration
|
||||||
|
func (h *BaseAPIHandler) UpdateClients(cfg *config.Config) { h.Cfg = cfg }
|
||||||
|
|
||||||
|
// GetAlt extracts the 'alt' parameter from the request query string.
|
||||||
|
// It checks both 'alt' and '$alt' parameters and returns the appropriate value.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The alt parameter value, or empty string if it's "sse"
|
||||||
|
func (h *BaseAPIHandler) GetAlt(c *gin.Context) string {
|
||||||
|
var alt string
|
||||||
|
var hasAlt bool
|
||||||
|
alt, hasAlt = c.GetQuery("alt")
|
||||||
|
if !hasAlt {
|
||||||
|
alt, _ = c.GetQuery("$alt")
|
||||||
|
}
|
||||||
|
if alt == "sse" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return alt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContextWithCancel creates a new context with cancellation capabilities.
|
||||||
|
// It embeds the Gin context and the API handler into the new context for later use.
|
||||||
|
// The returned cancel function also handles logging the API response if request logging is enabled.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - handler: The API handler associated with the request.
|
||||||
|
// - c: The Gin context of the current request.
|
||||||
|
// - ctx: The parent context.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - context.Context: The new context with cancellation and embedded values.
|
||||||
|
// - APIHandlerCancelFunc: A function to cancel the context and log the response.
|
||||||
|
func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *gin.Context, ctx context.Context) (context.Context, APIHandlerCancelFunc) {
|
||||||
|
newCtx, cancel := context.WithCancel(ctx)
|
||||||
|
newCtx = context.WithValue(newCtx, "gin", c)
|
||||||
|
newCtx = context.WithValue(newCtx, "handler", handler)
|
||||||
|
return newCtx, func(params ...interface{}) {
|
||||||
|
if h.Cfg.RequestLog {
|
||||||
|
if len(params) == 1 {
|
||||||
|
data := params[0]
|
||||||
|
switch data.(type) {
|
||||||
|
case []byte:
|
||||||
|
c.Set("API_RESPONSE", data.([]byte))
|
||||||
|
case error:
|
||||||
|
c.Set("API_RESPONSE", []byte(data.(error).Error()))
|
||||||
|
case string:
|
||||||
|
c.Set("API_RESPONSE", []byte(data.(string)))
|
||||||
|
case bool:
|
||||||
|
case nil:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteWithAuthManager executes a non-streaming request via the core auth manager.
|
||||||
|
// This path is the only supported execution route.
|
||||||
|
func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||||
|
providers := util.GetProviderName(modelName, h.Cfg)
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||||
|
}
|
||||||
|
req := coreexecutor.Request{
|
||||||
|
Model: modelName,
|
||||||
|
Payload: cloneBytes(rawJSON),
|
||||||
|
}
|
||||||
|
opts := coreexecutor.Options{
|
||||||
|
Stream: false,
|
||||||
|
Alt: alt,
|
||||||
|
OriginalRequest: cloneBytes(rawJSON),
|
||||||
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
|
}
|
||||||
|
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err}
|
||||||
|
}
|
||||||
|
return cloneBytes(resp.Payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.
|
||||||
|
// This path is the only supported execution route.
|
||||||
|
func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||||
|
providers := util.GetProviderName(modelName, h.Cfg)
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||||
|
}
|
||||||
|
req := coreexecutor.Request{
|
||||||
|
Model: modelName,
|
||||||
|
Payload: cloneBytes(rawJSON),
|
||||||
|
}
|
||||||
|
opts := coreexecutor.Options{
|
||||||
|
Stream: false,
|
||||||
|
Alt: alt,
|
||||||
|
OriginalRequest: cloneBytes(rawJSON),
|
||||||
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
|
}
|
||||||
|
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err}
|
||||||
|
}
|
||||||
|
return cloneBytes(resp.Payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.
|
||||||
|
// This path is the only supported execution route.
|
||||||
|
func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
||||||
|
providers := util.GetProviderName(modelName, h.Cfg)
|
||||||
|
if len(providers) == 0 {
|
||||||
|
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||||
|
errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||||
|
close(errChan)
|
||||||
|
return nil, errChan
|
||||||
|
}
|
||||||
|
req := coreexecutor.Request{
|
||||||
|
Model: modelName,
|
||||||
|
Payload: cloneBytes(rawJSON),
|
||||||
|
}
|
||||||
|
opts := coreexecutor.Options{
|
||||||
|
Stream: true,
|
||||||
|
Alt: alt,
|
||||||
|
OriginalRequest: cloneBytes(rawJSON),
|
||||||
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
|
}
|
||||||
|
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
||||||
|
if err != nil {
|
||||||
|
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||||
|
errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err}
|
||||||
|
close(errChan)
|
||||||
|
return nil, errChan
|
||||||
|
}
|
||||||
|
dataChan := make(chan []byte)
|
||||||
|
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(dataChan)
|
||||||
|
defer close(errChan)
|
||||||
|
for chunk := range chunks {
|
||||||
|
if chunk.Err != nil {
|
||||||
|
errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: chunk.Err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(chunk.Payload) > 0 {
|
||||||
|
dataChan <- cloneBytes(chunk.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return dataChan, errChan
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBytes(src []byte) []byte {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(src))
|
||||||
|
copy(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
||||||
|
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if msg != nil && msg.StatusCode > 0 {
|
||||||
|
status = msg.StatusCode
|
||||||
|
}
|
||||||
|
c.Status(status)
|
||||||
|
if msg != nil && msg.Error != nil {
|
||||||
|
_, _ = c.Writer.Write([]byte(msg.Error.Error()))
|
||||||
|
} else {
|
||||||
|
_, _ = c.Writer.Write([]byte(http.StatusText(status)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) {
|
||||||
|
if h.Cfg.RequestLog {
|
||||||
|
if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
|
||||||
|
if apiResponseErrors, isExist := ginContext.Get("API_RESPONSE_ERROR"); isExist {
|
||||||
|
if slicesAPIResponseError, isOk := apiResponseErrors.([]*interfaces.ErrorMessage); isOk {
|
||||||
|
slicesAPIResponseError = append(slicesAPIResponseError, err)
|
||||||
|
ginContext.Set("API_RESPONSE_ERROR", slicesAPIResponseError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new response data entry
|
||||||
|
ginContext.Set("API_RESPONSE_ERROR", []*interfaces.ErrorMessage{err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIHandlerCancelFunc is a function type for canceling an API handler's context.
|
||||||
|
// It can optionally accept parameters, which are used for logging the response.
|
||||||
|
type APIHandlerCancelFunc func(params ...interface{})
|
||||||
964
internal/api/handlers/management/auth_files.go
Normal file
964
internal/api/handlers/management/auth_files.go
Normal file
@@ -0,0 +1,964 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||||
|
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||||
|
// legacy client removed
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
oauthStatus = make(map[string]string)
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
|
||||||
|
|
||||||
|
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
|
||||||
|
if len(meta) == 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
for _, key := range lastRefreshKeys {
|
||||||
|
if val, ok := meta[key]; ok {
|
||||||
|
if ts, ok1 := parseLastRefreshValue(val); ok1 {
|
||||||
|
return ts, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLastRefreshValue(v any) (time.Time, bool) {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(val)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if ts, err := time.Parse(layout, s); err == nil {
|
||||||
|
return ts.UTC(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
|
||||||
|
return time.Unix(unix, 0).UTC(), true
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if val <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(int64(val), 0).UTC(), true
|
||||||
|
case int64:
|
||||||
|
if val <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(val, 0).UTC(), true
|
||||||
|
case int:
|
||||||
|
if val <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(int64(val), 0).UTC(), true
|
||||||
|
case json.Number:
|
||||||
|
if i, err := val.Int64(); err == nil && i > 0 {
|
||||||
|
return time.Unix(i, 0).UTC(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// List auth files
|
||||||
|
func (h *Handler) ListAuthFiles(c *gin.Context) {
|
||||||
|
entries, err := os.ReadDir(h.cfg.AuthDir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files := make([]gin.H, 0)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, errInfo := e.Info(); errInfo == nil {
|
||||||
|
fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
|
||||||
|
|
||||||
|
// Read file to get type field
|
||||||
|
full := filepath.Join(h.cfg.AuthDir, name)
|
||||||
|
if data, errRead := os.ReadFile(full); errRead == nil {
|
||||||
|
typeValue := gjson.GetBytes(data, "type").String()
|
||||||
|
fileData["type"] = typeValue
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, fileData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"files": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download single auth file by name
|
||||||
|
func (h *Handler) DownloadAuthFile(c *gin.Context) {
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
|
c.JSON(400, gin.H{"error": "name must end with .json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full := filepath.Join(h.cfg.AuthDir, name)
|
||||||
|
data, err := os.ReadFile(full)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.JSON(404, gin.H{"error": "file not found"})
|
||||||
|
} else {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
|
||||||
|
c.Data(200, "application/json", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload auth file: multipart or raw JSON with ?name=
|
||||||
|
func (h *Handler) UploadAuthFile(c *gin.Context) {
|
||||||
|
if h.authManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if file, err := c.FormFile("file"); err == nil && file != nil {
|
||||||
|
name := filepath.Base(file.Filename)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
|
c.JSON(400, gin.H{"error": "file must be .json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := filepath.Join(h.cfg.AuthDir, name)
|
||||||
|
if !filepath.IsAbs(dst) {
|
||||||
|
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
||||||
|
dst = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, errRead := os.ReadFile(dst)
|
||||||
|
if errRead != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
|
||||||
|
c.JSON(500, gin.H{"error": errReg.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
|
c.JSON(400, gin.H{"error": "name must end with .json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
||||||
|
if !filepath.IsAbs(dst) {
|
||||||
|
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
||||||
|
dst = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete auth files: single by name or all
|
||||||
|
func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
||||||
|
if h.authManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
|
||||||
|
entries, err := os.ReadDir(h.cfg.AuthDir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := filepath.Join(h.cfg.AuthDir, name)
|
||||||
|
if !filepath.IsAbs(full) {
|
||||||
|
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
||||||
|
full = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = os.Remove(full); err == nil {
|
||||||
|
deleted++
|
||||||
|
h.disableAuth(ctx, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
||||||
|
if !filepath.IsAbs(full) {
|
||||||
|
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
||||||
|
full = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Remove(full); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.JSON(404, gin.H{"error": "file not found"})
|
||||||
|
} else {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.disableAuth(ctx, full)
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
|
||||||
|
if h.authManager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("auth path is empty")
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
var err error
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read auth file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata := make(map[string]any)
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
return fmt.Errorf("invalid auth file: %w", err)
|
||||||
|
}
|
||||||
|
provider, _ := metadata["type"].(string)
|
||||||
|
if provider == "" {
|
||||||
|
provider = "unknown"
|
||||||
|
}
|
||||||
|
label := provider
|
||||||
|
if email, ok := metadata["email"].(string); ok && email != "" {
|
||||||
|
label = email
|
||||||
|
}
|
||||||
|
lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
|
||||||
|
|
||||||
|
attr := map[string]string{
|
||||||
|
"path": path,
|
||||||
|
"source": path,
|
||||||
|
}
|
||||||
|
auth := &coreauth.Auth{
|
||||||
|
ID: path,
|
||||||
|
Provider: provider,
|
||||||
|
Label: label,
|
||||||
|
Status: coreauth.StatusActive,
|
||||||
|
Attributes: attr,
|
||||||
|
Metadata: metadata,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if hasLastRefresh {
|
||||||
|
auth.LastRefreshedAt = lastRefresh
|
||||||
|
}
|
||||||
|
if existing, ok := h.authManager.GetByID(path); ok {
|
||||||
|
auth.CreatedAt = existing.CreatedAt
|
||||||
|
if !hasLastRefresh {
|
||||||
|
auth.LastRefreshedAt = existing.LastRefreshedAt
|
||||||
|
}
|
||||||
|
auth.NextRefreshAfter = existing.NextRefreshAfter
|
||||||
|
auth.Runtime = existing.Runtime
|
||||||
|
_, err := h.authManager.Update(ctx, auth)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := h.authManager.Register(ctx, auth)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||||
|
if h.authManager == nil || id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if auth, ok := h.authManager.GetByID(id); ok {
|
||||||
|
auth.Disabled = true
|
||||||
|
auth.Status = coreauth.StatusDisabled
|
||||||
|
auth.StatusMessage = "removed via management API"
|
||||||
|
auth.UpdatedAt = time.Now()
|
||||||
|
_, _ = h.authManager.Update(ctx, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) saveTokenRecord(ctx context.Context, record *sdkAuth.TokenRecord) (string, error) {
|
||||||
|
if record == nil {
|
||||||
|
return "", fmt.Errorf("token record is nil")
|
||||||
|
}
|
||||||
|
store := h.tokenStore
|
||||||
|
if store == nil {
|
||||||
|
store = sdkAuth.GetTokenStore()
|
||||||
|
h.tokenStore = store
|
||||||
|
}
|
||||||
|
return store.Save(ctx, h.cfg, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fmt.Println("Initializing Claude authentication...")
|
||||||
|
|
||||||
|
// Generate PKCE codes
|
||||||
|
pkceCodes, err := claude.GeneratePKCECodes()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate PKCE codes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random state parameter
|
||||||
|
state, err := misc.GenerateRandomState()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Claude auth service
|
||||||
|
anthropicAuth := claude.NewClaudeAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL (then override redirect_uri to reuse server port)
|
||||||
|
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Override redirect_uri in authorization URL to current server port
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Helper: wait for callback file
|
||||||
|
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
|
||||||
|
waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
oauthStatus[state] = "Timeout waiting for OAuth callback"
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||||
|
}
|
||||||
|
data, errRead := os.ReadFile(path)
|
||||||
|
if errRead == nil {
|
||||||
|
var m map[string]string
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Waiting for authentication callback...")
|
||||||
|
// Wait up to 5 minutes
|
||||||
|
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
|
||||||
|
if errWait != nil {
|
||||||
|
authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errStr := resultMap["error"]; errStr != "" {
|
||||||
|
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(oauthErr))
|
||||||
|
oauthStatus[state] = "Bad request"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resultMap["state"] != state {
|
||||||
|
authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
oauthStatus[state] = "State code error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse code (Claude may append state after '#')
|
||||||
|
rawCode := resultMap["code"]
|
||||||
|
code := strings.Split(rawCode, "#")[0]
|
||||||
|
|
||||||
|
// Exchange code for tokens (replicate logic using updated redirect_uri)
|
||||||
|
// Extract client_id from the modified auth URL
|
||||||
|
clientID := ""
|
||||||
|
if u2, errP := url.Parse(authURL); errP == nil {
|
||||||
|
clientID = u2.Query().Get("client_id")
|
||||||
|
}
|
||||||
|
// Build request
|
||||||
|
bodyMap := map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"state": state,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": clientID,
|
||||||
|
"redirect_uri": "http://localhost:54545/callback",
|
||||||
|
"code_verifier": pkceCodes.CodeVerifier,
|
||||||
|
}
|
||||||
|
bodyJSON, _ := json.Marshal(bodyMap)
|
||||||
|
|
||||||
|
httpClient := util.SetProxy(h.cfg, &http.Client{})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", strings.NewReader(string(bodyJSON)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errDo)
|
||||||
|
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
||||||
|
oauthStatus[state] = "Failed to exchange authorization code for tokens"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("failed to close response body: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
oauthStatus[state] = fmt.Sprintf("token exchange failed with status %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Account struct {
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
|
} `json:"account"`
|
||||||
|
}
|
||||||
|
if errU := json.Unmarshal(respBody, &tResp); errU != nil {
|
||||||
|
log.Errorf("failed to parse token response: %v", errU)
|
||||||
|
oauthStatus[state] = "Failed to parse token response"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bundle := &claude.ClaudeAuthBundle{
|
||||||
|
TokenData: claude.ClaudeTokenData{
|
||||||
|
AccessToken: tResp.AccessToken,
|
||||||
|
RefreshToken: tResp.RefreshToken,
|
||||||
|
Email: tResp.Account.EmailAddress,
|
||||||
|
Expire: time.Now().Add(time.Duration(tResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage
|
||||||
|
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
|
||||||
|
record := &sdkAuth.TokenRecord{
|
||||||
|
Provider: "claude",
|
||||||
|
FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
||||||
|
Storage: tokenStorage,
|
||||||
|
Metadata: map[string]string{"email": tokenStorage.Email},
|
||||||
|
}
|
||||||
|
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||||
|
if errSave != nil {
|
||||||
|
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||||
|
oauthStatus[state] = "Failed to save authentication tokens"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||||
|
if bundle.APIKey != "" {
|
||||||
|
fmt.Println("API key obtained and saved")
|
||||||
|
}
|
||||||
|
fmt.Println("You can now use Claude services through this CLI")
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Optional project ID from query
|
||||||
|
projectID := c.Query("project_id")
|
||||||
|
|
||||||
|
fmt.Println("Initializing Google authentication...")
|
||||||
|
|
||||||
|
// OAuth2 configuration (mirrors internal/auth/gemini)
|
||||||
|
conf := &oauth2.Config{
|
||||||
|
ClientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||||
|
ClientSecret: "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",
|
||||||
|
RedirectURL: "http://localhost:8085/oauth2callback",
|
||||||
|
Scopes: []string{
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build authorization URL and return it immediately
|
||||||
|
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
||||||
|
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait for callback file written by server route
|
||||||
|
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
|
||||||
|
fmt.Println("Waiting for authentication callback...")
|
||||||
|
deadline := time.Now().Add(5 * time.Minute)
|
||||||
|
var authCode string
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
log.Error("oauth flow timed out")
|
||||||
|
oauthStatus[state] = "OAuth flow timed out"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data, errR := os.ReadFile(waitFile); errR == nil {
|
||||||
|
var m map[string]string
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
_ = os.Remove(waitFile)
|
||||||
|
if errStr := m["error"]; errStr != "" {
|
||||||
|
log.Errorf("Authentication failed: %s", errStr)
|
||||||
|
oauthStatus[state] = "Authentication failed"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authCode = m["code"]
|
||||||
|
if authCode == "" {
|
||||||
|
log.Errorf("Authentication failed: code not found")
|
||||||
|
oauthStatus[state] = "Authentication failed: code not found"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for token
|
||||||
|
token, err := conf.Exchange(ctx, authCode)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to exchange token: %v", err)
|
||||||
|
oauthStatus[state] = "Failed to exchange token"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
||||||
|
httpClient := conf.Client(ctx, token)
|
||||||
|
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
||||||
|
if errNewRequest != nil {
|
||||||
|
log.Errorf("Could not get user info: %v", errNewRequest)
|
||||||
|
oauthStatus[state] = "Could not get user info"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||||
|
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
log.Errorf("Failed to execute request: %v", errDo)
|
||||||
|
oauthStatus[state] = "Failed to execute request"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Printf("warn: failed to close response body: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
oauthStatus[state] = fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := gjson.GetBytes(bodyBytes, "email").String()
|
||||||
|
if email != "" {
|
||||||
|
fmt.Printf("Authenticated user email: %s\n", email)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Failed to get user email from token")
|
||||||
|
oauthStatus[state] = "Failed to get user email from token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal/unmarshal oauth2.Token to generic map and enrich fields
|
||||||
|
var ifToken map[string]any
|
||||||
|
jsonData, _ := json.Marshal(token)
|
||||||
|
if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
|
||||||
|
log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
|
||||||
|
oauthStatus[state] = "Failed to unmarshal token"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
|
||||||
|
ifToken["client_id"] = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||||
|
ifToken["client_secret"] = "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"
|
||||||
|
ifToken["scopes"] = []string{
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
}
|
||||||
|
ifToken["universe_domain"] = "googleapis.com"
|
||||||
|
|
||||||
|
ts := geminiAuth.GeminiTokenStorage{
|
||||||
|
Token: ifToken,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
|
||||||
|
gemAuth := geminiAuth.NewGeminiAuth()
|
||||||
|
_, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
|
||||||
|
if errGetClient != nil {
|
||||||
|
log.Fatalf("failed to get authenticated client: %v", errGetClient)
|
||||||
|
oauthStatus[state] = "Failed to get authenticated client"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Authentication successful.")
|
||||||
|
|
||||||
|
record := &sdkAuth.TokenRecord{
|
||||||
|
Provider: "gemini",
|
||||||
|
FileName: fmt.Sprintf("gemini-%s.json", ts.Email),
|
||||||
|
Storage: &ts,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"email": ts.Email,
|
||||||
|
"project_id": ts.ProjectID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||||
|
if errSave != nil {
|
||||||
|
log.Fatalf("Failed to save token to file: %v", errSave)
|
||||||
|
oauthStatus[state] = "Failed to save token to file"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Secure1PSID string `json:"secure_1psid"`
|
||||||
|
Secure1PSIDTS string `json:"secure_1psidts"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.Secure1PSID = strings.TrimSpace(payload.Secure1PSID)
|
||||||
|
payload.Secure1PSIDTS = strings.TrimSpace(payload.Secure1PSIDTS)
|
||||||
|
payload.Label = strings.TrimSpace(payload.Label)
|
||||||
|
if payload.Secure1PSID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psid is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.Secure1PSIDTS == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psidts is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.Label == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "label is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sha := sha256.New()
|
||||||
|
sha.Write([]byte(payload.Secure1PSID))
|
||||||
|
hash := hex.EncodeToString(sha.Sum(nil))
|
||||||
|
fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
|
||||||
|
|
||||||
|
tokenStorage := &geminiAuth.GeminiWebTokenStorage{
|
||||||
|
Secure1PSID: payload.Secure1PSID,
|
||||||
|
Secure1PSIDTS: payload.Secure1PSIDTS,
|
||||||
|
Label: payload.Label,
|
||||||
|
}
|
||||||
|
// Provide a stable label (gemini-web-<hash>) for logging and identification
|
||||||
|
tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
|
||||||
|
|
||||||
|
record := &sdkAuth.TokenRecord{
|
||||||
|
Provider: "gemini-web",
|
||||||
|
FileName: fileName,
|
||||||
|
Storage: tokenStorage,
|
||||||
|
}
|
||||||
|
|
||||||
|
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||||
|
if errSave != nil {
|
||||||
|
log.Errorf("Failed to save Gemini Web token: %v", errSave)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully saved Gemini Web token to: %s\n", savedPath)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "file": filepath.Base(savedPath)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fmt.Println("Initializing Codex authentication...")
|
||||||
|
|
||||||
|
// Generate PKCE codes
|
||||||
|
pkceCodes, err := codex.GeneratePKCECodes()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate PKCE codes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random state parameter
|
||||||
|
state, err := misc.GenerateRandomState()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Codex auth service
|
||||||
|
openaiAuth := codex.NewCodexAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait for callback file
|
||||||
|
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
|
||||||
|
deadline := time.Now().Add(5 * time.Minute)
|
||||||
|
var code string
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
oauthStatus[state] = "Timeout waiting for OAuth callback"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data, errR := os.ReadFile(waitFile); errR == nil {
|
||||||
|
var m map[string]string
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
_ = os.Remove(waitFile)
|
||||||
|
if errStr := m["error"]; errStr != "" {
|
||||||
|
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(oauthErr))
|
||||||
|
oauthStatus[state] = "Bad Request"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m["state"] != state {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
|
||||||
|
oauthStatus[state] = "State code error"
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code = m["code"]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Authorization code received, exchanging for tokens...")
|
||||||
|
// Extract client_id from authURL
|
||||||
|
clientID := ""
|
||||||
|
if u2, errP := url.Parse(authURL); errP == nil {
|
||||||
|
clientID = u2.Query().Get("client_id")
|
||||||
|
}
|
||||||
|
// Exchange code for tokens with redirect equal to mgmtRedirect
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"client_id": {clientID},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {"http://localhost:1455/auth/callback"},
|
||||||
|
"code_verifier": {pkceCodes.CodeVerifier},
|
||||||
|
}
|
||||||
|
httpClient := util.SetProxy(h.cfg, &http.Client{})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errDo)
|
||||||
|
oauthStatus[state] = "Failed to exchange authorization code for tokens"
|
||||||
|
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
oauthStatus[state] = fmt.Sprintf("Token exchange failed with status %d", resp.StatusCode)
|
||||||
|
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if errU := json.Unmarshal(respBody, &tokenResp); errU != nil {
|
||||||
|
oauthStatus[state] = "Failed to parse token response"
|
||||||
|
log.Errorf("failed to parse token response: %v", errU)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, _ := codex.ParseJWTToken(tokenResp.IDToken)
|
||||||
|
email := ""
|
||||||
|
accountID := ""
|
||||||
|
if claims != nil {
|
||||||
|
email = claims.GetUserEmail()
|
||||||
|
accountID = claims.GetAccountID()
|
||||||
|
}
|
||||||
|
// Build bundle compatible with existing storage
|
||||||
|
bundle := &codex.CodexAuthBundle{
|
||||||
|
TokenData: codex.CodexTokenData{
|
||||||
|
IDToken: tokenResp.IDToken,
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
AccountID: accountID,
|
||||||
|
Email: email,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage and persist
|
||||||
|
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
|
||||||
|
record := &sdkAuth.TokenRecord{
|
||||||
|
Provider: "codex",
|
||||||
|
FileName: fmt.Sprintf("codex-%s.json", tokenStorage.Email),
|
||||||
|
Storage: tokenStorage,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"email": tokenStorage.Email,
|
||||||
|
"account_id": tokenStorage.AccountID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||||
|
if errSave != nil {
|
||||||
|
oauthStatus[state] = "Failed to save authentication tokens"
|
||||||
|
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||||
|
if bundle.APIKey != "" {
|
||||||
|
fmt.Println("API key obtained and saved")
|
||||||
|
}
|
||||||
|
fmt.Println("You can now use Codex services through this CLI")
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fmt.Println("Initializing Qwen authentication...")
|
||||||
|
|
||||||
|
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
||||||
|
// Initialize Qwen auth service
|
||||||
|
qwenAuth := qwen.NewQwenAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authURL := deviceFlow.VerificationURIComplete
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
fmt.Println("Waiting for authentication...")
|
||||||
|
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
||||||
|
if errPollForToken != nil {
|
||||||
|
oauthStatus[state] = "Authentication failed"
|
||||||
|
fmt.Printf("Authentication failed: %v\n", errPollForToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage
|
||||||
|
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
|
||||||
|
|
||||||
|
tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli())
|
||||||
|
record := &sdkAuth.TokenRecord{
|
||||||
|
Provider: "qwen",
|
||||||
|
FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
||||||
|
Storage: tokenStorage,
|
||||||
|
Metadata: map[string]string{"email": tokenStorage.Email},
|
||||||
|
}
|
||||||
|
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||||
|
if errSave != nil {
|
||||||
|
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||||
|
oauthStatus[state] = "Failed to save authentication tokens"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||||
|
fmt.Println("You can now use Qwen services through this CLI")
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetAuthStatus(c *gin.Context) {
|
||||||
|
state := c.Query("state")
|
||||||
|
if err, ok := oauthStatus[state]; ok {
|
||||||
|
if err != "" {
|
||||||
|
c.JSON(200, gin.H{"status": "error", "error": err})
|
||||||
|
} else {
|
||||||
|
c.JSON(200, gin.H{"status": "wait"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}
|
||||||
53
internal/api/handlers/management/config_basic.go
Normal file
53
internal/api/handlers/management/config_basic.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) GetConfig(c *gin.Context) {
|
||||||
|
c.JSON(200, h.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
|
||||||
|
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
|
||||||
|
|
||||||
|
// UsageStatisticsEnabled
|
||||||
|
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
|
||||||
|
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageStatisticsEnabled
|
||||||
|
func (h *Handler) GetLoggingToFile(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutLoggingToFile(c *gin.Context) {
|
||||||
|
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request log
|
||||||
|
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
|
||||||
|
func (h *Handler) PutRequestLog(c *gin.Context) {
|
||||||
|
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request retry
|
||||||
|
func (h *Handler) GetRequestRetry(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutRequestRetry(c *gin.Context) {
|
||||||
|
h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy URL
|
||||||
|
func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
|
||||||
|
func (h *Handler) PutProxyURL(c *gin.Context) {
|
||||||
|
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
|
||||||
|
}
|
||||||
|
func (h *Handler) DeleteProxyURL(c *gin.Context) {
|
||||||
|
h.cfg.ProxyURL = ""
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
348
internal/api/handlers/management/config_lists.go
Normal file
348
internal/api/handlers/management/config_lists.go
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generic helpers for list[string]
|
||||||
|
func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
|
||||||
|
data, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if err = json.Unmarshal(data, &arr); err != nil {
|
||||||
|
var obj struct {
|
||||||
|
Items []string `json:"items"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arr = obj.Items
|
||||||
|
}
|
||||||
|
set(arr)
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
|
||||||
|
var body struct {
|
||||||
|
Old *string `json:"old"`
|
||||||
|
New *string `json:"new"`
|
||||||
|
Index *int `json:"index"`
|
||||||
|
Value *string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
|
||||||
|
(*target)[*body.Index] = *body.Value
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Old != nil && body.New != nil {
|
||||||
|
for i := range *target {
|
||||||
|
if (*target)[i] == *body.Old {
|
||||||
|
(*target)[i] = *body.New
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*target = append(*target, *body.New)
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(400, gin.H{"error": "missing fields"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
|
||||||
|
if idxStr := c.Query("index"); idxStr != "" {
|
||||||
|
var idx int
|
||||||
|
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||||
|
if err == nil && idx >= 0 && idx < len(*target) {
|
||||||
|
*target = append((*target)[:idx], (*target)[idx+1:]...)
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val := c.Query("value"); val != "" {
|
||||||
|
out := make([]string, 0, len(*target))
|
||||||
|
for _, v := range *target {
|
||||||
|
if v != val {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*target = out
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(400, gin.H{"error": "missing index or value"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// api-keys
|
||||||
|
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
||||||
|
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
||||||
|
h.putStringList(c, func(v []string) { config.SyncInlineAPIKeys(h.cfg, v) }, nil)
|
||||||
|
}
|
||||||
|
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
||||||
|
h.patchStringList(c, &h.cfg.APIKeys, func() { config.SyncInlineAPIKeys(h.cfg, h.cfg.APIKeys) })
|
||||||
|
}
|
||||||
|
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
||||||
|
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { config.SyncInlineAPIKeys(h.cfg, h.cfg.APIKeys) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// generative-language-api-key
|
||||||
|
func (h *Handler) GetGlKeys(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutGlKeys(c *gin.Context) {
|
||||||
|
h.putStringList(c, func(v []string) { h.cfg.GlAPIKey = v }, nil)
|
||||||
|
}
|
||||||
|
func (h *Handler) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey, nil) }
|
||||||
|
func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey, nil) }
|
||||||
|
|
||||||
|
// claude-api-key: []ClaudeKey
|
||||||
|
func (h *Handler) GetClaudeKeys(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutClaudeKeys(c *gin.Context) {
|
||||||
|
data, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var arr []config.ClaudeKey
|
||||||
|
if err = json.Unmarshal(data, &arr); err != nil {
|
||||||
|
var obj struct {
|
||||||
|
Items []config.ClaudeKey `json:"items"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arr = obj.Items
|
||||||
|
}
|
||||||
|
h.cfg.ClaudeKey = arr
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Index *int `json:"index"`
|
||||||
|
Match *string `json:"match"`
|
||||||
|
Value *config.ClaudeKey `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
|
||||||
|
h.cfg.ClaudeKey[*body.Index] = *body.Value
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Match != nil {
|
||||||
|
for i := range h.cfg.ClaudeKey {
|
||||||
|
if h.cfg.ClaudeKey[i].APIKey == *body.Match {
|
||||||
|
h.cfg.ClaudeKey[i] = *body.Value
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(404, gin.H{"error": "item not found"})
|
||||||
|
}
|
||||||
|
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
||||||
|
if val := c.Query("api-key"); val != "" {
|
||||||
|
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
||||||
|
for _, v := range h.cfg.ClaudeKey {
|
||||||
|
if v.APIKey != val {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.cfg.ClaudeKey = out
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idxStr := c.Query("index"); idxStr != "" {
|
||||||
|
var idx int
|
||||||
|
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||||
|
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
|
||||||
|
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// openai-compatibility: []OpenAICompatibility
|
||||||
|
func (h *Handler) GetOpenAICompat(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"openai-compatibility": h.cfg.OpenAICompatibility})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||||
|
data, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var arr []config.OpenAICompatibility
|
||||||
|
if err = json.Unmarshal(data, &arr); err != nil {
|
||||||
|
var obj struct {
|
||||||
|
Items []config.OpenAICompatibility `json:"items"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arr = obj.Items
|
||||||
|
}
|
||||||
|
h.cfg.OpenAICompatibility = arr
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Index *int `json:"index"`
|
||||||
|
Value *config.OpenAICompatibility `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||||
|
h.cfg.OpenAICompatibility[*body.Index] = *body.Value
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name != nil {
|
||||||
|
for i := range h.cfg.OpenAICompatibility {
|
||||||
|
if h.cfg.OpenAICompatibility[i].Name == *body.Name {
|
||||||
|
h.cfg.OpenAICompatibility[i] = *body.Value
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(404, gin.H{"error": "item not found"})
|
||||||
|
}
|
||||||
|
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
||||||
|
if name := c.Query("name"); name != "" {
|
||||||
|
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
||||||
|
for _, v := range h.cfg.OpenAICompatibility {
|
||||||
|
if v.Name != name {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.cfg.OpenAICompatibility = out
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idxStr := c.Query("index"); idxStr != "" {
|
||||||
|
var idx int
|
||||||
|
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||||
|
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
|
||||||
|
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(400, gin.H{"error": "missing name or index"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// codex-api-key: []CodexKey
|
||||||
|
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutCodexKeys(c *gin.Context) {
|
||||||
|
data, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var arr []config.CodexKey
|
||||||
|
if err = json.Unmarshal(data, &arr); err != nil {
|
||||||
|
var obj struct {
|
||||||
|
Items []config.CodexKey `json:"items"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arr = obj.Items
|
||||||
|
}
|
||||||
|
h.cfg.CodexKey = arr
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Index *int `json:"index"`
|
||||||
|
Match *string `json:"match"`
|
||||||
|
Value *config.CodexKey `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||||
|
h.cfg.CodexKey[*body.Index] = *body.Value
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Match != nil {
|
||||||
|
for i := range h.cfg.CodexKey {
|
||||||
|
if h.cfg.CodexKey[i].APIKey == *body.Match {
|
||||||
|
h.cfg.CodexKey[i] = *body.Value
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(404, gin.H{"error": "item not found"})
|
||||||
|
}
|
||||||
|
func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
||||||
|
if val := c.Query("api-key"); val != "" {
|
||||||
|
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||||
|
for _, v := range h.cfg.CodexKey {
|
||||||
|
if v.APIKey != val {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.cfg.CodexKey = out
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idxStr := c.Query("index"); idxStr != "" {
|
||||||
|
var idx int
|
||||||
|
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||||
|
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
|
||||||
|
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
||||||
|
}
|
||||||
231
internal/api/handlers/management/handler.go
Normal file
231
internal/api/handlers/management/handler.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// Package management provides the management API handlers and middleware
|
||||||
|
// for configuring the server and managing auth files.
|
||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type attemptInfo struct {
|
||||||
|
count int
|
||||||
|
blockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler aggregates config reference, persistence path and helpers.
|
||||||
|
type Handler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
configFilePath string
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
attemptsMu sync.Mutex
|
||||||
|
failedAttempts map[string]*attemptInfo // keyed by client IP
|
||||||
|
authManager *coreauth.Manager
|
||||||
|
usageStats *usage.RequestStatistics
|
||||||
|
tokenStore sdkAuth.TokenStore
|
||||||
|
|
||||||
|
localPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new management handler instance.
|
||||||
|
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
cfg: cfg,
|
||||||
|
configFilePath: configFilePath,
|
||||||
|
failedAttempts: make(map[string]*attemptInfo),
|
||||||
|
authManager: manager,
|
||||||
|
usageStats: usage.GetRequestStatistics(),
|
||||||
|
tokenStore: sdkAuth.GetTokenStore(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig updates the in-memory config reference when the server hot-reloads.
|
||||||
|
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
|
||||||
|
|
||||||
|
// SetAuthManager updates the auth manager reference used by management endpoints.
|
||||||
|
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
|
||||||
|
|
||||||
|
// SetUsageStatistics allows replacing the usage statistics reference.
|
||||||
|
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
|
||||||
|
|
||||||
|
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
||||||
|
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
||||||
|
|
||||||
|
// Middleware enforces access control for management endpoints.
|
||||||
|
// All requests (local and remote) require a valid management key.
|
||||||
|
// Additionally, remote access requires allow-remote-management=true.
|
||||||
|
func (h *Handler) Middleware() gin.HandlerFunc {
|
||||||
|
const maxFailures = 5
|
||||||
|
const banDuration = 30 * time.Minute
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
|
||||||
|
|
||||||
|
fail := func() {}
|
||||||
|
if !localClient {
|
||||||
|
h.attemptsMu.Lock()
|
||||||
|
ai := h.failedAttempts[clientIP]
|
||||||
|
if ai != nil {
|
||||||
|
if !ai.blockedUntil.IsZero() {
|
||||||
|
if time.Now().Before(ai.blockedUntil) {
|
||||||
|
remaining := time.Until(ai.blockedUntil).Round(time.Second)
|
||||||
|
h.attemptsMu.Unlock()
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Ban expired, reset state
|
||||||
|
ai.blockedUntil = time.Time{}
|
||||||
|
ai.count = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.attemptsMu.Unlock()
|
||||||
|
|
||||||
|
if !h.cfg.RemoteManagement.AllowRemote {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fail = func() {
|
||||||
|
h.attemptsMu.Lock()
|
||||||
|
aip := h.failedAttempts[clientIP]
|
||||||
|
if aip == nil {
|
||||||
|
aip = &attemptInfo{}
|
||||||
|
h.failedAttempts[clientIP] = aip
|
||||||
|
}
|
||||||
|
aip.count++
|
||||||
|
if aip.count >= maxFailures {
|
||||||
|
aip.blockedUntil = time.Now().Add(banDuration)
|
||||||
|
aip.count = 0
|
||||||
|
}
|
||||||
|
h.attemptsMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secret := h.cfg.RemoteManagement.SecretKey
|
||||||
|
if secret == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either Authorization: Bearer <key> or X-Management-Key
|
||||||
|
var provided string
|
||||||
|
if ah := c.GetHeader("Authorization"); ah != "" {
|
||||||
|
parts := strings.SplitN(ah, " ", 2)
|
||||||
|
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||||
|
provided = parts[1]
|
||||||
|
} else {
|
||||||
|
provided = ah
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provided == "" {
|
||||||
|
provided = c.GetHeader("X-Management-Key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if provided == "" {
|
||||||
|
if !localClient {
|
||||||
|
fail()
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if localClient {
|
||||||
|
if lp := h.localPassword; lp != "" {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
|
||||||
|
if !localClient {
|
||||||
|
fail()
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !localClient {
|
||||||
|
h.attemptsMu.Lock()
|
||||||
|
if ai := h.failedAttempts[clientIP]; ai != nil {
|
||||||
|
ai.count = 0
|
||||||
|
ai.blockedUntil = time.Time{}
|
||||||
|
}
|
||||||
|
h.attemptsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist saves the current in-memory config to disk.
|
||||||
|
func (h *Handler) persist(c *gin.Context) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
// Preserve comments when writing
|
||||||
|
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for simple types
|
||||||
|
func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
|
||||||
|
var body struct {
|
||||||
|
Value *bool `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||||
|
var m map[string]any
|
||||||
|
if err2 := c.ShouldBindJSON(&m); err2 == nil {
|
||||||
|
for _, v := range m {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
set(b)
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set(*body.Value)
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
|
||||||
|
var body struct {
|
||||||
|
Value *int `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set(*body.Value)
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
|
||||||
|
var body struct {
|
||||||
|
Value *string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set(*body.Value)
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
18
internal/api/handlers/management/quota.go
Normal file
18
internal/api/handlers/management/quota.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// Quota exceeded toggles
|
||||||
|
func (h *Handler) GetSwitchProject(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutSwitchProject(c *gin.Context) {
|
||||||
|
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
|
||||||
|
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
|
||||||
|
}
|
||||||
17
internal/api/handlers/management/usage.go
Normal file
17
internal/api/handlers/management/usage.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUsageStatistics returns the in-memory request statistics snapshot.
|
||||||
|
func (h *Handler) GetUsageStatistics(c *gin.Context) {
|
||||||
|
var snapshot usage.StatisticsSnapshot
|
||||||
|
if h != nil && h.usageStats != nil {
|
||||||
|
snapshot = h.usageStats.Snapshot()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"usage": snapshot})
|
||||||
|
}
|
||||||
568
internal/api/handlers/openai/openai_handlers.go
Normal file
568
internal/api/handlers/openai/openai_handlers.go
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
// Package openai provides HTTP handlers for OpenAI API endpoints.
|
||||||
|
// This package implements the OpenAI-compatible API interface, including model listing
|
||||||
|
// and chat completion functionality. It supports both streaming and non-streaming responses,
|
||||||
|
// and manages a pool of clients to interact with backend services.
|
||||||
|
// The handlers translate OpenAI API requests to the appropriate backend format and
|
||||||
|
// convert responses back to OpenAI-compatible format.
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||||
|
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAIAPIHandler contains the handlers for OpenAI API endpoints.
|
||||||
|
// It holds a pool of clients to interact with the backend service.
|
||||||
|
type OpenAIAPIHandler struct {
|
||||||
|
*handlers.BaseAPIHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenAIAPIHandler creates a new OpenAI API handlers instance.
|
||||||
|
// It takes an BaseAPIHandler instance as input and returns an OpenAIAPIHandler.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - apiHandlers: The base API handlers instance
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *OpenAIAPIHandler: A new OpenAI API handlers instance
|
||||||
|
func NewOpenAIAPIHandler(apiHandlers *handlers.BaseAPIHandler) *OpenAIAPIHandler {
|
||||||
|
return &OpenAIAPIHandler{
|
||||||
|
BaseAPIHandler: apiHandlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerType returns the identifier for this handler implementation.
|
||||||
|
func (h *OpenAIAPIHandler) HandlerType() string {
|
||||||
|
return OpenAI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models returns the OpenAI-compatible model metadata supported by this handler.
|
||||||
|
func (h *OpenAIAPIHandler) Models() []map[string]any {
|
||||||
|
// Get dynamic models from the global registry
|
||||||
|
modelRegistry := registry.GetGlobalRegistry()
|
||||||
|
return modelRegistry.GetAvailableModels("openai")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIModels handles the /v1/models endpoint.
|
||||||
|
// It returns a list of available AI models with their capabilities
|
||||||
|
// and specifications in OpenAI-compatible format.
|
||||||
|
func (h *OpenAIAPIHandler) OpenAIModels(c *gin.Context) {
|
||||||
|
// Get all available models
|
||||||
|
allModels := h.Models()
|
||||||
|
|
||||||
|
// Filter to only include the 4 required fields: id, object, created, owned_by
|
||||||
|
filteredModels := make([]map[string]any, len(allModels))
|
||||||
|
for i, model := range allModels {
|
||||||
|
filteredModel := map[string]any{
|
||||||
|
"id": model["id"],
|
||||||
|
"object": model["object"],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add created field if it exists
|
||||||
|
if created, exists := model["created"]; exists {
|
||||||
|
filteredModel["created"] = created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add owned_by field if it exists
|
||||||
|
if ownedBy, exists := model["owned_by"]; exists {
|
||||||
|
filteredModel["owned_by"] = ownedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredModels[i] = filteredModel
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"object": "list",
|
||||||
|
"data": filteredModels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatCompletions handles the /v1/chat/completions endpoint.
|
||||||
|
// It determines whether the request is for a streaming or non-streaming response
|
||||||
|
// and calls the appropriate handler based on the model provider.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
|
||||||
|
rawJSON, err := c.GetRawData()
|
||||||
|
// If data retrieval fails, return a 400 Bad Request error.
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client requested a streaming response.
|
||||||
|
streamResult := gjson.GetBytes(rawJSON, "stream")
|
||||||
|
if streamResult.Type == gjson.True {
|
||||||
|
h.handleStreamingResponse(c, rawJSON)
|
||||||
|
} else {
|
||||||
|
h.handleNonStreamingResponse(c, rawJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completions handles the /v1/completions endpoint.
|
||||||
|
// It determines whether the request is for a streaming or non-streaming response
|
||||||
|
// and calls the appropriate handler based on the model provider.
|
||||||
|
// This endpoint follows the OpenAI completions API specification.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
func (h *OpenAIAPIHandler) Completions(c *gin.Context) {
|
||||||
|
rawJSON, err := c.GetRawData()
|
||||||
|
// If data retrieval fails, return a 400 Bad Request error.
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client requested a streaming response.
|
||||||
|
streamResult := gjson.GetBytes(rawJSON, "stream")
|
||||||
|
if streamResult.Type == gjson.True {
|
||||||
|
h.handleCompletionsStreamingResponse(c, rawJSON)
|
||||||
|
} else {
|
||||||
|
h.handleCompletionsNonStreamingResponse(c, rawJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertCompletionsRequestToChatCompletions converts OpenAI completions API request to chat completions format.
|
||||||
|
// This allows the completions endpoint to use the existing chat completions infrastructure.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - rawJSON: The raw JSON bytes of the completions request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []byte: The converted chat completions request
|
||||||
|
func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
|
||||||
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
|
// Extract prompt from completions request
|
||||||
|
prompt := root.Get("prompt").String()
|
||||||
|
if prompt == "" {
|
||||||
|
prompt = "Complete this:"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create chat completions structure
|
||||||
|
out := `{"model":"","messages":[{"role":"user","content":""}]}`
|
||||||
|
|
||||||
|
// Set model
|
||||||
|
if model := root.Get("model"); model.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "model", model.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the prompt as user message content
|
||||||
|
out, _ = sjson.Set(out, "messages.0.content", prompt)
|
||||||
|
|
||||||
|
// Copy other parameters from completions to chat completions
|
||||||
|
if maxTokens := root.Get("max_tokens"); maxTokens.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "max_tokens", maxTokens.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
if temperature := root.Get("temperature"); temperature.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "temperature", temperature.Float())
|
||||||
|
}
|
||||||
|
|
||||||
|
if topP := root.Get("top_p"); topP.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "top_p", topP.Float())
|
||||||
|
}
|
||||||
|
|
||||||
|
if frequencyPenalty := root.Get("frequency_penalty"); frequencyPenalty.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "frequency_penalty", frequencyPenalty.Float())
|
||||||
|
}
|
||||||
|
|
||||||
|
if presencePenalty := root.Get("presence_penalty"); presencePenalty.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "presence_penalty", presencePenalty.Float())
|
||||||
|
}
|
||||||
|
|
||||||
|
if stop := root.Get("stop"); stop.Exists() {
|
||||||
|
out, _ = sjson.SetRaw(out, "stop", stop.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stream := root.Get("stream"); stream.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "stream", stream.Bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
if logprobs := root.Get("logprobs"); logprobs.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "logprobs", logprobs.Bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
if topLogprobs := root.Get("top_logprobs"); topLogprobs.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "top_logprobs", topLogprobs.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
if echo := root.Get("echo"); echo.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "echo", echo.Bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertChatCompletionsResponseToCompletions converts chat completions API response back to completions format.
|
||||||
|
// This ensures the completions endpoint returns data in the expected format.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - rawJSON: The raw JSON bytes of the chat completions response
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []byte: The converted completions response
|
||||||
|
func convertChatCompletionsResponseToCompletions(rawJSON []byte) []byte {
|
||||||
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
|
// Base completions response structure
|
||||||
|
out := `{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`
|
||||||
|
|
||||||
|
// Copy basic fields
|
||||||
|
if id := root.Get("id"); id.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "id", id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if created := root.Get("created"); created.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "created", created.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
if model := root.Get("model"); model.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "model", model.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage := root.Get("usage"); usage.Exists() {
|
||||||
|
out, _ = sjson.SetRaw(out, "usage", usage.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert choices from chat completions to completions format
|
||||||
|
var choices []interface{}
|
||||||
|
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
|
||||||
|
chatChoices.ForEach(func(_, choice gjson.Result) bool {
|
||||||
|
completionsChoice := map[string]interface{}{
|
||||||
|
"index": choice.Get("index").Int(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text content from message.content
|
||||||
|
if message := choice.Get("message"); message.Exists() {
|
||||||
|
if content := message.Get("content"); content.Exists() {
|
||||||
|
completionsChoice["text"] = content.String()
|
||||||
|
}
|
||||||
|
} else if delta := choice.Get("delta"); delta.Exists() {
|
||||||
|
// For streaming responses, use delta.content
|
||||||
|
if content := delta.Get("content"); content.Exists() {
|
||||||
|
completionsChoice["text"] = content.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy finish_reason
|
||||||
|
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
|
||||||
|
completionsChoice["finish_reason"] = finishReason.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy logprobs if present
|
||||||
|
if logprobs := choice.Get("logprobs"); logprobs.Exists() {
|
||||||
|
completionsChoice["logprobs"] = logprobs.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
choices = append(choices, completionsChoice)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choices) > 0 {
|
||||||
|
choicesJSON, _ := json.Marshal(choices)
|
||||||
|
out, _ = sjson.SetRaw(out, "choices", string(choicesJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertChatCompletionsStreamChunkToCompletions converts a streaming chat completions chunk to completions format.
|
||||||
|
// This handles the real-time conversion of streaming response chunks and filters out empty text responses.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - chunkData: The raw JSON bytes of a single chat completions stream chunk
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []byte: The converted completions stream chunk, or nil if should be filtered out
|
||||||
|
func convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {
|
||||||
|
root := gjson.ParseBytes(chunkData)
|
||||||
|
|
||||||
|
// Check if this chunk has any meaningful content
|
||||||
|
hasContent := false
|
||||||
|
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
|
||||||
|
chatChoices.ForEach(func(_, choice gjson.Result) bool {
|
||||||
|
// Check if delta has content or finish_reason
|
||||||
|
if delta := choice.Get("delta"); delta.Exists() {
|
||||||
|
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
||||||
|
hasContent = true
|
||||||
|
return false // Break out of forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check for finish_reason to ensure we don't skip final chunks
|
||||||
|
if finishReason := choice.Get("finish_reason"); finishReason.Exists() && finishReason.String() != "" && finishReason.String() != "null" {
|
||||||
|
hasContent = true
|
||||||
|
return false // Break out of forEach
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no meaningful content, return nil to indicate this chunk should be skipped
|
||||||
|
if !hasContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base completions stream response structure
|
||||||
|
out := `{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`
|
||||||
|
|
||||||
|
// Copy basic fields
|
||||||
|
if id := root.Get("id"); id.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "id", id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if created := root.Get("created"); created.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "created", created.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
if model := root.Get("model"); model.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "model", model.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert choices from chat completions delta to completions format
|
||||||
|
var choices []interface{}
|
||||||
|
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
|
||||||
|
chatChoices.ForEach(func(_, choice gjson.Result) bool {
|
||||||
|
completionsChoice := map[string]interface{}{
|
||||||
|
"index": choice.Get("index").Int(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text content from delta.content
|
||||||
|
if delta := choice.Get("delta"); delta.Exists() {
|
||||||
|
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
||||||
|
completionsChoice["text"] = content.String()
|
||||||
|
} else {
|
||||||
|
completionsChoice["text"] = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completionsChoice["text"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy finish_reason
|
||||||
|
if finishReason := choice.Get("finish_reason"); finishReason.Exists() && finishReason.String() != "null" {
|
||||||
|
completionsChoice["finish_reason"] = finishReason.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy logprobs if present
|
||||||
|
if logprobs := choice.Get("logprobs"); logprobs.Exists() {
|
||||||
|
completionsChoice["logprobs"] = logprobs.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
choices = append(choices, completionsChoice)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choices) > 0 {
|
||||||
|
choicesJSON, _ := json.Marshal(choices)
|
||||||
|
out, _ = sjson.SetRaw(out, "choices", string(choicesJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamingResponse handles non-streaming chat completion responses
|
||||||
|
// for Gemini models. It selects a client from the pool, sends the request, and
|
||||||
|
// aggregates the response before sending it back to the client in OpenAI format.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
|
||||||
|
func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamingResponse handles streaming responses for Gemini models.
|
||||||
|
// It establishes a streaming connection with the backend service and forwards
|
||||||
|
// the response chunks to the client in real-time using Server-Sent Events.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
|
||||||
|
func (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
// Get the http.Flusher interface to manually flush the response.
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Streaming not supported",
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
dataChan, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))
|
||||||
|
h.handleStreamResult(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCompletionsNonStreamingResponse handles non-streaming completions responses.
|
||||||
|
// It converts completions request to chat completions format, sends to backend,
|
||||||
|
// then converts the response back to completions format before sending to client.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
// - rawJSON: The raw JSON bytes of the OpenAI-compatible completions request
|
||||||
|
func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Convert completions request to chat completions format
|
||||||
|
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completionsResp := convertChatCompletionsResponseToCompletions(resp)
|
||||||
|
_, _ = c.Writer.Write(completionsResp)
|
||||||
|
cliCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCompletionsStreamingResponse handles streaming completions responses.
|
||||||
|
// It converts completions request to chat completions format, streams from backend,
|
||||||
|
// then converts each response chunk back to completions format before sending to client.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
// - rawJSON: The raw JSON bytes of the OpenAI-compatible completions request
|
||||||
|
func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
// Get the http.Flusher interface to manually flush the response.
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Streaming not supported",
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert completions request to chat completions format
|
||||||
|
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
dataChan, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
cliCancel(c.Request.Context().Err())
|
||||||
|
return
|
||||||
|
case chunk, isOk := <-dataChan:
|
||||||
|
if !isOk {
|
||||||
|
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
converted := convertChatCompletionsStreamChunkToCompletions(chunk)
|
||||||
|
if converted != nil {
|
||||||
|
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(converted))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
case errMsg, isOk := <-errChan:
|
||||||
|
if !isOk {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
var execErr error
|
||||||
|
if errMsg != nil {
|
||||||
|
execErr = errMsg.Error
|
||||||
|
}
|
||||||
|
cliCancel(execErr)
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *OpenAIAPIHandler) handleStreamResult(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
cancel(c.Request.Context().Err())
|
||||||
|
return
|
||||||
|
case chunk, ok := <-data:
|
||||||
|
if !ok {
|
||||||
|
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
cancel(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunk))
|
||||||
|
flusher.Flush()
|
||||||
|
case errMsg, ok := <-errs:
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
var execErr error
|
||||||
|
if errMsg != nil {
|
||||||
|
execErr = errMsg.Error
|
||||||
|
}
|
||||||
|
cancel(execErr)
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
internal/api/handlers/openai/openai_responses_handlers.go
Normal file
194
internal/api/handlers/openai/openai_responses_handlers.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// Package openai provides HTTP handlers for OpenAIResponses API endpoints.
|
||||||
|
// This package implements the OpenAIResponses-compatible API interface, including model listing
|
||||||
|
// and chat completion functionality. It supports both streaming and non-streaming responses,
|
||||||
|
// and manages a pool of clients to interact with backend services.
|
||||||
|
// The handlers translate OpenAIResponses API requests to the appropriate backend format and
|
||||||
|
// convert responses back to OpenAIResponses-compatible format.
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||||
|
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAIResponsesAPIHandler contains the handlers for OpenAIResponses API endpoints.
|
||||||
|
// It holds a pool of clients to interact with the backend service.
|
||||||
|
type OpenAIResponsesAPIHandler struct {
|
||||||
|
*handlers.BaseAPIHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenAIResponsesAPIHandler creates a new OpenAIResponses API handlers instance.
|
||||||
|
// It takes an BaseAPIHandler instance as input and returns an OpenAIResponsesAPIHandler.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - apiHandlers: The base API handlers instance
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *OpenAIResponsesAPIHandler: A new OpenAIResponses API handlers instance
|
||||||
|
func NewOpenAIResponsesAPIHandler(apiHandlers *handlers.BaseAPIHandler) *OpenAIResponsesAPIHandler {
|
||||||
|
return &OpenAIResponsesAPIHandler{
|
||||||
|
BaseAPIHandler: apiHandlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerType returns the identifier for this handler implementation.
|
||||||
|
func (h *OpenAIResponsesAPIHandler) HandlerType() string {
|
||||||
|
return OpenaiResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models returns the OpenAIResponses-compatible model metadata supported by this handler.
|
||||||
|
func (h *OpenAIResponsesAPIHandler) Models() []map[string]any {
|
||||||
|
// Get dynamic models from the global registry
|
||||||
|
modelRegistry := registry.GetGlobalRegistry()
|
||||||
|
return modelRegistry.GetAvailableModels("openai")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIResponsesModels handles the /v1/models endpoint.
|
||||||
|
// It returns a list of available AI models with their capabilities
|
||||||
|
// and specifications in OpenAIResponses-compatible format.
|
||||||
|
func (h *OpenAIResponsesAPIHandler) OpenAIResponsesModels(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"object": "list",
|
||||||
|
"data": h.Models(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responses handles the /v1/responses endpoint.
|
||||||
|
// It determines whether the request is for a streaming or non-streaming response
|
||||||
|
// and calls the appropriate handler based on the model provider.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) {
|
||||||
|
rawJSON, err := c.GetRawData()
|
||||||
|
// If data retrieval fails, return a 400 Bad Request error.
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client requested a streaming response.
|
||||||
|
streamResult := gjson.GetBytes(rawJSON, "stream")
|
||||||
|
if streamResult.Type == gjson.True {
|
||||||
|
h.handleStreamingResponse(c, rawJSON)
|
||||||
|
} else {
|
||||||
|
h.handleNonStreamingResponse(c, rawJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamingResponse handles non-streaming chat completion responses
|
||||||
|
// for Gemini models. It selects a client from the pool, sends the request, and
|
||||||
|
// aggregates the response before sending it back to the client in OpenAIResponses format.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
// - rawJSON: The raw JSON bytes of the OpenAIResponses-compatible request
|
||||||
|
func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
defer func() {
|
||||||
|
cliCancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(resp)
|
||||||
|
return
|
||||||
|
|
||||||
|
// no legacy fallback
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamingResponse handles streaming responses for Gemini models.
|
||||||
|
// It establishes a streaming connection with the backend service and forwards
|
||||||
|
// the response chunks to the client in real-time using Server-Sent Events.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The Gin context containing the HTTP request and response
|
||||||
|
// - rawJSON: The raw JSON bytes of the OpenAIResponses-compatible request
|
||||||
|
func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
// Get the http.Flusher interface to manually flush the response.
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||||
|
Error: handlers.ErrorDetail{
|
||||||
|
Message: "Streaming not supported",
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// New core execution path
|
||||||
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
dataChan, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
||||||
|
h.forwardResponsesStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
cancel(c.Request.Context().Err())
|
||||||
|
return
|
||||||
|
case chunk, ok := <-data:
|
||||||
|
if !ok {
|
||||||
|
_, _ = c.Writer.Write([]byte("\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
cancel(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(chunk, []byte("event:")) {
|
||||||
|
_, _ = c.Writer.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
_, _ = c.Writer.Write(chunk)
|
||||||
|
_, _ = c.Writer.Write([]byte("\n"))
|
||||||
|
|
||||||
|
flusher.Flush()
|
||||||
|
case errMsg, ok := <-errs:
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
var execErr error
|
||||||
|
if errMsg != nil {
|
||||||
|
execErr = errMsg.Error
|
||||||
|
}
|
||||||
|
cancel(execErr)
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
internal/api/middleware/request_logging.go
Normal file
92
internal/api/middleware/request_logging.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
|
||||||
|
// This file contains the request logging middleware that captures comprehensive
|
||||||
|
// request and response data when enabled through configuration.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.
|
||||||
|
// It captures detailed information about the request and response, including headers and body,
|
||||||
|
// and uses the provided RequestLogger to record this data. If logging is disabled in the
|
||||||
|
// logger, the middleware has minimal overhead.
|
||||||
|
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Early return if logging is disabled (zero overhead)
|
||||||
|
if !logger.IsEnabled() {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture request information
|
||||||
|
requestInfo, err := captureRequestInfo(c)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue processing
|
||||||
|
// In a real implementation, you might want to use a proper logger here
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response writer wrapper
|
||||||
|
wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)
|
||||||
|
c.Writer = wrapper
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// Finalize logging after request processing
|
||||||
|
if err = wrapper.Finalize(c); err != nil {
|
||||||
|
// Log error but don't interrupt the response
|
||||||
|
// In a real implementation, you might want to use a proper logger here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureRequestInfo extracts relevant information from the incoming HTTP request.
|
||||||
|
// It captures the URL, method, headers, and body. The request body is read and then
|
||||||
|
// restored so that it can be processed by subsequent handlers.
|
||||||
|
func captureRequestInfo(c *gin.Context) (*RequestInfo, error) {
|
||||||
|
// Capture URL
|
||||||
|
url := c.Request.URL.String()
|
||||||
|
if c.Request.URL.Path != "" {
|
||||||
|
url = c.Request.URL.Path
|
||||||
|
if c.Request.URL.RawQuery != "" {
|
||||||
|
url += "?" + c.Request.URL.RawQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture method
|
||||||
|
method := c.Request.Method
|
||||||
|
|
||||||
|
// Capture headers
|
||||||
|
headers := make(map[string][]string)
|
||||||
|
for key, values := range c.Request.Header {
|
||||||
|
headers[key] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture request body
|
||||||
|
var body []byte
|
||||||
|
if c.Request.Body != nil {
|
||||||
|
// Read the body
|
||||||
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the body for the actual request processing
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||||
|
body = bodyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RequestInfo{
|
||||||
|
URL: url,
|
||||||
|
Method: method,
|
||||||
|
Headers: headers,
|
||||||
|
Body: body,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
309
internal/api/middleware/response_writer.go
Normal file
309
internal/api/middleware/response_writer.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// Package middleware provides Gin HTTP middleware for the CLI Proxy API server.
|
||||||
|
// It includes a sophisticated response writer wrapper designed to capture and log request and response data,
|
||||||
|
// including support for streaming responses, without impacting latency.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
|
||||||
|
type RequestInfo struct {
|
||||||
|
URL string // URL is the request URL.
|
||||||
|
Method string // Method is the HTTP method (e.g., GET, POST).
|
||||||
|
Headers map[string][]string // Headers contains the request headers.
|
||||||
|
Body []byte // Body is the raw request body.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data.
|
||||||
|
// It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.
|
||||||
|
type ResponseWriterWrapper struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses.
|
||||||
|
isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
|
||||||
|
streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
|
||||||
|
chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
|
||||||
|
streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
|
||||||
|
logger logging.RequestLogger // logger is the instance of the request logger service.
|
||||||
|
requestInfo *RequestInfo // requestInfo holds the details of the original request.
|
||||||
|
statusCode int // statusCode stores the HTTP status code of the response.
|
||||||
|
headers map[string][]string // headers stores the response headers.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.
|
||||||
|
// It takes the original gin.ResponseWriter, a logger instance, and request information.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - w: The original gin.ResponseWriter to wrap.
|
||||||
|
// - logger: The logging service to use for recording requests.
|
||||||
|
// - requestInfo: The pre-captured information about the incoming request.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A pointer to a new ResponseWriterWrapper.
|
||||||
|
func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
|
||||||
|
return &ResponseWriterWrapper{
|
||||||
|
ResponseWriter: w,
|
||||||
|
body: &bytes.Buffer{},
|
||||||
|
logger: logger,
|
||||||
|
requestInfo: requestInfo,
|
||||||
|
headers: make(map[string][]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write wraps the underlying ResponseWriter's Write method to capture response data.
|
||||||
|
// For non-streaming responses, it writes to an internal buffer. For streaming responses,
|
||||||
|
// it sends data chunks to a non-blocking channel for asynchronous logging.
|
||||||
|
// CRITICAL: This method prioritizes writing to the client to ensure zero latency,
|
||||||
|
// handling logging operations subsequently.
|
||||||
|
func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
|
||||||
|
// Ensure headers are captured before first write
|
||||||
|
// This is critical because Write() may trigger WriteHeader() internally
|
||||||
|
w.ensureHeadersCaptured()
|
||||||
|
|
||||||
|
// CRITICAL: Write to client first (zero latency)
|
||||||
|
n, err := w.ResponseWriter.Write(data)
|
||||||
|
|
||||||
|
// THEN: Handle logging based on response type
|
||||||
|
if w.isStreaming {
|
||||||
|
// For streaming responses: Send to async logging channel (non-blocking)
|
||||||
|
if w.chunkChannel != nil {
|
||||||
|
select {
|
||||||
|
case w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy
|
||||||
|
default: // Channel full, skip logging to avoid blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-streaming responses: Buffer complete response
|
||||||
|
w.body.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader wraps the underlying ResponseWriter's WriteHeader method.
|
||||||
|
// It captures the status code, detects if the response is streaming based on the Content-Type header,
|
||||||
|
// and initializes the appropriate logging mechanism (standard or streaming).
|
||||||
|
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
||||||
|
w.statusCode = statusCode
|
||||||
|
|
||||||
|
// Capture response headers using the new method
|
||||||
|
w.captureCurrentHeaders()
|
||||||
|
|
||||||
|
// Detect streaming based on Content-Type
|
||||||
|
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
||||||
|
w.isStreaming = w.detectStreaming(contentType)
|
||||||
|
|
||||||
|
// If streaming, initialize streaming log writer
|
||||||
|
if w.isStreaming && w.logger.IsEnabled() {
|
||||||
|
streamWriter, err := w.logger.LogStreamingRequest(
|
||||||
|
w.requestInfo.URL,
|
||||||
|
w.requestInfo.Method,
|
||||||
|
w.requestInfo.Headers,
|
||||||
|
w.requestInfo.Body,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
w.streamWriter = streamWriter
|
||||||
|
w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
|
||||||
|
doneChan := make(chan struct{})
|
||||||
|
w.streamDone = doneChan
|
||||||
|
|
||||||
|
// Start async chunk processor
|
||||||
|
go w.processStreamingChunks(doneChan)
|
||||||
|
|
||||||
|
// Write status immediately
|
||||||
|
_ = streamWriter.WriteStatus(statusCode, w.headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original WriteHeader
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureHeadersCaptured is a helper function to make sure response headers are captured.
|
||||||
|
// It is safe to call this method multiple times; it will always refresh the headers
|
||||||
|
// with the latest state from the underlying ResponseWriter.
|
||||||
|
func (w *ResponseWriterWrapper) ensureHeadersCaptured() {
|
||||||
|
// Always capture the current headers to ensure we have the latest state
|
||||||
|
w.captureCurrentHeaders()
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureCurrentHeaders reads all headers from the underlying ResponseWriter and stores them
|
||||||
|
// in the wrapper's headers map. It creates copies of the header values to prevent race conditions.
|
||||||
|
func (w *ResponseWriterWrapper) captureCurrentHeaders() {
|
||||||
|
// Initialize headers map if needed
|
||||||
|
if w.headers == nil {
|
||||||
|
w.headers = make(map[string][]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture all current headers from the underlying ResponseWriter
|
||||||
|
for key, values := range w.ResponseWriter.Header() {
|
||||||
|
// Make a copy of the values slice to avoid reference issues
|
||||||
|
headerValues := make([]string, len(values))
|
||||||
|
copy(headerValues, values)
|
||||||
|
w.headers[key] = headerValues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectStreaming determines if a response should be treated as a streaming response.
|
||||||
|
// It checks for a "text/event-stream" Content-Type or a '"stream": true'
|
||||||
|
// field in the original request body.
|
||||||
|
func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
|
||||||
|
// Check Content-Type for Server-Sent Events
|
||||||
|
if strings.Contains(contentType, "text/event-stream") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check request body for streaming indicators
|
||||||
|
if w.requestInfo.Body != nil {
|
||||||
|
bodyStr := string(w.requestInfo.Body)
|
||||||
|
if strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
|
||||||
|
// It asynchronously writes each chunk to the streaming log writer.
|
||||||
|
func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
|
||||||
|
if done == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
if w.streamWriter == nil || w.chunkChannel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for chunk := range w.chunkChannel {
|
||||||
|
w.streamWriter.WriteChunkAsync(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize completes the logging process for the request and response.
|
||||||
|
// For streaming responses, it closes the chunk channel and the stream writer.
|
||||||
|
// For non-streaming responses, it logs the complete request and response details,
|
||||||
|
// including any API-specific request/response data stored in the Gin context.
|
||||||
|
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
||||||
|
if !w.logger.IsEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.isStreaming {
|
||||||
|
// Close streaming channel and writer
|
||||||
|
if w.chunkChannel != nil {
|
||||||
|
close(w.chunkChannel)
|
||||||
|
w.chunkChannel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.streamDone != nil {
|
||||||
|
<-w.streamDone
|
||||||
|
w.streamDone = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.streamWriter != nil {
|
||||||
|
err := w.streamWriter.Close()
|
||||||
|
w.streamWriter = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Capture final status code and headers if not already captured
|
||||||
|
finalStatusCode := w.statusCode
|
||||||
|
if finalStatusCode == 0 {
|
||||||
|
// Get status from underlying ResponseWriter if available
|
||||||
|
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {
|
||||||
|
finalStatusCode = statusWriter.Status()
|
||||||
|
} else {
|
||||||
|
finalStatusCode = 200 // Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have the latest headers before finalizing
|
||||||
|
w.ensureHeadersCaptured()
|
||||||
|
|
||||||
|
// Use the captured headers as the final headers
|
||||||
|
finalHeaders := make(map[string][]string)
|
||||||
|
for key, values := range w.headers {
|
||||||
|
// Make a copy of the values slice to avoid reference issues
|
||||||
|
headerValues := make([]string, len(values))
|
||||||
|
copy(headerValues, values)
|
||||||
|
finalHeaders[key] = headerValues
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiRequestBody []byte
|
||||||
|
apiRequest, isExist := c.Get("API_REQUEST")
|
||||||
|
if isExist {
|
||||||
|
var ok bool
|
||||||
|
apiRequestBody, ok = apiRequest.([]byte)
|
||||||
|
if !ok {
|
||||||
|
apiRequestBody = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResponseBody []byte
|
||||||
|
apiResponse, isExist := c.Get("API_RESPONSE")
|
||||||
|
if isExist {
|
||||||
|
var ok bool
|
||||||
|
apiResponseBody, ok = apiResponse.([]byte)
|
||||||
|
if !ok {
|
||||||
|
apiResponseBody = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var slicesAPIResponseError []*interfaces.ErrorMessage
|
||||||
|
apiResponseError, isExist := c.Get("API_RESPONSE_ERROR")
|
||||||
|
if isExist {
|
||||||
|
var ok bool
|
||||||
|
slicesAPIResponseError, ok = apiResponseError.([]*interfaces.ErrorMessage)
|
||||||
|
if !ok {
|
||||||
|
slicesAPIResponseError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log complete non-streaming response
|
||||||
|
return w.logger.LogRequest(
|
||||||
|
w.requestInfo.URL,
|
||||||
|
w.requestInfo.Method,
|
||||||
|
w.requestInfo.Headers,
|
||||||
|
w.requestInfo.Body,
|
||||||
|
finalStatusCode,
|
||||||
|
finalHeaders,
|
||||||
|
w.body.Bytes(),
|
||||||
|
apiRequestBody,
|
||||||
|
apiResponseBody,
|
||||||
|
slicesAPIResponseError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the HTTP response status code captured by the wrapper.
|
||||||
|
// It defaults to 200 if WriteHeader has not been called.
|
||||||
|
func (w *ResponseWriterWrapper) Status() int {
|
||||||
|
if w.statusCode == 0 {
|
||||||
|
return 200 // Default status code
|
||||||
|
}
|
||||||
|
return w.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the size of the response body in bytes for non-streaming responses.
|
||||||
|
// For streaming responses, it returns -1, as the total size is unknown.
|
||||||
|
func (w *ResponseWriterWrapper) Size() int {
|
||||||
|
if w.isStreaming {
|
||||||
|
return -1 // Unknown size for streaming responses
|
||||||
|
}
|
||||||
|
return w.body.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written returns true if the response header has been written (i.e., a status code has been set).
|
||||||
|
func (w *ResponseWriterWrapper) Written() bool {
|
||||||
|
return w.statusCode != 0
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
// ErrorResponse represents an error response
|
|
||||||
type ErrorResponse struct {
|
|
||||||
Error ErrorDetail `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorDetail represents error details
|
|
||||||
type ErrorDetail struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,76 +1,254 @@
|
|||||||
|
// Package api provides the HTTP API server implementation for the CLI Proxy API.
|
||||||
|
// It includes the main server struct, routing setup, middleware for CORS and authentication,
|
||||||
|
// and integration with various AI API handlers (OpenAI, Claude, Gemini).
|
||||||
|
// The server supports hot-reloading of clients and configuration.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/claude"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/gemini"
|
||||||
|
managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/openai"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server represents the API server
|
type serverOptionConfig struct {
|
||||||
|
extraMiddleware []gin.HandlerFunc
|
||||||
|
engineConfigurator func(*gin.Engine)
|
||||||
|
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
|
||||||
|
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
|
||||||
|
localPassword string
|
||||||
|
keepAliveEnabled bool
|
||||||
|
keepAliveTimeout time.Duration
|
||||||
|
keepAliveOnTimeout func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerOption customises HTTP server construction.
|
||||||
|
type ServerOption func(*serverOptionConfig)
|
||||||
|
|
||||||
|
func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLogger(cfg.RequestLog, "logs", filepath.Dir(configPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMiddleware appends additional Gin middleware during server construction.
|
||||||
|
func WithMiddleware(mw ...gin.HandlerFunc) ServerOption {
|
||||||
|
return func(cfg *serverOptionConfig) {
|
||||||
|
cfg.extraMiddleware = append(cfg.extraMiddleware, mw...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.
|
||||||
|
func WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {
|
||||||
|
return func(cfg *serverOptionConfig) {
|
||||||
|
cfg.engineConfigurator = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRouterConfigurator appends a callback after default routes are registered.
|
||||||
|
func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {
|
||||||
|
return func(cfg *serverOptionConfig) {
|
||||||
|
cfg.routerConfigurator = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.
|
||||||
|
func WithLocalManagementPassword(password string) ServerOption {
|
||||||
|
return func(cfg *serverOptionConfig) {
|
||||||
|
cfg.localPassword = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.
|
||||||
|
func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {
|
||||||
|
return func(cfg *serverOptionConfig) {
|
||||||
|
if timeout <= 0 || onTimeout == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.keepAliveEnabled = true
|
||||||
|
cfg.keepAliveTimeout = timeout
|
||||||
|
cfg.keepAliveOnTimeout = onTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRequestLoggerFactory customises request logger creation.
|
||||||
|
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
|
||||||
|
return func(cfg *serverOptionConfig) {
|
||||||
|
cfg.requestLoggerFactory = factory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server represents the main API server.
|
||||||
|
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
// engine is the Gin web framework engine instance.
|
||||||
engine *gin.Engine
|
engine *gin.Engine
|
||||||
|
|
||||||
|
// server is the underlying HTTP server.
|
||||||
server *http.Server
|
server *http.Server
|
||||||
handlers *APIHandlers
|
|
||||||
cfg *ServerConfig
|
// handlers contains the API handlers for processing requests.
|
||||||
|
handlers *handlers.BaseAPIHandler
|
||||||
|
|
||||||
|
// cfg holds the current server configuration.
|
||||||
|
cfg *config.Config
|
||||||
|
|
||||||
|
// accessManager handles request authentication providers.
|
||||||
|
accessManager *sdkaccess.Manager
|
||||||
|
|
||||||
|
// requestLogger is the request logger instance for dynamic configuration updates.
|
||||||
|
requestLogger logging.RequestLogger
|
||||||
|
loggerToggle func(bool)
|
||||||
|
|
||||||
|
// configFilePath is the absolute path to the YAML config file for persistence.
|
||||||
|
configFilePath string
|
||||||
|
|
||||||
|
// management handler
|
||||||
|
mgmt *managementHandlers.Handler
|
||||||
|
|
||||||
|
localPassword string
|
||||||
|
|
||||||
|
keepAliveEnabled bool
|
||||||
|
keepAliveTimeout time.Duration
|
||||||
|
keepAliveOnTimeout func()
|
||||||
|
keepAliveHeartbeat chan struct{}
|
||||||
|
keepAliveStop chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig contains configuration for the API server
|
// NewServer creates and initializes a new API server instance.
|
||||||
type ServerConfig struct {
|
// It sets up the Gin engine, middleware, routes, and handlers.
|
||||||
Port string
|
//
|
||||||
Debug bool
|
// Parameters:
|
||||||
ApiKeys []string
|
// - cfg: The server configuration
|
||||||
|
// - authManager: core runtime auth manager
|
||||||
|
// - accessManager: request authentication manager
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *Server: A new server instance
|
||||||
|
func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {
|
||||||
|
optionState := &serverOptionConfig{
|
||||||
|
requestLoggerFactory: defaultRequestLoggerFactory,
|
||||||
|
}
|
||||||
|
for i := range opts {
|
||||||
|
opts[i](optionState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server instance
|
|
||||||
func NewServer(config *ServerConfig, cliClients []*client.Client) *Server {
|
|
||||||
// Set gin mode
|
// Set gin mode
|
||||||
if !config.Debug {
|
if !cfg.Debug {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create handlers
|
|
||||||
handlers := NewAPIHandlers(cliClients, config.Debug)
|
|
||||||
|
|
||||||
// Create gin engine
|
// Create gin engine
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
|
if optionState.engineConfigurator != nil {
|
||||||
|
optionState.engineConfigurator(engine)
|
||||||
|
}
|
||||||
|
|
||||||
// Add middleware
|
// Add middleware
|
||||||
engine.Use(gin.Logger())
|
engine.Use(logging.GinLogrusLogger())
|
||||||
engine.Use(gin.Recovery())
|
engine.Use(logging.GinLogrusRecovery())
|
||||||
|
for _, mw := range optionState.extraMiddleware {
|
||||||
|
engine.Use(mw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request logging middleware (positioned after recovery, before auth)
|
||||||
|
// Resolve logs directory relative to the configuration file directory.
|
||||||
|
var requestLogger logging.RequestLogger
|
||||||
|
var toggle func(bool)
|
||||||
|
if optionState.requestLoggerFactory != nil {
|
||||||
|
requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)
|
||||||
|
}
|
||||||
|
if requestLogger != nil {
|
||||||
|
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
|
||||||
|
if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||||
|
toggle = setter.SetEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
engine.Use(corsMiddleware())
|
engine.Use(corsMiddleware())
|
||||||
|
|
||||||
// Create server instance
|
// Create server instance
|
||||||
s := &Server{
|
s := &Server{
|
||||||
engine: engine,
|
engine: engine,
|
||||||
handlers: handlers,
|
handlers: handlers.NewBaseAPIHandlers(cfg, authManager),
|
||||||
cfg: config,
|
cfg: cfg,
|
||||||
|
accessManager: accessManager,
|
||||||
|
requestLogger: requestLogger,
|
||||||
|
loggerToggle: toggle,
|
||||||
|
configFilePath: configFilePath,
|
||||||
}
|
}
|
||||||
|
s.applyAccessConfig(nil, cfg)
|
||||||
|
// Initialize management handler
|
||||||
|
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||||
|
if optionState.localPassword != "" {
|
||||||
|
s.mgmt.SetLocalPassword(optionState.localPassword)
|
||||||
|
}
|
||||||
|
s.localPassword = optionState.localPassword
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
|
if optionState.routerConfigurator != nil {
|
||||||
|
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if optionState.keepAliveEnabled {
|
||||||
|
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: ":" + config.Port,
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
Handler: engine,
|
Handler: engine,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupRoutes configures the API routes
|
// setupRoutes configures the API routes for the server.
|
||||||
|
// It defines the endpoints and associates them with their respective handlers.
|
||||||
func (s *Server) setupRoutes() {
|
func (s *Server) setupRoutes() {
|
||||||
|
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
|
||||||
|
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
|
||||||
|
geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)
|
||||||
|
claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)
|
||||||
|
openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
|
||||||
|
|
||||||
// OpenAI compatible API routes
|
// OpenAI compatible API routes
|
||||||
v1 := s.engine.Group("/v1")
|
v1 := s.engine.Group("/v1")
|
||||||
v1.Use(AuthMiddleware(s.cfg))
|
v1.Use(AuthMiddleware(s.accessManager))
|
||||||
{
|
{
|
||||||
v1.GET("/models", s.handlers.Models)
|
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
|
||||||
v1.POST("/chat/completions", s.handlers.ChatCompletions)
|
v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
|
||||||
|
v1.POST("/completions", openaiHandlers.Completions)
|
||||||
|
v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
|
||||||
|
v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
|
||||||
|
v1.POST("/responses", openaiResponsesHandlers.Responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini compatible API routes
|
||||||
|
v1beta := s.engine.Group("/v1beta")
|
||||||
|
v1beta.Use(AuthMiddleware(s.accessManager))
|
||||||
|
{
|
||||||
|
v1beta.GET("/models", geminiHandlers.GeminiModels)
|
||||||
|
v1beta.POST("/models/:action", geminiHandlers.GeminiHandler)
|
||||||
|
v1beta.GET("/models/:action", geminiHandlers.GeminiGetHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root endpoint
|
// Root endpoint
|
||||||
@@ -80,17 +258,241 @@ func (s *Server) setupRoutes() {
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"endpoints": []string{
|
"endpoints": []string{
|
||||||
"POST /v1/chat/completions",
|
"POST /v1/chat/completions",
|
||||||
|
"POST /v1/completions",
|
||||||
"GET /v1/models",
|
"GET /v1/models",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
|
||||||
|
|
||||||
|
// OAuth callback endpoints (reuse main server port)
|
||||||
|
// These endpoints receive provider redirects and persist
|
||||||
|
// the short-lived code/state for the waiting goroutine.
|
||||||
|
s.engine.GET("/anthropic/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
// Persist to a temporary file keyed by state
|
||||||
|
if state != "" {
|
||||||
|
file := fmt.Sprintf("%s/.oauth-anthropic-%s.oauth", s.cfg.AuthDir, state)
|
||||||
|
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
})
|
||||||
|
|
||||||
|
s.engine.GET("/codex/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
if state != "" {
|
||||||
|
file := fmt.Sprintf("%s/.oauth-codex-%s.oauth", s.cfg.AuthDir, state)
|
||||||
|
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
})
|
||||||
|
|
||||||
|
s.engine.GET("/google/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
if state != "" {
|
||||||
|
file := fmt.Sprintf("%s/.oauth-gemini-%s.oauth", s.cfg.AuthDir, state)
|
||||||
|
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Management API routes (delegated to management handlers)
|
||||||
|
// New logic: if remote-management-key is empty, do not expose any management endpoint (404).
|
||||||
|
if s.cfg.RemoteManagement.SecretKey != "" {
|
||||||
|
mgmt := s.engine.Group("/v0/management")
|
||||||
|
mgmt.Use(s.mgmt.Middleware())
|
||||||
|
{
|
||||||
|
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||||
|
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||||
|
|
||||||
|
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||||
|
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||||
|
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
||||||
|
|
||||||
|
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
|
||||||
|
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||||
|
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||||
|
|
||||||
|
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
|
||||||
|
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||||
|
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||||
|
|
||||||
|
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
|
||||||
|
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
|
||||||
|
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
|
||||||
|
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
|
||||||
|
|
||||||
|
mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
|
||||||
|
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||||
|
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||||
|
|
||||||
|
mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
|
||||||
|
mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||||
|
mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||||
|
|
||||||
|
mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
|
||||||
|
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
|
||||||
|
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
||||||
|
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
||||||
|
|
||||||
|
mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)
|
||||||
|
mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)
|
||||||
|
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
|
||||||
|
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
|
||||||
|
|
||||||
|
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
|
||||||
|
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
|
||||||
|
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
|
||||||
|
|
||||||
|
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
|
||||||
|
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
|
||||||
|
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
|
||||||
|
|
||||||
|
mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
|
||||||
|
mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
|
||||||
|
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
|
||||||
|
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
|
||||||
|
|
||||||
|
mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
|
||||||
|
mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
|
||||||
|
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
|
||||||
|
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
|
||||||
|
|
||||||
|
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
|
||||||
|
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
|
||||||
|
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
|
||||||
|
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
|
||||||
|
|
||||||
|
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||||
|
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||||
|
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||||
|
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||||
|
|
||||||
|
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||||
|
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||||
|
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||||
|
mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)
|
||||||
|
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||||
|
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the API server
|
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
|
||||||
|
if timeout <= 0 || onTimeout == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.keepAliveEnabled = true
|
||||||
|
s.keepAliveTimeout = timeout
|
||||||
|
s.keepAliveOnTimeout = onTimeout
|
||||||
|
s.keepAliveHeartbeat = make(chan struct{}, 1)
|
||||||
|
s.keepAliveStop = make(chan struct{}, 1)
|
||||||
|
|
||||||
|
s.engine.GET("/keep-alive", s.handleKeepAlive)
|
||||||
|
|
||||||
|
go s.watchKeepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleKeepAlive(c *gin.Context) {
|
||||||
|
if s.localPassword != "" {
|
||||||
|
provided := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
if provided != "" {
|
||||||
|
parts := strings.SplitN(provided, " ", 2)
|
||||||
|
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
|
||||||
|
provided = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provided == "" {
|
||||||
|
provided = strings.TrimSpace(c.GetHeader("X-Local-Password"))
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(s.localPassword)) != 1 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.signalKeepAlive()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) signalKeepAlive() {
|
||||||
|
if !s.keepAliveEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.keepAliveHeartbeat <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) watchKeepAlive() {
|
||||||
|
if !s.keepAliveEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(s.keepAliveTimeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
log.Warnf("keep-alive endpoint idle for %s, shutting down", s.keepAliveTimeout)
|
||||||
|
if s.keepAliveOnTimeout != nil {
|
||||||
|
s.keepAliveOnTimeout()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-s.keepAliveHeartbeat:
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(s.keepAliveTimeout)
|
||||||
|
case <-s.keepAliveStop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
|
||||||
|
// that routes to different handlers based on the User-Agent header.
|
||||||
|
// If User-Agent starts with "claude-cli", it routes to Claude handler,
|
||||||
|
// otherwise it routes to OpenAI handler.
|
||||||
|
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
|
// Route to Claude handler if User-Agent starts with "claude-cli"
|
||||||
|
if strings.HasPrefix(userAgent, "claude-cli") {
|
||||||
|
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
|
||||||
|
claudeHandler.ClaudeModels(c)
|
||||||
|
} else {
|
||||||
|
// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)
|
||||||
|
openaiHandler.OpenAIModels(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening for and serving HTTP requests.
|
||||||
|
// It's a blocking call and will only return on an unrecoverable error.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the server fails to start
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
log.Debugf("Starting API server on %s", s.server.Addr)
|
log.Debugf("Starting API server on %s", s.server.Addr)
|
||||||
|
|
||||||
// Start the HTTP server
|
// Start the HTTP server.
|
||||||
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
return fmt.Errorf("failed to start HTTP server: %v", err)
|
return fmt.Errorf("failed to start HTTP server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,11 +500,25 @@ func (s *Server) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the API server
|
// Stop gracefully shuts down the API server without interrupting any
|
||||||
|
// active connections.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for graceful shutdown
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the server fails to stop
|
||||||
func (s *Server) Stop(ctx context.Context) error {
|
func (s *Server) Stop(ctx context.Context) error {
|
||||||
log.Debug("Stopping API server...")
|
log.Debug("Stopping API server...")
|
||||||
|
|
||||||
// Shutdown the HTTP server
|
if s.keepAliveEnabled {
|
||||||
|
select {
|
||||||
|
case s.keepAliveStop <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown the HTTP server.
|
||||||
if err := s.server.Shutdown(ctx); err != nil {
|
if err := s.server.Shutdown(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
|
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -111,12 +527,16 @@ func (s *Server) Stop(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// corsMiddleware adds CORS headers
|
// corsMiddleware returns a Gin middleware handler that adds CORS headers
|
||||||
|
// to every response, allowing cross-origin requests.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - gin.HandlerFunc: The CORS middleware handler
|
||||||
func corsMiddleware() gin.HandlerFunc {
|
func corsMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
c.Header("Access-Control-Allow-Headers", "*")
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
@@ -127,50 +547,143 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthMiddleware authenticates requests using API keys
|
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
|
||||||
func AuthMiddleware(cfg *ServerConfig) gin.HandlerFunc {
|
if s == nil || s.accessManager == nil || newCfg == nil {
|
||||||
return func(c *gin.Context) {
|
|
||||||
if len(cfg.ApiKeys) == 0 {
|
|
||||||
c.Next()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
existing := s.accessManager.Providers()
|
||||||
// Get the Authorization header
|
providers, added, updated, removed, err := sdkaccess.ReconcileProviders(oldCfg, newCfg, existing)
|
||||||
authHeader := c.GetHeader("Authorization")
|
if err != nil {
|
||||||
if authHeader == "" {
|
log.Errorf("failed to reconcile request auth providers: %v", err)
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "Missing API key",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.accessManager.SetProviders(providers)
|
||||||
// Extract the API key
|
if len(added)+len(updated)+len(removed) > 0 {
|
||||||
parts := strings.Split(authHeader, " ")
|
log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
|
||||||
var apiKey string
|
log.Debugf("auth provider changes details - added=%v updated=%v removed=%v", added, updated, removed)
|
||||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
|
||||||
apiKey = parts[1]
|
|
||||||
} else {
|
} else {
|
||||||
apiKey = authHeader
|
log.Debug("auth providers unchanged after config update")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the API key in the in-memory list
|
// UpdateClients updates the server's client list and configuration.
|
||||||
var foundKey string
|
// This method is called when the configuration or authentication tokens change.
|
||||||
for i := range cfg.ApiKeys {
|
//
|
||||||
if cfg.ApiKeys[i] == apiKey {
|
// Parameters:
|
||||||
foundKey = cfg.ApiKeys[i]
|
// - clients: The new slice of AI service clients
|
||||||
break
|
// - cfg: The new application configuration
|
||||||
|
func (s *Server) UpdateClients(cfg *config.Config) {
|
||||||
|
oldCfg := s.cfg
|
||||||
|
|
||||||
|
// Update request logger enabled state if it has changed
|
||||||
|
previousRequestLog := false
|
||||||
|
if oldCfg != nil {
|
||||||
|
previousRequestLog = oldCfg.RequestLog
|
||||||
|
}
|
||||||
|
if s.requestLogger != nil && (oldCfg == nil || previousRequestLog != cfg.RequestLog) {
|
||||||
|
if s.loggerToggle != nil {
|
||||||
|
s.loggerToggle(cfg.RequestLog)
|
||||||
|
} else if toggler, ok := s.requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||||
|
toggler.SetEnabled(cfg.RequestLog)
|
||||||
|
}
|
||||||
|
if oldCfg != nil {
|
||||||
|
log.Debugf("request logging updated from %t to %t", previousRequestLog, cfg.RequestLog)
|
||||||
|
} else {
|
||||||
|
log.Debugf("request logging toggled to %t", cfg.RequestLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if foundKey == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
if oldCfg != nil && oldCfg.LoggingToFile != cfg.LoggingToFile {
|
||||||
"error": "Invalid API key",
|
if err := logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||||
})
|
log.Errorf("failed to reconfigure log output: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
|
||||||
|
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||||
|
if oldCfg != nil {
|
||||||
|
log.Debugf("usage_statistics_enabled updated from %t to %t", oldCfg.UsageStatisticsEnabled, cfg.UsageStatisticsEnabled)
|
||||||
|
} else {
|
||||||
|
log.Debugf("usage_statistics_enabled toggled to %t", cfg.UsageStatisticsEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update log level dynamically when debug flag changes
|
||||||
|
if oldCfg == nil || oldCfg.Debug != cfg.Debug {
|
||||||
|
util.SetLogLevel(cfg)
|
||||||
|
if oldCfg != nil {
|
||||||
|
log.Debugf("debug mode updated from %t to %t", oldCfg.Debug, cfg.Debug)
|
||||||
|
} else {
|
||||||
|
log.Debugf("debug mode toggled to %t", cfg.Debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.applyAccessConfig(oldCfg, cfg)
|
||||||
|
s.cfg = cfg
|
||||||
|
s.handlers.UpdateClients(cfg)
|
||||||
|
if s.mgmt != nil {
|
||||||
|
s.mgmt.SetConfig(cfg)
|
||||||
|
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count client sources from configuration and auth directory
|
||||||
|
authFiles := util.CountAuthFiles(cfg.AuthDir)
|
||||||
|
glAPIKeyCount := len(cfg.GlAPIKey)
|
||||||
|
claudeAPIKeyCount := len(cfg.ClaudeKey)
|
||||||
|
codexAPIKeyCount := len(cfg.CodexKey)
|
||||||
|
openAICompatCount := 0
|
||||||
|
for i := range cfg.OpenAICompatibility {
|
||||||
|
openAICompatCount += len(cfg.OpenAICompatibility[i].APIKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := authFiles + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
||||||
|
fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n",
|
||||||
|
total,
|
||||||
|
authFiles,
|
||||||
|
glAPIKeyCount,
|
||||||
|
claudeAPIKeyCount,
|
||||||
|
codexAPIKeyCount,
|
||||||
|
openAICompatCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (management handlers moved to internal/api/handlers/management)
|
||||||
|
|
||||||
|
// AuthMiddleware returns a Gin middleware handler that authenticates requests
|
||||||
|
// using the configured authentication providers. When no providers are available,
|
||||||
|
// it allows all requests (legacy behaviour).
|
||||||
|
func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if manager == nil {
|
||||||
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the API key and user in the context
|
result, err := manager.Authenticate(c.Request.Context(), c.Request)
|
||||||
c.Set("apiKey", foundKey)
|
if err == nil {
|
||||||
|
if result != nil {
|
||||||
|
c.Set("apiKey", result.Principal)
|
||||||
|
c.Set("accessProvider", result.Provider)
|
||||||
|
if len(result.Metadata) > 0 {
|
||||||
|
c.Set("accessMetadata", result.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"})
|
||||||
|
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||||
|
default:
|
||||||
|
log.Errorf("authentication middleware error: %v", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacy clientsToSlice removed; handlers no longer consume legacy client slices
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/skratchdot/open-golang/open"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"golang.org/x/net/proxy"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/google"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
oauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
|
||||||
oauthClientSecret = "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"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
oauthScopes = []string{
|
|
||||||
"https://www.googleapis.com/auth/cloud-platform",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetAuthenticatedClient configures and returns an HTTP client with OAuth2 tokens.
|
|
||||||
// It handles the entire flow: loading, refreshing, and fetching new tokens.
|
|
||||||
func GetAuthenticatedClient(ctx context.Context, ts *TokenStorage, cfg *config.Config) (*http.Client, error) {
|
|
||||||
proxyURL, err := url.Parse(cfg.ProxyUrl)
|
|
||||||
if err == nil {
|
|
||||||
if proxyURL.Scheme == "socks5" {
|
|
||||||
username := proxyURL.User.Username()
|
|
||||||
password, _ := proxyURL.User.Password()
|
|
||||||
auth := &proxy.Auth{
|
|
||||||
User: username,
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct)
|
|
||||||
if errSOCKS5 != nil {
|
|
||||||
log.Fatalf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
|
||||||
}
|
|
||||||
|
|
||||||
transport := &http.Transport{
|
|
||||||
DialContext: func(ctx context.Context, network, addr string) (c net.Conn, err error) {
|
|
||||||
return dialer.Dial(network, addr)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
proxyClient := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
|
||||||
} else if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
|
||||||
transport := &http.Transport{
|
|
||||||
Proxy: http.ProxyURL(proxyURL),
|
|
||||||
}
|
|
||||||
proxyClient := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
}
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conf := &oauth2.Config{
|
|
||||||
ClientID: oauthClientID,
|
|
||||||
ClientSecret: oauthClientSecret,
|
|
||||||
RedirectURL: "http://localhost:8085/oauth2callback", // Placeholder, will be updated
|
|
||||||
Scopes: oauthScopes,
|
|
||||||
Endpoint: google.Endpoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
var token *oauth2.Token
|
|
||||||
|
|
||||||
if ts.Token == nil {
|
|
||||||
log.Info("Could not load token from file, starting OAuth flow.")
|
|
||||||
token, err = getTokenFromWeb(ctx, conf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get token from web: %w", err)
|
|
||||||
}
|
|
||||||
newTs, errSaveTokenToFile := createTokenStorage(ctx, conf, token, ts.ProjectID)
|
|
||||||
if errSaveTokenToFile != nil {
|
|
||||||
log.Errorf("Warning: failed to save token to file: %v", err)
|
|
||||||
return nil, errSaveTokenToFile
|
|
||||||
}
|
|
||||||
*ts = *newTs
|
|
||||||
}
|
|
||||||
tsToken, _ := json.Marshal(ts.Token)
|
|
||||||
if err = json.Unmarshal(tsToken, &token); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return conf.Client(ctx, token), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTokenStorage creates a token storage.
|
|
||||||
func createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*TokenStorage, error) {
|
|
||||||
httpClient := config.Client(ctx, token)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not get user info: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
emailResult := gjson.GetBytes(bodyBytes, "email")
|
|
||||||
if emailResult.Exists() && emailResult.Type == gjson.String {
|
|
||||||
log.Infof("Authenticated user email: %s", emailResult.String())
|
|
||||||
} else {
|
|
||||||
log.Info("Failed to get user email from token")
|
|
||||||
}
|
|
||||||
|
|
||||||
var ifToken map[string]any
|
|
||||||
jsonData, _ := json.Marshal(token)
|
|
||||||
err = json.Unmarshal(jsonData, &ifToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
|
|
||||||
ifToken["client_id"] = oauthClientID
|
|
||||||
ifToken["client_secret"] = oauthClientSecret
|
|
||||||
ifToken["scopes"] = oauthScopes
|
|
||||||
ifToken["universe_domain"] = "googleapis.com"
|
|
||||||
|
|
||||||
ts := TokenStorage{
|
|
||||||
Token: ifToken,
|
|
||||||
ProjectID: projectID,
|
|
||||||
Email: emailResult.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTokenFromWeb starts a local server to handle the OAuth2 flow.
|
|
||||||
func getTokenFromWeb(ctx context.Context, config *oauth2.Config) (*oauth2.Token, error) {
|
|
||||||
// Use a channel to pass the authorization code from the HTTP handler to the main function.
|
|
||||||
codeChan := make(chan string)
|
|
||||||
errChan := make(chan error)
|
|
||||||
|
|
||||||
// Create a new HTTP server.
|
|
||||||
server := &http.Server{Addr: "localhost:8085"}
|
|
||||||
config.RedirectURL = "http://localhost:8085/oauth2callback"
|
|
||||||
|
|
||||||
http.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.URL.Query().Get("error"); err != "" {
|
|
||||||
_, _ = fmt.Fprintf(w, "Authentication failed: %s", err)
|
|
||||||
errChan <- fmt.Errorf("authentication failed via callback: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
_, _ = fmt.Fprint(w, "Authentication failed: code not found.")
|
|
||||||
errChan <- fmt.Errorf("code not found in callback")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprint(w, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
|
||||||
codeChan <- code
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start the server in a goroutine.
|
|
||||||
go func() {
|
|
||||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Fatalf("ListenAndServe(): %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Open the authorization URL in the user's browser.
|
|
||||||
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
|
||||||
log.Debugf("CLI login required.\nAttempting to open authentication page in your browser.\nIf it does not open, please navigate to this URL:\n\n%s\n", authURL)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
err = open.Run(authURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to open browser: %v. Please open the URL manually.", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the authorization code or an error.
|
|
||||||
var authCode string
|
|
||||||
select {
|
|
||||||
case code := <-codeChan:
|
|
||||||
authCode = code
|
|
||||||
case err = <-errChan:
|
|
||||||
return nil, err
|
|
||||||
case <-time.After(5 * time.Minute): // Timeout
|
|
||||||
return nil, fmt.Errorf("oauth flow timed out")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown the server.
|
|
||||||
if err = server.Shutdown(ctx); err != nil {
|
|
||||||
log.Errorf("Failed to shut down server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange the authorization code for a token.
|
|
||||||
token, err := config.Exchange(ctx, authCode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Authentication successful.")
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
32
internal/auth/claude/anthropic.go
Normal file
32
internal/auth/claude/anthropic.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
|
||||||
|
type PKCECodes struct {
|
||||||
|
// CodeVerifier is the cryptographically random string used to correlate
|
||||||
|
// the authorization request to the token request
|
||||||
|
CodeVerifier string `json:"code_verifier"`
|
||||||
|
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
|
||||||
|
CodeChallenge string `json:"code_challenge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeTokenData holds OAuth token information from Anthropic
|
||||||
|
type ClaudeTokenData struct {
|
||||||
|
// AccessToken is the OAuth2 access token for API access
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is used to obtain new access tokens
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// Email is the Anthropic account email
|
||||||
|
Email string `json:"email"`
|
||||||
|
// Expire is the timestamp of the token expire
|
||||||
|
Expire string `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeAuthBundle aggregates authentication data after OAuth flow completion
|
||||||
|
type ClaudeAuthBundle struct {
|
||||||
|
// APIKey is the Anthropic API key obtained from token exchange
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
// TokenData contains the OAuth tokens from the authentication flow
|
||||||
|
TokenData ClaudeTokenData `json:"token_data"`
|
||||||
|
// LastRefresh is the timestamp of the last token refresh
|
||||||
|
LastRefresh string `json:"last_refresh"`
|
||||||
|
}
|
||||||
346
internal/auth/claude/anthropic_auth.go
Normal file
346
internal/auth/claude/anthropic_auth.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.
|
||||||
|
// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)
|
||||||
|
// for secure authentication with Claude API, including token exchange, refresh, and storage.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anthropicAuthURL = "https://claude.ai/oauth/authorize"
|
||||||
|
anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token"
|
||||||
|
anthropicClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
redirectURI = "http://localhost:54545/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
|
||||||
|
// It contains access token, refresh token, and associated user/organization information.
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Organization struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"organization"`
|
||||||
|
Account struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
|
} `json:"account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeAuth handles Anthropic OAuth2 authentication flow.
|
||||||
|
// It provides methods for generating authorization URLs, exchanging codes for tokens,
|
||||||
|
// and refreshing expired tokens using PKCE for enhanced security.
|
||||||
|
type ClaudeAuth struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClaudeAuth creates a new Anthropic authentication service.
|
||||||
|
// It initializes the HTTP client with proxy settings from the configuration.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration containing proxy settings
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ClaudeAuth: A new Claude authentication service instance
|
||||||
|
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
|
||||||
|
return &ClaudeAuth{
|
||||||
|
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAuthURL creates the OAuth authorization URL with PKCE.
|
||||||
|
// This method generates a secure authorization URL including PKCE challenge codes
|
||||||
|
// for the OAuth2 flow with Anthropic's API.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - state: A random state parameter for CSRF protection
|
||||||
|
// - pkceCodes: The PKCE codes for secure code exchange
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The complete authorization URL
|
||||||
|
// - string: The state parameter for verification
|
||||||
|
// - error: An error if PKCE codes are missing or URL generation fails
|
||||||
|
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {
|
||||||
|
if pkceCodes == nil {
|
||||||
|
return "", "", fmt.Errorf("PKCE codes are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"code": {"true"},
|
||||||
|
"client_id": {anthropicClientID},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"redirect_uri": {redirectURI},
|
||||||
|
"scope": {"org:create_api_key user:profile user:inference"},
|
||||||
|
"code_challenge": {pkceCodes.CodeChallenge},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"state": {state},
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := fmt.Sprintf("%s?%s", anthropicAuthURL, params.Encode())
|
||||||
|
return authURL, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCodeAndState extracts the authorization code and state from the callback response.
|
||||||
|
// It handles the parsing of the code parameter which may contain additional fragments.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - code: The raw code parameter from the OAuth callback
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - parsedCode: The extracted authorization code
|
||||||
|
// - parsedState: The extracted state parameter if present
|
||||||
|
func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {
|
||||||
|
splits := strings.Split(code, "#")
|
||||||
|
parsedCode = splits[0]
|
||||||
|
if len(splits) > 1 {
|
||||||
|
parsedState = splits[1]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeCodeForTokens exchanges authorization code for access tokens.
|
||||||
|
// This method implements the OAuth2 token exchange flow using PKCE for security.
|
||||||
|
// It sends the authorization code along with PKCE verifier to get access and refresh tokens.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for the request
|
||||||
|
// - code: The authorization code received from OAuth callback
|
||||||
|
// - state: The state parameter for verification
|
||||||
|
// - pkceCodes: The PKCE codes for secure verification
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ClaudeAuthBundle: The complete authentication bundle with tokens
|
||||||
|
// - error: An error if token exchange fails
|
||||||
|
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {
|
||||||
|
if pkceCodes == nil {
|
||||||
|
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||||
|
}
|
||||||
|
newCode, newState := o.parseCodeAndState(code)
|
||||||
|
|
||||||
|
// Prepare token exchange request
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"code": newCode,
|
||||||
|
"state": state,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": anthropicClientID,
|
||||||
|
"redirect_uri": redirectURI,
|
||||||
|
"code_verifier": pkceCodes.CodeVerifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include state if present
|
||||||
|
if newState != "" {
|
||||||
|
reqBody["state"] = newState
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Debugf("Token exchange request: %s", string(jsonBody))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := o.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("failed to close response body: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||||
|
}
|
||||||
|
// log.Debugf("Token response: %s", string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
// log.Debugf("Token response: %s", string(body))
|
||||||
|
|
||||||
|
var tokenResp tokenResponse
|
||||||
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token data
|
||||||
|
tokenData := ClaudeTokenData{
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
Email: tokenResp.Account.EmailAddress,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth bundle
|
||||||
|
bundle := &ClaudeAuthBundle{
|
||||||
|
TokenData: tokenData,
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokens refreshes the access token using the refresh token.
|
||||||
|
// This method exchanges a valid refresh token for a new access token,
|
||||||
|
// extending the user's authenticated session.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for the request
|
||||||
|
// - refreshToken: The refresh token to use for getting new access token
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ClaudeTokenData: The new token data with updated access token
|
||||||
|
// - error: An error if token refresh fails
|
||||||
|
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
|
||||||
|
if refreshToken == "" {
|
||||||
|
return nil, fmt.Errorf("refresh token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"client_id": anthropicClientID,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := o.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Debugf("Token response: %s", string(body))
|
||||||
|
|
||||||
|
var tokenResp tokenResponse
|
||||||
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token data
|
||||||
|
return &ClaudeTokenData{
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
Email: tokenResp.Account.EmailAddress,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info.
|
||||||
|
// This method converts the authentication bundle into a token storage structure
|
||||||
|
// suitable for persistence and later use.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - bundle: The authentication bundle containing token data
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ClaudeTokenStorage: A new token storage instance
|
||||||
|
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {
|
||||||
|
storage := &ClaudeTokenStorage{
|
||||||
|
AccessToken: bundle.TokenData.AccessToken,
|
||||||
|
RefreshToken: bundle.TokenData.RefreshToken,
|
||||||
|
LastRefresh: bundle.LastRefresh,
|
||||||
|
Email: bundle.TokenData.Email,
|
||||||
|
Expire: bundle.TokenData.Expire,
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokensWithRetry refreshes tokens with automatic retry logic.
|
||||||
|
// This method implements exponential backoff retry logic for token refresh operations,
|
||||||
|
// providing resilience against temporary network or service issues.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for the request
|
||||||
|
// - refreshToken: The refresh token to use
|
||||||
|
// - maxRetries: The maximum number of retry attempts
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ClaudeTokenData: The refreshed token data
|
||||||
|
// - error: An error if all retry attempts fail
|
||||||
|
func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
// Wait before retry
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(time.Duration(attempt) * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||||
|
if err == nil {
|
||||||
|
return tokenData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTokenStorage updates an existing token storage with new token data.
|
||||||
|
// This method refreshes the token storage with newly obtained access and refresh tokens,
|
||||||
|
// updating timestamps and expiration information.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - storage: The existing token storage to update
|
||||||
|
// - tokenData: The new token data to apply
|
||||||
|
func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {
|
||||||
|
storage.AccessToken = tokenData.AccessToken
|
||||||
|
storage.RefreshToken = tokenData.RefreshToken
|
||||||
|
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||||
|
storage.Email = tokenData.Email
|
||||||
|
storage.Expire = tokenData.Expire
|
||||||
|
}
|
||||||
167
internal/auth/claude/errors.go
Normal file
167
internal/auth/claude/errors.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// Package claude provides authentication and token management functionality
|
||||||
|
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthError represents an OAuth-specific error.
|
||||||
|
type OAuthError struct {
|
||||||
|
// Code is the OAuth error code.
|
||||||
|
Code string `json:"error"`
|
||||||
|
// Description is a human-readable description of the error.
|
||||||
|
Description string `json:"error_description,omitempty"`
|
||||||
|
// URI is a URI identifying a human-readable web page with information about the error.
|
||||||
|
URI string `json:"error_uri,omitempty"`
|
||||||
|
// StatusCode is the HTTP status code associated with the error.
|
||||||
|
StatusCode int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a string representation of the OAuth error.
|
||||||
|
func (e *OAuthError) Error() string {
|
||||||
|
if e.Description != "" {
|
||||||
|
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||||
|
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||||
|
return &OAuthError{
|
||||||
|
Code: code,
|
||||||
|
Description: description,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticationError represents authentication-related errors.
|
||||||
|
type AuthenticationError struct {
|
||||||
|
// Type is the type of authentication error.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// Message is a human-readable message describing the error.
|
||||||
|
Message string `json:"message"`
|
||||||
|
// Code is the HTTP status code associated with the error.
|
||||||
|
Code int `json:"code"`
|
||||||
|
// Cause is the underlying error that caused this authentication error.
|
||||||
|
Cause error `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a string representation of the authentication error.
|
||||||
|
func (e *AuthenticationError) Error() string {
|
||||||
|
if e.Cause != nil {
|
||||||
|
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common authentication error types.
|
||||||
|
var (
|
||||||
|
// ErrTokenExpired = &AuthenticationError{
|
||||||
|
// Type: "token_expired",
|
||||||
|
// Message: "Access token has expired",
|
||||||
|
// Code: http.StatusUnauthorized,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||||
|
ErrInvalidState = &AuthenticationError{
|
||||||
|
Type: "invalid_state",
|
||||||
|
Message: "OAuth state parameter is invalid",
|
||||||
|
Code: http.StatusBadRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||||
|
ErrCodeExchangeFailed = &AuthenticationError{
|
||||||
|
Type: "code_exchange_failed",
|
||||||
|
Message: "Failed to exchange authorization code for tokens",
|
||||||
|
Code: http.StatusBadRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||||
|
ErrServerStartFailed = &AuthenticationError{
|
||||||
|
Type: "server_start_failed",
|
||||||
|
Message: "Failed to start OAuth callback server",
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||||
|
ErrPortInUse = &AuthenticationError{
|
||||||
|
Type: "port_in_use",
|
||||||
|
Message: "OAuth callback port is already in use",
|
||||||
|
Code: 13, // Special exit code for port-in-use
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||||
|
ErrCallbackTimeout = &AuthenticationError{
|
||||||
|
Type: "callback_timeout",
|
||||||
|
Message: "Timeout waiting for OAuth callback",
|
||||||
|
Code: http.StatusRequestTimeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||||
|
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||||
|
return &AuthenticationError{
|
||||||
|
Type: baseErr.Type,
|
||||||
|
Message: baseErr.Message,
|
||||||
|
Code: baseErr.Code,
|
||||||
|
Cause: cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticationError checks if an error is an authentication error.
|
||||||
|
func IsAuthenticationError(err error) bool {
|
||||||
|
var authenticationError *AuthenticationError
|
||||||
|
ok := errors.As(err, &authenticationError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOAuthError checks if an error is an OAuth error.
|
||||||
|
func IsOAuthError(err error) bool {
|
||||||
|
var oAuthError *OAuthError
|
||||||
|
ok := errors.As(err, &oAuthError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||||
|
func GetUserFriendlyMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case IsAuthenticationError(err):
|
||||||
|
var authErr *AuthenticationError
|
||||||
|
errors.As(err, &authErr)
|
||||||
|
switch authErr.Type {
|
||||||
|
case "token_expired":
|
||||||
|
return "Your authentication has expired. Please log in again."
|
||||||
|
case "token_invalid":
|
||||||
|
return "Your authentication is invalid. Please log in again."
|
||||||
|
case "authentication_required":
|
||||||
|
return "Please log in to continue."
|
||||||
|
case "port_in_use":
|
||||||
|
return "The required port is already in use. Please close any applications using port 3000 and try again."
|
||||||
|
case "callback_timeout":
|
||||||
|
return "Authentication timed out. Please try again."
|
||||||
|
case "browser_open_failed":
|
||||||
|
return "Could not open your browser automatically. Please copy and paste the URL manually."
|
||||||
|
default:
|
||||||
|
return "Authentication failed. Please try again."
|
||||||
|
}
|
||||||
|
case IsOAuthError(err):
|
||||||
|
var oauthErr *OAuthError
|
||||||
|
errors.As(err, &oauthErr)
|
||||||
|
switch oauthErr.Code {
|
||||||
|
case "access_denied":
|
||||||
|
return "Authentication was cancelled or denied."
|
||||||
|
case "invalid_request":
|
||||||
|
return "Invalid authentication request. Please try again."
|
||||||
|
case "server_error":
|
||||||
|
return "Authentication server error. Please try again later."
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "An unexpected error occurred. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
218
internal/auth/claude/html_templates.go
Normal file
218
internal/auth/claude/html_templates.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
// Package claude provides authentication and token management functionality
|
||||||
|
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
// LoginSuccessHtml is the HTML template displayed to users after successful OAuth authentication.
|
||||||
|
// This template provides a user-friendly success page with options to close the window
|
||||||
|
// or navigate to the Claude platform. It includes automatic window closing functionality
|
||||||
|
// and keyboard accessibility features.
|
||||||
|
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Successful - Claude</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.setup-notice {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.setup-notice h3 {
|
||||||
|
color: #92400e;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.setup-notice p {
|
||||||
|
color: #92400e;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.setup-notice a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.setup-notice a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.button-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
.button-secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.countdown {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p class="subtitle">You have successfully authenticated with Claude. You can now close this window and return to your terminal to continue.</p>
|
||||||
|
|
||||||
|
{{SETUP_NOTICE}}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button button-primary" onclick="window.close()">
|
||||||
|
<span>Close Window</span>
|
||||||
|
</button>
|
||||||
|
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
|
||||||
|
<span>Open Platform</span>
|
||||||
|
<span>↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="countdown">
|
||||||
|
This window will close automatically in <span id="countdown">10</span> seconds
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let countdown = 10;
|
||||||
|
const countdownElement = document.getElementById('countdown');
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
countdown--;
|
||||||
|
countdownElement.textContent = countdown;
|
||||||
|
|
||||||
|
if (countdown <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Close window when user presses Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus the close button for keyboard accessibility
|
||||||
|
document.querySelector('.button-primary').focus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// SetupNoticeHtml is the HTML template for the setup notice section.
|
||||||
|
// This template is embedded within the success page to inform users about
|
||||||
|
// additional setup steps required to complete their Claude account configuration.
|
||||||
|
const SetupNoticeHtml = `
|
||||||
|
<div class="setup-notice">
|
||||||
|
<h3>Additional Setup Required</h3>
|
||||||
|
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Claude</a> to configure your account.</p>
|
||||||
|
</div>`
|
||||||
320
internal/auth/claude/oauth_server.go
Normal file
320
internal/auth/claude/oauth_server.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
// Package claude provides authentication and token management functionality
|
||||||
|
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||||
|
// It listens for the authorization code response from the OAuth provider
|
||||||
|
// and captures the necessary parameters to complete the authentication flow.
|
||||||
|
type OAuthServer struct {
|
||||||
|
// server is the underlying HTTP server instance
|
||||||
|
server *http.Server
|
||||||
|
// port is the port number on which the server listens
|
||||||
|
port int
|
||||||
|
// resultChan is a channel for sending OAuth results
|
||||||
|
resultChan chan *OAuthResult
|
||||||
|
// errorChan is a channel for sending OAuth errors
|
||||||
|
errorChan chan error
|
||||||
|
// mu is a mutex for protecting server state
|
||||||
|
mu sync.Mutex
|
||||||
|
// running indicates whether the server is currently running
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthResult contains the result of the OAuth callback.
|
||||||
|
// It holds either the authorization code and state for successful authentication
|
||||||
|
// or an error message if the authentication failed.
|
||||||
|
type OAuthResult struct {
|
||||||
|
// Code is the authorization code received from the OAuth provider
|
||||||
|
Code string
|
||||||
|
// State is the state parameter used to prevent CSRF attacks
|
||||||
|
State string
|
||||||
|
// Error contains any error message if the OAuth flow failed
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthServer creates a new OAuth callback server.
|
||||||
|
// It initializes the server with the specified port and creates channels
|
||||||
|
// for handling OAuth results and errors.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - port: The port number on which the server should listen
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *OAuthServer: A new OAuthServer instance
|
||||||
|
func NewOAuthServer(port int) *OAuthServer {
|
||||||
|
return &OAuthServer{
|
||||||
|
port: port,
|
||||||
|
resultChan: make(chan *OAuthResult, 1),
|
||||||
|
errorChan: make(chan error, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the OAuth callback server.
|
||||||
|
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||||
|
// and begins listening on the specified port.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the server fails to start
|
||||||
|
func (s *OAuthServer) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.running {
|
||||||
|
return fmt.Errorf("server is already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if port is available
|
||||||
|
if !s.isPortAvailable() {
|
||||||
|
return fmt.Errorf("port %d is already in use", s.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/callback", s.handleCallback)
|
||||||
|
mux.HandleFunc("/success", s.handleSuccess)
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.port),
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = true
|
||||||
|
|
||||||
|
// Start server in goroutine
|
||||||
|
go func() {
|
||||||
|
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give server a moment to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the OAuth callback server.
|
||||||
|
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for controlling the shutdown process
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the server fails to stop gracefully
|
||||||
|
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if !s.running || s.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Stopping OAuth callback server")
|
||||||
|
|
||||||
|
// Create a context with timeout for shutdown
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := s.server.Shutdown(shutdownCtx)
|
||||||
|
s.running = false
|
||||||
|
s.server = nil
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||||
|
// It blocks until either an OAuth result is received, an error occurs,
|
||||||
|
// or the specified timeout is reached.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - timeout: The maximum time to wait for the callback
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *OAuthResult: The OAuth result if successful
|
||||||
|
// - error: An error if the callback times out or an error occurs
|
||||||
|
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||||
|
select {
|
||||||
|
case result := <-s.resultChan:
|
||||||
|
return result, nil
|
||||||
|
case err := <-s.errorChan:
|
||||||
|
return nil, err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallback handles the OAuth callback endpoint.
|
||||||
|
// It extracts the authorization code and state from the callback URL,
|
||||||
|
// validates the parameters, and sends the result to the waiting channel.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - w: The HTTP response writer
|
||||||
|
// - r: The HTTP request
|
||||||
|
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Debug("Received OAuth callback")
|
||||||
|
|
||||||
|
// Validate request method
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract parameters
|
||||||
|
query := r.URL.Query()
|
||||||
|
code := query.Get("code")
|
||||||
|
state := query.Get("state")
|
||||||
|
errorParam := query.Get("error")
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if errorParam != "" {
|
||||||
|
log.Errorf("OAuth error received: %s", errorParam)
|
||||||
|
result := &OAuthResult{
|
||||||
|
Error: errorParam,
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
log.Error("No authorization code received")
|
||||||
|
result := &OAuthResult{
|
||||||
|
Error: "no_code",
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == "" {
|
||||||
|
log.Error("No state parameter received")
|
||||||
|
result := &OAuthResult{
|
||||||
|
Error: "no_state",
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
http.Error(w, "No state parameter received", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send successful result
|
||||||
|
result := &OAuthResult{
|
||||||
|
Code: code,
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
|
||||||
|
// Redirect to success page
|
||||||
|
http.Redirect(w, r, "/success", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSuccess handles the success page endpoint.
|
||||||
|
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - w: The HTTP response writer
|
||||||
|
// - r: The HTTP request
|
||||||
|
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Debug("Serving success page")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Parse query parameters for customization
|
||||||
|
query := r.URL.Query()
|
||||||
|
setupRequired := query.Get("setup_required") == "true"
|
||||||
|
platformURL := query.Get("platform_url")
|
||||||
|
if platformURL == "" {
|
||||||
|
platformURL = "https://console.anthropic.com/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate success page HTML with dynamic content
|
||||||
|
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
|
||||||
|
|
||||||
|
_, err := w.Write([]byte(successHTML))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to write success page: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSuccessHTML creates the HTML content for the success page.
|
||||||
|
// It customizes the page based on whether additional setup is required
|
||||||
|
// and includes a link to the platform.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - setupRequired: Whether additional setup is required after authentication
|
||||||
|
// - platformURL: The URL to the platform for additional setup
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The HTML content for the success page
|
||||||
|
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||||
|
html := LoginSuccessHtml
|
||||||
|
|
||||||
|
// Replace platform URL placeholder
|
||||||
|
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
|
||||||
|
|
||||||
|
// Add setup notice if required
|
||||||
|
if setupRequired {
|
||||||
|
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
|
||||||
|
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
|
||||||
|
} else {
|
||||||
|
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendResult sends the OAuth result to the waiting channel.
|
||||||
|
// It ensures that the result is sent without blocking the handler.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - result: The OAuth result to send
|
||||||
|
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||||
|
select {
|
||||||
|
case s.resultChan <- result:
|
||||||
|
log.Debug("OAuth result sent to channel")
|
||||||
|
default:
|
||||||
|
log.Warn("OAuth result channel is full, result dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPortAvailable checks if the specified port is available.
|
||||||
|
// It attempts to listen on the port to determine availability.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the port is available, false otherwise
|
||||||
|
func (s *OAuthServer) isPortAvailable() bool {
|
||||||
|
addr := fmt.Sprintf(":%d", s.port)
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = listener.Close()
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns whether the server is currently running.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the server is running, false otherwise
|
||||||
|
func (s *OAuthServer) IsRunning() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.running
|
||||||
|
}
|
||||||
56
internal/auth/claude/pkce.go
Normal file
56
internal/auth/claude/pkce.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Package claude provides authentication and token management functionality
|
||||||
|
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
|
||||||
|
// following RFC 7636 specifications for OAuth 2.0 PKCE extension.
|
||||||
|
// This provides additional security for the OAuth flow by ensuring that
|
||||||
|
// only the client that initiated the request can exchange the authorization code.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *PKCECodes: A struct containing the code verifier and challenge
|
||||||
|
// - error: An error if the generation fails, nil otherwise
|
||||||
|
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||||
|
// Generate code verifier: 43-128 characters, URL-safe
|
||||||
|
codeVerifier, err := generateCodeVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate code challenge using S256 method
|
||||||
|
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||||
|
|
||||||
|
return &PKCECodes{
|
||||||
|
CodeVerifier: codeVerifier,
|
||||||
|
CodeChallenge: codeChallenge,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCodeVerifier creates a cryptographically random string
|
||||||
|
// of 128 characters using URL-safe base64 encoding
|
||||||
|
func generateCodeVerifier() (string, error) {
|
||||||
|
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||||
|
bytes := make([]byte, 96)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to URL-safe base64 without padding
|
||||||
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCodeChallenge creates a SHA256 hash of the code verifier
|
||||||
|
// and encodes it using URL-safe base64 encoding without padding
|
||||||
|
func generateCodeChallenge(codeVerifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||||
|
}
|
||||||
73
internal/auth/claude/token.go
Normal file
73
internal/auth/claude/token.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Package claude provides authentication and token management functionality
|
||||||
|
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
|
||||||
|
// It maintains compatibility with the existing auth system while adding Claude-specific fields
|
||||||
|
// for managing access tokens, refresh tokens, and user account information.
|
||||||
|
type ClaudeTokenStorage struct {
|
||||||
|
// IDToken is the JWT ID token containing user claims and identity information.
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
|
||||||
|
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
|
||||||
|
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
|
||||||
|
// LastRefresh is the timestamp of the last token refresh operation.
|
||||||
|
LastRefresh string `json:"last_refresh"`
|
||||||
|
|
||||||
|
// Email is the Anthropic account email address associated with this token.
|
||||||
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
// Type indicates the authentication provider type, always "claude" for this storage.
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
// Expire is the timestamp when the current access token expires.
|
||||||
|
Expire string `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the Claude token storage to a JSON file.
|
||||||
|
// This method creates the necessary directory structure and writes the token
|
||||||
|
// data in JSON format to the specified file path for persistent storage.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - authFilePath: The full path where the token file should be saved
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the operation fails, nil otherwise
|
||||||
|
func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
ts.Type = "claude"
|
||||||
|
|
||||||
|
// Create directory structure if it doesn't exist
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the token file
|
||||||
|
f, err := os.Create(authFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Encode and write the token data as JSON
|
||||||
|
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
171
internal/auth/codex/errors.go
Normal file
171
internal/auth/codex/errors.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthError represents an OAuth-specific error.
|
||||||
|
type OAuthError struct {
|
||||||
|
// Code is the OAuth error code.
|
||||||
|
Code string `json:"error"`
|
||||||
|
// Description is a human-readable description of the error.
|
||||||
|
Description string `json:"error_description,omitempty"`
|
||||||
|
// URI is a URI identifying a human-readable web page with information about the error.
|
||||||
|
URI string `json:"error_uri,omitempty"`
|
||||||
|
// StatusCode is the HTTP status code associated with the error.
|
||||||
|
StatusCode int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a string representation of the OAuth error.
|
||||||
|
func (e *OAuthError) Error() string {
|
||||||
|
if e.Description != "" {
|
||||||
|
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||||
|
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||||
|
return &OAuthError{
|
||||||
|
Code: code,
|
||||||
|
Description: description,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticationError represents authentication-related errors.
|
||||||
|
type AuthenticationError struct {
|
||||||
|
// Type is the type of authentication error.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// Message is a human-readable message describing the error.
|
||||||
|
Message string `json:"message"`
|
||||||
|
// Code is the HTTP status code associated with the error.
|
||||||
|
Code int `json:"code"`
|
||||||
|
// Cause is the underlying error that caused this authentication error.
|
||||||
|
Cause error `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a string representation of the authentication error.
|
||||||
|
func (e *AuthenticationError) Error() string {
|
||||||
|
if e.Cause != nil {
|
||||||
|
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common authentication error types.
|
||||||
|
var (
|
||||||
|
// ErrTokenExpired = &AuthenticationError{
|
||||||
|
// Type: "token_expired",
|
||||||
|
// Message: "Access token has expired",
|
||||||
|
// Code: http.StatusUnauthorized,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||||
|
ErrInvalidState = &AuthenticationError{
|
||||||
|
Type: "invalid_state",
|
||||||
|
Message: "OAuth state parameter is invalid",
|
||||||
|
Code: http.StatusBadRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||||
|
ErrCodeExchangeFailed = &AuthenticationError{
|
||||||
|
Type: "code_exchange_failed",
|
||||||
|
Message: "Failed to exchange authorization code for tokens",
|
||||||
|
Code: http.StatusBadRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||||
|
ErrServerStartFailed = &AuthenticationError{
|
||||||
|
Type: "server_start_failed",
|
||||||
|
Message: "Failed to start OAuth callback server",
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||||
|
ErrPortInUse = &AuthenticationError{
|
||||||
|
Type: "port_in_use",
|
||||||
|
Message: "OAuth callback port is already in use",
|
||||||
|
Code: 13, // Special exit code for port-in-use
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||||
|
ErrCallbackTimeout = &AuthenticationError{
|
||||||
|
Type: "callback_timeout",
|
||||||
|
Message: "Timeout waiting for OAuth callback",
|
||||||
|
Code: http.StatusRequestTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrBrowserOpenFailed represents an error when opening the browser for authentication fails.
|
||||||
|
ErrBrowserOpenFailed = &AuthenticationError{
|
||||||
|
Type: "browser_open_failed",
|
||||||
|
Message: "Failed to open browser for authentication",
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||||
|
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||||
|
return &AuthenticationError{
|
||||||
|
Type: baseErr.Type,
|
||||||
|
Message: baseErr.Message,
|
||||||
|
Code: baseErr.Code,
|
||||||
|
Cause: cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticationError checks if an error is an authentication error.
|
||||||
|
func IsAuthenticationError(err error) bool {
|
||||||
|
var authenticationError *AuthenticationError
|
||||||
|
ok := errors.As(err, &authenticationError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOAuthError checks if an error is an OAuth error.
|
||||||
|
func IsOAuthError(err error) bool {
|
||||||
|
var oAuthError *OAuthError
|
||||||
|
ok := errors.As(err, &oAuthError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||||
|
func GetUserFriendlyMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case IsAuthenticationError(err):
|
||||||
|
var authErr *AuthenticationError
|
||||||
|
errors.As(err, &authErr)
|
||||||
|
switch authErr.Type {
|
||||||
|
case "token_expired":
|
||||||
|
return "Your authentication has expired. Please log in again."
|
||||||
|
case "token_invalid":
|
||||||
|
return "Your authentication is invalid. Please log in again."
|
||||||
|
case "authentication_required":
|
||||||
|
return "Please log in to continue."
|
||||||
|
case "port_in_use":
|
||||||
|
return "The required port is already in use. Please close any applications using port 3000 and try again."
|
||||||
|
case "callback_timeout":
|
||||||
|
return "Authentication timed out. Please try again."
|
||||||
|
case "browser_open_failed":
|
||||||
|
return "Could not open your browser automatically. Please copy and paste the URL manually."
|
||||||
|
default:
|
||||||
|
return "Authentication failed. Please try again."
|
||||||
|
}
|
||||||
|
case IsOAuthError(err):
|
||||||
|
var oauthErr *OAuthError
|
||||||
|
errors.As(err, &oauthErr)
|
||||||
|
switch oauthErr.Code {
|
||||||
|
case "access_denied":
|
||||||
|
return "Authentication was cancelled or denied."
|
||||||
|
case "invalid_request":
|
||||||
|
return "Invalid authentication request. Please try again."
|
||||||
|
case "server_error":
|
||||||
|
return "Authentication server error. Please try again later."
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "An unexpected error occurred. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
214
internal/auth/codex/html_templates.go
Normal file
214
internal/auth/codex/html_templates.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package codex
|
||||||
|
|
||||||
|
// LoginSuccessHTML is the HTML template for the page shown after a successful
|
||||||
|
// OAuth2 authentication with Codex. It informs the user that the authentication
|
||||||
|
// was successful and provides a countdown timer to automatically close the window.
|
||||||
|
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Successful - Codex</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.setup-notice {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.setup-notice h3 {
|
||||||
|
color: #92400e;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.setup-notice p {
|
||||||
|
color: #92400e;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.setup-notice a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.setup-notice a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.button-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
.button-secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.countdown {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p class="subtitle">You have successfully authenticated with Codex. You can now close this window and return to your terminal to continue.</p>
|
||||||
|
|
||||||
|
{{SETUP_NOTICE}}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button button-primary" onclick="window.close()">
|
||||||
|
<span>Close Window</span>
|
||||||
|
</button>
|
||||||
|
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
|
||||||
|
<span>Open Platform</span>
|
||||||
|
<span>↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="countdown">
|
||||||
|
This window will close automatically in <span id="countdown">10</span> seconds
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let countdown = 10;
|
||||||
|
const countdownElement = document.getElementById('countdown');
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
countdown--;
|
||||||
|
countdownElement.textContent = countdown;
|
||||||
|
|
||||||
|
if (countdown <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Close window when user presses Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus the close button for keyboard accessibility
|
||||||
|
document.querySelector('.button-primary').focus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// SetupNoticeHTML is the HTML template for the section that provides instructions
|
||||||
|
// for additional setup. This is displayed on the success page when further actions
|
||||||
|
// are required from the user.
|
||||||
|
const SetupNoticeHtml = `
|
||||||
|
<div class="setup-notice">
|
||||||
|
<h3>Additional Setup Required</h3>
|
||||||
|
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Codex</a> to configure your account.</p>
|
||||||
|
</div>`
|
||||||
102
internal/auth/codex/jwt_parser.go
Normal file
102
internal/auth/codex/jwt_parser.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTClaims represents the claims section of a JSON Web Token (JWT).
|
||||||
|
// It includes standard claims like issuer, subject, and expiration time, as well as
|
||||||
|
// custom claims specific to OpenAI's authentication.
|
||||||
|
type JWTClaims struct {
|
||||||
|
AtHash string `json:"at_hash"`
|
||||||
|
Aud []string `json:"aud"`
|
||||||
|
AuthProvider string `json:"auth_provider"`
|
||||||
|
AuthTime int `json:"auth_time"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Exp int `json:"exp"`
|
||||||
|
CodexAuthInfo CodexAuthInfo `json:"https://api.openai.com/auth"`
|
||||||
|
Iat int `json:"iat"`
|
||||||
|
Iss string `json:"iss"`
|
||||||
|
Jti string `json:"jti"`
|
||||||
|
Rat int `json:"rat"`
|
||||||
|
Sid string `json:"sid"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organizations defines the structure for organization details within the JWT claims.
|
||||||
|
// It holds information about the user's organization, such as ID, role, and title.
|
||||||
|
type Organizations struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexAuthInfo contains authentication-related details specific to Codex.
|
||||||
|
// This includes ChatGPT account information, subscription status, and user/organization IDs.
|
||||||
|
type CodexAuthInfo struct {
|
||||||
|
ChatgptAccountID string `json:"chatgpt_account_id"`
|
||||||
|
ChatgptPlanType string `json:"chatgpt_plan_type"`
|
||||||
|
ChatgptSubscriptionActiveStart any `json:"chatgpt_subscription_active_start"`
|
||||||
|
ChatgptSubscriptionActiveUntil any `json:"chatgpt_subscription_active_until"`
|
||||||
|
ChatgptSubscriptionLastChecked time.Time `json:"chatgpt_subscription_last_checked"`
|
||||||
|
ChatgptUserID string `json:"chatgpt_user_id"`
|
||||||
|
Groups []any `json:"groups"`
|
||||||
|
Organizations []Organizations `json:"organizations"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseJWTToken parses a JWT token string and extracts its claims without performing
|
||||||
|
// cryptographic signature verification. This is useful for introspecting the token's
|
||||||
|
// contents to retrieve user information from an ID token after it has been validated
|
||||||
|
// by the authentication server.
|
||||||
|
func ParseJWTToken(token string) (*JWTClaims, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid JWT token format: expected 3 parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the claims (payload) part
|
||||||
|
claimsData, err := base64URLDecode(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims JWTClaims
|
||||||
|
if err = json.Unmarshal(claimsData, &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64URLDecode decodes a Base64 URL-encoded string, adding padding if necessary.
|
||||||
|
// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures
|
||||||
|
// correct decoding by re-adding the padding before decoding.
|
||||||
|
func base64URLDecode(data string) ([]byte, error) {
|
||||||
|
// Add padding if necessary
|
||||||
|
switch len(data) % 4 {
|
||||||
|
case 2:
|
||||||
|
data += "=="
|
||||||
|
case 3:
|
||||||
|
data += "="
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.URLEncoding.DecodeString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserEmail extracts the user's email address from the JWT claims.
|
||||||
|
func (c *JWTClaims) GetUserEmail() string {
|
||||||
|
return c.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccountID extracts the user's account ID (subject) from the JWT claims.
|
||||||
|
// It retrieves the unique identifier for the user's ChatGPT account.
|
||||||
|
func (c *JWTClaims) GetAccountID() string {
|
||||||
|
return c.CodexAuthInfo.ChatgptAccountID
|
||||||
|
}
|
||||||
317
internal/auth/codex/oauth_server.go
Normal file
317
internal/auth/codex/oauth_server.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||||
|
// It listens for the authorization code response from the OAuth provider
|
||||||
|
// and captures the necessary parameters to complete the authentication flow.
|
||||||
|
type OAuthServer struct {
|
||||||
|
// server is the underlying HTTP server instance
|
||||||
|
server *http.Server
|
||||||
|
// port is the port number on which the server listens
|
||||||
|
port int
|
||||||
|
// resultChan is a channel for sending OAuth results
|
||||||
|
resultChan chan *OAuthResult
|
||||||
|
// errorChan is a channel for sending OAuth errors
|
||||||
|
errorChan chan error
|
||||||
|
// mu is a mutex for protecting server state
|
||||||
|
mu sync.Mutex
|
||||||
|
// running indicates whether the server is currently running
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthResult contains the result of the OAuth callback.
|
||||||
|
// It holds either the authorization code and state for successful authentication
|
||||||
|
// or an error message if the authentication failed.
|
||||||
|
type OAuthResult struct {
|
||||||
|
// Code is the authorization code received from the OAuth provider
|
||||||
|
Code string
|
||||||
|
// State is the state parameter used to prevent CSRF attacks
|
||||||
|
State string
|
||||||
|
// Error contains any error message if the OAuth flow failed
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthServer creates a new OAuth callback server.
|
||||||
|
// It initializes the server with the specified port and creates channels
|
||||||
|
// for handling OAuth results and errors.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - port: The port number on which the server should listen
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *OAuthServer: A new OAuthServer instance
|
||||||
|
func NewOAuthServer(port int) *OAuthServer {
|
||||||
|
return &OAuthServer{
|
||||||
|
port: port,
|
||||||
|
resultChan: make(chan *OAuthResult, 1),
|
||||||
|
errorChan: make(chan error, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the OAuth callback server.
|
||||||
|
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||||
|
// and begins listening on the specified port.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the server fails to start
|
||||||
|
func (s *OAuthServer) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.running {
|
||||||
|
return fmt.Errorf("server is already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if port is available
|
||||||
|
if !s.isPortAvailable() {
|
||||||
|
return fmt.Errorf("port %d is already in use", s.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/auth/callback", s.handleCallback)
|
||||||
|
mux.HandleFunc("/success", s.handleSuccess)
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.port),
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = true
|
||||||
|
|
||||||
|
// Start server in goroutine
|
||||||
|
go func() {
|
||||||
|
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give server a moment to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the OAuth callback server.
|
||||||
|
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for controlling the shutdown process
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the server fails to stop gracefully
|
||||||
|
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if !s.running || s.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Stopping OAuth callback server")
|
||||||
|
|
||||||
|
// Create a context with timeout for shutdown
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := s.server.Shutdown(shutdownCtx)
|
||||||
|
s.running = false
|
||||||
|
s.server = nil
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||||
|
// It blocks until either an OAuth result is received, an error occurs,
|
||||||
|
// or the specified timeout is reached.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - timeout: The maximum time to wait for the callback
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *OAuthResult: The OAuth result if successful
|
||||||
|
// - error: An error if the callback times out or an error occurs
|
||||||
|
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||||
|
select {
|
||||||
|
case result := <-s.resultChan:
|
||||||
|
return result, nil
|
||||||
|
case err := <-s.errorChan:
|
||||||
|
return nil, err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallback handles the OAuth callback endpoint.
|
||||||
|
// It extracts the authorization code and state from the callback URL,
|
||||||
|
// validates the parameters, and sends the result to the waiting channel.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - w: The HTTP response writer
|
||||||
|
// - r: The HTTP request
|
||||||
|
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Debug("Received OAuth callback")
|
||||||
|
|
||||||
|
// Validate request method
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract parameters
|
||||||
|
query := r.URL.Query()
|
||||||
|
code := query.Get("code")
|
||||||
|
state := query.Get("state")
|
||||||
|
errorParam := query.Get("error")
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if errorParam != "" {
|
||||||
|
log.Errorf("OAuth error received: %s", errorParam)
|
||||||
|
result := &OAuthResult{
|
||||||
|
Error: errorParam,
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
log.Error("No authorization code received")
|
||||||
|
result := &OAuthResult{
|
||||||
|
Error: "no_code",
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == "" {
|
||||||
|
log.Error("No state parameter received")
|
||||||
|
result := &OAuthResult{
|
||||||
|
Error: "no_state",
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
http.Error(w, "No state parameter received", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send successful result
|
||||||
|
result := &OAuthResult{
|
||||||
|
Code: code,
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
s.sendResult(result)
|
||||||
|
|
||||||
|
// Redirect to success page
|
||||||
|
http.Redirect(w, r, "/success", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSuccess handles the success page endpoint.
|
||||||
|
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - w: The HTTP response writer
|
||||||
|
// - r: The HTTP request
|
||||||
|
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Debug("Serving success page")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Parse query parameters for customization
|
||||||
|
query := r.URL.Query()
|
||||||
|
setupRequired := query.Get("setup_required") == "true"
|
||||||
|
platformURL := query.Get("platform_url")
|
||||||
|
if platformURL == "" {
|
||||||
|
platformURL = "https://platform.openai.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate success page HTML with dynamic content
|
||||||
|
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
|
||||||
|
|
||||||
|
_, err := w.Write([]byte(successHTML))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to write success page: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSuccessHTML creates the HTML content for the success page.
|
||||||
|
// It customizes the page based on whether additional setup is required
|
||||||
|
// and includes a link to the platform.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - setupRequired: Whether additional setup is required after authentication
|
||||||
|
// - platformURL: The URL to the platform for additional setup
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The HTML content for the success page
|
||||||
|
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||||
|
html := LoginSuccessHtml
|
||||||
|
|
||||||
|
// Replace platform URL placeholder
|
||||||
|
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
|
||||||
|
|
||||||
|
// Add setup notice if required
|
||||||
|
if setupRequired {
|
||||||
|
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
|
||||||
|
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
|
||||||
|
} else {
|
||||||
|
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendResult sends the OAuth result to the waiting channel.
|
||||||
|
// It ensures that the result is sent without blocking the handler.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - result: The OAuth result to send
|
||||||
|
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||||
|
select {
|
||||||
|
case s.resultChan <- result:
|
||||||
|
log.Debug("OAuth result sent to channel")
|
||||||
|
default:
|
||||||
|
log.Warn("OAuth result channel is full, result dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPortAvailable checks if the specified port is available.
|
||||||
|
// It attempts to listen on the port to determine availability.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the port is available, false otherwise
|
||||||
|
func (s *OAuthServer) isPortAvailable() bool {
|
||||||
|
addr := fmt.Sprintf(":%d", s.port)
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = listener.Close()
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns whether the server is currently running.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the server is running, false otherwise
|
||||||
|
func (s *OAuthServer) IsRunning() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.running
|
||||||
|
}
|
||||||
39
internal/auth/codex/openai.go
Normal file
39
internal/auth/codex/openai.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package codex
|
||||||
|
|
||||||
|
// PKCECodes holds the verification codes for the OAuth2 PKCE (Proof Key for Code Exchange) flow.
|
||||||
|
// PKCE is an extension to the Authorization Code flow to prevent CSRF and authorization code injection attacks.
|
||||||
|
type PKCECodes struct {
|
||||||
|
// CodeVerifier is the cryptographically random string used to correlate
|
||||||
|
// the authorization request to the token request
|
||||||
|
CodeVerifier string `json:"code_verifier"`
|
||||||
|
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
|
||||||
|
CodeChallenge string `json:"code_challenge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexTokenData holds the OAuth token information obtained from OpenAI.
|
||||||
|
// It includes the ID token, access token, refresh token, and associated user details.
|
||||||
|
type CodexTokenData struct {
|
||||||
|
// IDToken is the JWT ID token containing user claims
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
// AccessToken is the OAuth2 access token for API access
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is used to obtain new access tokens
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// AccountID is the OpenAI account identifier
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
// Email is the OpenAI account email
|
||||||
|
Email string `json:"email"`
|
||||||
|
// Expire is the timestamp of the token expire
|
||||||
|
Expire string `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexAuthBundle aggregates all authentication-related data after the OAuth flow is complete.
|
||||||
|
// This includes the API key, token data, and the timestamp of the last refresh.
|
||||||
|
type CodexAuthBundle struct {
|
||||||
|
// APIKey is the OpenAI API key obtained from token exchange
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
// TokenData contains the OAuth tokens from the authentication flow
|
||||||
|
TokenData CodexTokenData `json:"token_data"`
|
||||||
|
// LastRefresh is the timestamp of the last token refresh
|
||||||
|
LastRefresh string `json:"last_refresh"`
|
||||||
|
}
|
||||||
286
internal/auth/codex/openai_auth.go
Normal file
286
internal/auth/codex/openai_auth.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// Package codex provides authentication and token management for OpenAI's Codex API.
|
||||||
|
// It handles the OAuth2 flow, including generating authorization URLs, exchanging
|
||||||
|
// authorization codes for tokens, and refreshing expired tokens. The package also
|
||||||
|
// defines data structures for storing and managing Codex authentication credentials.
|
||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
openaiAuthURL = "https://auth.openai.com/oauth/authorize"
|
||||||
|
openaiTokenURL = "https://auth.openai.com/oauth/token"
|
||||||
|
openaiClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
redirectURI = "http://localhost:1455/auth/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodexAuth handles the OpenAI OAuth2 authentication flow.
|
||||||
|
// It manages the HTTP client and provides methods for generating authorization URLs,
|
||||||
|
// exchanging authorization codes for tokens, and refreshing access tokens.
|
||||||
|
type CodexAuth struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCodexAuth creates a new CodexAuth service instance.
|
||||||
|
// It initializes an HTTP client with proxy settings from the provided configuration.
|
||||||
|
func NewCodexAuth(cfg *config.Config) *CodexAuth {
|
||||||
|
return &CodexAuth{
|
||||||
|
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange).
|
||||||
|
// It constructs the URL with the necessary parameters, including the client ID,
|
||||||
|
// response type, redirect URI, scopes, and PKCE challenge.
|
||||||
|
func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) {
|
||||||
|
if pkceCodes == nil {
|
||||||
|
return "", fmt.Errorf("PKCE codes are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {openaiClientID},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"redirect_uri": {redirectURI},
|
||||||
|
"scope": {"openid email profile offline_access"},
|
||||||
|
"state": {state},
|
||||||
|
"code_challenge": {pkceCodes.CodeChallenge},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"prompt": {"login"},
|
||||||
|
"id_token_add_organizations": {"true"},
|
||||||
|
"codex_cli_simplified_flow": {"true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := fmt.Sprintf("%s?%s", openaiAuthURL, params.Encode())
|
||||||
|
return authURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
|
||||||
|
// It performs an HTTP POST request to the OpenAI token endpoint with the provided
|
||||||
|
// authorization code and PKCE verifier.
|
||||||
|
func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
|
||||||
|
if pkceCodes == nil {
|
||||||
|
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare token exchange request
|
||||||
|
data := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"client_id": {openaiClientID},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {redirectURI},
|
||||||
|
"code_verifier": {pkceCodes.CodeVerifier},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := o.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||||
|
}
|
||||||
|
// log.Debugf("Token response: %s", string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse token response
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract account ID from ID token
|
||||||
|
claims, err := ParseJWTToken(tokenResp.IDToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to parse ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := ""
|
||||||
|
email := ""
|
||||||
|
if claims != nil {
|
||||||
|
accountID = claims.GetAccountID()
|
||||||
|
email = claims.GetUserEmail()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token data
|
||||||
|
tokenData := CodexTokenData{
|
||||||
|
IDToken: tokenResp.IDToken,
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
AccountID: accountID,
|
||||||
|
Email: email,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth bundle
|
||||||
|
bundle := &CodexAuthBundle{
|
||||||
|
TokenData: tokenData,
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokens refreshes an access token using a refresh token.
|
||||||
|
// This method is called when an access token has expired. It makes a request to the
|
||||||
|
// token endpoint to obtain a new set of tokens.
|
||||||
|
func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) {
|
||||||
|
if refreshToken == "" {
|
||||||
|
return nil, fmt.Errorf("refresh token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := url.Values{
|
||||||
|
"client_id": {openaiClientID},
|
||||||
|
"grant_type": {"refresh_token"},
|
||||||
|
"refresh_token": {refreshToken},
|
||||||
|
"scope": {"openid profile email"},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := o.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract account ID from ID token
|
||||||
|
claims, err := ParseJWTToken(tokenResp.IDToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to parse refreshed ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := ""
|
||||||
|
email := ""
|
||||||
|
if claims != nil {
|
||||||
|
accountID = claims.GetAccountID()
|
||||||
|
email = claims.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CodexTokenData{
|
||||||
|
IDToken: tokenResp.IDToken,
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
AccountID: accountID,
|
||||||
|
Email: email,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle.
|
||||||
|
// It populates the storage struct with token data, user information, and timestamps.
|
||||||
|
func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage {
|
||||||
|
storage := &CodexTokenStorage{
|
||||||
|
IDToken: bundle.TokenData.IDToken,
|
||||||
|
AccessToken: bundle.TokenData.AccessToken,
|
||||||
|
RefreshToken: bundle.TokenData.RefreshToken,
|
||||||
|
AccountID: bundle.TokenData.AccountID,
|
||||||
|
LastRefresh: bundle.LastRefresh,
|
||||||
|
Email: bundle.TokenData.Email,
|
||||||
|
Expire: bundle.TokenData.Expire,
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism.
|
||||||
|
// It attempts to refresh the tokens up to a specified maximum number of retries,
|
||||||
|
// with an exponential backoff strategy to handle transient network errors.
|
||||||
|
func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
// Wait before retry
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(time.Duration(attempt) * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||||
|
if err == nil {
|
||||||
|
return tokenData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
|
||||||
|
// This is typically called after a successful token refresh to persist the new credentials.
|
||||||
|
func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
|
||||||
|
storage.IDToken = tokenData.IDToken
|
||||||
|
storage.AccessToken = tokenData.AccessToken
|
||||||
|
storage.RefreshToken = tokenData.RefreshToken
|
||||||
|
storage.AccountID = tokenData.AccountID
|
||||||
|
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||||
|
storage.Email = tokenData.Email
|
||||||
|
storage.Expire = tokenData.Expire
|
||||||
|
}
|
||||||
56
internal/auth/codex/pkce.go
Normal file
56
internal/auth/codex/pkce.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Package codex provides authentication and token management functionality
|
||||||
|
// for OpenAI's Codex AI services. It handles OAuth2 PKCE (Proof Key for Code Exchange)
|
||||||
|
// code generation for secure authentication flows.
|
||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.
|
||||||
|
// It creates a cryptographically random code verifier and its corresponding
|
||||||
|
// SHA256 code challenge, as specified in RFC 7636. This is a critical security
|
||||||
|
// feature for the OAuth 2.0 authorization code flow.
|
||||||
|
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||||
|
// Generate code verifier: 43-128 characters, URL-safe
|
||||||
|
codeVerifier, err := generateCodeVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate code challenge using S256 method
|
||||||
|
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||||
|
|
||||||
|
return &PKCECodes{
|
||||||
|
CodeVerifier: codeVerifier,
|
||||||
|
CodeChallenge: codeChallenge,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCodeVerifier creates a cryptographically secure random string to be used
|
||||||
|
// as the code verifier in the PKCE flow. The verifier is a high-entropy string
|
||||||
|
// that is later used to prove possession of the client that initiated the
|
||||||
|
// authorization request.
|
||||||
|
func generateCodeVerifier() (string, error) {
|
||||||
|
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||||
|
bytes := make([]byte, 96)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to URL-safe base64 without padding
|
||||||
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCodeChallenge creates a code challenge from a given code verifier.
|
||||||
|
// The challenge is derived by taking the SHA256 hash of the verifier and then
|
||||||
|
// Base64 URL-encoding the result. This is sent in the initial authorization
|
||||||
|
// request and later verified against the verifier.
|
||||||
|
func generateCodeChallenge(codeVerifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||||
|
}
|
||||||
66
internal/auth/codex/token.go
Normal file
66
internal/auth/codex/token.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Package codex provides authentication and token management functionality
|
||||||
|
// for OpenAI's Codex AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Codex API.
|
||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
||||||
|
// It maintains compatibility with the existing auth system while adding Codex-specific fields
|
||||||
|
// for managing access tokens, refresh tokens, and user account information.
|
||||||
|
type CodexTokenStorage struct {
|
||||||
|
// IDToken is the JWT ID token containing user claims and identity information.
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// AccountID is the OpenAI account identifier associated with this token.
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
// LastRefresh is the timestamp of the last token refresh operation.
|
||||||
|
LastRefresh string `json:"last_refresh"`
|
||||||
|
// Email is the OpenAI account email address associated with this token.
|
||||||
|
Email string `json:"email"`
|
||||||
|
// Type indicates the authentication provider type, always "codex" for this storage.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// Expire is the timestamp when the current access token expires.
|
||||||
|
Expire string `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the Codex token storage to a JSON file.
|
||||||
|
// This method creates the necessary directory structure and writes the token
|
||||||
|
// data in JSON format to the specified file path for persistent storage.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - authFilePath: The full path where the token file should be saved
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the operation fails, nil otherwise
|
||||||
|
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
ts.Type = "codex"
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(authFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
26
internal/auth/empty/token.go
Normal file
26
internal/auth/empty/token.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Package empty provides a no-operation token storage implementation.
|
||||||
|
// This package is used when authentication tokens are not required or when
|
||||||
|
// using API key-based authentication instead of OAuth tokens for any provider.
|
||||||
|
package empty
|
||||||
|
|
||||||
|
// EmptyStorage is a no-operation implementation of the TokenStorage interface.
|
||||||
|
// It provides empty implementations for scenarios where token storage is not needed,
|
||||||
|
// such as when using API keys instead of OAuth tokens for authentication.
|
||||||
|
type EmptyStorage struct {
|
||||||
|
// Type indicates the authentication provider type, always "empty" for this implementation.
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile is a no-operation implementation that always succeeds.
|
||||||
|
// This method satisfies the TokenStorage interface but performs no actual file operations
|
||||||
|
// since empty storage doesn't require persistent token data.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - _: The file path parameter is ignored in this implementation
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Always returns nil (no error)
|
||||||
|
func (ts *EmptyStorage) SaveTokenToFile(_ string) error {
|
||||||
|
ts.Type = "empty"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
64
internal/auth/gemini/gemini-web_token.go
Normal file
64
internal/auth/gemini/gemini-web_token.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Package gemini provides authentication and token management functionality
|
||||||
|
// for Google's Gemini AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Gemini API.
|
||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiWebTokenStorage stores cookie information for Google Gemini Web authentication.
|
||||||
|
type GeminiWebTokenStorage struct {
|
||||||
|
Secure1PSID string `json:"secure_1psid"`
|
||||||
|
Secure1PSIDTS string `json:"secure_1psidts"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
LastRefresh string `json:"last_refresh,omitempty"`
|
||||||
|
// Label is a stable account identifier used for logging, e.g. "gemini-web-<hash>".
|
||||||
|
// It is derived from the auth file name when not explicitly set.
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the Gemini Web token storage to a JSON file.
|
||||||
|
func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
ts.Type = "gemini-web"
|
||||||
|
// Auto-derive a stable label from the file name if missing.
|
||||||
|
if ts.Label == "" {
|
||||||
|
base := filepath.Base(authFilePath)
|
||||||
|
if strings.HasSuffix(strings.ToLower(base), ".json") {
|
||||||
|
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
}
|
||||||
|
if base != "" {
|
||||||
|
ts.Label = base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts.LastRefresh == "" {
|
||||||
|
ts.LastRefresh = time.Now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(authFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := f.Close(); errClose != nil {
|
||||||
|
log.Errorf("failed to close file: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
301
internal/auth/gemini/gemini_auth.go
Normal file
301
internal/auth/gemini/gemini_auth.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// Package gemini provides authentication and token management functionality
|
||||||
|
// for Google's Gemini AI services. It handles OAuth2 authentication flows,
|
||||||
|
// including obtaining tokens via web-based authorization, storing tokens,
|
||||||
|
// and refreshing them when they expire.
|
||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||||
|
geminiOauthClientSecret = "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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
geminiOauthScopes = []string{
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiAuth provides methods for handling the Gemini OAuth2 authentication flow.
|
||||||
|
// It encapsulates the logic for obtaining, storing, and refreshing authentication tokens
|
||||||
|
// for Google's Gemini AI services.
|
||||||
|
type GeminiAuth struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeminiAuth creates a new instance of GeminiAuth.
|
||||||
|
func NewGeminiAuth() *GeminiAuth {
|
||||||
|
return &GeminiAuth{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthenticatedClient configures and returns an HTTP client ready for making authenticated API calls.
|
||||||
|
// It manages the entire OAuth2 flow, including handling proxies, loading existing tokens,
|
||||||
|
// initiating a new web-based OAuth flow if necessary, and refreshing tokens.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for the HTTP client
|
||||||
|
// - ts: The Gemini token storage containing authentication tokens
|
||||||
|
// - cfg: The configuration containing proxy settings
|
||||||
|
// - noBrowser: Optional parameter to disable browser opening
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *http.Client: An HTTP client configured with authentication
|
||||||
|
// - error: An error if the client configuration fails, nil otherwise
|
||||||
|
func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiTokenStorage, cfg *config.Config, noBrowser ...bool) (*http.Client, error) {
|
||||||
|
// Configure proxy settings for the HTTP client if a proxy URL is provided.
|
||||||
|
proxyURL, err := url.Parse(cfg.ProxyURL)
|
||||||
|
if err == nil {
|
||||||
|
var transport *http.Transport
|
||||||
|
if proxyURL.Scheme == "socks5" {
|
||||||
|
// Handle SOCKS5 proxy.
|
||||||
|
username := proxyURL.User.Username()
|
||||||
|
password, _ := proxyURL.User.Password()
|
||||||
|
auth := &proxy.Auth{User: username, Password: password}
|
||||||
|
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct)
|
||||||
|
if errSOCKS5 != nil {
|
||||||
|
log.Fatalf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||||
|
}
|
||||||
|
transport = &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.Dial(network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
||||||
|
// Handle HTTP/HTTPS proxy.
|
||||||
|
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if transport != nil {
|
||||||
|
proxyClient := &http.Client{Transport: transport}
|
||||||
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the OAuth2 client.
|
||||||
|
conf := &oauth2.Config{
|
||||||
|
ClientID: geminiOauthClientID,
|
||||||
|
ClientSecret: geminiOauthClientSecret,
|
||||||
|
RedirectURL: "http://localhost:8085/oauth2callback", // This will be used by the local server.
|
||||||
|
Scopes: geminiOauthScopes,
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
var token *oauth2.Token
|
||||||
|
|
||||||
|
// If no token is found in storage, initiate the web-based OAuth flow.
|
||||||
|
if ts.Token == nil {
|
||||||
|
fmt.Printf("Could not load token from file, starting OAuth flow.\n")
|
||||||
|
token, err = g.getTokenFromWeb(ctx, conf, noBrowser...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get token from web: %w", err)
|
||||||
|
}
|
||||||
|
// After getting a new token, create a new token storage object with user info.
|
||||||
|
newTs, errCreateTokenStorage := g.createTokenStorage(ctx, conf, token, ts.ProjectID)
|
||||||
|
if errCreateTokenStorage != nil {
|
||||||
|
log.Errorf("Warning: failed to create token storage: %v", errCreateTokenStorage)
|
||||||
|
return nil, errCreateTokenStorage
|
||||||
|
}
|
||||||
|
*ts = *newTs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal the stored token into an oauth2.Token object.
|
||||||
|
tsToken, _ := json.Marshal(ts.Token)
|
||||||
|
if err = json.Unmarshal(tsToken, &token); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return an HTTP client that automatically handles token refreshing.
|
||||||
|
return conf.Client(ctx, token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTokenStorage creates a new GeminiTokenStorage object. It fetches the user's email
|
||||||
|
// using the provided token and populates the storage structure.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for the HTTP request
|
||||||
|
// - config: The OAuth2 configuration
|
||||||
|
// - token: The OAuth2 token to use for authentication
|
||||||
|
// - projectID: The Google Cloud Project ID to associate with this token
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *GeminiTokenStorage: A new token storage object with user information
|
||||||
|
// - error: An error if the token storage creation fails, nil otherwise
|
||||||
|
func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
|
||||||
|
httpClient := config.Client(ctx, token)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not get user info: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to execute request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err = resp.Body.Close(); err != nil {
|
||||||
|
log.Printf("warn: failed to close response body: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
emailResult := gjson.GetBytes(bodyBytes, "email")
|
||||||
|
if emailResult.Exists() && emailResult.Type == gjson.String {
|
||||||
|
fmt.Printf("Authenticated user email: %s\n", emailResult.String())
|
||||||
|
} else {
|
||||||
|
fmt.Println("Failed to get user email from token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ifToken map[string]any
|
||||||
|
jsonData, _ := json.Marshal(token)
|
||||||
|
err = json.Unmarshal(jsonData, &ifToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
|
||||||
|
ifToken["client_id"] = geminiOauthClientID
|
||||||
|
ifToken["client_secret"] = geminiOauthClientSecret
|
||||||
|
ifToken["scopes"] = geminiOauthScopes
|
||||||
|
ifToken["universe_domain"] = "googleapis.com"
|
||||||
|
|
||||||
|
ts := GeminiTokenStorage{
|
||||||
|
Token: ifToken,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Email: emailResult.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTokenFromWeb initiates the web-based OAuth2 authorization flow.
|
||||||
|
// It starts a local HTTP server to listen for the callback from Google's auth server,
|
||||||
|
// opens the user's browser to the authorization URL, and exchanges the received
|
||||||
|
// authorization code for an access token.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: The context for the HTTP client
|
||||||
|
// - config: The OAuth2 configuration
|
||||||
|
// - noBrowser: Optional parameter to disable browser opening
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *oauth2.Token: The OAuth2 token obtained from the authorization flow
|
||||||
|
// - error: An error if the token acquisition fails, nil otherwise
|
||||||
|
func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, noBrowser ...bool) (*oauth2.Token, error) {
|
||||||
|
// Use a channel to pass the authorization code from the HTTP handler to the main function.
|
||||||
|
codeChan := make(chan string)
|
||||||
|
errChan := make(chan error)
|
||||||
|
|
||||||
|
// Create a new HTTP server with its own multiplexer.
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server := &http.Server{Addr: ":8085", Handler: mux}
|
||||||
|
config.RedirectURL = "http://localhost:8085/oauth2callback"
|
||||||
|
|
||||||
|
mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.URL.Query().Get("error"); err != "" {
|
||||||
|
_, _ = fmt.Fprintf(w, "Authentication failed: %s", err)
|
||||||
|
errChan <- fmt.Errorf("authentication failed via callback: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
_, _ = fmt.Fprint(w, "Authentication failed: code not found.")
|
||||||
|
errChan <- fmt.Errorf("code not found in callback")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
codeChan <- code
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the server in a goroutine.
|
||||||
|
go func() {
|
||||||
|
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("ListenAndServe(): %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Open the authorization URL in the user's browser.
|
||||||
|
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
|
|
||||||
|
if len(noBrowser) == 1 && !noBrowser[0] {
|
||||||
|
fmt.Println("Opening browser for authentication...")
|
||||||
|
|
||||||
|
// Check if browser is available
|
||||||
|
if !browser.IsAvailable() {
|
||||||
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
|
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
} else {
|
||||||
|
if err := browser.OpenURL(authURL); err != nil {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||||
|
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
|
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
|
// Log platform info for debugging
|
||||||
|
platformInfo := browser.GetPlatformInfo()
|
||||||
|
log.Debugf("Browser platform info: %+v", platformInfo)
|
||||||
|
} else {
|
||||||
|
log.Debug("Browser opened successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
|
fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Waiting for authentication callback...")
|
||||||
|
|
||||||
|
// Wait for the authorization code or an error.
|
||||||
|
var authCode string
|
||||||
|
select {
|
||||||
|
case code := <-codeChan:
|
||||||
|
authCode = code
|
||||||
|
case err := <-errChan:
|
||||||
|
return nil, err
|
||||||
|
case <-time.After(5 * time.Minute): // Timeout
|
||||||
|
return nil, fmt.Errorf("oauth flow timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown the server.
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
log.Errorf("Failed to shut down server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange the authorization code for a token.
|
||||||
|
token, err := config.Exchange(ctx, authCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Authentication successful.")
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
69
internal/auth/gemini/gemini_token.go
Normal file
69
internal/auth/gemini/gemini_token.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Package gemini provides authentication and token management functionality
|
||||||
|
// for Google's Gemini AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Gemini API.
|
||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiTokenStorage stores OAuth2 token information for Google Gemini API authentication.
|
||||||
|
// It maintains compatibility with the existing auth system while adding Gemini-specific fields
|
||||||
|
// for managing access tokens, refresh tokens, and user account information.
|
||||||
|
type GeminiTokenStorage struct {
|
||||||
|
// Token holds the raw OAuth2 token data, including access and refresh tokens.
|
||||||
|
Token any `json:"token"`
|
||||||
|
|
||||||
|
// ProjectID is the Google Cloud Project ID associated with this token.
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
|
||||||
|
// Email is the email address of the authenticated user.
|
||||||
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
// Auto indicates if the project ID was automatically selected.
|
||||||
|
Auto bool `json:"auto"`
|
||||||
|
|
||||||
|
// Checked indicates if the associated Cloud AI API has been verified as enabled.
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
|
||||||
|
// Type indicates the authentication provider type, always "gemini" for this storage.
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the Gemini token storage to a JSON file.
|
||||||
|
// This method creates the necessary directory structure and writes the token
|
||||||
|
// data in JSON format to the specified file path for persistent storage.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - authFilePath: The full path where the token file should be saved
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the operation fails, nil otherwise
|
||||||
|
func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
ts.Type = "gemini"
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(authFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := f.Close(); errClose != nil {
|
||||||
|
log.Errorf("failed to close file: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
// Package auth provides authentication functionality for various AI service providers.
|
||||||
|
// It includes interfaces and implementations for token storage and authentication methods.
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
type TokenStorage struct {
|
// TokenStorage defines the interface for storing authentication tokens.
|
||||||
Token any `json:"token"`
|
// Implementations of this interface should provide methods to persist
|
||||||
ProjectID string `json:"project_id"`
|
// authentication tokens to a file system location.
|
||||||
Email string `json:"email"`
|
type TokenStorage interface {
|
||||||
Auto bool `json:"auto"`
|
// SaveTokenToFile persists authentication tokens to the specified file path.
|
||||||
Checked bool `json:"checked"`
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - authFilePath: The file path where the authentication tokens should be saved
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the save operation fails, nil otherwise
|
||||||
|
SaveTokenToFile(authFilePath string) error
|
||||||
}
|
}
|
||||||
|
|||||||
359
internal/auth/qwen/qwen_auth.go
Normal file
359
internal/auth/qwen/qwen_auth.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
package qwen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// QwenOAuthDeviceCodeEndpoint is the URL for initiating the OAuth 2.0 device authorization flow.
|
||||||
|
QwenOAuthDeviceCodeEndpoint = "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||||
|
// QwenOAuthTokenEndpoint is the URL for exchanging device codes or refresh tokens for access tokens.
|
||||||
|
QwenOAuthTokenEndpoint = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||||
|
// QwenOAuthClientID is the client identifier for the Qwen OAuth 2.0 application.
|
||||||
|
QwenOAuthClientID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||||
|
// QwenOAuthScope defines the permissions requested by the application.
|
||||||
|
QwenOAuthScope = "openid profile email model.completion"
|
||||||
|
// QwenOAuthGrantType specifies the grant type for the device code flow.
|
||||||
|
QwenOAuthGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QwenTokenData represents the OAuth credentials, including access and refresh tokens.
|
||||||
|
type QwenTokenData struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is used to obtain a new access token when the current one expires.
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
// TokenType indicates the type of token, typically "Bearer".
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
// ResourceURL specifies the base URL of the resource server.
|
||||||
|
ResourceURL string `json:"resource_url,omitempty"`
|
||||||
|
// Expire indicates the expiration date and time of the access token.
|
||||||
|
Expire string `json:"expiry_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceFlow represents the response from the device authorization endpoint.
|
||||||
|
type DeviceFlow struct {
|
||||||
|
// DeviceCode is the code that the client uses to poll for an access token.
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
// UserCode is the code that the user enters at the verification URI.
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
// VerificationURI is the URL where the user can enter the user code to authorize the device.
|
||||||
|
VerificationURI string `json:"verification_uri"`
|
||||||
|
// VerificationURIComplete is a URI that includes the user_code, which can be used to automatically
|
||||||
|
// fill in the code on the verification page.
|
||||||
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||||
|
// ExpiresIn is the time in seconds until the device_code and user_code expire.
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
// Interval is the minimum time in seconds that the client should wait between polling requests.
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
// CodeVerifier is the cryptographically random string used in the PKCE flow.
|
||||||
|
CodeVerifier string `json:"code_verifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QwenTokenResponse represents the successful token response from the token endpoint.
|
||||||
|
type QwenTokenResponse struct {
|
||||||
|
// AccessToken is the token used to access protected resources.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is used to obtain a new access token.
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
// TokenType indicates the type of token, typically "Bearer".
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
// ResourceURL specifies the base URL of the resource server.
|
||||||
|
ResourceURL string `json:"resource_url,omitempty"`
|
||||||
|
// ExpiresIn is the time in seconds until the access token expires.
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QwenAuth manages authentication and token handling for the Qwen API.
|
||||||
|
type QwenAuth struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQwenAuth creates a new QwenAuth instance with a proxy-configured HTTP client.
|
||||||
|
func NewQwenAuth(cfg *config.Config) *QwenAuth {
|
||||||
|
return &QwenAuth{
|
||||||
|
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCodeVerifier generates a cryptographically random string for the PKCE code verifier.
|
||||||
|
func (qa *QwenAuth) generateCodeVerifier() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCodeChallenge creates a SHA-256 hash of the code verifier, used as the PKCE code challenge.
|
||||||
|
func (qa *QwenAuth) generateCodeChallenge(codeVerifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePKCEPair creates a new code verifier and its corresponding code challenge for PKCE.
|
||||||
|
func (qa *QwenAuth) generatePKCEPair() (string, string, error) {
|
||||||
|
codeVerifier, err := qa.generateCodeVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
codeChallenge := qa.generateCodeChallenge(codeVerifier)
|
||||||
|
return codeVerifier, codeChallenge, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokens exchanges a refresh token for a new access token.
|
||||||
|
func (qa *QwenAuth) RefreshTokens(ctx context.Context, refreshToken string) (*QwenTokenData, error) {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("grant_type", "refresh_token")
|
||||||
|
data.Set("refresh_token", refreshToken)
|
||||||
|
data.Set("client_id", QwenOAuthClientID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthTokenEndpoint, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := qa.httpClient.Do(req)
|
||||||
|
|
||||||
|
// resp, err := qa.httpClient.PostForm(QwenOAuthTokenEndpoint, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errorData map[string]interface{}
|
||||||
|
if err = json.Unmarshal(body, &errorData); err == nil {
|
||||||
|
return nil, fmt.Errorf("token refresh failed: %v - %v", errorData["error"], errorData["error_description"])
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenData QwenTokenResponse
|
||||||
|
if err = json.Unmarshal(body, &tokenData); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &QwenTokenData{
|
||||||
|
AccessToken: tokenData.AccessToken,
|
||||||
|
TokenType: tokenData.TokenType,
|
||||||
|
RefreshToken: tokenData.RefreshToken,
|
||||||
|
ResourceURL: tokenData.ResourceURL,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenData.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitiateDeviceFlow starts the OAuth 2.0 device authorization flow and returns the device flow details.
|
||||||
|
func (qa *QwenAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceFlow, error) {
|
||||||
|
// Generate PKCE code verifier and challenge
|
||||||
|
codeVerifier, codeChallenge, err := qa.generatePKCEPair()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate PKCE pair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("client_id", QwenOAuthClientID)
|
||||||
|
data.Set("scope", QwenOAuthScope)
|
||||||
|
data.Set("code_challenge", codeChallenge)
|
||||||
|
data.Set("code_challenge_method", "S256")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthDeviceCodeEndpoint, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := qa.httpClient.Do(req)
|
||||||
|
|
||||||
|
// resp, err := qa.httpClient.PostForm(QwenOAuthDeviceCodeEndpoint, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("device authorization request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("device authorization failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result DeviceFlow
|
||||||
|
if err = json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse device flow response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the response indicates success
|
||||||
|
if result.DeviceCode == "" {
|
||||||
|
return nil, fmt.Errorf("device authorization failed: device_code not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the code_verifier to the result so it can be used later for polling
|
||||||
|
result.CodeVerifier = codeVerifier
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollForToken polls the token endpoint with the device code to obtain an access token.
|
||||||
|
func (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenData, error) {
|
||||||
|
pollInterval := 5 * time.Second
|
||||||
|
maxAttempts := 60 // 5 minutes max
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("grant_type", QwenOAuthGrantType)
|
||||||
|
data.Set("client_id", QwenOAuthClientID)
|
||||||
|
data.Set("device_code", deviceCode)
|
||||||
|
data.Set("code_verifier", codeVerifier)
|
||||||
|
|
||||||
|
resp, err := http.PostForm(QwenOAuthTokenEndpoint, data)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
|
||||||
|
var errorData map[string]interface{}
|
||||||
|
if err = json.Unmarshal(body, &errorData); err == nil {
|
||||||
|
// According to OAuth RFC 8628, handle standard polling responses
|
||||||
|
if resp.StatusCode == http.StatusBadRequest {
|
||||||
|
errorType, _ := errorData["error"].(string)
|
||||||
|
switch errorType {
|
||||||
|
case "authorization_pending":
|
||||||
|
// User has not yet approved the authorization request. Continue polling.
|
||||||
|
fmt.Printf("Polling attempt %d/%d...\n\n", attempt+1, maxAttempts)
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
continue
|
||||||
|
case "slow_down":
|
||||||
|
// Client is polling too frequently. Increase poll interval.
|
||||||
|
pollInterval = time.Duration(float64(pollInterval) * 1.5)
|
||||||
|
if pollInterval > 10*time.Second {
|
||||||
|
pollInterval = 10 * time.Second
|
||||||
|
}
|
||||||
|
fmt.Printf("Server requested to slow down, increasing poll interval to %v\n\n", pollInterval)
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
continue
|
||||||
|
case "expired_token":
|
||||||
|
return nil, fmt.Errorf("device code expired. Please restart the authentication process")
|
||||||
|
case "access_denied":
|
||||||
|
return nil, fmt.Errorf("authorization denied by user. Please restart the authentication process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, return with proper error information
|
||||||
|
errorType, _ := errorData["error"].(string)
|
||||||
|
errorDesc, _ := errorData["error_description"].(string)
|
||||||
|
return nil, fmt.Errorf("device token poll failed: %s - %s", errorType, errorDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If JSON parsing fails, fall back to text response
|
||||||
|
return nil, fmt.Errorf("device token poll failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
|
||||||
|
}
|
||||||
|
// log.Debugf("%s", string(body))
|
||||||
|
// Success - parse token data
|
||||||
|
var response QwenTokenResponse
|
||||||
|
if err = json.Unmarshal(body, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to QwenTokenData format and save
|
||||||
|
tokenData := &QwenTokenData{
|
||||||
|
AccessToken: response.AccessToken,
|
||||||
|
RefreshToken: response.RefreshToken,
|
||||||
|
TokenType: response.TokenType,
|
||||||
|
ResourceURL: response.ResourceURL,
|
||||||
|
Expire: time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("authentication timeout. Please restart the authentication process")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokensWithRetry attempts to refresh tokens with a specified number of retries upon failure.
|
||||||
|
func (o *QwenAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*QwenTokenData, error) {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
// Wait before retry
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(time.Duration(attempt) * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||||
|
if err == nil {
|
||||||
|
return tokenData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenStorage creates a QwenTokenStorage object from a QwenTokenData object.
|
||||||
|
func (o *QwenAuth) CreateTokenStorage(tokenData *QwenTokenData) *QwenTokenStorage {
|
||||||
|
storage := &QwenTokenStorage{
|
||||||
|
AccessToken: tokenData.AccessToken,
|
||||||
|
RefreshToken: tokenData.RefreshToken,
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
ResourceURL: tokenData.ResourceURL,
|
||||||
|
Expire: tokenData.Expire,
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTokenStorage updates an existing token storage with new token data
|
||||||
|
func (o *QwenAuth) UpdateTokenStorage(storage *QwenTokenStorage, tokenData *QwenTokenData) {
|
||||||
|
storage.AccessToken = tokenData.AccessToken
|
||||||
|
storage.RefreshToken = tokenData.RefreshToken
|
||||||
|
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||||
|
storage.ResourceURL = tokenData.ResourceURL
|
||||||
|
storage.Expire = tokenData.Expire
|
||||||
|
}
|
||||||
63
internal/auth/qwen/qwen_token.go
Normal file
63
internal/auth/qwen/qwen_token.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Package qwen provides authentication and token management functionality
|
||||||
|
// for Alibaba's Qwen AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the Qwen API.
|
||||||
|
package qwen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
|
||||||
|
// It maintains compatibility with the existing auth system while adding Qwen-specific fields
|
||||||
|
// for managing access tokens, refresh tokens, and user account information.
|
||||||
|
type QwenTokenStorage struct {
|
||||||
|
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// LastRefresh is the timestamp of the last token refresh operation.
|
||||||
|
LastRefresh string `json:"last_refresh"`
|
||||||
|
// ResourceURL is the base URL for API requests.
|
||||||
|
ResourceURL string `json:"resource_url"`
|
||||||
|
// Email is the Qwen account email address associated with this token.
|
||||||
|
Email string `json:"email"`
|
||||||
|
// Type indicates the authentication provider type, always "qwen" for this storage.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// Expire is the timestamp when the current access token expires.
|
||||||
|
Expire string `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the Qwen token storage to a JSON file.
|
||||||
|
// This method creates the necessary directory structure and writes the token
|
||||||
|
// data in JSON format to the specified file path for persistent storage.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - authFilePath: The full path where the token file should be saved
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the operation fails, nil otherwise
|
||||||
|
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
ts.Type = "qwen"
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(authFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
146
internal/browser/browser.go
Normal file
146
internal/browser/browser.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Package browser provides cross-platform functionality for opening URLs in the default web browser.
|
||||||
|
// It abstracts the underlying operating system commands and provides a simple interface.
|
||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/skratchdot/open-golang/open"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenURL opens the specified URL in the default web browser.
|
||||||
|
// It first attempts to use a platform-agnostic library and falls back to
|
||||||
|
// platform-specific commands if that fails.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The URL to open.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An error if the URL cannot be opened, otherwise nil.
|
||||||
|
func OpenURL(url string) error {
|
||||||
|
fmt.Printf("Attempting to open URL in browser: %s\n", url)
|
||||||
|
|
||||||
|
// Try using the open-golang library first
|
||||||
|
err := open.Run(url)
|
||||||
|
if err == nil {
|
||||||
|
log.Debug("Successfully opened URL using open-golang library")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("open-golang failed: %v, trying platform-specific commands", err)
|
||||||
|
|
||||||
|
// Fallback to platform-specific commands
|
||||||
|
return openURLPlatformSpecific(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openURLPlatformSpecific is a helper function that opens a URL using OS-specific commands.
|
||||||
|
// This serves as a fallback mechanism for OpenURL.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The URL to open.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An error if the URL cannot be opened, otherwise nil.
|
||||||
|
func openURLPlatformSpecific(url string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin": // macOS
|
||||||
|
cmd = exec.Command("open", url)
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||||
|
case "linux":
|
||||||
|
// Try common Linux browsers in order of preference
|
||||||
|
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
|
||||||
|
for _, browser := range browsers {
|
||||||
|
if _, err := exec.LookPath(browser); err == nil {
|
||||||
|
cmd = exec.Command(browser, url)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
return fmt.Errorf("no suitable browser found on Linux system")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Running command: %s %v", cmd.Path, cmd.Args[1:])
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start browser command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Successfully opened URL using platform-specific command")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAvailable checks if the system has a command available to open a web browser.
|
||||||
|
// It verifies the presence of necessary commands for the current operating system.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - true if a browser can be opened, false otherwise.
|
||||||
|
func IsAvailable() bool {
|
||||||
|
// First check if open-golang can work
|
||||||
|
testErr := open.Run("about:blank")
|
||||||
|
if testErr == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check platform-specific commands
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
_, err := exec.LookPath("open")
|
||||||
|
return err == nil
|
||||||
|
case "windows":
|
||||||
|
_, err := exec.LookPath("rundll32")
|
||||||
|
return err == nil
|
||||||
|
case "linux":
|
||||||
|
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
|
||||||
|
for _, browser := range browsers {
|
||||||
|
if _, err := exec.LookPath(browser); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlatformInfo returns a map containing details about the current platform's
|
||||||
|
// browser opening capabilities, including the OS, architecture, and available commands.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A map with platform-specific browser support information.
|
||||||
|
func GetPlatformInfo() map[string]interface{} {
|
||||||
|
info := map[string]interface{}{
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"available": IsAvailable(),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
info["default_command"] = "open"
|
||||||
|
case "windows":
|
||||||
|
info["default_command"] = "rundll32"
|
||||||
|
case "linux":
|
||||||
|
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
|
||||||
|
var availableBrowsers []string
|
||||||
|
for _, browser := range browsers {
|
||||||
|
if _, err := exec.LookPath(browser); err == nil {
|
||||||
|
availableBrowsers = append(availableBrowsers, browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info["available_browsers"] = availableBrowsers
|
||||||
|
if len(availableBrowsers) > 0 {
|
||||||
|
info["default_command"] = availableBrowsers[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/auth"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
codeAssistEndpoint = "https://cloudcode-pa.googleapis.com"
|
|
||||||
apiVersion = "v1internal"
|
|
||||||
pluginVersion = "0.1.9"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is the main client for interacting with the CLI API.
|
|
||||||
type Client struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
RequestMutex sync.Mutex
|
|
||||||
tokenStorage *auth.TokenStorage
|
|
||||||
cfg *config.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new CLI API client.
|
|
||||||
func NewClient(httpClient *http.Client, ts *auth.TokenStorage, cfg *config.Config) *Client {
|
|
||||||
return &Client{
|
|
||||||
httpClient: httpClient,
|
|
||||||
tokenStorage: ts,
|
|
||||||
cfg: cfg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetProjectID(projectID string) {
|
|
||||||
c.tokenStorage.ProjectID = projectID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetIsAuto(auto bool) {
|
|
||||||
c.tokenStorage.Auto = auto
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetIsChecked(checked bool) {
|
|
||||||
c.tokenStorage.Checked = checked
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) IsChecked() bool {
|
|
||||||
return c.tokenStorage.Checked
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) IsAuto() bool {
|
|
||||||
return c.tokenStorage.Auto
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetEmail() string {
|
|
||||||
return c.tokenStorage.Email
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetProjectID() string {
|
|
||||||
return c.tokenStorage.ProjectID
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupUser performs the initial user onboarding and setup.
|
|
||||||
func (c *Client) SetupUser(ctx context.Context, email, projectID string) error {
|
|
||||||
c.tokenStorage.Email = email
|
|
||||||
log.Info("Performing user onboarding...")
|
|
||||||
|
|
||||||
// 1. LoadCodeAssist
|
|
||||||
loadAssistReqBody := map[string]interface{}{
|
|
||||||
"metadata": getClientMetadata(),
|
|
||||||
}
|
|
||||||
if projectID != "" {
|
|
||||||
loadAssistReqBody["cloudaicompanionProject"] = projectID
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadAssistResp map[string]interface{}
|
|
||||||
err := c.makeAPIRequest(ctx, "loadCodeAssist", "POST", loadAssistReqBody, &loadAssistResp)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load code assist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// a, _ := json.Marshal(&loadAssistResp)
|
|
||||||
// log.Debug(string(a))
|
|
||||||
//
|
|
||||||
// a, _ = json.Marshal(loadAssistReqBody)
|
|
||||||
// log.Debug(string(a))
|
|
||||||
|
|
||||||
// 2. OnboardUser
|
|
||||||
var onboardTierID = "legacy-tier"
|
|
||||||
if tiers, ok := loadAssistResp["allowedTiers"].([]interface{}); ok {
|
|
||||||
for _, t := range tiers {
|
|
||||||
if tier, tierOk := t.(map[string]interface{}); tierOk {
|
|
||||||
if isDefault, isDefaultOk := tier["isDefault"].(bool); isDefaultOk && isDefault {
|
|
||||||
if id, idOk := tier["id"].(string); idOk {
|
|
||||||
onboardTierID = id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onboardProjectID := projectID
|
|
||||||
if p, ok := loadAssistResp["cloudaicompanionProject"].(string); ok && p != "" {
|
|
||||||
onboardProjectID = p
|
|
||||||
}
|
|
||||||
|
|
||||||
onboardReqBody := map[string]interface{}{
|
|
||||||
"tierId": onboardTierID,
|
|
||||||
"metadata": getClientMetadata(),
|
|
||||||
}
|
|
||||||
if onboardProjectID != "" {
|
|
||||||
onboardReqBody["cloudaicompanionProject"] = onboardProjectID
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("failed to start user onboarding, need define a project id")
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
var lroResp map[string]interface{}
|
|
||||||
err = c.makeAPIRequest(ctx, "onboardUser", "POST", onboardReqBody, &lroResp)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start user onboarding: %w", err)
|
|
||||||
}
|
|
||||||
// a, _ := json.Marshal(&lroResp)
|
|
||||||
// log.Debug(string(a))
|
|
||||||
|
|
||||||
// 3. Poll Long-Running Operation (LRO)
|
|
||||||
done, doneOk := lroResp["done"].(bool)
|
|
||||||
if doneOk && done {
|
|
||||||
if project, projectOk := lroResp["response"].(map[string]interface{})["cloudaicompanionProject"].(map[string]interface{}); projectOk {
|
|
||||||
if projectID != "" {
|
|
||||||
c.tokenStorage.ProjectID = projectID
|
|
||||||
} else {
|
|
||||||
c.tokenStorage.ProjectID = project["id"].(string)
|
|
||||||
}
|
|
||||||
log.Infof("Onboarding complete. Using Project ID: %s", c.tokenStorage.ProjectID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Println("Onboarding in progress, waiting 5 seconds...")
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeAPIRequest handles making requests to the CLI API endpoints.
|
|
||||||
func (c *Client) makeAPIRequest(ctx context.Context, endpoint, method string, body interface{}, result interface{}) error {
|
|
||||||
var reqBody io.Reader
|
|
||||||
if body != nil {
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
|
||||||
}
|
|
||||||
reqBody = bytes.NewBuffer(jsonBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, apiVersion, endpoint)
|
|
||||||
if strings.HasPrefix(endpoint, "operations/") {
|
|
||||||
url = fmt.Sprintf("%s/%s", codeAssistEndpoint, endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers
|
|
||||||
metadataStr := getClientMetadataString()
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", getUserAgent())
|
|
||||||
req.Header.Set("Client-Metadata", metadataStr)
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to execute request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil {
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode response body: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamAPIRequest handles making streaming requests to the CLI API endpoints.
|
|
||||||
func (c *Client) StreamAPIRequest(ctx context.Context, endpoint string, body interface{}) (io.ReadCloser, *ErrorMessage) {
|
|
||||||
var jsonBody []byte
|
|
||||||
var err error
|
|
||||||
if byteBody, ok := body.([]byte); ok {
|
|
||||||
jsonBody = byteBody
|
|
||||||
} else {
|
|
||||||
jsonBody, err = json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to marshal request body: %w", err)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// log.Debug(string(jsonBody))
|
|
||||||
reqBody := bytes.NewBuffer(jsonBody)
|
|
||||||
|
|
||||||
// Add alt=sse for streaming
|
|
||||||
url := fmt.Sprintf("%s/%s:%s?alt=sse", codeAssistEndpoint, apiVersion, endpoint)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to create request: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
|
|
||||||
if err != nil {
|
|
||||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to get token: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers
|
|
||||||
metadataStr := getClientMetadataString()
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", getUserAgent())
|
|
||||||
req.Header.Set("Client-Metadata", metadataStr)
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to execute request: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
return nil, &ErrorMessage{resp.StatusCode, fmt.Errorf(string(bodyBytes))}
|
|
||||||
// return nil, fmt.Errorf("api streaming request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.Body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMessageStream handles a single conversational turn, including tool calls.
|
|
||||||
func (c *Client) SendMessageStream(ctx context.Context, rawJson []byte, model string, contents []Content, tools []ToolDeclaration) (<-chan []byte, <-chan *ErrorMessage) {
|
|
||||||
dataTag := []byte("data: ")
|
|
||||||
errChan := make(chan *ErrorMessage)
|
|
||||||
dataChan := make(chan []byte)
|
|
||||||
go func() {
|
|
||||||
defer close(errChan)
|
|
||||||
defer close(dataChan)
|
|
||||||
|
|
||||||
request := GenerateContentRequest{
|
|
||||||
Contents: contents,
|
|
||||||
GenerationConfig: GenerationConfig{
|
|
||||||
ThinkingConfig: GenerationConfigThinkingConfig{
|
|
||||||
IncludeThoughts: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
request.Tools = tools
|
|
||||||
|
|
||||||
requestBody := map[string]interface{}{
|
|
||||||
"project": c.tokenStorage.ProjectID, // Assuming ProjectID is available
|
|
||||||
"request": request,
|
|
||||||
"model": model,
|
|
||||||
}
|
|
||||||
|
|
||||||
byteRequestBody, _ := json.Marshal(requestBody)
|
|
||||||
|
|
||||||
// log.Debug(string(byteRequestBody))
|
|
||||||
|
|
||||||
reasoningEffortResult := gjson.GetBytes(rawJson, "reasoning_effort")
|
|
||||||
if reasoningEffortResult.String() == "none" {
|
|
||||||
byteRequestBody, _ = sjson.DeleteBytes(byteRequestBody, "request.generationConfig.thinkingConfig.include_thoughts")
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
|
|
||||||
} else if reasoningEffortResult.String() == "auto" {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
|
||||||
} else if reasoningEffortResult.String() == "low" {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
|
|
||||||
} else if reasoningEffortResult.String() == "medium" {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
|
|
||||||
} else if reasoningEffortResult.String() == "high" {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 24576)
|
|
||||||
} else {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
temperatureResult := gjson.GetBytes(rawJson, "temperature")
|
|
||||||
if temperatureResult.Exists() && temperatureResult.Type == gjson.Number {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.temperature", temperatureResult.Num)
|
|
||||||
}
|
|
||||||
|
|
||||||
topPResult := gjson.GetBytes(rawJson, "top_p")
|
|
||||||
if topPResult.Exists() && topPResult.Type == gjson.Number {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topP", topPResult.Num)
|
|
||||||
}
|
|
||||||
|
|
||||||
topKResult := gjson.GetBytes(rawJson, "top_k")
|
|
||||||
if topKResult.Exists() && topKResult.Type == gjson.Number {
|
|
||||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topK", topKResult.Num)
|
|
||||||
}
|
|
||||||
|
|
||||||
// log.Debug(string(byteRequestBody))
|
|
||||||
|
|
||||||
stream, err := c.StreamAPIRequest(ctx, "streamGenerateContent", byteRequestBody)
|
|
||||||
if err != nil {
|
|
||||||
// log.Println(err)
|
|
||||||
errChan <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stream)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
// log.Printf("Received stream chunk: %s", line)
|
|
||||||
if bytes.HasPrefix(line, dataTag) {
|
|
||||||
dataChan <- line[6:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if errScanner := scanner.Err(); errScanner != nil {
|
|
||||||
// log.Println(err)
|
|
||||||
errChan <- &ErrorMessage{500, errScanner}
|
|
||||||
_ = stream.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = stream.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return dataChan, errChan
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CheckCloudAPIIsEnabled() (bool, error) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer func() {
|
|
||||||
c.RequestMutex.Unlock()
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
c.RequestMutex.Lock()
|
|
||||||
|
|
||||||
requestBody := `{"project":"%s","request":{"contents":[{"role":"user","parts":[{"text":"Be concise. What is the capital of France?"}]}],"generationConfig":{"thinkingConfig":{"include_thoughts":false,"thinkingBudget":0}}},"model":"gemini-2.5-flash"}`
|
|
||||||
requestBody = fmt.Sprintf(requestBody, c.tokenStorage.ProjectID)
|
|
||||||
// log.Debug(requestBody)
|
|
||||||
stream, err := c.StreamAPIRequest(ctx, "streamGenerateContent", []byte(requestBody))
|
|
||||||
if err != nil {
|
|
||||||
if err.StatusCode == 403 {
|
|
||||||
errJson := err.Error.Error()
|
|
||||||
codeResult := gjson.Get(errJson, "error.code")
|
|
||||||
if codeResult.Exists() && codeResult.Type == gjson.Number {
|
|
||||||
if codeResult.Int() == 403 {
|
|
||||||
activationUrlResult := gjson.Get(errJson, "error.details.0.metadata.activationUrl")
|
|
||||||
if activationUrlResult.Exists() {
|
|
||||||
log.Warnf(
|
|
||||||
"\n\nPlease activate your account with this url:\n\n%s\n And execute this command again:\n%s --login --project_id %s",
|
|
||||||
activationUrlResult.String(),
|
|
||||||
os.Args[0],
|
|
||||||
c.tokenStorage.ProjectID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stream)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if !strings.HasPrefix(line, "data: ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if scannerErr := scanner.Err(); scannerErr != nil {
|
|
||||||
_ = stream.Close()
|
|
||||||
} else {
|
|
||||||
_ = stream.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetProjectList(ctx context.Context) (*GCPProject, error) {
|
|
||||||
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not get project list: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
var project GCPProject
|
|
||||||
err = json.Unmarshal(bodyBytes, &project)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal project list: %w", err)
|
|
||||||
}
|
|
||||||
return &project, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SaveTokenToFile() error {
|
|
||||||
if err := os.MkdirAll(c.cfg.AuthDir, 0700); err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("%s-%s.json", c.tokenStorage.Email, c.tokenStorage.ProjectID))
|
|
||||||
log.Infof("Saving credentials to %s", fileName)
|
|
||||||
f, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create token file: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = f.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err = json.NewEncoder(f).Encode(c.tokenStorage); err != nil {
|
|
||||||
return fmt.Errorf("failed to write token to file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getClientMetadata returns metadata about the client environment.
|
|
||||||
func getClientMetadata() map[string]string {
|
|
||||||
return map[string]string{
|
|
||||||
"ideType": "IDE_UNSPECIFIED",
|
|
||||||
"platform": getPlatform(),
|
|
||||||
"pluginType": "GEMINI",
|
|
||||||
"pluginVersion": pluginVersion,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getClientMetadataString returns the metadata as a comma-separated string.
|
|
||||||
func getClientMetadataString() string {
|
|
||||||
md := getClientMetadata()
|
|
||||||
parts := make([]string, 0, len(md))
|
|
||||||
for k, v := range md {
|
|
||||||
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserAgent() string {
|
|
||||||
return fmt.Sprintf(fmt.Sprintf("GeminiCLI/%s (%s; %s)", pluginVersion, runtime.GOOS, runtime.GOARCH))
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPlatform returns the OS and architecture in the format expected by the API.
|
|
||||||
func getPlatform() string {
|
|
||||||
goOS := runtime.GOOS
|
|
||||||
arch := runtime.GOARCH
|
|
||||||
switch goOS {
|
|
||||||
case "darwin":
|
|
||||||
return fmt.Sprintf("DARWIN_%s", strings.ToUpper(arch))
|
|
||||||
case "linux":
|
|
||||||
return fmt.Sprintf("LINUX_%s", strings.ToUpper(arch))
|
|
||||||
case "windows":
|
|
||||||
return fmt.Sprintf("WINDOWS_%s", strings.ToUpper(arch))
|
|
||||||
default:
|
|
||||||
return "PLATFORM_UNSPECIFIED"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type ErrorMessage struct {
|
|
||||||
StatusCode int
|
|
||||||
Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
type GCPProject struct {
|
|
||||||
Projects []GCPProjectProjects `json:"projects"`
|
|
||||||
}
|
|
||||||
type GCPProjectLabels struct {
|
|
||||||
GenerativeLanguage string `json:"generative-language"`
|
|
||||||
}
|
|
||||||
type GCPProjectProjects struct {
|
|
||||||
ProjectNumber string `json:"projectNumber"`
|
|
||||||
ProjectID string `json:"projectId"`
|
|
||||||
LifecycleState string `json:"lifecycleState"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Labels GCPProjectLabels `json:"labels"`
|
|
||||||
CreateTime time.Time `json:"createTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Content struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Parts []Part `json:"parts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Part represents a single part of a message's content.
|
|
||||||
type Part struct {
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
InlineData *InlineData `json:"inlineData,omitempty"`
|
|
||||||
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
|
|
||||||
FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type InlineData struct {
|
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
|
||||||
Data string `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FunctionCall represents a tool call requested by the model.
|
|
||||||
type FunctionCall struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args map[string]interface{} `json:"args"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FunctionResponse represents the result of a tool execution.
|
|
||||||
type FunctionResponse struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Response map[string]interface{} `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateContentRequest is the request payload for the streamGenerateContent endpoint.
|
|
||||||
type GenerateContentRequest struct {
|
|
||||||
Contents []Content `json:"contents"`
|
|
||||||
Tools []ToolDeclaration `json:"tools,omitempty"`
|
|
||||||
GenerationConfig `json:"generationConfig"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerationConfig defines model generation parameters.
|
|
||||||
type GenerationConfig struct {
|
|
||||||
ThinkingConfig GenerationConfigThinkingConfig `json:"thinkingConfig,omitempty"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
|
||||||
TopP float64 `json:"topP,omitempty"`
|
|
||||||
TopK float64 `json:"topK,omitempty"`
|
|
||||||
// Temperature, TopP, TopK, etc. can be added here.
|
|
||||||
}
|
|
||||||
|
|
||||||
type GenerationConfigThinkingConfig struct {
|
|
||||||
IncludeThoughts bool `json:"include_thoughts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolDeclaration is the structure for declaring tools to the API.
|
|
||||||
// For now, we'll assume a simple structure. A more complete implementation
|
|
||||||
// would mirror the OpenAPI schema definition.
|
|
||||||
type ToolDeclaration struct {
|
|
||||||
FunctionDeclarations []interface{} `json:"functionDeclarations"`
|
|
||||||
}
|
|
||||||
54
internal/cmd/anthropic_login.go
Normal file
54
internal/cmd/anthropic_login.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoClaudeLogin triggers the Claude OAuth flow through the shared authentication manager.
|
||||||
|
// It initiates the OAuth authentication process for Anthropic Claude services and saves
|
||||||
|
// the authentication tokens to the configured auth directory.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration
|
||||||
|
// - options: Login options including browser behavior and prompts
|
||||||
|
func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
Metadata: map[string]string{},
|
||||||
|
Prompt: options.Prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, savedPath, err := manager.Login(context.Background(), "claude", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
var authErr *claude.AuthenticationError
|
||||||
|
if errors.As(err, &authErr) {
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
if authErr.Type == claude.ErrPortInUse.Type {
|
||||||
|
os.Exit(claude.ErrPortInUse.Code)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Claude authentication failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Claude authentication successful!")
|
||||||
|
}
|
||||||
22
internal/cmd/auth_manager.go
Normal file
22
internal/cmd/auth_manager.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAuthManager creates a new authentication manager instance with all supported
|
||||||
|
// authenticators and a file-based token store. It initializes authenticators for
|
||||||
|
// Gemini, Codex, Claude, and Qwen providers.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *sdkAuth.Manager: A configured authentication manager instance
|
||||||
|
func newAuthManager() *sdkAuth.Manager {
|
||||||
|
store := sdkAuth.GetTokenStore()
|
||||||
|
manager := sdkAuth.NewManager(store,
|
||||||
|
sdkAuth.NewGeminiAuthenticator(),
|
||||||
|
sdkAuth.NewCodexAuthenticator(),
|
||||||
|
sdkAuth.NewClaudeAuthenticator(),
|
||||||
|
sdkAuth.NewQwenAuthenticator(),
|
||||||
|
)
|
||||||
|
return manager
|
||||||
|
}
|
||||||
190
internal/cmd/gemini-web_auth.go
Normal file
190
internal/cmd/gemini-web_auth.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// Package cmd provides command-line interface functionality for the CLI Proxy API.
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// banner prints a simple ASCII banner for clarity without ANSI colors.
|
||||||
|
func banner(title string) {
|
||||||
|
line := strings.Repeat("=", len(title)+8)
|
||||||
|
fmt.Println(line)
|
||||||
|
fmt.Println("=== " + title + " ===")
|
||||||
|
fmt.Println(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoGeminiWebAuth handles the process of creating a Gemini Web token file.
|
||||||
|
// New flow:
|
||||||
|
// 1. Prompt user to paste the full cookie string.
|
||||||
|
// 2. Extract __Secure-1PSID and __Secure-1PSIDTS from the cookie string.
|
||||||
|
// 3. Call https://accounts.google.com/ListAccounts with the cookie to obtain email.
|
||||||
|
// 4. Save auth file with the same structure, and set Label to the email.
|
||||||
|
func DoGeminiWebAuth(cfg *config.Config) {
|
||||||
|
var secure1psid, secure1psidts, email string
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
isMacOS := strings.HasPrefix(runtime.GOOS, "darwin")
|
||||||
|
cookieProvided := false
|
||||||
|
banner("Gemini Web Cookie Sign-in")
|
||||||
|
if !isMacOS {
|
||||||
|
// NOTE: Provide extra guidance for macOS users or anyone unsure about retrieving cookies.
|
||||||
|
fmt.Println("--- Cookie Input ---")
|
||||||
|
fmt.Println(">> Paste your full Google Cookie and press Enter")
|
||||||
|
fmt.Println("Tip: If you are on macOS, or don't know how to get the cookie, just press Enter and follow the prompts.")
|
||||||
|
fmt.Print("Cookie: ")
|
||||||
|
rawCookie, _ := reader.ReadString('\n')
|
||||||
|
rawCookie = strings.TrimSpace(rawCookie)
|
||||||
|
if rawCookie == "" {
|
||||||
|
// Skip cookie-based parsing; fall back to manual field prompts.
|
||||||
|
fmt.Println("==> No cookie provided. Proceeding with manual input.")
|
||||||
|
} else {
|
||||||
|
cookieProvided = true
|
||||||
|
// Parse K=V cookie pairs separated by ';'
|
||||||
|
cookieMap := make(map[string]string)
|
||||||
|
parts := strings.Split(rawCookie, ";")
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if eq := strings.Index(p, "="); eq > 0 {
|
||||||
|
k := strings.TrimSpace(p[:eq])
|
||||||
|
v := strings.TrimSpace(p[eq+1:])
|
||||||
|
if k != "" {
|
||||||
|
cookieMap[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secure1psid = strings.TrimSpace(cookieMap["__Secure-1PSID"])
|
||||||
|
secure1psidts = strings.TrimSpace(cookieMap["__Secure-1PSIDTS"])
|
||||||
|
|
||||||
|
// Build HTTP client with proxy settings respected.
|
||||||
|
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
httpClient = util.SetProxy(cfg, httpClient)
|
||||||
|
|
||||||
|
// Request ListAccounts to extract email as label (use POST per upstream behavior).
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://accounts.google.com/ListAccounts", nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("!! Failed to create request:", err)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Cookie", rawCookie)
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Origin", "https://accounts.google.com")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("!! Request to ListAccounts failed:", err)
|
||||||
|
} else {
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
fmt.Printf("!! ListAccounts returned status code: %d\n", resp.StatusCode)
|
||||||
|
} else {
|
||||||
|
var payload []any
|
||||||
|
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
fmt.Println("!! Failed to parse ListAccounts response:", err)
|
||||||
|
} else {
|
||||||
|
// Expected structure like: ["gaia.l.a.r", [["gaia.l.a",1,"Name","email@example.com", ... ]]]
|
||||||
|
if len(payload) >= 2 {
|
||||||
|
if accounts, ok := payload[1].([]any); ok && len(accounts) >= 1 {
|
||||||
|
if first, ok1 := accounts[0].([]any); ok1 && len(first) >= 4 {
|
||||||
|
if em, ok2 := first[3].(string); ok2 {
|
||||||
|
email = strings.TrimSpace(em)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
fmt.Println("!! Failed to parse email from ListAccounts response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: prompt user to input missing values
|
||||||
|
if secure1psid == "" {
|
||||||
|
if cookieProvided && !isMacOS {
|
||||||
|
fmt.Println("!! Cookie missing __Secure-1PSID.")
|
||||||
|
}
|
||||||
|
fmt.Print("Enter __Secure-1PSID: ")
|
||||||
|
v, _ := reader.ReadString('\n')
|
||||||
|
secure1psid = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if secure1psidts == "" {
|
||||||
|
if cookieProvided && !isMacOS {
|
||||||
|
fmt.Println("!! Cookie missing __Secure-1PSIDTS.")
|
||||||
|
}
|
||||||
|
fmt.Print("Enter __Secure-1PSIDTS: ")
|
||||||
|
v, _ := reader.ReadString('\n')
|
||||||
|
secure1psidts = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if secure1psid == "" || secure1psidts == "" {
|
||||||
|
// Use print instead of logger to avoid log redirection.
|
||||||
|
fmt.Println("!! __Secure-1PSID and __Secure-1PSIDTS cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isMacOS {
|
||||||
|
fmt.Print("Enter your account email: ")
|
||||||
|
v, _ := reader.ReadString('\n')
|
||||||
|
email = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a filename based on the SHA256 hash of the PSID
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte(secure1psid))
|
||||||
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
|
||||||
|
|
||||||
|
// Decide label: prefer email; fallback prompt then file name without .json
|
||||||
|
defaultLabel := strings.TrimSuffix(fileName, ".json")
|
||||||
|
label := email
|
||||||
|
if label == "" {
|
||||||
|
fmt.Print(fmt.Sprintf("Enter label for this auth (default: %s): ", defaultLabel))
|
||||||
|
v, _ := reader.ReadString('\n')
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v != "" {
|
||||||
|
label = v
|
||||||
|
} else {
|
||||||
|
label = defaultLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStorage := &gemini.GeminiWebTokenStorage{
|
||||||
|
Secure1PSID: secure1psid,
|
||||||
|
Secure1PSIDTS: secure1psidts,
|
||||||
|
Label: label,
|
||||||
|
}
|
||||||
|
record := &sdkAuth.TokenRecord{
|
||||||
|
Provider: "gemini-web",
|
||||||
|
FileName: fileName,
|
||||||
|
Storage: tokenStorage,
|
||||||
|
}
|
||||||
|
store := sdkAuth.GetTokenStore()
|
||||||
|
savedPath, err := store.Save(context.Background(), cfg, record)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("!! Failed to save Gemini Web token to file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("==> Successfully saved Gemini Web token!")
|
||||||
|
fmt.Println("==> Saved to:", savedPath)
|
||||||
|
}
|
||||||
@@ -1,77 +1,69 @@
|
|||||||
|
// Package cmd provides command-line interface functionality for the CLI Proxy API server.
|
||||||
|
// It includes authentication flows for various AI service providers, service startup,
|
||||||
|
// and other command-line operations.
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/auth"
|
"errors"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
"fmt"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func DoLogin(cfg *config.Config, projectID string) {
|
// DoLogin handles Google Gemini authentication using the shared authentication manager.
|
||||||
var err error
|
// It initiates the OAuth flow for Google Gemini services and saves the authentication
|
||||||
var ts auth.TokenStorage
|
// tokens to the configured auth directory.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration
|
||||||
|
// - projectID: Optional Google Cloud project ID for Gemini services
|
||||||
|
// - options: Login options including browser behavior and prompts
|
||||||
|
func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
|
||||||
|
metadata := map[string]string{}
|
||||||
if projectID != "" {
|
if projectID != "" {
|
||||||
ts.ProjectID = projectID
|
metadata["project_id"] = projectID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Initialize authenticated HTTP Client
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
clientCtx := context.Background()
|
NoBrowser: options.NoBrowser,
|
||||||
|
ProjectID: projectID,
|
||||||
log.Info("Initializing authentication...")
|
Metadata: metadata,
|
||||||
httpClient, errGetClient := auth.GetAuthenticatedClient(clientCtx, &ts, cfg)
|
Prompt: options.Prompt,
|
||||||
if errGetClient != nil {
|
|
||||||
log.Fatalf("failed to get authenticated client: %v", errGetClient)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.Info("Authentication successful.")
|
|
||||||
|
|
||||||
// 3. Initialize CLI Client
|
_, savedPath, err := manager.Login(context.Background(), "gemini", cfg, authOpts)
|
||||||
cliClient := client.NewClient(httpClient, &ts, cfg)
|
|
||||||
err = cliClient.SetupUser(clientCtx, ts.Email, projectID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "failed to start user onboarding, need define a project id" {
|
var selectionErr *sdkAuth.ProjectSelectionError
|
||||||
log.Error("failed to start user onboarding")
|
if errors.As(err, &selectionErr) {
|
||||||
project, errGetProjectList := cliClient.GetProjectList(clientCtx)
|
fmt.Println(selectionErr.Error())
|
||||||
if errGetProjectList != nil {
|
projects := selectionErr.ProjectsDisplay()
|
||||||
log.Fatalf("failed to complete user setup: %v", err)
|
if len(projects) > 0 {
|
||||||
} else {
|
fmt.Println("========================================================================")
|
||||||
log.Infof("Your account %s needs specify a project id.", ts.Email)
|
for _, p := range projects {
|
||||||
log.Info("========================================================================")
|
fmt.Printf("Project ID: %s\n", p.ProjectID)
|
||||||
for i := 0; i < len(project.Projects); i++ {
|
fmt.Printf("Project Name: %s\n", p.Name)
|
||||||
log.Infof("Project ID: %s", project.Projects[i].ProjectID)
|
fmt.Println("------------------------------------------------------------------------")
|
||||||
log.Infof("Project Name: %s", project.Projects[i].Name)
|
|
||||||
log.Info("========================================================================")
|
|
||||||
}
|
}
|
||||||
log.Infof("Please run this command to login again:\n\n%s --login --project_id <project_id>\n", os.Args[0])
|
fmt.Println("Please rerun the login command with --project_id <project_id>.")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Log as a warning because in some cases, the CLI might still be usable
|
|
||||||
// or the user might want to retry setup later.
|
|
||||||
log.Fatalf("failed to complete user setup: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
auto := projectID == ""
|
|
||||||
cliClient.SetIsAuto(auto)
|
|
||||||
|
|
||||||
if !cliClient.IsChecked() && !cliClient.IsAuto() {
|
|
||||||
isChecked, checkErr := cliClient.CheckCloudAPIIsEnabled()
|
|
||||||
if checkErr != nil {
|
|
||||||
log.Fatalf("failed to check cloud api is enabled: %v", checkErr)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cliClient.SetIsChecked(isChecked)
|
log.Fatalf("Gemini authentication failed: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
if !cliClient.IsChecked() && !cliClient.IsAuto() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = cliClient.SaveTokenToFile()
|
if savedPath != "" {
|
||||||
if err != nil {
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
log.Fatal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("Gemini authentication successful!")
|
||||||
}
|
}
|
||||||
|
|||||||
64
internal/cmd/openai_login.go
Normal file
64
internal/cmd/openai_login.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginOptions contains options for the login processes.
|
||||||
|
// It provides configuration for authentication flows including browser behavior
|
||||||
|
// and interactive prompting capabilities.
|
||||||
|
type LoginOptions struct {
|
||||||
|
// NoBrowser indicates whether to skip opening the browser automatically.
|
||||||
|
NoBrowser bool
|
||||||
|
|
||||||
|
// Prompt allows the caller to provide interactive input when needed.
|
||||||
|
Prompt func(prompt string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoCodexLogin triggers the Codex OAuth flow through the shared authentication manager.
|
||||||
|
// It initiates the OAuth authentication process for OpenAI Codex services and saves
|
||||||
|
// the authentication tokens to the configured auth directory.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration
|
||||||
|
// - options: Login options including browser behavior and prompts
|
||||||
|
func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
Metadata: map[string]string{},
|
||||||
|
Prompt: options.Prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
var authErr *codex.AuthenticationError
|
||||||
|
if errors.As(err, &authErr) {
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
if authErr.Type == codex.ErrPortInUse.Type {
|
||||||
|
os.Exit(codex.ErrPortInUse.Code)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Codex authentication failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
fmt.Println("Codex authentication successful!")
|
||||||
|
}
|
||||||
60
internal/cmd/qwen_login.go
Normal file
60
internal/cmd/qwen_login.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoQwenLogin handles the Qwen device flow using the shared authentication manager.
|
||||||
|
// It initiates the device-based authentication process for Qwen services and saves
|
||||||
|
// the authentication tokens to the configured auth directory.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration
|
||||||
|
// - options: Login options including browser behavior and prompts
|
||||||
|
func DoQwenLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
|
||||||
|
promptFn := options.Prompt
|
||||||
|
if promptFn == nil {
|
||||||
|
promptFn = func(prompt string) (string, error) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(prompt)
|
||||||
|
var value string
|
||||||
|
_, err := fmt.Scanln(&value)
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
Metadata: map[string]string{},
|
||||||
|
Prompt: promptFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, savedPath, err := manager.Login(context.Background(), "qwen", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
var emailErr *sdkAuth.EmailRequiredError
|
||||||
|
if errors.As(err, &emailErr) {
|
||||||
|
log.Error(emailErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Qwen authentication failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Qwen authentication successful!")
|
||||||
|
}
|
||||||
@@ -1,99 +1,55 @@
|
|||||||
|
// Package cmd provides command-line interface functionality for the CLI Proxy API server.
|
||||||
|
// It includes authentication flows for various AI service providers, service startup,
|
||||||
|
// and other command-line operations.
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/api"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/auth"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartService(cfg *config.Config) {
|
// StartService builds and runs the proxy service using the exported SDK.
|
||||||
// Create API server configuration
|
// It creates a new proxy service instance, sets up signal handling for graceful shutdown,
|
||||||
apiConfig := &api.ServerConfig{
|
// and starts the service with the provided configuration.
|
||||||
Port: fmt.Sprintf("%d", cfg.Port),
|
//
|
||||||
Debug: cfg.Debug,
|
// Parameters:
|
||||||
ApiKeys: cfg.ApiKeys,
|
// - cfg: The application configuration
|
||||||
|
// - configPath: The path to the configuration file
|
||||||
|
// - localPassword: Optional password accepted for local management requests
|
||||||
|
func StartService(cfg *config.Config, configPath string, localPassword string) {
|
||||||
|
builder := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath(configPath).
|
||||||
|
WithLocalManagementPassword(localPassword)
|
||||||
|
|
||||||
|
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
runCtx := ctxSignal
|
||||||
|
if localPassword != "" {
|
||||||
|
var keepAliveCancel context.CancelFunc
|
||||||
|
runCtx, keepAliveCancel = context.WithCancel(ctxSignal)
|
||||||
|
builder = builder.WithServerOptions(api.WithKeepAliveEndpoint(10*time.Second, func() {
|
||||||
|
log.Warn("keep-alive endpoint idle for 10s, shutting down")
|
||||||
|
keepAliveCancel()
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
cliClients := make([]*client.Client, 0)
|
service, err := builder.Build()
|
||||||
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatalf("failed to build proxy service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
|
err = service.Run(runCtx)
|
||||||
log.Debugf("Loading token from: %s", path)
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
f, errOpen := os.Open(path)
|
log.Fatalf("proxy service exited with error: %v", err)
|
||||||
if errOpen != nil {
|
|
||||||
return errOpen
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = f.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var ts auth.TokenStorage
|
|
||||||
if err = json.NewDecoder(f).Decode(&ts); err == nil {
|
|
||||||
// 2. Initialize authenticated HTTP Client
|
|
||||||
clientCtx := context.Background()
|
|
||||||
|
|
||||||
log.Info("Initializing authentication...")
|
|
||||||
httpClient, errGetClient := auth.GetAuthenticatedClient(clientCtx, &ts, cfg)
|
|
||||||
if errGetClient != nil {
|
|
||||||
log.Fatalf("failed to get authenticated client: %v", errGetClient)
|
|
||||||
return errGetClient
|
|
||||||
}
|
|
||||||
log.Info("Authentication successful.")
|
|
||||||
|
|
||||||
// 3. Initialize CLI Client
|
|
||||||
cliClient := client.NewClient(httpClient, &ts, cfg)
|
|
||||||
cliClients = append(cliClients, cliClient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create API server
|
|
||||||
apiServer := api.NewServer(apiConfig, cliClients)
|
|
||||||
log.Infof("Starting API server on port %s", apiConfig.Port)
|
|
||||||
if err = apiServer.Start(); err != nil {
|
|
||||||
log.Fatalf("API server failed to start: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up graceful shutdown
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-sigChan:
|
|
||||||
log.Debugf("Received shutdown signal. Cleaning up...")
|
|
||||||
|
|
||||||
// Create shutdown context
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
_ = ctx // Mark ctx as used to avoid error, as apiServer.Stop(ctx) is commented out
|
|
||||||
|
|
||||||
// Stop API server
|
|
||||||
if err = apiServer.Stop(ctx); err != nil {
|
|
||||||
log.Debugf("Error stopping API server: %v", err)
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
log.Debugf("Cleanup completed. Exiting...")
|
|
||||||
os.Exit(0)
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,579 @@
|
|||||||
|
// Package config provides configuration management for the CLI Proxy API server.
|
||||||
|
// It handles loading and parsing YAML configuration files, and provides structured
|
||||||
|
// access to application settings including server port, authentication directory,
|
||||||
|
// debug settings, proxy configuration, and API keys.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application's configuration
|
// Config represents the application's configuration, loaded from a YAML file.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `yaml:"port"`
|
// Port is the network port on which the API server will listen.
|
||||||
AuthDir string `yaml:"auth_dir"`
|
Port int `yaml:"port" json:"-"`
|
||||||
Debug bool `yaml:"debug"`
|
|
||||||
ProxyUrl string `yaml:"proxy-url"`
|
// AuthDir is the directory where authentication token files are stored.
|
||||||
ApiKeys []string `yaml:"api_keys"`
|
AuthDir string `yaml:"auth-dir" json:"-"`
|
||||||
|
|
||||||
|
// Debug enables or disables debug-level logging and other debug features.
|
||||||
|
Debug bool `yaml:"debug" json:"debug"`
|
||||||
|
|
||||||
|
// LoggingToFile controls whether application logs are written to rotating files or stdout.
|
||||||
|
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
|
||||||
|
|
||||||
|
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
|
||||||
|
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
|
||||||
|
|
||||||
|
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
||||||
|
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||||
|
|
||||||
|
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||||
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||||
|
|
||||||
|
// Access holds request authentication provider configuration.
|
||||||
|
Access AccessConfig `yaml:"auth" json:"auth"`
|
||||||
|
|
||||||
|
// QuotaExceeded defines the behavior when a quota is exceeded.
|
||||||
|
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
||||||
|
|
||||||
|
// GlAPIKey is the API key for the generative language API.
|
||||||
|
GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"`
|
||||||
|
|
||||||
|
// RequestLog enables or disables detailed request logging functionality.
|
||||||
|
RequestLog bool `yaml:"request-log" json:"request-log"`
|
||||||
|
|
||||||
|
// RequestRetry defines the retry times when the request failed.
|
||||||
|
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
||||||
|
|
||||||
|
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
|
||||||
|
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
|
||||||
|
|
||||||
|
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
||||||
|
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
||||||
|
|
||||||
|
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
|
||||||
|
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"`
|
||||||
|
|
||||||
|
// RemoteManagement nests management-related options under 'remote-management'.
|
||||||
|
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
|
||||||
|
|
||||||
|
// GeminiWeb groups configuration for Gemini Web client
|
||||||
|
GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// / LoadConfig loads the configuration from the specified file
|
// AccessConfig groups request authentication providers.
|
||||||
|
type AccessConfig struct {
|
||||||
|
// Providers lists configured authentication providers.
|
||||||
|
Providers []AccessProvider `yaml:"providers" json:"providers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessProvider describes a request authentication provider entry.
|
||||||
|
type AccessProvider struct {
|
||||||
|
// Name is the instance identifier for the provider.
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
|
// Type selects the provider implementation registered via the SDK.
|
||||||
|
Type string `yaml:"type" json:"type"`
|
||||||
|
|
||||||
|
// SDK optionally names a third-party SDK module providing this provider.
|
||||||
|
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
|
||||||
|
|
||||||
|
// APIKeys lists inline keys for providers that require them.
|
||||||
|
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
||||||
|
|
||||||
|
// Config passes provider-specific options to the implementation.
|
||||||
|
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
|
||||||
|
AccessProviderTypeConfigAPIKey = "config-api-key"
|
||||||
|
|
||||||
|
// DefaultAccessProviderName is applied when no provider name is supplied.
|
||||||
|
DefaultAccessProviderName = "config-inline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
|
||||||
|
type GeminiWebConfig struct {
|
||||||
|
// Context enables JSON-based conversation reuse.
|
||||||
|
// Defaults to true if not set in YAML (see LoadConfig).
|
||||||
|
Context bool `yaml:"context" json:"context"`
|
||||||
|
|
||||||
|
// CodeMode, when true, enables coding mode behaviors for Gemini Web:
|
||||||
|
// - Attach the predefined "Coding partner" Gem
|
||||||
|
// - Enable XML wrapping hint for tool markup
|
||||||
|
// - Merge <think> content into visible content for tool-friendly output
|
||||||
|
CodeMode bool `yaml:"code-mode" json:"code-mode"`
|
||||||
|
|
||||||
|
// MaxCharsPerRequest caps the number of characters (runes) sent to
|
||||||
|
// Gemini Web in a single request. Long prompts will be split into
|
||||||
|
// multiple requests with a continuation hint, and only the final
|
||||||
|
// request will carry any files. When unset or <=0, a conservative
|
||||||
|
// default of 1,000,000 will be used.
|
||||||
|
MaxCharsPerRequest int `yaml:"max-chars-per-request" json:"max-chars-per-request"`
|
||||||
|
|
||||||
|
// DisableContinuationHint, when true, disables the continuation hint for split prompts.
|
||||||
|
// The hint is enabled by default.
|
||||||
|
DisableContinuationHint bool `yaml:"disable-continuation-hint,omitempty" json:"disable-continuation-hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteManagement holds management API configuration under 'remote-management'.
|
||||||
|
type RemoteManagement struct {
|
||||||
|
// AllowRemote toggles remote (non-localhost) access to management API.
|
||||||
|
AllowRemote bool `yaml:"allow-remote"`
|
||||||
|
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
|
||||||
|
SecretKey string `yaml:"secret-key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaExceeded defines the behavior when API quota limits are exceeded.
|
||||||
|
// It provides configuration options for automatic failover mechanisms.
|
||||||
|
type QuotaExceeded struct {
|
||||||
|
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
|
||||||
|
SwitchProject bool `yaml:"switch-project" json:"switch-project"`
|
||||||
|
|
||||||
|
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
|
||||||
|
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeKey represents the configuration for a Claude API key,
|
||||||
|
// including the API key itself and an optional base URL for the API endpoint.
|
||||||
|
type ClaudeKey struct {
|
||||||
|
// APIKey is the authentication key for accessing Claude API services.
|
||||||
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
|
// BaseURL is the base URL for the Claude API endpoint.
|
||||||
|
// If empty, the default Claude API URL will be used.
|
||||||
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexKey represents the configuration for a Codex API key,
|
||||||
|
// including the API key itself and an optional base URL for the API endpoint.
|
||||||
|
type CodexKey struct {
|
||||||
|
// APIKey is the authentication key for accessing Codex API services.
|
||||||
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
|
// BaseURL is the base URL for the Codex API endpoint.
|
||||||
|
// If empty, the default Codex API URL will be used.
|
||||||
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
||||||
|
// with external providers, allowing model aliases to be routed through OpenAI API format.
|
||||||
|
type OpenAICompatibility struct {
|
||||||
|
// Name is the identifier for this OpenAI compatibility configuration.
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
|
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
||||||
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||||
|
|
||||||
|
// APIKeys are the authentication keys for accessing the external API services.
|
||||||
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||||
|
|
||||||
|
// Models defines the model configurations including aliases for routing.
|
||||||
|
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
|
||||||
|
// including the actual model name and its alias for API routing.
|
||||||
|
type OpenAICompatibilityModel struct {
|
||||||
|
// Name is the actual model name used by the external provider.
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
|
// Alias is the model name alias that clients will use to reference this model.
|
||||||
|
Alias string `yaml:"alias" json:"alias"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig reads a YAML configuration file from the given path,
|
||||||
|
// unmarshals it into a Config struct, applies environment variable overrides,
|
||||||
|
// and returns it.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - configFile: The path to the YAML configuration file
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *Config: The loaded configuration
|
||||||
|
// - error: An error if the configuration could not be loaded
|
||||||
func LoadConfig(configFile string) (*Config, error) {
|
func LoadConfig(configFile string) (*Config, error) {
|
||||||
// Read the configuration file
|
// Read the entire configuration file into memory.
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
// If reading the file fails
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return an error
|
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the YAML data
|
// Unmarshal the YAML data into the Config struct.
|
||||||
var config Config
|
var config Config
|
||||||
// If parsing the YAML data fails
|
// Set defaults before unmarshal so that absent keys keep defaults.
|
||||||
|
config.LoggingToFile = true
|
||||||
|
config.UsageStatisticsEnabled = true
|
||||||
|
config.GeminiWeb.Context = true
|
||||||
if err = yaml.Unmarshal(data, &config); err != nil {
|
if err = yaml.Unmarshal(data, &config); err != nil {
|
||||||
// Return an error
|
|
||||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the configuration
|
// Hash remote management key if plaintext is detected (nested)
|
||||||
|
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
|
||||||
|
if config.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(config.RemoteManagement.SecretKey) {
|
||||||
|
hashed, errHash := hashSecret(config.RemoteManagement.SecretKey)
|
||||||
|
if errHash != nil {
|
||||||
|
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
|
||||||
|
}
|
||||||
|
config.RemoteManagement.SecretKey = hashed
|
||||||
|
|
||||||
|
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
|
||||||
|
// Preserve YAML comments and ordering; update only the nested key.
|
||||||
|
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync request authentication providers with inline API keys for backwards compatibility.
|
||||||
|
syncInlineAccessProvider(&config)
|
||||||
|
|
||||||
|
// Return the populated configuration struct.
|
||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncInlineAPIKeys updates the inline API key provider and top-level APIKeys field.
|
||||||
|
func SyncInlineAPIKeys(cfg *Config, keys []string) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cloned := append([]string(nil), keys...)
|
||||||
|
cfg.APIKeys = cloned
|
||||||
|
if provider := cfg.ConfigAPIKeyProvider(); provider != nil {
|
||||||
|
if provider.Name == "" {
|
||||||
|
provider.Name = DefaultAccessProviderName
|
||||||
|
}
|
||||||
|
provider.APIKeys = cloned
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||||
|
Name: DefaultAccessProviderName,
|
||||||
|
Type: AccessProviderTypeConfigAPIKey,
|
||||||
|
APIKeys: cloned,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigAPIKeyProvider returns the first inline API key provider if present.
|
||||||
|
func (c *Config) ConfigAPIKeyProvider() *AccessProvider {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range c.Access.Providers {
|
||||||
|
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
|
||||||
|
if c.Access.Providers[i].Name == "" {
|
||||||
|
c.Access.Providers[i].Name = DefaultAccessProviderName
|
||||||
|
}
|
||||||
|
return &c.Access.Providers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncInlineAccessProvider(cfg *Config) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cfg.Access.Providers) == 0 {
|
||||||
|
if len(cfg.APIKeys) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||||
|
Name: DefaultAccessProviderName,
|
||||||
|
Type: AccessProviderTypeConfigAPIKey,
|
||||||
|
APIKeys: append([]string(nil), cfg.APIKeys...),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider := cfg.ConfigAPIKeyProvider()
|
||||||
|
if provider == nil {
|
||||||
|
if len(cfg.APIKeys) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||||
|
Name: DefaultAccessProviderName,
|
||||||
|
Type: AccessProviderTypeConfigAPIKey,
|
||||||
|
APIKeys: append([]string(nil), cfg.APIKeys...),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(provider.APIKeys) == 0 && len(cfg.APIKeys) > 0 {
|
||||||
|
provider.APIKeys = append([]string(nil), cfg.APIKeys...)
|
||||||
|
}
|
||||||
|
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
|
||||||
|
func looksLikeBcrypt(s string) bool {
|
||||||
|
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashSecret hashes the given secret using bcrypt.
|
||||||
|
func hashSecret(secret string) (string, error) {
|
||||||
|
// Use default cost for simplicity.
|
||||||
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
|
||||||
|
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
|
||||||
|
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||||
|
// Load original YAML as a node tree to preserve comments and ordering.
|
||||||
|
data, err := os.ReadFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var original yaml.Node
|
||||||
|
if err = yaml.Unmarshal(data, &original); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
|
||||||
|
return fmt.Errorf("invalid yaml document structure")
|
||||||
|
}
|
||||||
|
if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("expected root mapping node")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
|
||||||
|
rendered, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var generated yaml.Node
|
||||||
|
if err = yaml.Unmarshal(rendered, &generated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
|
||||||
|
return fmt.Errorf("invalid generated yaml structure")
|
||||||
|
}
|
||||||
|
if generated.Content[0].Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("expected generated root mapping node")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
||||||
|
mergeMappingPreserve(original.Content[0], generated.Content[0])
|
||||||
|
|
||||||
|
// Write back.
|
||||||
|
f, err := os.Create(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
enc := yaml.NewEncoder(f)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err = enc.Encode(&original); err != nil {
|
||||||
|
_ = enc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return enc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
|
||||||
|
// while preserving comments and positions.
|
||||||
|
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
||||||
|
data, err := os.ReadFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var root yaml.Node
|
||||||
|
if err = yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
|
||||||
|
return fmt.Errorf("invalid yaml document structure")
|
||||||
|
}
|
||||||
|
node := root.Content[0]
|
||||||
|
// descend mapping nodes following path
|
||||||
|
for i, key := range path {
|
||||||
|
if i == len(path)-1 {
|
||||||
|
// set final scalar
|
||||||
|
v := getOrCreateMapValue(node, key)
|
||||||
|
v.Kind = yaml.ScalarNode
|
||||||
|
v.Tag = "!!str"
|
||||||
|
v.Value = value
|
||||||
|
} else {
|
||||||
|
next := getOrCreateMapValue(node, key)
|
||||||
|
if next.Kind != yaml.MappingNode {
|
||||||
|
next.Kind = yaml.MappingNode
|
||||||
|
next.Tag = "!!map"
|
||||||
|
}
|
||||||
|
node = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := os.Create(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
enc := yaml.NewEncoder(f)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err = enc.Encode(&root); err != nil {
|
||||||
|
_ = enc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return enc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreateMapValue finds the value node for a given key in a mapping node.
|
||||||
|
// If not found, it appends a new key/value pair and returns the new value node.
|
||||||
|
func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
|
||||||
|
if mapNode.Kind != yaml.MappingNode {
|
||||||
|
mapNode.Kind = yaml.MappingNode
|
||||||
|
mapNode.Tag = "!!map"
|
||||||
|
mapNode.Content = nil
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||||
|
k := mapNode.Content[i]
|
||||||
|
if k.Value == key {
|
||||||
|
return mapNode.Content[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// append new key/value
|
||||||
|
mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
|
||||||
|
val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
|
||||||
|
mapNode.Content = append(mapNode.Content, val)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
|
||||||
|
// key order and comments of existing keys in dst. Unknown keys from src are appended
|
||||||
|
// to dst at the end, copying their node structure from src.
|
||||||
|
func mergeMappingPreserve(dst, src *yaml.Node) {
|
||||||
|
if dst == nil || src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
|
||||||
|
// If kinds do not match, prefer replacing dst with src semantics in-place
|
||||||
|
// but keep dst node object to preserve any attached comments at the parent level.
|
||||||
|
copyNodeShallow(dst, src)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build a lookup of existing keys in dst
|
||||||
|
for i := 0; i+1 < len(src.Content); i += 2 {
|
||||||
|
sk := src.Content[i]
|
||||||
|
sv := src.Content[i+1]
|
||||||
|
idx := findMapKeyIndex(dst, sk.Value)
|
||||||
|
if idx >= 0 {
|
||||||
|
// Merge into existing value node
|
||||||
|
dv := dst.Content[idx+1]
|
||||||
|
mergeNodePreserve(dv, sv)
|
||||||
|
} else {
|
||||||
|
// Append new key/value pair by deep-copying from src
|
||||||
|
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
||||||
|
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
||||||
|
// in-place by index.
|
||||||
|
func mergeNodePreserve(dst, src *yaml.Node) {
|
||||||
|
if dst == nil || src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch src.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
if dst.Kind != yaml.MappingNode {
|
||||||
|
copyNodeShallow(dst, src)
|
||||||
|
}
|
||||||
|
mergeMappingPreserve(dst, src)
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
// Preserve explicit null style if dst was null and src is empty sequence
|
||||||
|
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
|
||||||
|
// Keep as null to preserve original style
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dst.Kind != yaml.SequenceNode {
|
||||||
|
dst.Kind = yaml.SequenceNode
|
||||||
|
dst.Tag = "!!seq"
|
||||||
|
dst.Content = nil
|
||||||
|
}
|
||||||
|
// Update elements in place
|
||||||
|
minContent := len(dst.Content)
|
||||||
|
if len(src.Content) < minContent {
|
||||||
|
minContent = len(src.Content)
|
||||||
|
}
|
||||||
|
for i := 0; i < minContent; i++ {
|
||||||
|
if dst.Content[i] == nil {
|
||||||
|
dst.Content[i] = deepCopyNode(src.Content[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mergeNodePreserve(dst.Content[i], src.Content[i])
|
||||||
|
}
|
||||||
|
// Append any extra items from src
|
||||||
|
for i := len(dst.Content); i < len(src.Content); i++ {
|
||||||
|
dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
|
||||||
|
}
|
||||||
|
// Truncate if dst has extra items not in src
|
||||||
|
if len(src.Content) < len(dst.Content) {
|
||||||
|
dst.Content = dst.Content[:len(src.Content)]
|
||||||
|
}
|
||||||
|
case yaml.ScalarNode, yaml.AliasNode:
|
||||||
|
// For scalars, update Tag and Value but keep Style from dst to preserve quoting
|
||||||
|
dst.Kind = src.Kind
|
||||||
|
dst.Tag = src.Tag
|
||||||
|
dst.Value = src.Value
|
||||||
|
// Keep dst.Style as-is intentionally
|
||||||
|
case 0:
|
||||||
|
// Unknown/empty kind; do nothing
|
||||||
|
default:
|
||||||
|
// Fallback: replace shallowly
|
||||||
|
copyNodeShallow(dst, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
|
||||||
|
// Returns -1 when not found.
|
||||||
|
func findMapKeyIndex(mapNode *yaml.Node, key string) int {
|
||||||
|
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||||
|
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepCopyNode creates a deep copy of a yaml.Node graph.
|
||||||
|
func deepCopyNode(n *yaml.Node) *yaml.Node {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *n
|
||||||
|
if len(n.Content) > 0 {
|
||||||
|
cp.Content = make([]*yaml.Node, len(n.Content))
|
||||||
|
for i := range n.Content {
|
||||||
|
cp.Content[i] = deepCopyNode(n.Content[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyNodeShallow copies type/tag/value and resets content to match src, but
|
||||||
|
// keeps the same destination node pointer to preserve parent relations/comments.
|
||||||
|
func copyNodeShallow(dst, src *yaml.Node) {
|
||||||
|
if dst == nil || src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst.Kind = src.Kind
|
||||||
|
dst.Tag = src.Tag
|
||||||
|
dst.Value = src.Value
|
||||||
|
// Replace content with deep copy from src
|
||||||
|
if len(src.Content) > 0 {
|
||||||
|
dst.Content = make([]*yaml.Node, len(src.Content))
|
||||||
|
for i := range src.Content {
|
||||||
|
dst.Content[i] = deepCopyNode(src.Content[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dst.Content = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
27
internal/constant/constant.go
Normal file
27
internal/constant/constant.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Package constant defines provider name constants used throughout the CLI Proxy API.
|
||||||
|
// These constants identify different AI service providers and their variants,
|
||||||
|
// ensuring consistent naming across the application.
|
||||||
|
package constant
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Gemini represents the Google Gemini provider identifier.
|
||||||
|
Gemini = "gemini"
|
||||||
|
|
||||||
|
// GeminiCLI represents the Google Gemini CLI provider identifier.
|
||||||
|
GeminiCLI = "gemini-cli"
|
||||||
|
|
||||||
|
// GeminiWeb represents the Google Gemini Web provider identifier.
|
||||||
|
GeminiWeb = "gemini-web"
|
||||||
|
|
||||||
|
// Codex represents the OpenAI Codex provider identifier.
|
||||||
|
Codex = "codex"
|
||||||
|
|
||||||
|
// Claude represents the Anthropic Claude provider identifier.
|
||||||
|
Claude = "claude"
|
||||||
|
|
||||||
|
// OpenAI represents the OpenAI provider identifier.
|
||||||
|
OpenAI = "openai"
|
||||||
|
|
||||||
|
// OpenaiResponse represents the OpenAI response format identifier.
|
||||||
|
OpenaiResponse = "openai-response"
|
||||||
|
)
|
||||||
17
internal/interfaces/api_handler.go
Normal file
17
internal/interfaces/api_handler.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
|
||||||
|
// These interfaces provide a common contract for different components of the application,
|
||||||
|
// such as AI service clients, API handlers, and data models.
|
||||||
|
package interfaces
|
||||||
|
|
||||||
|
// APIHandler defines the interface that all API handlers must implement.
|
||||||
|
// This interface provides methods for identifying handler types and retrieving
|
||||||
|
// supported models for different AI service endpoints.
|
||||||
|
type APIHandler interface {
|
||||||
|
// HandlerType returns the type identifier for this API handler.
|
||||||
|
// This is used to determine which request/response translators to use.
|
||||||
|
HandlerType() string
|
||||||
|
|
||||||
|
// Models returns a list of supported models for this API handler.
|
||||||
|
// Each model is represented as a map containing model metadata.
|
||||||
|
Models() []map[string]any
|
||||||
|
}
|
||||||
150
internal/interfaces/client_models.go
Normal file
150
internal/interfaces/client_models.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
|
||||||
|
// These interfaces provide a common contract for different components of the application,
|
||||||
|
// such as AI service clients, API handlers, and data models.
|
||||||
|
package interfaces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GCPProject represents the response structure for a Google Cloud project list request.
|
||||||
|
// This structure is used when fetching available projects for a Google Cloud account.
|
||||||
|
type GCPProject struct {
|
||||||
|
// Projects is a list of Google Cloud projects accessible by the user.
|
||||||
|
Projects []GCPProjectProjects `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCPProjectLabels defines the labels associated with a GCP project.
|
||||||
|
// These labels can contain metadata about the project's purpose or configuration.
|
||||||
|
type GCPProjectLabels struct {
|
||||||
|
// GenerativeLanguage indicates if the project has generative language APIs enabled.
|
||||||
|
GenerativeLanguage string `json:"generative-language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCPProjectProjects contains details about a single Google Cloud project.
|
||||||
|
// This includes identifying information, metadata, and configuration details.
|
||||||
|
type GCPProjectProjects struct {
|
||||||
|
// ProjectNumber is the unique numeric identifier for the project.
|
||||||
|
ProjectNumber string `json:"projectNumber"`
|
||||||
|
|
||||||
|
// ProjectID is the unique string identifier for the project.
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
|
||||||
|
// LifecycleState indicates the current state of the project (e.g., "ACTIVE").
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
|
||||||
|
// Name is the human-readable name of the project.
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Labels contains metadata labels associated with the project.
|
||||||
|
Labels GCPProjectLabels `json:"labels"`
|
||||||
|
|
||||||
|
// CreateTime is the timestamp when the project was created.
|
||||||
|
CreateTime time.Time `json:"createTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content represents a single message in a conversation, with a role and parts.
|
||||||
|
// This structure models a message exchange between a user and an AI model.
|
||||||
|
type Content struct {
|
||||||
|
// Role indicates who sent the message ("user", "model", or "tool").
|
||||||
|
Role string `json:"role"`
|
||||||
|
|
||||||
|
// Parts is a collection of content parts that make up the message.
|
||||||
|
Parts []Part `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part represents a distinct piece of content within a message.
|
||||||
|
// A part can be text, inline data (like an image), a function call, or a function response.
|
||||||
|
type Part struct {
|
||||||
|
// Text contains plain text content.
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
// InlineData contains base64-encoded data with its MIME type (e.g., images).
|
||||||
|
InlineData *InlineData `json:"inlineData,omitempty"`
|
||||||
|
|
||||||
|
// FunctionCall represents a tool call requested by the model.
|
||||||
|
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
|
||||||
|
|
||||||
|
// FunctionResponse represents the result of a tool execution.
|
||||||
|
FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineData represents base64-encoded data with its MIME type.
|
||||||
|
// This is typically used for embedding images or other binary data in requests.
|
||||||
|
type InlineData struct {
|
||||||
|
// MimeType specifies the media type of the embedded data (e.g., "image/png").
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
|
||||||
|
// Data contains the base64-encoded binary data.
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionCall represents a tool call requested by the model.
|
||||||
|
// It includes the function name and its arguments that the model wants to execute.
|
||||||
|
type FunctionCall struct {
|
||||||
|
// Name is the identifier of the function to be called.
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Args contains the arguments to pass to the function.
|
||||||
|
Args map[string]interface{} `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionResponse represents the result of a tool execution.
|
||||||
|
// This is sent back to the model after a tool call has been processed.
|
||||||
|
type FunctionResponse struct {
|
||||||
|
// Name is the identifier of the function that was called.
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Response contains the result data from the function execution.
|
||||||
|
Response map[string]interface{} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateContentRequest is the top-level request structure for the streamGenerateContent endpoint.
|
||||||
|
// This structure defines all the parameters needed for generating content from an AI model.
|
||||||
|
type GenerateContentRequest struct {
|
||||||
|
// SystemInstruction provides system-level instructions that guide the model's behavior.
|
||||||
|
SystemInstruction *Content `json:"systemInstruction,omitempty"`
|
||||||
|
|
||||||
|
// Contents is the conversation history between the user and the model.
|
||||||
|
Contents []Content `json:"contents"`
|
||||||
|
|
||||||
|
// Tools defines the available tools/functions that the model can call.
|
||||||
|
Tools []ToolDeclaration `json:"tools,omitempty"`
|
||||||
|
|
||||||
|
// GenerationConfig contains parameters that control the model's generation behavior.
|
||||||
|
GenerationConfig `json:"generationConfig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerationConfig defines parameters that control the model's generation behavior.
|
||||||
|
// These parameters affect the creativity, randomness, and reasoning of the model's responses.
|
||||||
|
type GenerationConfig struct {
|
||||||
|
// ThinkingConfig specifies configuration for the model's "thinking" process.
|
||||||
|
ThinkingConfig GenerationConfigThinkingConfig `json:"thinkingConfig,omitempty"`
|
||||||
|
|
||||||
|
// Temperature controls the randomness of the model's responses.
|
||||||
|
// Values closer to 0 make responses more deterministic, while values closer to 1 increase randomness.
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
|
||||||
|
// TopP controls nucleus sampling, which affects the diversity of responses.
|
||||||
|
// It limits the model to consider only the top P% of probability mass.
|
||||||
|
TopP float64 `json:"topP,omitempty"`
|
||||||
|
|
||||||
|
// TopK limits the model to consider only the top K most likely tokens.
|
||||||
|
// This can help control the quality and diversity of generated text.
|
||||||
|
TopK float64 `json:"topK,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerationConfigThinkingConfig specifies configuration for the model's "thinking" process.
|
||||||
|
// This controls whether the model should output its reasoning process along with the final answer.
|
||||||
|
type GenerationConfigThinkingConfig struct {
|
||||||
|
// IncludeThoughts determines whether the model should output its reasoning process.
|
||||||
|
// When enabled, the model will include its step-by-step thinking in the response.
|
||||||
|
IncludeThoughts bool `json:"include_thoughts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolDeclaration defines the structure for declaring tools (like functions)
|
||||||
|
// that the model can call during content generation.
|
||||||
|
type ToolDeclaration struct {
|
||||||
|
// FunctionDeclarations is a list of available functions that the model can call.
|
||||||
|
FunctionDeclarations []interface{} `json:"functionDeclarations"`
|
||||||
|
}
|
||||||
20
internal/interfaces/error_message.go
Normal file
20
internal/interfaces/error_message.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
|
||||||
|
// These interfaces provide a common contract for different components of the application,
|
||||||
|
// such as AI service clients, API handlers, and data models.
|
||||||
|
package interfaces
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// ErrorMessage encapsulates an error with an associated HTTP status code.
|
||||||
|
// This structure is used to provide detailed error information including
|
||||||
|
// both the HTTP status and the underlying error.
|
||||||
|
type ErrorMessage struct {
|
||||||
|
// StatusCode is the HTTP status code returned by the API.
|
||||||
|
StatusCode int
|
||||||
|
|
||||||
|
// Error is the underlying error that occurred.
|
||||||
|
Error error
|
||||||
|
|
||||||
|
// Addon contains additional headers to be added to the response.
|
||||||
|
Addon http.Header
|
||||||
|
}
|
||||||
15
internal/interfaces/types.go
Normal file
15
internal/interfaces/types.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Package interfaces provides type aliases for backwards compatibility with translator functions.
|
||||||
|
// It defines common interface types used throughout the CLI Proxy API for request and response
|
||||||
|
// transformation operations, maintaining compatibility with the SDK translator package.
|
||||||
|
package interfaces
|
||||||
|
|
||||||
|
import sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
|
||||||
|
// Backwards compatible aliases for translator function types.
|
||||||
|
type TranslateRequestFunc = sdktranslator.RequestTransform
|
||||||
|
|
||||||
|
type TranslateResponseFunc = sdktranslator.ResponseStreamTransform
|
||||||
|
|
||||||
|
type TranslateResponseNonStreamFunc = sdktranslator.ResponseNonStreamTransform
|
||||||
|
|
||||||
|
type TranslateResponse = sdktranslator.ResponseTransform
|
||||||
78
internal/logging/gin_logger.go
Normal file
78
internal/logging/gin_logger.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Package logging provides Gin middleware for HTTP request logging and panic recovery.
|
||||||
|
// It integrates Gin web framework with logrus for structured logging of HTTP requests,
|
||||||
|
// responses, and error handling with panic recovery capabilities.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
|
||||||
|
// using logrus. It captures request details including method, path, status code, latency,
|
||||||
|
// client IP, and any error messages, formatting them in a Gin-style log format.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - gin.HandlerFunc: A middleware handler for request logging
|
||||||
|
func GinLogrusLogger() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
raw := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
if raw != "" {
|
||||||
|
path = path + "?" + raw
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := time.Since(start)
|
||||||
|
if latency > time.Minute {
|
||||||
|
latency = latency.Truncate(time.Second)
|
||||||
|
} else {
|
||||||
|
latency = latency.Truncate(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode := c.Writer.Status()
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
method := c.Request.Method
|
||||||
|
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||||
|
timestamp := time.Now().Format("2006/01/02 - 15:04:05")
|
||||||
|
logLine := fmt.Sprintf("[GIN] %s | %3d | %13v | %15s | %-7s \"%s\"", timestamp, statusCode, latency, clientIP, method, path)
|
||||||
|
if errorMessage != "" {
|
||||||
|
logLine = logLine + " | " + errorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case statusCode >= http.StatusInternalServerError:
|
||||||
|
log.Error(logLine)
|
||||||
|
case statusCode >= http.StatusBadRequest:
|
||||||
|
log.Warn(logLine)
|
||||||
|
default:
|
||||||
|
log.Info(logLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GinLogrusRecovery returns a Gin middleware handler that recovers from panics and logs
|
||||||
|
// them using logrus. When a panic occurs, it captures the panic value, stack trace,
|
||||||
|
// and request path, then returns a 500 Internal Server Error response to the client.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - gin.HandlerFunc: A middleware handler for panic recovery
|
||||||
|
func GinLogrusRecovery() gin.HandlerFunc {
|
||||||
|
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"panic": recovered,
|
||||||
|
"stack": string(debug.Stack()),
|
||||||
|
"path": c.Request.URL.Path,
|
||||||
|
}).Error("recovered from panic")
|
||||||
|
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
}
|
||||||
117
internal/logging/global_logger.go
Normal file
117
internal/logging/global_logger.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
setupOnce sync.Once
|
||||||
|
writerMu sync.Mutex
|
||||||
|
logWriter *lumberjack.Logger
|
||||||
|
ginInfoWriter *io.PipeWriter
|
||||||
|
ginErrorWriter *io.PipeWriter
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogFormatter defines a custom log format for logrus.
|
||||||
|
// This formatter adds timestamp, level, and source location to each log entry.
|
||||||
|
type LogFormatter struct{}
|
||||||
|
|
||||||
|
// Format renders a single log entry with custom formatting.
|
||||||
|
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
||||||
|
var buffer *bytes.Buffer
|
||||||
|
if entry.Buffer != nil {
|
||||||
|
buffer = entry.Buffer
|
||||||
|
} else {
|
||||||
|
buffer = &bytes.Buffer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||||
|
message := strings.TrimRight(entry.Message, "\r\n")
|
||||||
|
formatted := fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
|
||||||
|
buffer.WriteString(formatted)
|
||||||
|
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupBaseLogger configures the shared logrus instance and Gin writers.
|
||||||
|
// It is safe to call multiple times; initialization happens only once.
|
||||||
|
func SetupBaseLogger() {
|
||||||
|
setupOnce.Do(func() {
|
||||||
|
log.SetOutput(os.Stdout)
|
||||||
|
log.SetReportCaller(true)
|
||||||
|
log.SetFormatter(&LogFormatter{})
|
||||||
|
|
||||||
|
ginInfoWriter = log.StandardLogger().Writer()
|
||||||
|
gin.DefaultWriter = ginInfoWriter
|
||||||
|
ginErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)
|
||||||
|
gin.DefaultErrorWriter = ginErrorWriter
|
||||||
|
gin.DebugPrintFunc = func(format string, values ...interface{}) {
|
||||||
|
format = strings.TrimRight(format, "\r\n")
|
||||||
|
log.StandardLogger().Infof(format, values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.RegisterExitHandler(closeLogOutputs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLogOutput switches the global log destination between rotating files and stdout.
|
||||||
|
func ConfigureLogOutput(loggingToFile bool) error {
|
||||||
|
SetupBaseLogger()
|
||||||
|
|
||||||
|
writerMu.Lock()
|
||||||
|
defer writerMu.Unlock()
|
||||||
|
|
||||||
|
if loggingToFile {
|
||||||
|
const logDir = "logs"
|
||||||
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("logging: failed to create log directory: %w", err)
|
||||||
|
}
|
||||||
|
if logWriter != nil {
|
||||||
|
_ = logWriter.Close()
|
||||||
|
}
|
||||||
|
logWriter = &lumberjack.Logger{
|
||||||
|
Filename: filepath.Join(logDir, "main.log"),
|
||||||
|
MaxSize: 10,
|
||||||
|
MaxBackups: 0,
|
||||||
|
MaxAge: 0,
|
||||||
|
Compress: false,
|
||||||
|
}
|
||||||
|
log.SetOutput(logWriter)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if logWriter != nil {
|
||||||
|
_ = logWriter.Close()
|
||||||
|
logWriter = nil
|
||||||
|
}
|
||||||
|
log.SetOutput(os.Stdout)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeLogOutputs() {
|
||||||
|
writerMu.Lock()
|
||||||
|
defer writerMu.Unlock()
|
||||||
|
|
||||||
|
if logWriter != nil {
|
||||||
|
_ = logWriter.Close()
|
||||||
|
logWriter = nil
|
||||||
|
}
|
||||||
|
if ginInfoWriter != nil {
|
||||||
|
_ = ginInfoWriter.Close()
|
||||||
|
ginInfoWriter = nil
|
||||||
|
}
|
||||||
|
if ginErrorWriter != nil {
|
||||||
|
_ = ginErrorWriter.Close()
|
||||||
|
ginErrorWriter = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
612
internal/logging/request_logger.go
Normal file
612
internal/logging/request_logger.go
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
// Package logging provides request logging functionality for the CLI Proxy API server.
|
||||||
|
// It handles capturing and storing detailed HTTP request and response data when enabled
|
||||||
|
// through configuration, supporting both regular and streaming responses.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestLogger defines the interface for logging HTTP requests and responses.
|
||||||
|
// It provides methods for logging both regular and streaming HTTP request/response cycles.
|
||||||
|
type RequestLogger interface {
|
||||||
|
// LogRequest logs a complete non-streaming request/response cycle.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
// - method: The HTTP method
|
||||||
|
// - requestHeaders: The request headers
|
||||||
|
// - body: The request body
|
||||||
|
// - statusCode: The response status code
|
||||||
|
// - responseHeaders: The response headers
|
||||||
|
// - response: The raw response data
|
||||||
|
// - apiRequest: The API request data
|
||||||
|
// - apiResponse: The API response data
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if logging fails, nil otherwise
|
||||||
|
LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error
|
||||||
|
|
||||||
|
// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
// - method: The HTTP method
|
||||||
|
// - headers: The request headers
|
||||||
|
// - body: The request body
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - StreamingLogWriter: A writer for streaming response chunks
|
||||||
|
// - error: An error if logging initialization fails, nil otherwise
|
||||||
|
LogStreamingRequest(url, method string, headers map[string][]string, body []byte) (StreamingLogWriter, error)
|
||||||
|
|
||||||
|
// IsEnabled returns whether request logging is currently enabled.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if logging is enabled, false otherwise
|
||||||
|
IsEnabled() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamingLogWriter handles real-time logging of streaming response chunks.
|
||||||
|
// It provides methods for writing streaming response data asynchronously.
|
||||||
|
type StreamingLogWriter interface {
|
||||||
|
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - chunk: The response chunk to write
|
||||||
|
WriteChunkAsync(chunk []byte)
|
||||||
|
|
||||||
|
// WriteStatus writes the response status and headers to the log.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - status: The response status code
|
||||||
|
// - headers: The response headers
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if writing fails, nil otherwise
|
||||||
|
WriteStatus(status int, headers map[string][]string) error
|
||||||
|
|
||||||
|
// Close finalizes the log file and cleans up resources.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if closing fails, nil otherwise
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileRequestLogger implements RequestLogger using file-based storage.
|
||||||
|
// It provides file-based logging functionality for HTTP requests and responses.
|
||||||
|
type FileRequestLogger struct {
|
||||||
|
// enabled indicates whether request logging is currently enabled.
|
||||||
|
enabled bool
|
||||||
|
|
||||||
|
// logsDir is the directory where log files are stored.
|
||||||
|
logsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileRequestLogger creates a new file-based request logger.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - enabled: Whether request logging should be enabled
|
||||||
|
// - logsDir: The directory where log files should be stored (can be relative)
|
||||||
|
// - configDir: The directory of the configuration file; when logsDir is
|
||||||
|
// relative, it will be resolved relative to this directory
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *FileRequestLogger: A new file-based request logger instance
|
||||||
|
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
|
||||||
|
// Resolve logsDir relative to the configuration file directory when it's not absolute.
|
||||||
|
if !filepath.IsAbs(logsDir) {
|
||||||
|
// If configDir is provided, resolve logsDir relative to it.
|
||||||
|
if configDir != "" {
|
||||||
|
logsDir = filepath.Join(configDir, logsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &FileRequestLogger{
|
||||||
|
enabled: enabled,
|
||||||
|
logsDir: logsDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns whether request logging is currently enabled.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if logging is enabled, false otherwise
|
||||||
|
func (l *FileRequestLogger) IsEnabled() bool {
|
||||||
|
return l.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled updates the request logging enabled state.
|
||||||
|
// This method allows dynamic enabling/disabling of request logging.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - enabled: Whether request logging should be enabled
|
||||||
|
func (l *FileRequestLogger) SetEnabled(enabled bool) {
|
||||||
|
l.enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogRequest logs a complete non-streaming request/response cycle to a file.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
// - method: The HTTP method
|
||||||
|
// - requestHeaders: The request headers
|
||||||
|
// - body: The request body
|
||||||
|
// - statusCode: The response status code
|
||||||
|
// - responseHeaders: The response headers
|
||||||
|
// - response: The raw response data
|
||||||
|
// - apiRequest: The API request data
|
||||||
|
// - apiResponse: The API response data
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if logging fails, nil otherwise
|
||||||
|
func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error {
|
||||||
|
if !l.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure logs directory exists
|
||||||
|
if err := l.ensureLogsDir(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create logs directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
filename := l.generateFilename(url)
|
||||||
|
filePath := filepath.Join(l.logsDir, filename)
|
||||||
|
|
||||||
|
// Decompress response if needed
|
||||||
|
decompressedResponse, err := l.decompressResponse(responseHeaders, response)
|
||||||
|
if err != nil {
|
||||||
|
// If decompression fails, log the error but continue with original response
|
||||||
|
decompressedResponse = append(response, []byte(fmt.Sprintf("\n[DECOMPRESSION ERROR: %v]", err))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create log content
|
||||||
|
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders, apiResponseErrors)
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogStreamingRequest initiates logging for a streaming request.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
// - method: The HTTP method
|
||||||
|
// - headers: The request headers
|
||||||
|
// - body: The request body
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - StreamingLogWriter: A writer for streaming response chunks
|
||||||
|
// - error: An error if logging initialization fails, nil otherwise
|
||||||
|
func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[string][]string, body []byte) (StreamingLogWriter, error) {
|
||||||
|
if !l.enabled {
|
||||||
|
return &NoOpStreamingLogWriter{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure logs directory exists
|
||||||
|
if err := l.ensureLogsDir(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
filename := l.generateFilename(url)
|
||||||
|
filePath := filepath.Join(l.logsDir, filename)
|
||||||
|
|
||||||
|
// Create and open file
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write initial request information
|
||||||
|
requestInfo := l.formatRequestInfo(url, method, headers, body)
|
||||||
|
if _, err = file.WriteString(requestInfo); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, fmt.Errorf("failed to write request info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create streaming writer
|
||||||
|
writer := &FileStreamingLogWriter{
|
||||||
|
file: file,
|
||||||
|
chunkChan: make(chan []byte, 100), // Buffered channel for async writes
|
||||||
|
closeChan: make(chan struct{}),
|
||||||
|
errorChan: make(chan error, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start async writer goroutine
|
||||||
|
go writer.asyncWriter()
|
||||||
|
|
||||||
|
return writer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureLogsDir creates the logs directory if it doesn't exist.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if directory creation fails, nil otherwise
|
||||||
|
func (l *FileRequestLogger) ensureLogsDir() error {
|
||||||
|
if _, err := os.Stat(l.logsDir); os.IsNotExist(err) {
|
||||||
|
return os.MkdirAll(l.logsDir, 0755)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFilename creates a sanitized filename from the URL path and current timestamp.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A sanitized filename for the log file
|
||||||
|
func (l *FileRequestLogger) generateFilename(url string) string {
|
||||||
|
// Extract path from URL
|
||||||
|
path := url
|
||||||
|
if strings.Contains(url, "?") {
|
||||||
|
path = strings.Split(url, "?")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading slash
|
||||||
|
if strings.HasPrefix(path, "/") {
|
||||||
|
path = path[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize path for filename
|
||||||
|
sanitized := l.sanitizeForFilename(path)
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
timestamp := time.Now().Format("2006-01-02T150405-.000000000")
|
||||||
|
timestamp = strings.Replace(timestamp, ".", "", -1)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s.log", sanitized, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeForFilename replaces characters that are not safe for filenames.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - path: The path to sanitize
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A sanitized filename
|
||||||
|
func (l *FileRequestLogger) sanitizeForFilename(path string) string {
|
||||||
|
// Replace slashes with hyphens
|
||||||
|
sanitized := strings.ReplaceAll(path, "/", "-")
|
||||||
|
|
||||||
|
// Replace colons with hyphens
|
||||||
|
sanitized = strings.ReplaceAll(sanitized, ":", "-")
|
||||||
|
|
||||||
|
// Replace other problematic characters with hyphens
|
||||||
|
reg := regexp.MustCompile(`[<>:"|?*\s]`)
|
||||||
|
sanitized = reg.ReplaceAllString(sanitized, "-")
|
||||||
|
|
||||||
|
// Remove multiple consecutive hyphens
|
||||||
|
reg = regexp.MustCompile(`-+`)
|
||||||
|
sanitized = reg.ReplaceAllString(sanitized, "-")
|
||||||
|
|
||||||
|
// Remove leading/trailing hyphens
|
||||||
|
sanitized = strings.Trim(sanitized, "-")
|
||||||
|
|
||||||
|
// Handle empty result
|
||||||
|
if sanitized == "" {
|
||||||
|
sanitized = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLogContent creates the complete log content for non-streaming requests.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
// - method: The HTTP method
|
||||||
|
// - headers: The request headers
|
||||||
|
// - body: The request body
|
||||||
|
// - apiRequest: The API request data
|
||||||
|
// - apiResponse: The API response data
|
||||||
|
// - response: The raw response data
|
||||||
|
// - status: The response status code
|
||||||
|
// - responseHeaders: The response headers
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The formatted log content
|
||||||
|
func (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body, apiRequest, apiResponse, response []byte, status int, responseHeaders map[string][]string, apiResponseErrors []*interfaces.ErrorMessage) string {
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
// Request info
|
||||||
|
content.WriteString(l.formatRequestInfo(url, method, headers, body))
|
||||||
|
|
||||||
|
content.WriteString("=== API REQUEST ===\n")
|
||||||
|
content.Write(apiRequest)
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i := 0; i < len(apiResponseErrors); i++ {
|
||||||
|
content.WriteString("=== API ERROR RESPONSE ===\n")
|
||||||
|
content.WriteString(fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode))
|
||||||
|
content.WriteString(apiResponseErrors[i].Error.Error())
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
content.WriteString("=== API RESPONSE ===\n")
|
||||||
|
content.Write(apiResponse)
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Response section
|
||||||
|
content.WriteString("=== RESPONSE ===\n")
|
||||||
|
content.WriteString(fmt.Sprintf("Status: %d\n", status))
|
||||||
|
|
||||||
|
if responseHeaders != nil {
|
||||||
|
for key, values := range responseHeaders {
|
||||||
|
for _, value := range values {
|
||||||
|
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.WriteString("\n")
|
||||||
|
content.Write(response)
|
||||||
|
content.WriteString("\n")
|
||||||
|
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decompressResponse decompresses response data based on Content-Encoding header.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - responseHeaders: The response headers
|
||||||
|
// - response: The response data to decompress
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []byte: The decompressed response data
|
||||||
|
// - error: An error if decompression fails, nil otherwise
|
||||||
|
func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]string, response []byte) ([]byte, error) {
|
||||||
|
if responseHeaders == nil || len(response) == 0 {
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Encoding header
|
||||||
|
var contentEncoding string
|
||||||
|
for key, values := range responseHeaders {
|
||||||
|
if strings.ToLower(key) == "content-encoding" && len(values) > 0 {
|
||||||
|
contentEncoding = strings.ToLower(values[0])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch contentEncoding {
|
||||||
|
case "gzip":
|
||||||
|
return l.decompressGzip(response)
|
||||||
|
case "deflate":
|
||||||
|
return l.decompressDeflate(response)
|
||||||
|
default:
|
||||||
|
// No compression or unsupported compression
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decompressGzip decompresses gzip-encoded data.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - data: The gzip-encoded data to decompress
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []byte: The decompressed data
|
||||||
|
// - error: An error if decompression fails, nil otherwise
|
||||||
|
func (l *FileRequestLogger) decompressGzip(data []byte) ([]byte, error) {
|
||||||
|
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = reader.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
decompressed, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decompress gzip data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decompressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decompressDeflate decompresses deflate-encoded data.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - data: The deflate-encoded data to decompress
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []byte: The decompressed data
|
||||||
|
// - error: An error if decompression fails, nil otherwise
|
||||||
|
func (l *FileRequestLogger) decompressDeflate(data []byte) ([]byte, error) {
|
||||||
|
reader := flate.NewReader(bytes.NewReader(data))
|
||||||
|
defer func() {
|
||||||
|
_ = reader.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
decompressed, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decompress deflate data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decompressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRequestInfo creates the request information section of the log.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The request URL
|
||||||
|
// - method: The HTTP method
|
||||||
|
// - headers: The request headers
|
||||||
|
// - body: The request body
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The formatted request information
|
||||||
|
func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[string][]string, body []byte) string {
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
content.WriteString("=== REQUEST INFO ===\n")
|
||||||
|
content.WriteString(fmt.Sprintf("URL: %s\n", url))
|
||||||
|
content.WriteString(fmt.Sprintf("Method: %s\n", method))
|
||||||
|
content.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
|
||||||
|
content.WriteString("\n")
|
||||||
|
|
||||||
|
content.WriteString("=== HEADERS ===\n")
|
||||||
|
for key, values := range headers {
|
||||||
|
for _, value := range values {
|
||||||
|
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.WriteString("\n")
|
||||||
|
|
||||||
|
content.WriteString("=== REQUEST BODY ===\n")
|
||||||
|
content.Write(body)
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.
|
||||||
|
// It handles asynchronous writing of streaming response chunks to a file.
|
||||||
|
type FileStreamingLogWriter struct {
|
||||||
|
// file is the file where log data is written.
|
||||||
|
file *os.File
|
||||||
|
|
||||||
|
// chunkChan is a channel for receiving response chunks to write.
|
||||||
|
chunkChan chan []byte
|
||||||
|
|
||||||
|
// closeChan is a channel for signaling when the writer is closed.
|
||||||
|
closeChan chan struct{}
|
||||||
|
|
||||||
|
// errorChan is a channel for reporting errors during writing.
|
||||||
|
errorChan chan error
|
||||||
|
|
||||||
|
// statusWritten indicates whether the response status has been written.
|
||||||
|
statusWritten bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - chunk: The response chunk to write
|
||||||
|
func (w *FileStreamingLogWriter) WriteChunkAsync(chunk []byte) {
|
||||||
|
if w.chunkChan == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a copy of the chunk to avoid data races
|
||||||
|
chunkCopy := make([]byte, len(chunk))
|
||||||
|
copy(chunkCopy, chunk)
|
||||||
|
|
||||||
|
// Non-blocking send
|
||||||
|
select {
|
||||||
|
case w.chunkChan <- chunkCopy:
|
||||||
|
default:
|
||||||
|
// Channel is full, skip this chunk to avoid blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteStatus writes the response status and headers to the log.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - status: The response status code
|
||||||
|
// - headers: The response headers
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if writing fails, nil otherwise
|
||||||
|
func (w *FileStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {
|
||||||
|
if w.file == nil || w.statusWritten {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var content strings.Builder
|
||||||
|
content.WriteString("========================================\n")
|
||||||
|
content.WriteString("=== RESPONSE ===\n")
|
||||||
|
content.WriteString(fmt.Sprintf("Status: %d\n", status))
|
||||||
|
|
||||||
|
for key, values := range headers {
|
||||||
|
for _, value := range values {
|
||||||
|
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.WriteString("\n")
|
||||||
|
|
||||||
|
_, err := w.file.WriteString(content.String())
|
||||||
|
if err == nil {
|
||||||
|
w.statusWritten = true
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close finalizes the log file and cleans up resources.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if closing fails, nil otherwise
|
||||||
|
func (w *FileStreamingLogWriter) Close() error {
|
||||||
|
if w.chunkChan != nil {
|
||||||
|
close(w.chunkChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for async writer to finish
|
||||||
|
if w.closeChan != nil {
|
||||||
|
<-w.closeChan
|
||||||
|
w.chunkChan = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.file != nil {
|
||||||
|
return w.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// asyncWriter runs in a goroutine to handle async chunk writing.
|
||||||
|
// It continuously reads chunks from the channel and writes them to the file.
|
||||||
|
func (w *FileStreamingLogWriter) asyncWriter() {
|
||||||
|
defer close(w.closeChan)
|
||||||
|
|
||||||
|
for chunk := range w.chunkChan {
|
||||||
|
if w.file != nil {
|
||||||
|
_, _ = w.file.Write(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoOpStreamingLogWriter is a no-operation implementation for when logging is disabled.
|
||||||
|
// It implements the StreamingLogWriter interface but performs no actual logging operations.
|
||||||
|
type NoOpStreamingLogWriter struct{}
|
||||||
|
|
||||||
|
// WriteChunkAsync is a no-op implementation that does nothing.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - chunk: The response chunk (ignored)
|
||||||
|
func (w *NoOpStreamingLogWriter) WriteChunkAsync(_ []byte) {}
|
||||||
|
|
||||||
|
// WriteStatus is a no-op implementation that does nothing and always returns nil.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - status: The response status code (ignored)
|
||||||
|
// - headers: The response headers (ignored)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Always returns nil
|
||||||
|
func (w *NoOpStreamingLogWriter) WriteStatus(_ int, _ map[string][]string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close is a no-op implementation that does nothing and always returns nil.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Always returns nil
|
||||||
|
func (w *NoOpStreamingLogWriter) Close() error { return nil }
|
||||||
13
internal/misc/claude_code_instructions.go
Normal file
13
internal/misc/claude_code_instructions.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
|
||||||
|
// This package contains general-purpose helpers and embedded resources that do not fit into
|
||||||
|
// more specific domain packages. It includes embedded instructional text for Claude Code-related operations.
|
||||||
|
package misc
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// ClaudeCodeInstructions holds the content of the claude_code_instructions.txt file,
|
||||||
|
// which is embedded into the application binary at compile time. This variable
|
||||||
|
// contains specific instructions for Claude Code model interactions and code generation guidance.
|
||||||
|
//
|
||||||
|
//go:embed claude_code_instructions.txt
|
||||||
|
var ClaudeCodeInstructions string
|
||||||
1
internal/misc/claude_code_instructions.txt
Normal file
1
internal/misc/claude_code_instructions.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}]
|
||||||
23
internal/misc/codex_instructions.go
Normal file
23
internal/misc/codex_instructions.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
|
||||||
|
// This package contains general-purpose helpers and embedded resources that do not fit into
|
||||||
|
// more specific domain packages. It includes embedded instructional text for Codex-related operations.
|
||||||
|
package misc
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// CodexInstructions holds the content of the codex_instructions.txt file,
|
||||||
|
// which is embedded into the application binary at compile time. This variable
|
||||||
|
// contains instructional text used for Codex-related operations and model guidance.
|
||||||
|
//
|
||||||
|
//go:embed gpt_5_instructions.txt
|
||||||
|
var GPT5Instructions string
|
||||||
|
|
||||||
|
//go:embed gpt_5_codex_instructions.txt
|
||||||
|
var GPT5CodexInstructions string
|
||||||
|
|
||||||
|
func CodexInstructions(modelName string) string {
|
||||||
|
if modelName == "gpt-5-codex" {
|
||||||
|
return GPT5CodexInstructions
|
||||||
|
}
|
||||||
|
return GPT5Instructions
|
||||||
|
}
|
||||||
26
internal/misc/credentials.go
Normal file
26
internal/misc/credentials.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Separator used to visually group related log lines.
|
||||||
|
var credentialSeparator = strings.Repeat("-", 70)
|
||||||
|
|
||||||
|
// LogSavingCredentials emits a consistent log message when persisting auth material.
|
||||||
|
func LogSavingCredentials(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Use filepath.Clean so logs remain stable even if callers pass redundant separators.
|
||||||
|
fmt.Printf("Saving credentials to %s\n", filepath.Clean(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogCredentialSeparator adds a visual separator to group auth/key processing logs.
|
||||||
|
func LogCredentialSeparator() {
|
||||||
|
log.Debug(credentialSeparator)
|
||||||
|
}
|
||||||
1
internal/misc/gpt_5_codex_instructions.txt
Normal file
1
internal/misc/gpt_5_codex_instructions.txt
Normal file
File diff suppressed because one or more lines are too long
1
internal/misc/gpt_5_instructions.txt
Normal file
1
internal/misc/gpt_5_instructions.txt
Normal file
File diff suppressed because one or more lines are too long
37
internal/misc/header_utils.go
Normal file
37
internal/misc/header_utils.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Package misc provides miscellaneous utility functions for the CLI Proxy API server.
|
||||||
|
// It includes helper functions for HTTP header manipulation and other common operations
|
||||||
|
// that don't fit into more specific packages.
|
||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureHeader ensures that a header exists in the target header map by checking
|
||||||
|
// multiple sources in order of priority: source headers, existing target headers,
|
||||||
|
// and finally the default value. It only sets the header if it's not already present
|
||||||
|
// and the value is not empty after trimming whitespace.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The target header map to modify
|
||||||
|
// - source: The source header map to check first (can be nil)
|
||||||
|
// - key: The header key to ensure
|
||||||
|
// - defaultValue: The default value to use if no other source provides a value
|
||||||
|
func EnsureHeader(target http.Header, source http.Header, key, defaultValue string) {
|
||||||
|
if target == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if source != nil {
|
||||||
|
if val := strings.TrimSpace(source.Get(key)); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.Get(key)) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(defaultValue); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package api
|
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
|
||||||
|
// This package contains general-purpose helpers and embedded resources that do not fit into
|
||||||
|
// more specific domain packages. It includes a comprehensive MIME type mapping for file operations.
|
||||||
|
package misc
|
||||||
|
|
||||||
|
// MimeTypes is a comprehensive map of file extensions to their corresponding MIME types.
|
||||||
|
// This map is used to determine the Content-Type header for file uploads and other
|
||||||
|
// operations where the MIME type needs to be identified from a file extension.
|
||||||
|
// The list is extensive to cover a wide range of common and uncommon file formats.
|
||||||
var MimeTypes = map[string]string{
|
var MimeTypes = map[string]string{
|
||||||
"ez": "application/andrew-inset",
|
"ez": "application/andrew-inset",
|
||||||
"aw": "application/applixware",
|
"aw": "application/applixware",
|
||||||
21
internal/misc/oauth.go
Normal file
21
internal/misc/oauth.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateRandomState generates a cryptographically secure random state parameter
|
||||||
|
// for OAuth2 flows to prevent CSRF attacks.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A hexadecimal encoded random state string
|
||||||
|
// - error: An error if the random generation fails, nil otherwise
|
||||||
|
func GenerateRandomState() (string, error) {
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
882
internal/provider/gemini-web/client.go
Normal file
882
internal/provider/gemini-web/client.go
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
package geminiwebapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiClient is the async http client interface (Go port)
|
||||||
|
type GeminiClient struct {
|
||||||
|
Cookies map[string]string
|
||||||
|
Proxy string
|
||||||
|
Running bool
|
||||||
|
httpClient *http.Client
|
||||||
|
AccessToken string
|
||||||
|
Timeout time.Duration
|
||||||
|
insecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP bootstrap utilities -------------------------------------------------
|
||||||
|
type httpOptions struct {
|
||||||
|
ProxyURL string
|
||||||
|
Insecure bool
|
||||||
|
FollowRedirects bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient(opts httpOptions) *http.Client {
|
||||||
|
transport := &http.Transport{}
|
||||||
|
if opts.ProxyURL != "" {
|
||||||
|
if pu, err := url.Parse(opts.ProxyURL); err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(pu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Insecure {
|
||||||
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
|
}
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Transport: transport, Timeout: 60 * time.Second, Jar: jar}
|
||||||
|
if !opts.FollowRedirects {
|
||||||
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyHeaders(req *http.Request, headers http.Header) {
|
||||||
|
for k, v := range headers {
|
||||||
|
for _, vv := range v {
|
||||||
|
req.Header.Add(k, vv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCookies(req *http.Request, cookies map[string]string) {
|
||||||
|
for k, v := range cookies {
|
||||||
|
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendInitRequest(cookies map[string]string, proxy string, insecure bool) (*http.Response, map[string]string, error) {
|
||||||
|
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, EndpointInit, nil)
|
||||||
|
applyHeaders(req, HeadersGemini)
|
||||||
|
applyCookies(req, cookies)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return resp, nil, &AuthError{Msg: resp.Status}
|
||||||
|
}
|
||||||
|
outCookies := map[string]string{}
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
outCookies[c.Name] = c.Value
|
||||||
|
}
|
||||||
|
for k, v := range cookies {
|
||||||
|
outCookies[k] = v
|
||||||
|
}
|
||||||
|
return resp, outCookies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, insecure bool) (string, map[string]string, error) {
|
||||||
|
extraCookies := map[string]string{}
|
||||||
|
{
|
||||||
|
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if verbose {
|
||||||
|
log.Debugf("priming google cookies failed: %v", err)
|
||||||
|
}
|
||||||
|
} else if resp != nil {
|
||||||
|
if u, err := url.Parse(EndpointGoogle); err == nil {
|
||||||
|
for _, c := range client.Jar.Cookies(u) {
|
||||||
|
extraCookies[c.Name] = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trySets := make([]map[string]string, 0, 8)
|
||||||
|
|
||||||
|
if v1, ok1 := baseCookies["__Secure-1PSID"]; ok1 {
|
||||||
|
if v2, ok2 := baseCookies["__Secure-1PSIDTS"]; ok2 {
|
||||||
|
merged := map[string]string{"__Secure-1PSID": v1, "__Secure-1PSIDTS": v2}
|
||||||
|
if nid, ok := baseCookies["NID"]; ok {
|
||||||
|
merged["NID"] = nid
|
||||||
|
}
|
||||||
|
trySets = append(trySets, merged)
|
||||||
|
} else if verbose {
|
||||||
|
log.Debug("Skipping base cookies: __Secure-1PSIDTS missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(extraCookies) > 0 {
|
||||||
|
trySets = append(trySets, extraCookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
reToken := regexp.MustCompile(`"SNlM0e":"([^"]+)"`)
|
||||||
|
|
||||||
|
for _, cookies := range trySets {
|
||||||
|
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
|
||||||
|
if err != nil {
|
||||||
|
if verbose {
|
||||||
|
log.Warnf("Failed init request: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
matches := reToken.FindStringSubmatch(string(body))
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
token := matches[1]
|
||||||
|
if verbose {
|
||||||
|
fmt.Println("Gemini access token acquired.")
|
||||||
|
}
|
||||||
|
return token, mergedCookies, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, &AuthError{Msg: "Failed to retrieve token."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (string, error) {
|
||||||
|
_, ok := cookies["__Secure-1PSID"]
|
||||||
|
if !ok {
|
||||||
|
return "", &AuthError{Msg: "__Secure-1PSID missing"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse shared HTTP client helper for consistency.
|
||||||
|
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, strings.NewReader("[000,\"-0000000000000000000\"]"))
|
||||||
|
applyHeaders(req, HeadersRotateCookies)
|
||||||
|
applyCookies(req, cookies)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return "", &AuthError{Msg: "unauthorized"}
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", errors.New(resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "__Secure-1PSIDTS" {
|
||||||
|
return c.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: check cookie jar in case the Set-Cookie was on a redirect hop
|
||||||
|
if u, err := url.Parse(EndpointRotateCookies); err == nil && client.Jar != nil {
|
||||||
|
for _, c := range client.Jar.Cookies(u) {
|
||||||
|
if c.Name == "__Secure-1PSIDTS" && c.Value != "" {
|
||||||
|
return c.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskToken28 masks a sensitive token for safe logging. Keep middle partially visible.
|
||||||
|
func MaskToken28(s string) string {
|
||||||
|
n := len(s)
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if n < 20 {
|
||||||
|
return strings.Repeat("*", n)
|
||||||
|
}
|
||||||
|
midStart := n/2 - 2
|
||||||
|
if midStart < 8 {
|
||||||
|
midStart = 8
|
||||||
|
}
|
||||||
|
if midStart+4 > n-8 {
|
||||||
|
midStart = n - 8 - 4
|
||||||
|
if midStart < 8 {
|
||||||
|
midStart = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefixByte := s[:8]
|
||||||
|
middle := s[midStart : midStart+4]
|
||||||
|
suffix := s[n-8:]
|
||||||
|
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
var NanoBananaModel = map[string]struct{}{
|
||||||
|
"gemini-2.5-flash-image-preview": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
|
||||||
|
func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient {
|
||||||
|
c := &GeminiClient{
|
||||||
|
Cookies: map[string]string{},
|
||||||
|
Proxy: proxy,
|
||||||
|
Running: false,
|
||||||
|
Timeout: 300 * time.Second,
|
||||||
|
insecure: false,
|
||||||
|
}
|
||||||
|
if secure1psid != "" {
|
||||||
|
c.Cookies["__Secure-1PSID"] = secure1psid
|
||||||
|
if secure1psidts != "" {
|
||||||
|
c.Cookies["__Secure-1PSIDTS"] = secure1psidts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range opts {
|
||||||
|
f(c)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithInsecureTLS sets skipping TLS verification (to mirror httpx verify=False)
|
||||||
|
func WithInsecureTLS(insecure bool) func(*GeminiClient) {
|
||||||
|
return func(c *GeminiClient) { c.insecure = insecure }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the access token and http client.
|
||||||
|
func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error {
|
||||||
|
// get access token
|
||||||
|
token, validCookies, err := getAccessToken(c.Cookies, c.Proxy, verbose, c.insecure)
|
||||||
|
if err != nil {
|
||||||
|
c.Close(0)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.AccessToken = token
|
||||||
|
c.Cookies = validCookies
|
||||||
|
|
||||||
|
tr := &http.Transport{}
|
||||||
|
if c.Proxy != "" {
|
||||||
|
if pu, errParse := url.Parse(c.Proxy); errParse == nil {
|
||||||
|
tr.Proxy = http.ProxyURL(pu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.insecure {
|
||||||
|
// set via roundtripper in utils_get_access_token for token; here we reuse via default Transport
|
||||||
|
// intentionally not adding here, as requests rely on endpoints with normal TLS
|
||||||
|
}
|
||||||
|
c.httpClient = &http.Client{Transport: tr, Timeout: time.Duration(timeoutSec * float64(time.Second))}
|
||||||
|
c.Running = true
|
||||||
|
|
||||||
|
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
|
||||||
|
if verbose {
|
||||||
|
fmt.Println("Gemini client initialized successfully.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GeminiClient) Close(delaySec float64) {
|
||||||
|
if delaySec > 0 {
|
||||||
|
time.Sleep(time.Duration(delaySec * float64(time.Second)))
|
||||||
|
}
|
||||||
|
c.Running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureRunning mirrors the decorator behavior and retries on APIError.
|
||||||
|
func (c *GeminiClient) ensureRunning() error {
|
||||||
|
if c.Running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.Init(float64(c.Timeout/time.Second), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateTS performs a RotateCookies request and returns the new __Secure-1PSIDTS value (if any).
|
||||||
|
func (c *GeminiClient) RotateTS() (string, error) {
|
||||||
|
if c == nil {
|
||||||
|
return "", fmt.Errorf("gemini web client is nil")
|
||||||
|
}
|
||||||
|
return rotate1PSIDTS(c.Cookies, c.Proxy, c.insecure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateContent sends a prompt (with optional files) and parses the response into ModelOutput.
|
||||||
|
func (c *GeminiClient) GenerateContent(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
|
||||||
|
var empty ModelOutput
|
||||||
|
if prompt == "" {
|
||||||
|
return empty, &ValueError{Msg: "Prompt cannot be empty."}
|
||||||
|
}
|
||||||
|
if err := c.ensureRunning(); err != nil {
|
||||||
|
return empty, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry wrapper similar to decorator (retry=2)
|
||||||
|
retries := 2
|
||||||
|
for {
|
||||||
|
out, err := c.generateOnce(prompt, files, model, gem, chat)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
var imgErr *ImageGenerationError
|
||||||
|
shouldRetry := false
|
||||||
|
if errors.As(err, &imgErr) {
|
||||||
|
if retries > 1 {
|
||||||
|
retries = 1
|
||||||
|
} // only once for image generation
|
||||||
|
shouldRetry = true
|
||||||
|
} else if errors.As(err, &apiErr) {
|
||||||
|
shouldRetry = true
|
||||||
|
}
|
||||||
|
if shouldRetry && retries > 0 {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
retries--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return empty, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAnyLen(slice []any, index int) []any {
|
||||||
|
if index < len(slice) {
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
gap := index + 1 - len(slice)
|
||||||
|
return append(slice, make([]any, gap)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
|
||||||
|
var empty ModelOutput
|
||||||
|
// Build f.req
|
||||||
|
var uploaded [][]any
|
||||||
|
for _, fp := range files {
|
||||||
|
id, err := uploadFile(fp, c.Proxy, c.insecure)
|
||||||
|
if err != nil {
|
||||||
|
return empty, err
|
||||||
|
}
|
||||||
|
name, err := parseFileName(fp)
|
||||||
|
if err != nil {
|
||||||
|
return empty, err
|
||||||
|
}
|
||||||
|
uploaded = append(uploaded, []any{[]any{id}, name})
|
||||||
|
}
|
||||||
|
var item0 any
|
||||||
|
if len(uploaded) > 0 {
|
||||||
|
item0 = []any{prompt, 0, nil, uploaded}
|
||||||
|
} else {
|
||||||
|
item0 = []any{prompt}
|
||||||
|
}
|
||||||
|
var item2 any = nil
|
||||||
|
if chat != nil {
|
||||||
|
item2 = chat.Metadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := []any{item0, nil, item2}
|
||||||
|
requestedModel := strings.ToLower(model.Name)
|
||||||
|
if chat != nil && chat.RequestedModel() != "" {
|
||||||
|
requestedModel = chat.RequestedModel()
|
||||||
|
}
|
||||||
|
if _, ok := NanoBananaModel[requestedModel]; ok {
|
||||||
|
inner = ensureAnyLen(inner, 49)
|
||||||
|
inner[49] = 14
|
||||||
|
}
|
||||||
|
if gem != nil {
|
||||||
|
// pad with 16 nils then gem ID
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
inner = append(inner, nil)
|
||||||
|
}
|
||||||
|
inner = append(inner, gem.ID)
|
||||||
|
}
|
||||||
|
innerJSON, _ := json.Marshal(inner)
|
||||||
|
outer := []any{nil, string(innerJSON)}
|
||||||
|
outerJSON, _ := json.Marshal(outer)
|
||||||
|
|
||||||
|
// form
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("at", c.AccessToken)
|
||||||
|
form.Set("f.req", string(outerJSON))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode()))
|
||||||
|
applyHeaders(req, HeadersGemini)
|
||||||
|
applyHeaders(req, model.ModelHeader)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||||
|
applyCookies(req, c.Cookies)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return empty, &TimeoutError{GeminiError{Msg: "Generate content request timed out."}}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
// Surface 429 as TemporarilyBlocked to match reference behavior
|
||||||
|
c.Close(0)
|
||||||
|
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
c.Close(0)
|
||||||
|
return empty, &APIError{Msg: fmt.Sprintf("Failed to generate contents. Status %d", resp.StatusCode)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read body and split lines; take the 3rd line (index 2)
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
parts := strings.Split(string(b), "\n")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
c.Close(0)
|
||||||
|
return empty, &APIError{Msg: "Invalid response data received."}
|
||||||
|
}
|
||||||
|
var responseJSON []any
|
||||||
|
if err = json.Unmarshal([]byte(parts[2]), &responseJSON); err != nil {
|
||||||
|
c.Close(0)
|
||||||
|
return empty, &APIError{Msg: "Invalid response data received."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find body where main_part[4] exists
|
||||||
|
var (
|
||||||
|
body any
|
||||||
|
bodyIndex int
|
||||||
|
)
|
||||||
|
for i, p := range responseJSON {
|
||||||
|
arr, ok := p.([]any)
|
||||||
|
if !ok || len(arr) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, ok := arr[2].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mainPart []any
|
||||||
|
if err = json.Unmarshal([]byte(s), &mainPart); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(mainPart) > 4 && mainPart[4] != nil {
|
||||||
|
body = mainPart
|
||||||
|
bodyIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
// Fallback: scan subsequent lines to locate a data frame with a non-empty body (mainPart[4]).
|
||||||
|
var lastTop []any
|
||||||
|
for li := 3; li < len(parts) && body == nil; li++ {
|
||||||
|
line := strings.TrimSpace(parts[li])
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var top []any
|
||||||
|
if err = json.Unmarshal([]byte(line), &top); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastTop = top
|
||||||
|
for i, p := range top {
|
||||||
|
arr, ok := p.([]any)
|
||||||
|
if !ok || len(arr) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, ok := arr[2].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mainPart []any
|
||||||
|
if err = json.Unmarshal([]byte(s), &mainPart); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(mainPart) > 4 && mainPart[4] != nil {
|
||||||
|
body = mainPart
|
||||||
|
bodyIndex = i
|
||||||
|
responseJSON = top
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse nested error code to align with error mapping
|
||||||
|
var top []any
|
||||||
|
// Prefer lastTop from fallback scan; otherwise try parts[2]
|
||||||
|
if len(lastTop) > 0 {
|
||||||
|
top = lastTop
|
||||||
|
} else {
|
||||||
|
_ = json.Unmarshal([]byte(parts[2]), &top)
|
||||||
|
}
|
||||||
|
if len(top) > 0 {
|
||||||
|
if code, ok := extractErrorCode(top); ok {
|
||||||
|
switch code {
|
||||||
|
case ErrorUsageLimitExceeded:
|
||||||
|
return empty, &UsageLimitExceeded{GeminiError{Msg: fmt.Sprintf("Failed to generate contents. Usage limit of %s has exceeded. Please try switching to another model.", model.Name)}}
|
||||||
|
case ErrorModelInconsistent:
|
||||||
|
return empty, &ModelInvalid{GeminiError{Msg: "Selected model is inconsistent or unavailable."}}
|
||||||
|
case ErrorModelHeaderInvalid:
|
||||||
|
return empty, &APIError{Msg: "Invalid model header string. Please update the selected model header."}
|
||||||
|
case ErrorIPTemporarilyBlocked:
|
||||||
|
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Debug("Invalid response: control frames only; no body found")
|
||||||
|
// Close the client to force re-initialization on next request (parity with reference client behavior)
|
||||||
|
c.Close(0)
|
||||||
|
return empty, &APIError{Msg: "Failed to generate contents. Invalid response data received."}
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyArr := body.([]any)
|
||||||
|
// metadata
|
||||||
|
var metadata []string
|
||||||
|
if len(bodyArr) > 1 {
|
||||||
|
if metaArr, ok := bodyArr[1].([]any); ok {
|
||||||
|
for _, v := range metaArr {
|
||||||
|
if s, isOk := v.(string); isOk {
|
||||||
|
metadata = append(metadata, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates parsing
|
||||||
|
candContainer, ok := bodyArr[4].([]any)
|
||||||
|
if !ok {
|
||||||
|
return empty, &APIError{Msg: "Failed to parse response body."}
|
||||||
|
}
|
||||||
|
candidates := make([]Candidate, 0, len(candContainer))
|
||||||
|
reCard := regexp.MustCompile(`^http://googleusercontent\.com/card_content/\d+`)
|
||||||
|
reGen := regexp.MustCompile(`http://googleusercontent\.com/image_generation_content/\d+`)
|
||||||
|
|
||||||
|
for ci, candAny := range candContainer {
|
||||||
|
cArr, isOk := candAny.([]any)
|
||||||
|
if !isOk {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// text: cArr[1][0]
|
||||||
|
var text string
|
||||||
|
if len(cArr) > 1 {
|
||||||
|
if sArr, isOk1 := cArr[1].([]any); isOk1 && len(sArr) > 0 {
|
||||||
|
text, _ = sArr[0].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reCard.MatchString(text) {
|
||||||
|
// candidate[22] and candidate[22][0] or text
|
||||||
|
if len(cArr) > 22 {
|
||||||
|
if arr, isOk1 := cArr[22].([]any); isOk1 && len(arr) > 0 {
|
||||||
|
if s, isOk2 := arr[0].(string); isOk2 {
|
||||||
|
text = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// thoughts: candidate[37][0][0]
|
||||||
|
var thoughts *string
|
||||||
|
if len(cArr) > 37 {
|
||||||
|
if a, ok1 := cArr[37].([]any); ok1 && len(a) > 0 {
|
||||||
|
if b1, ok2 := a[0].([]any); ok2 && len(b1) > 0 {
|
||||||
|
if s, ok3 := b1[0].(string); ok3 {
|
||||||
|
ss := decodeHTML(s)
|
||||||
|
thoughts = &ss
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// web images: candidate[12][1]
|
||||||
|
var webImages []WebImage
|
||||||
|
var imgSection any
|
||||||
|
if len(cArr) > 12 {
|
||||||
|
imgSection = cArr[12]
|
||||||
|
}
|
||||||
|
if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 1 {
|
||||||
|
if imagesArr, ok2 := arr[1].([]any); ok2 {
|
||||||
|
for _, wiAny := range imagesArr {
|
||||||
|
wiArr, ok3 := wiAny.([]any)
|
||||||
|
if !ok3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// url: wiArr[0][0][0], title: wiArr[7][0], alt: wiArr[0][4]
|
||||||
|
var urlStr, title, alt string
|
||||||
|
if len(wiArr) > 0 {
|
||||||
|
if a, ok5 := wiArr[0].([]any); ok5 && len(a) > 0 {
|
||||||
|
if b1, ok6 := a[0].([]any); ok6 && len(b1) > 0 {
|
||||||
|
urlStr, _ = b1[0].(string)
|
||||||
|
}
|
||||||
|
if len(a) > 4 {
|
||||||
|
if s, ok6 := a[4].(string); ok6 {
|
||||||
|
alt = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(wiArr) > 7 {
|
||||||
|
if a, ok4 := wiArr[7].([]any); ok4 && len(a) > 0 {
|
||||||
|
title, _ = a[0].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webImages = append(webImages, WebImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generated images
|
||||||
|
var genImages []GeneratedImage
|
||||||
|
hasGen := false
|
||||||
|
if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 7 {
|
||||||
|
if a, ok2 := arr[7].([]any); ok2 && len(a) > 0 && a[0] != nil {
|
||||||
|
hasGen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasGen {
|
||||||
|
// find img part
|
||||||
|
var imgBody []any
|
||||||
|
for pi := bodyIndex; pi < len(responseJSON); pi++ {
|
||||||
|
part := responseJSON[pi]
|
||||||
|
arr, ok1 := part.([]any)
|
||||||
|
if !ok1 || len(arr) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, ok1 := arr[2].(string)
|
||||||
|
if !ok1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mp []any
|
||||||
|
if err = json.Unmarshal([]byte(s), &mp); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(mp) > 4 {
|
||||||
|
if tt, ok2 := mp[4].([]any); ok2 && len(tt) > ci {
|
||||||
|
if sec, ok3 := tt[ci].([]any); ok3 && len(sec) > 12 {
|
||||||
|
if ss, ok4 := sec[12].([]any); ok4 && len(ss) > 7 {
|
||||||
|
if first, ok5 := ss[7].([]any); ok5 && len(first) > 0 && first[0] != nil {
|
||||||
|
imgBody = mp
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if imgBody == nil {
|
||||||
|
return empty, &ImageGenerationError{APIError{Msg: "Failed to parse generated images."}}
|
||||||
|
}
|
||||||
|
imgCand := imgBody[4].([]any)[ci].([]any)
|
||||||
|
if len(imgCand) > 1 {
|
||||||
|
if a, ok1 := imgCand[1].([]any); ok1 && len(a) > 0 {
|
||||||
|
if s, ok2 := a[0].(string); ok2 {
|
||||||
|
text = strings.TrimSpace(reGen.ReplaceAllString(s, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// images list at imgCand[12][7][0]
|
||||||
|
if len(imgCand) > 12 {
|
||||||
|
if s1, ok1 := imgCand[12].([]any); ok1 && len(s1) > 7 {
|
||||||
|
if s2, ok2 := s1[7].([]any); ok2 && len(s2) > 0 {
|
||||||
|
if s3, ok3 := s2[0].([]any); ok3 {
|
||||||
|
for ii, giAny := range s3 {
|
||||||
|
ga, ok4 := giAny.([]any)
|
||||||
|
if !ok4 || len(ga) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// url: ga[0][3][3]
|
||||||
|
var urlStr, title, alt string
|
||||||
|
if a, ok5 := ga[0].([]any); ok5 && len(a) > 3 {
|
||||||
|
if b1, ok6 := a[3].([]any); ok6 && len(b1) > 3 {
|
||||||
|
urlStr, _ = b1[3].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// title from ga[3][6]
|
||||||
|
if len(ga) > 3 {
|
||||||
|
if a, ok5 := ga[3].([]any); ok5 {
|
||||||
|
if len(a) > 6 {
|
||||||
|
if v, ok6 := a[6].(float64); ok6 && v != 0 {
|
||||||
|
title = fmt.Sprintf("[Generated Image %.0f]", v)
|
||||||
|
} else {
|
||||||
|
title = "[Generated Image]"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = "[Generated Image]"
|
||||||
|
}
|
||||||
|
// alt from ga[3][5][ii] fallback
|
||||||
|
if len(a) > 5 {
|
||||||
|
if tt, ok6 := a[5].([]any); ok6 {
|
||||||
|
if ii < len(tt) {
|
||||||
|
if s, ok7 := tt[ii].(string); ok7 {
|
||||||
|
alt = s
|
||||||
|
}
|
||||||
|
} else if len(tt) > 0 {
|
||||||
|
if s, ok7 := tt[0].(string); ok7 {
|
||||||
|
alt = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genImages = append(genImages, GeneratedImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}, Cookies: c.Cookies})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cand := Candidate{
|
||||||
|
RCID: fmt.Sprintf("%v", cArr[0]),
|
||||||
|
Text: decodeHTML(text),
|
||||||
|
Thoughts: thoughts,
|
||||||
|
WebImages: webImages,
|
||||||
|
GeneratedImages: genImages,
|
||||||
|
}
|
||||||
|
candidates = append(candidates, cand)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return empty, &GeminiError{Msg: "Failed to generate contents. No output data found in response."}
|
||||||
|
}
|
||||||
|
output := ModelOutput{Metadata: metadata, Candidates: candidates, Chosen: 0}
|
||||||
|
if chat != nil {
|
||||||
|
chat.lastOutput = &output
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractErrorCode attempts to navigate the known nested error structure and fetch the integer code.
|
||||||
|
// Mirrors reference path: response_json[0][5][2][0][1][0]
|
||||||
|
func extractErrorCode(top []any) (int, bool) {
|
||||||
|
if len(top) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
a, ok := top[0].([]any)
|
||||||
|
if !ok || len(a) <= 5 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
b, ok := a[5].([]any)
|
||||||
|
if !ok || len(b) <= 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
c, ok := b[2].([]any)
|
||||||
|
if !ok || len(c) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
d, ok := c[0].([]any)
|
||||||
|
if !ok || len(d) <= 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
e, ok := d[1].([]any)
|
||||||
|
if !ok || len(e) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, ok := e[0].(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(f), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartChat returns a ChatSession attached to the client
|
||||||
|
func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession {
|
||||||
|
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatSession holds conversation metadata
|
||||||
|
type ChatSession struct {
|
||||||
|
client *GeminiClient
|
||||||
|
metadata []string // cid, rid, rcid
|
||||||
|
lastOutput *ModelOutput
|
||||||
|
model Model
|
||||||
|
gem *Gem
|
||||||
|
requestedModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ChatSession) String() string {
|
||||||
|
var cid, rid, rcid string
|
||||||
|
if len(cs.metadata) > 0 {
|
||||||
|
cid = cs.metadata[0]
|
||||||
|
}
|
||||||
|
if len(cs.metadata) > 1 {
|
||||||
|
rid = cs.metadata[1]
|
||||||
|
}
|
||||||
|
if len(cs.metadata) > 2 {
|
||||||
|
rcid = cs.metadata[2]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("ChatSession(cid='%s', rid='%s', rcid='%s')", cid, rid, rcid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMeta(v []string) []string {
|
||||||
|
out := []string{"", "", ""}
|
||||||
|
for i := 0; i < len(v) && i < 3; i++ {
|
||||||
|
out[i] = v[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ChatSession) Metadata() []string { return cs.metadata }
|
||||||
|
func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) }
|
||||||
|
func (cs *ChatSession) RequestedModel() string { return cs.requestedModel }
|
||||||
|
func (cs *ChatSession) SetRequestedModel(name string) {
|
||||||
|
cs.requestedModel = strings.ToLower(name)
|
||||||
|
}
|
||||||
|
func (cs *ChatSession) CID() string {
|
||||||
|
if len(cs.metadata) > 0 {
|
||||||
|
return cs.metadata[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (cs *ChatSession) RID() string {
|
||||||
|
if len(cs.metadata) > 1 {
|
||||||
|
return cs.metadata[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (cs *ChatSession) RCID() string {
|
||||||
|
if len(cs.metadata) > 2 {
|
||||||
|
return cs.metadata[2]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (cs *ChatSession) setCID(v string) {
|
||||||
|
if len(cs.metadata) < 1 {
|
||||||
|
cs.metadata = normalizeMeta(cs.metadata)
|
||||||
|
}
|
||||||
|
cs.metadata[0] = v
|
||||||
|
}
|
||||||
|
func (cs *ChatSession) setRID(v string) {
|
||||||
|
if len(cs.metadata) < 2 {
|
||||||
|
cs.metadata = normalizeMeta(cs.metadata)
|
||||||
|
}
|
||||||
|
cs.metadata[1] = v
|
||||||
|
}
|
||||||
|
func (cs *ChatSession) setRCID(v string) {
|
||||||
|
if len(cs.metadata) < 3 {
|
||||||
|
cs.metadata = normalizeMeta(cs.metadata)
|
||||||
|
}
|
||||||
|
cs.metadata[2] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage shortcut to client's GenerateContent
|
||||||
|
func (cs *ChatSession) SendMessage(prompt string, files []string) (ModelOutput, error) {
|
||||||
|
out, err := cs.client.GenerateContent(prompt, files, cs.model, cs.gem, cs)
|
||||||
|
if err == nil {
|
||||||
|
cs.lastOutput = &out
|
||||||
|
cs.SetMetadata(out.Metadata)
|
||||||
|
cs.setRCID(out.RCID())
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChooseCandidate selects a candidate from last output and updates rcid
|
||||||
|
func (cs *ChatSession) ChooseCandidate(index int) (ModelOutput, error) {
|
||||||
|
if cs.lastOutput == nil {
|
||||||
|
return ModelOutput{}, &ValueError{Msg: "No previous output data found in this chat session."}
|
||||||
|
}
|
||||||
|
if index >= len(cs.lastOutput.Candidates) {
|
||||||
|
return ModelOutput{}, &ValueError{Msg: fmt.Sprintf("Index %d exceeds candidates", index)}
|
||||||
|
}
|
||||||
|
cs.lastOutput.Chosen = index
|
||||||
|
cs.setRCID(cs.lastOutput.RCID())
|
||||||
|
return *cs.lastOutput, nil
|
||||||
|
}
|
||||||
542
internal/provider/gemini-web/media.go
Normal file
542
internal/provider/gemini-web/media.go
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
package geminiwebapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Image helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Alt string
|
||||||
|
Proxy string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i Image) String() string {
|
||||||
|
short := i.URL
|
||||||
|
if len(short) > 20 {
|
||||||
|
short = short[:8] + "..." + short[len(short)-12:]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Image(title='%s', alt='%s', url='%s')", i.Title, i.Alt, short)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i Image) Save(path string, filename string, cookies map[string]string, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
||||||
|
if filename == "" {
|
||||||
|
// Try to parse filename from URL.
|
||||||
|
u := i.URL
|
||||||
|
if p := strings.Split(u, "/"); len(p) > 0 {
|
||||||
|
filename = p[len(p)-1]
|
||||||
|
}
|
||||||
|
if q := strings.Split(filename, "?"); len(q) > 0 {
|
||||||
|
filename = q[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regex validation (pattern: ^(.*\.\w+)) to extract name with extension.
|
||||||
|
if filename != "" {
|
||||||
|
re := regexp.MustCompile(`^(.*\.\w+)`)
|
||||||
|
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
||||||
|
filename = m[1]
|
||||||
|
} else {
|
||||||
|
if verbose {
|
||||||
|
log.Warnf("Invalid filename: %s", filename)
|
||||||
|
}
|
||||||
|
if skipInvalidFilename {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Build client using shared helper to keep proxy/TLS behavior consistent.
|
||||||
|
client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true})
|
||||||
|
client.Timeout = 120 * time.Second
|
||||||
|
|
||||||
|
// Helper to set raw Cookie header using provided cookies (parity with the reference client behavior).
|
||||||
|
buildCookieHeader := func(m map[string]string) string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s", k, m[k]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
rawCookie := buildCookieHeader(cookies)
|
||||||
|
|
||||||
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
// Ensure provided cookies are always sent across redirects (domain-agnostic).
|
||||||
|
if rawCookie != "" {
|
||||||
|
req.Header.Set("Cookie", rawCookie)
|
||||||
|
}
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return errors.New("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, i.URL, nil)
|
||||||
|
if rawCookie != "" {
|
||||||
|
req.Header.Set("Cookie", rawCookie)
|
||||||
|
}
|
||||||
|
// Add browser-like headers to improve compatibility.
|
||||||
|
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
|
||||||
|
req.Header.Set("Connection", "keep-alive")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "image") {
|
||||||
|
log.Warnf("Content type of %s is not image, but %s.", filename, ct)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
path = "temp"
|
||||||
|
}
|
||||||
|
if err = os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(path, filename)
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
_ = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Image saved as %s\n", dest)
|
||||||
|
}
|
||||||
|
abspath, _ := filepath.Abs(dest)
|
||||||
|
return abspath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebImage struct{ Image }
|
||||||
|
|
||||||
|
type GeneratedImage struct {
|
||||||
|
Image
|
||||||
|
Cookies map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GeneratedImage) Save(path string, filename string, fullSize bool, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
||||||
|
if len(g.Cookies) == 0 {
|
||||||
|
return "", &ValueError{Msg: "GeneratedImage requires cookies."}
|
||||||
|
}
|
||||||
|
strURL := g.URL
|
||||||
|
if fullSize {
|
||||||
|
strURL = strURL + "=s2048"
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
name := time.Now().Format("20060102150405")
|
||||||
|
if len(strURL) >= 10 {
|
||||||
|
name = fmt.Sprintf("%s_%s.png", name, strURL[len(strURL)-10:])
|
||||||
|
} else {
|
||||||
|
name += ".png"
|
||||||
|
}
|
||||||
|
filename = name
|
||||||
|
}
|
||||||
|
tmp := g.Image
|
||||||
|
tmp.URL = strURL
|
||||||
|
return tmp.Save(path, filename, g.Cookies, verbose, skipInvalidFilename, insecure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request parsing & file helpers -------------------------------------------
|
||||||
|
|
||||||
|
func ParseMessagesAndFiles(rawJSON []byte) ([]RoleText, [][]byte, []string, [][]int, error) {
|
||||||
|
var messages []RoleText
|
||||||
|
var files [][]byte
|
||||||
|
var mimes []string
|
||||||
|
var perMsgFileIdx [][]int
|
||||||
|
|
||||||
|
contents := gjson.GetBytes(rawJSON, "contents")
|
||||||
|
if contents.Exists() {
|
||||||
|
contents.ForEach(func(_, content gjson.Result) bool {
|
||||||
|
role := NormalizeRole(content.Get("role").String())
|
||||||
|
var b strings.Builder
|
||||||
|
startFile := len(files)
|
||||||
|
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||||
|
if text := part.Get("text"); text.Exists() {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(text.String())
|
||||||
|
}
|
||||||
|
if inlineData := part.Get("inlineData"); inlineData.Exists() {
|
||||||
|
data := inlineData.Get("data").String()
|
||||||
|
if data != "" {
|
||||||
|
if dec, err := base64.StdEncoding.DecodeString(data); err == nil {
|
||||||
|
files = append(files, dec)
|
||||||
|
m := inlineData.Get("mimeType").String()
|
||||||
|
if m == "" {
|
||||||
|
m = inlineData.Get("mime_type").String()
|
||||||
|
}
|
||||||
|
mimes = append(mimes, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
messages = append(messages, RoleText{Role: role, Text: b.String()})
|
||||||
|
endFile := len(files)
|
||||||
|
if endFile > startFile {
|
||||||
|
idxs := make([]int, 0, endFile-startFile)
|
||||||
|
for i := startFile; i < endFile; i++ {
|
||||||
|
idxs = append(idxs, i)
|
||||||
|
}
|
||||||
|
perMsgFileIdx = append(perMsgFileIdx, idxs)
|
||||||
|
} else {
|
||||||
|
perMsgFileIdx = append(perMsgFileIdx, nil)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return messages, files, mimes, perMsgFileIdx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaterializeInlineFiles(files [][]byte, mimes []string) ([]string, *interfaces.ErrorMessage) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(files))
|
||||||
|
for i, data := range files {
|
||||||
|
ext := MimeToExt(mimes, i)
|
||||||
|
f, err := os.CreateTemp("", "gemini-upload-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to create temp file: %w", err)}
|
||||||
|
}
|
||||||
|
if _, err = f.Write(data); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(f.Name())
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to write temp file: %w", err)}
|
||||||
|
}
|
||||||
|
if err = f.Close(); err != nil {
|
||||||
|
_ = os.Remove(f.Name())
|
||||||
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to close temp file: %w", err)}
|
||||||
|
}
|
||||||
|
paths = append(paths, f.Name())
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupFiles(paths []string) {
|
||||||
|
for _, p := range paths {
|
||||||
|
if p != "" {
|
||||||
|
_ = os.Remove(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchGeneratedImageData(gi GeneratedImage) (string, string, error) {
|
||||||
|
path, err := gi.Save("", "", true, false, true, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(path) }()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
mime := http.DetectContentType(b)
|
||||||
|
if !strings.HasPrefix(mime, "image/") {
|
||||||
|
if guessed := mimeFromExtension(filepath.Ext(path)); guessed != "" {
|
||||||
|
mime = guessed
|
||||||
|
} else {
|
||||||
|
mime = "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mime, base64.StdEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MimeToExt(mimes []string, i int) string {
|
||||||
|
if i < len(mimes) {
|
||||||
|
return MimeToPreferredExt(strings.ToLower(mimes[i]))
|
||||||
|
}
|
||||||
|
return ".png"
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferredExtByMIME = map[string]string{
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
"image/heic": ".heic",
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
func MimeToPreferredExt(mime string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(mime))
|
||||||
|
if normalized == "" {
|
||||||
|
return ".png"
|
||||||
|
}
|
||||||
|
if ext, ok := preferredExtByMIME[normalized]; ok {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
return ".png"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeFromExtension(ext string) string {
|
||||||
|
cleaned := strings.TrimPrefix(strings.ToLower(ext), ".")
|
||||||
|
if cleaned == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if mt, ok := misc.MimeTypes[cleaned]; ok && mt != "" {
|
||||||
|
return mt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// File upload helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
func uploadFile(path string, proxy string, insecure bool) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, err := mw.CreateFormFile("file", filepath.Base(path))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(fw, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = mw.Close()
|
||||||
|
|
||||||
|
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||||
|
client.Timeout = 300 * time.Second
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
|
||||||
|
applyHeaders(req, HeadersUpload)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
req.Header.Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", &APIError{Msg: resp.Status}
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFileName(path string) (string, error) {
|
||||||
|
if st, err := os.Stat(path); err != nil || st.IsDir() {
|
||||||
|
return "", &ValueError{Msg: path + " is not a valid file."}
|
||||||
|
}
|
||||||
|
return filepath.Base(path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response formatting helpers ----------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
reGoogle = regexp.MustCompile("(\\()?\\[`([^`]+?)`\\]\\(https://www\\.google\\.com/search\\?q=[^)]*\\)(\\))?")
|
||||||
|
reColonNum = regexp.MustCompile(`([^:]+:\d+)`)
|
||||||
|
reInline = regexp.MustCompile("`(\\[[^\\]]+\\]\\([^\\)]+\\))`")
|
||||||
|
)
|
||||||
|
|
||||||
|
func unescapeGeminiText(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, "\\<", "<")
|
||||||
|
s = strings.ReplaceAll(s, "\\_", "_")
|
||||||
|
s = strings.ReplaceAll(s, "\\>", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func postProcessModelText(text string) string {
|
||||||
|
text = reGoogle.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
subs := reGoogle.FindStringSubmatch(m)
|
||||||
|
if len(subs) < 4 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
outerOpen := subs[1]
|
||||||
|
display := subs[2]
|
||||||
|
target := display
|
||||||
|
if loc := reColonNum.FindString(display); loc != "" {
|
||||||
|
target = loc
|
||||||
|
}
|
||||||
|
newSeg := "[`" + display + "`](" + target + ")"
|
||||||
|
if outerOpen != "" {
|
||||||
|
return "(" + newSeg + ")"
|
||||||
|
}
|
||||||
|
return newSeg
|
||||||
|
})
|
||||||
|
text = reInline.ReplaceAllString(text, "$1")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateTokens(s string) int {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rc := float64(utf8.RuneCountInString(s))
|
||||||
|
if rc <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
est := int(math.Ceil(rc / 4.0))
|
||||||
|
if est < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return est
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertOutputToGemini converts simplified ModelOutput to Gemini API-like JSON.
|
||||||
|
// promptText is used only to estimate usage tokens to populate usage fields.
|
||||||
|
func ConvertOutputToGemini(output *ModelOutput, modelName string, promptText string) ([]byte, error) {
|
||||||
|
if output == nil || len(output.Candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty output")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]map[string]any, 0, 2)
|
||||||
|
|
||||||
|
var thoughtsText string
|
||||||
|
if output.Candidates[0].Thoughts != nil {
|
||||||
|
if t := strings.TrimSpace(*output.Candidates[0].Thoughts); t != "" {
|
||||||
|
thoughtsText = unescapeGeminiText(t)
|
||||||
|
parts = append(parts, map[string]any{
|
||||||
|
"text": thoughtsText,
|
||||||
|
"thought": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := unescapeGeminiText(output.Candidates[0].Text)
|
||||||
|
finalText := postProcessModelText(visible)
|
||||||
|
if finalText != "" {
|
||||||
|
parts = append(parts, map[string]any{"text": finalText})
|
||||||
|
}
|
||||||
|
|
||||||
|
if imgs := output.Candidates[0].GeneratedImages; len(imgs) > 0 {
|
||||||
|
for _, gi := range imgs {
|
||||||
|
if mime, data, err := FetchGeneratedImageData(gi); err == nil && data != "" {
|
||||||
|
parts = append(parts, map[string]any{
|
||||||
|
"inlineData": map[string]any{
|
||||||
|
"mimeType": mime,
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptTokens := estimateTokens(promptText)
|
||||||
|
completionTokens := estimateTokens(finalText)
|
||||||
|
thoughtsTokens := 0
|
||||||
|
if thoughtsText != "" {
|
||||||
|
thoughtsTokens = estimateTokens(thoughtsText)
|
||||||
|
}
|
||||||
|
totalTokens := promptTokens + completionTokens
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
resp := map[string]any{
|
||||||
|
"candidates": []any{
|
||||||
|
map[string]any{
|
||||||
|
"content": map[string]any{
|
||||||
|
"parts": parts,
|
||||||
|
"role": "model",
|
||||||
|
},
|
||||||
|
"finishReason": "stop",
|
||||||
|
"index": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"createTime": now.Format(time.RFC3339Nano),
|
||||||
|
"responseId": fmt.Sprintf("gemini-web-%d", now.UnixNano()),
|
||||||
|
"modelVersion": modelName,
|
||||||
|
"usageMetadata": map[string]any{
|
||||||
|
"promptTokenCount": promptTokens,
|
||||||
|
"candidatesTokenCount": completionTokens,
|
||||||
|
"thoughtsTokenCount": thoughtsTokens,
|
||||||
|
"totalTokenCount": totalTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal gemini response: %w", err)
|
||||||
|
}
|
||||||
|
return ensureColonSpacing(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureColonSpacing inserts a single space after JSON key-value colons while
|
||||||
|
// leaving string content untouched. This matches the relaxed formatting used by
|
||||||
|
// Gemini responses and keeps downstream text-processing tools compatible with
|
||||||
|
// the proxy output.
|
||||||
|
func ensureColonSpacing(b []byte) []byte {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.Grow(len(b) + len(b)/8)
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
ch := b[i]
|
||||||
|
out.WriteByte(ch)
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case '\\':
|
||||||
|
escaped = true
|
||||||
|
case '"':
|
||||||
|
inString = !inString
|
||||||
|
case ':':
|
||||||
|
if !inString && i+1 < len(b) {
|
||||||
|
next := b[i+1]
|
||||||
|
if next != ' ' && next != '\n' && next != '\r' && next != '\t' {
|
||||||
|
out.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
310
internal/provider/gemini-web/models.go
Normal file
310
internal/provider/gemini-web/models.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package geminiwebapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gemini web endpoints and default headers ----------------------------------
|
||||||
|
const (
|
||||||
|
EndpointGoogle = "https://www.google.com"
|
||||||
|
EndpointInit = "https://gemini.google.com/app"
|
||||||
|
EndpointGenerate = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
|
||||||
|
EndpointRotateCookies = "https://accounts.google.com/RotateCookies"
|
||||||
|
EndpointUpload = "https://content-push.googleapis.com/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
HeadersGemini = http.Header{
|
||||||
|
"Content-Type": []string{"application/x-www-form-urlencoded;charset=utf-8"},
|
||||||
|
"Host": []string{"gemini.google.com"},
|
||||||
|
"Origin": []string{"https://gemini.google.com"},
|
||||||
|
"Referer": []string{"https://gemini.google.com/"},
|
||||||
|
"User-Agent": []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
||||||
|
"X-Same-Domain": []string{"1"},
|
||||||
|
}
|
||||||
|
HeadersRotateCookies = http.Header{
|
||||||
|
"Content-Type": []string{"application/json"},
|
||||||
|
}
|
||||||
|
HeadersUpload = http.Header{
|
||||||
|
"Push-ID": []string{"feeds/mcudyrk2a4khkz"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model metadata -------------------------------------------------------------
|
||||||
|
type Model struct {
|
||||||
|
Name string
|
||||||
|
ModelHeader http.Header
|
||||||
|
AdvancedOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ModelUnspecified = Model{
|
||||||
|
Name: "unspecified",
|
||||||
|
ModelHeader: http.Header{},
|
||||||
|
AdvancedOnly: false,
|
||||||
|
}
|
||||||
|
ModelG25Flash = Model{
|
||||||
|
Name: "gemini-2.5-flash",
|
||||||
|
ModelHeader: http.Header{
|
||||||
|
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"71c2d248d3b102ff\",null,null,0,[4]]"},
|
||||||
|
},
|
||||||
|
AdvancedOnly: false,
|
||||||
|
}
|
||||||
|
ModelG25Pro = Model{
|
||||||
|
Name: "gemini-2.5-pro",
|
||||||
|
ModelHeader: http.Header{
|
||||||
|
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"4af6c7f5da75d65d\",null,null,0,[4]]"},
|
||||||
|
},
|
||||||
|
AdvancedOnly: false,
|
||||||
|
}
|
||||||
|
ModelG20Flash = Model{
|
||||||
|
Name: "gemini-2.0-flash",
|
||||||
|
ModelHeader: http.Header{
|
||||||
|
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"f299729663a2343f\"]"},
|
||||||
|
},
|
||||||
|
AdvancedOnly: false,
|
||||||
|
}
|
||||||
|
ModelG20FlashThinking = Model{
|
||||||
|
Name: "gemini-2.0-flash-thinking",
|
||||||
|
ModelHeader: http.Header{
|
||||||
|
"x-goog-ext-525001261-jspb": []string{"[null,null,null,null,\"7ca48d02d802f20a\"]"},
|
||||||
|
},
|
||||||
|
AdvancedOnly: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func ModelFromName(name string) (Model, error) {
|
||||||
|
switch name {
|
||||||
|
case ModelUnspecified.Name:
|
||||||
|
return ModelUnspecified, nil
|
||||||
|
case ModelG25Flash.Name:
|
||||||
|
return ModelG25Flash, nil
|
||||||
|
case ModelG25Pro.Name:
|
||||||
|
return ModelG25Pro, nil
|
||||||
|
case ModelG20Flash.Name:
|
||||||
|
return ModelG20Flash, nil
|
||||||
|
case ModelG20FlashThinking.Name:
|
||||||
|
return ModelG20FlashThinking, nil
|
||||||
|
default:
|
||||||
|
return Model{}, &ValueError{Msg: "Unknown model name: " + name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known error codes returned from the server.
|
||||||
|
const (
|
||||||
|
ErrorUsageLimitExceeded = 1037
|
||||||
|
ErrorModelInconsistent = 1050
|
||||||
|
ErrorModelHeaderInvalid = 1052
|
||||||
|
ErrorIPTemporarilyBlocked = 1060
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
GeminiWebAliasOnce sync.Once
|
||||||
|
GeminiWebAliasMap map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnsureGeminiWebAliasMap() {
|
||||||
|
GeminiWebAliasOnce.Do(func() {
|
||||||
|
GeminiWebAliasMap = make(map[string]string)
|
||||||
|
for _, m := range registry.GetGeminiModels() {
|
||||||
|
if m.ID == "gemini-2.5-flash-lite" {
|
||||||
|
continue
|
||||||
|
} else if m.ID == "gemini-2.5-flash" {
|
||||||
|
GeminiWebAliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
|
||||||
|
}
|
||||||
|
alias := AliasFromModelID(m.ID)
|
||||||
|
GeminiWebAliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
|
||||||
|
EnsureGeminiWebAliasMap()
|
||||||
|
aliased := make([]*registry.ModelInfo, 0)
|
||||||
|
for _, m := range registry.GetGeminiModels() {
|
||||||
|
if m.ID == "gemini-2.5-flash-lite" {
|
||||||
|
continue
|
||||||
|
} else if m.ID == "gemini-2.5-flash" {
|
||||||
|
cpy := *m
|
||||||
|
cpy.ID = "gemini-2.5-flash-image-preview"
|
||||||
|
cpy.Name = "gemini-2.5-flash-image-preview"
|
||||||
|
cpy.DisplayName = "Nano Banana"
|
||||||
|
cpy.Description = "Gemini 2.5 Flash Preview Image"
|
||||||
|
aliased = append(aliased, &cpy)
|
||||||
|
}
|
||||||
|
cpy := *m
|
||||||
|
cpy.ID = AliasFromModelID(m.ID)
|
||||||
|
cpy.Name = cpy.ID
|
||||||
|
aliased = append(aliased, &cpy)
|
||||||
|
}
|
||||||
|
return aliased
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapAliasToUnderlying(name string) string {
|
||||||
|
EnsureGeminiWebAliasMap()
|
||||||
|
n := strings.ToLower(name)
|
||||||
|
if u, ok := GeminiWebAliasMap[n]; ok {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
const suffix = "-web"
|
||||||
|
if strings.HasSuffix(n, suffix) {
|
||||||
|
return strings.TrimSuffix(n, suffix)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func AliasFromModelID(modelID string) string {
|
||||||
|
return modelID + "-web"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation domain structures -------------------------------------------
|
||||||
|
type RoleText struct {
|
||||||
|
Role string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationRecord struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Metadata []string `json:"metadata,omitempty"`
|
||||||
|
Messages []StoredMessage `json:"messages"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Candidate struct {
|
||||||
|
RCID string
|
||||||
|
Text string
|
||||||
|
Thoughts *string
|
||||||
|
WebImages []WebImage
|
||||||
|
GeneratedImages []GeneratedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Candidate) String() string {
|
||||||
|
t := c.Text
|
||||||
|
if len(t) > 20 {
|
||||||
|
t = t[:20] + "..."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Candidate(rcid='%s', text='%s', images=%d)", c.RCID, t, len(c.WebImages)+len(c.GeneratedImages))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Candidate) Images() []Image {
|
||||||
|
images := make([]Image, 0, len(c.WebImages)+len(c.GeneratedImages))
|
||||||
|
for _, wi := range c.WebImages {
|
||||||
|
images = append(images, wi.Image)
|
||||||
|
}
|
||||||
|
for _, gi := range c.GeneratedImages {
|
||||||
|
images = append(images, gi.Image)
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelOutput struct {
|
||||||
|
Metadata []string
|
||||||
|
Candidates []Candidate
|
||||||
|
Chosen int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ModelOutput) String() string { return m.Text() }
|
||||||
|
|
||||||
|
func (m ModelOutput) Text() string {
|
||||||
|
if len(m.Candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.Candidates[m.Chosen].Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ModelOutput) Thoughts() *string {
|
||||||
|
if len(m.Candidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Candidates[m.Chosen].Thoughts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ModelOutput) Images() []Image {
|
||||||
|
if len(m.Candidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Candidates[m.Chosen].Images()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ModelOutput) RCID() string {
|
||||||
|
if len(m.Candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.Candidates[m.Chosen].RCID
|
||||||
|
}
|
||||||
|
|
||||||
|
type Gem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description *string
|
||||||
|
Prompt *string
|
||||||
|
Predefined bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g Gem) String() string {
|
||||||
|
return fmt.Sprintf("Gem(id='%s', name='%s', description='%v', prompt='%v', predefined=%v)", g.ID, g.Name, g.Description, g.Prompt, g.Predefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHTML(s string) string { return html.UnescapeString(s) }
|
||||||
|
|
||||||
|
// Error hierarchy -----------------------------------------------------------
|
||||||
|
type AuthError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *AuthError) Error() string {
|
||||||
|
if e.Msg == "" {
|
||||||
|
return "authentication error"
|
||||||
|
}
|
||||||
|
return e.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Msg == "" {
|
||||||
|
return "api error"
|
||||||
|
}
|
||||||
|
return e.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageGenerationError struct{ APIError }
|
||||||
|
|
||||||
|
type GeminiError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *GeminiError) Error() string {
|
||||||
|
if e.Msg == "" {
|
||||||
|
return "gemini error"
|
||||||
|
}
|
||||||
|
return e.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeoutError struct{ GeminiError }
|
||||||
|
|
||||||
|
type UsageLimitExceeded struct{ GeminiError }
|
||||||
|
|
||||||
|
type ModelInvalid struct{ GeminiError }
|
||||||
|
|
||||||
|
type TemporarilyBlocked struct{ GeminiError }
|
||||||
|
|
||||||
|
type ValueError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *ValueError) Error() string {
|
||||||
|
if e.Msg == "" {
|
||||||
|
return "value error"
|
||||||
|
}
|
||||||
|
return e.Msg
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user