Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Git and GitHub folders
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Docker and CI/CD related files
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
.goreleaser.yml
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
# Documentation and license
|
||||||
|
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
|
||||||
|
config.yaml
|
||||||
14
.github/workflows/docker-image.yml
vendored
14
.github/workflows/docker-image.yml
vendored
@@ -24,8 +24,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Generate App Version
|
- name: Generate Build Metadata
|
||||||
run: echo APP_VERSION=`git describe --tags --always` >> $GITHUB_ENV
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -35,8 +38,9 @@ jobs:
|
|||||||
linux/arm64
|
linux/arm64
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_NAME=${{ env.APP_NAME }}
|
VERSION=${{ env.VERSION }}
|
||||||
APP_VERSION=${{ env.APP_VERSION }}
|
COMMIT=${{ env.COMMIT }}
|
||||||
|
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKERHUB_REPO }}:latest
|
${{ env.DOCKERHUB_REPO }}:latest
|
||||||
${{ env.DOCKERHUB_REPO }}:${{ env.APP_VERSION }}
|
${{ 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 }}
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
config.yaml
|
config.yaml
|
||||||
docs/
|
docs/*
|
||||||
logs/
|
logs/*
|
||||||
|
auths/*
|
||||||
|
!auths/.gitkeep
|
||||||
|
AGENTS.md
|
||||||
@@ -9,6 +9,8 @@ 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:
|
archives:
|
||||||
- id: "cli-proxy-api"
|
- id: "cli-proxy-api"
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
@@ -19,4 +21,17 @@ archives:
|
|||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
- README_CN.md
|
- README_CN.md
|
||||||
- config.example.yaml
|
- config.example.yaml
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- '^docs:'
|
||||||
|
- '^test:'
|
||||||
@@ -8,7 +8,11 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o ./CLIProxyAPI ./cmd/server/
|
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
|
FROM alpine:3.22.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,284 +1,522 @@
|
|||||||
# Management API
|
# Management API
|
||||||
|
|
||||||
Base URL: `http://localhost:8317/v0/management`
|
Base path: `http://localhost:8317/v0/management`
|
||||||
|
|
||||||
This API manages runtime configuration and authentication files for the CLI Proxy API. All changes persist to the YAML config file and are hot‑reloaded by the server.
|
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 changed via API and must be edited in the config file, then restart if needed:
|
Note: The following options cannot be modified via API and must be set in the config file (restart if needed):
|
||||||
- `allow-remote-management`
|
- `allow-remote-management`
|
||||||
- `remote-management-key` (stored as bcrypt hash after startup if plaintext was provided)
|
- `remote-management-key` (if plaintext is detected at startup, it is automatically bcrypt‑hashed and written back to the config)
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
- All requests (including localhost) must include a management key.
|
- All requests (including localhost) must provide a valid management key.
|
||||||
- Remote access additionally requires `allow-remote-management: true` in config.
|
- Remote access requires enabling remote management in the config: `allow-remote-management: true`.
|
||||||
- Provide the key via one of:
|
- Provide the management key (in plaintext) via either:
|
||||||
- `Authorization: Bearer <plaintext-key>`
|
- `Authorization: Bearer <plaintext-key>`
|
||||||
- `X-Management-Key: <plaintext-key>`
|
- `X-Management-Key: <plaintext-key>`
|
||||||
|
|
||||||
If a plaintext key is present in the config on startup, it is bcrypt-hashed and written back to the config file automatically. If `remote-management-key` is empty, the Management API is entirely disabled (404 for `/v0/management/*`).
|
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
|
## Request/Response Conventions
|
||||||
|
|
||||||
- Content type: `application/json` unless noted.
|
- Content-Type: `application/json` (unless otherwise noted).
|
||||||
- Boolean/int/string updates use body: `{ "value": <type> }`.
|
- Boolean/int/string updates: request body is `{ "value": <type> }`.
|
||||||
- Array PUT bodies can be either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`.
|
- Array PUT: either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`.
|
||||||
- Array PATCH accepts either `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`.
|
- Array PATCH: supports `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`.
|
||||||
- Object-array PATCH supports either index or key match (documented per endpoint).
|
- Object-array PATCH: supports matching by index or by key field (specified per endpoint).
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
### 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"}]}],"allow-localhost-unauthenticated":true}
|
||||||
|
```
|
||||||
|
|
||||||
### Debug
|
### Debug
|
||||||
- GET `/debug` — get current debug flag
|
- GET `/debug` — Get the current debug state
|
||||||
- PUT/PATCH `/debug` — set debug (boolean)
|
|
||||||
|
|
||||||
Example (set true):
|
|
||||||
```bash
|
|
||||||
curl -X PUT \
|
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"value":true}' \
|
|
||||||
http://localhost:8317/v0/management/debug
|
|
||||||
```
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Proxy URL
|
|
||||||
- GET `/proxy-url` — get proxy URL string
|
|
||||||
- PUT/PATCH `/proxy-url` — set proxy URL string
|
|
||||||
- DELETE `/proxy-url` — clear proxy URL
|
|
||||||
|
|
||||||
Example (set):
|
|
||||||
```bash
|
|
||||||
curl -X PATCH -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
|
|
||||||
```
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quota Exceeded Behavior
|
|
||||||
- GET `/quota-exceeded/switch-project`
|
|
||||||
- PUT/PATCH `/quota-exceeded/switch-project` — boolean
|
|
||||||
- GET `/quota-exceeded/switch-preview-model`
|
|
||||||
- PUT/PATCH `/quota-exceeded/switch-preview-model` — boolean
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```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" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Keys (proxy server auth)
|
|
||||||
- GET `/api-keys` — return the full list
|
|
||||||
- PUT `/api-keys` — replace the full list
|
|
||||||
- PATCH `/api-keys` — update one entry (by `old/new` or `index/value`)
|
|
||||||
- DELETE `/api-keys` — remove one entry (by `?value=` or `?index=`)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```bash
|
|
||||||
# Replace list
|
|
||||||
curl -X PUT -H 'Content-Type: application/json' \
|
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
|
||||||
-d '["k1","k2","k3"]' \
|
|
||||||
http://localhost:8317/v0/management/api-keys
|
|
||||||
|
|
||||||
# Patch: replace k2 -> k2b
|
|
||||||
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
|
|
||||||
|
|
||||||
# Delete by value
|
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
|
|
||||||
```
|
|
||||||
Response (GET):
|
|
||||||
```json
|
|
||||||
{ "api-keys": ["k1","k2b","k3"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generative Language API Keys (Gemini)
|
|
||||||
- GET `/generative-language-api-key`
|
|
||||||
- PUT `/generative-language-api-key`
|
|
||||||
- PATCH `/generative-language-api-key`
|
|
||||||
- DELETE `/generative-language-api-key`
|
|
||||||
|
|
||||||
Same request/response shapes as API keys.
|
|
||||||
|
|
||||||
### Codex API Keys (OpenAI)
|
|
||||||
- GET `/codex-api-key`
|
|
||||||
- Request:
|
- Request:
|
||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/debug
|
||||||
```
|
```
|
||||||
- Response:
|
- Response:
|
||||||
```json
|
```json
|
||||||
{ "codex-api-key": ["sk-proj-01","sk-proj-02"] }
|
{ "debug": false }
|
||||||
```
|
```
|
||||||
- PUT `/codex-api-key`
|
- PUT/PATCH `/debug` — Set debug (boolean)
|
||||||
- Request:
|
- Request:
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT -H 'Content-Type: application/json' \
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
-d '["sk-proj-1","sk-proj-2"]' \
|
-d '{"value":true}' \
|
||||||
http://localhost:8317/v0/management/codex-api-key
|
http://localhost:8317/v0/management/debug
|
||||||
```
|
```
|
||||||
- Response:
|
- Response:
|
||||||
```json
|
```json
|
||||||
{ "status": "ok" }
|
{ "status": "ok" }
|
||||||
```
|
```
|
||||||
- PATCH `/codex-api-key`
|
|
||||||
|
### 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:
|
- Request:
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH -H 'Content-Type: application/json' \
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
-d '{"old":"sk-proj-1","new":"sk-proj-1b"}' \
|
-d '{"value":true}' \
|
||||||
http://localhost:8317/v0/management/codex-api-key
|
http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
|
||||||
```
|
```
|
||||||
- Response:
|
- Response:
|
||||||
```json
|
```json
|
||||||
{ "status": "ok" }
|
{ "status": "ok" }
|
||||||
```
|
```
|
||||||
- DELETE `/codex-api-key`
|
|
||||||
|
### API Keys (proxy service auth)
|
||||||
|
- GET `/api-keys` — Return the full list
|
||||||
- Request:
|
- Request:
|
||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?value=sk-proj-2'
|
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:
|
- Response:
|
||||||
```json
|
```json
|
||||||
{ "status": "ok" }
|
{ "status": "ok" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Request Logging
|
### Gemini API Key (Generative Language)
|
||||||
- GET `/request-log` — get boolean
|
- GET `/generative-language-api-key`
|
||||||
- PUT/PATCH `/request-log` — set boolean
|
- Request:
|
||||||
|
```bash
|
||||||
### Request Retry
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/generative-language-api-key
|
||||||
- GET `/request-retry` — get integer
|
```
|
||||||
- PUT/PATCH `/request-retry` — set integer
|
|
||||||
|
|
||||||
### Allow Localhost Unauthenticated
|
|
||||||
- GET `/allow-localhost-unauthenticated` — get boolean
|
|
||||||
- PUT/PATCH `/allow-localhost-unauthenticated` — set boolean
|
|
||||||
|
|
||||||
### Claude API Keys (object array)
|
|
||||||
- GET `/claude-api-key` — full list
|
|
||||||
- PUT `/claude-api-key` — replace list
|
|
||||||
- PATCH `/claude-api-key` — update one item (by `index` or `match` API key)
|
|
||||||
- DELETE `/claude-api-key` — remove one item (`?api-key=` or `?index=`)
|
|
||||||
|
|
||||||
Object shape:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api-key": "sk-...",
|
|
||||||
"base-url": "https://custom.example.com" // optional
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```bash
|
|
||||||
# Replace list
|
|
||||||
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
|
|
||||||
|
|
||||||
# Patch by index
|
|
||||||
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
|
|
||||||
|
|
||||||
# Patch by match (api-key)
|
|
||||||
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
|
|
||||||
|
|
||||||
# Delete by api-key
|
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2'
|
|
||||||
```
|
|
||||||
Response (GET):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"claude-api-key": [
|
|
||||||
{ "api-key": "sk-a", "base-url": "" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI Compatibility Providers (object array)
|
|
||||||
- GET `/openai-compatibility` — full list
|
|
||||||
- PUT `/openai-compatibility` — replace list
|
|
||||||
- PATCH `/openai-compatibility` — update one item by `index` or `name`
|
|
||||||
- DELETE `/openai-compatibility` — remove by `?name=` or `?index=`
|
|
||||||
|
|
||||||
Object shape:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "openrouter",
|
|
||||||
"base-url": "https://openrouter.ai/api/v1",
|
|
||||||
"api-keys": ["sk-..."],
|
|
||||||
"models": [ {"name": "moonshotai/kimi-k2:free", "alias": "kimi-k2"} ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```bash
|
|
||||||
# Replace list
|
|
||||||
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
|
|
||||||
|
|
||||||
# Patch by name
|
|
||||||
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
|
|
||||||
|
|
||||||
# Delete by index
|
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
|
|
||||||
```
|
|
||||||
Response (GET):
|
|
||||||
```json
|
|
||||||
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "...", "api-keys": [], "models": [] } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auth Files Management
|
|
||||||
|
|
||||||
List JSON token files under `auth-dir`, download/upload/delete.
|
|
||||||
|
|
||||||
- GET `/auth-files` — list
|
|
||||||
- Response:
|
- Response:
|
||||||
```json
|
```json
|
||||||
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] }
|
{ "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" }
|
||||||
```
|
```
|
||||||
|
|
||||||
- GET `/auth-files/download?name=<file.json>` — download a single file
|
### 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" }
|
||||||
|
```
|
||||||
|
|
||||||
- POST `/auth-files` — upload
|
### Request Retry Count
|
||||||
- Multipart form: field `file` (must be `.json`)
|
- GET `/request-retry` — Get integer
|
||||||
- Or raw JSON body with `?name=<file.json>`
|
- Request:
|
||||||
- Response: `{ "status": "ok" }`
|
```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" }
|
||||||
|
```
|
||||||
|
|
||||||
- DELETE `/auth-files?name=<file.json>` — delete a single file
|
### Allow Localhost Unauthenticated
|
||||||
- DELETE `/auth-files?all=true` — delete all `.json` files in `auth-dir`
|
- GET `/allow-localhost-unauthenticated` — Get boolean
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/allow-localhost-unauthenticated
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "allow-localhost-unauthenticated": false }
|
||||||
|
```
|
||||||
|
- PUT/PATCH `/allow-localhost-unauthenticated` — 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/allow-localhost-unauthenticated
|
||||||
|
```
|
||||||
|
- 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 }
|
||||||
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
Generic error shapes:
|
Generic error format:
|
||||||
- 400 Bad Request: `{ "error": "invalid body" }`
|
- 400 Bad Request: `{ "error": "invalid body" }`
|
||||||
- 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }`
|
- 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }`
|
||||||
- 403 Forbidden: `{ "error": "remote management disabled" }`
|
- 403 Forbidden: `{ "error": "remote management disabled" }`
|
||||||
@@ -287,6 +525,6 @@ Generic error shapes:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Changes are written to the YAML configuration file and picked up by the server’s file watcher to hot-reload clients and settings.
|
- 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` must be edited in the configuration file and cannot be changed via the API.
|
- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,17 @@
|
|||||||
|
|
||||||
## 端点说明
|
## 端点说明
|
||||||
|
|
||||||
|
### 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"}]}],"allow-localhost-unauthenticated":true}
|
||||||
|
```
|
||||||
|
|
||||||
### Debug
|
### Debug
|
||||||
- GET `/debug` — 获取当前 debug 状态
|
- GET `/debug` — 获取当前 debug 状态
|
||||||
- 请求:
|
- 请求:
|
||||||
@@ -233,72 +244,60 @@
|
|||||||
{ "status": "ok" }
|
{ "status": "ok" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Codex API Key(OpenAI)
|
### Codex API KEY(对象数组)
|
||||||
- GET `/codex-api-key`
|
- GET `/codex-api-key` — 列出全部
|
||||||
- 请求:
|
- 请求:
|
||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
|
||||||
```
|
```
|
||||||
- 响应:
|
- 响应:
|
||||||
```json
|
```json
|
||||||
{ "codex-api-key": ["sk-proj-01","sk-proj-02"] }
|
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||||
```
|
```
|
||||||
- PUT `/codex-api-key`
|
- PUT `/codex-api-key` — 完整改写列表
|
||||||
- 请求:
|
- 请求:
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT -H 'Content-Type: application/json' \
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
-d '["sk-proj-1","sk-proj-2"]' \
|
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||||
http://localhost:8317/v0/management/codex-api-key
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
```
|
```
|
||||||
- 响应:
|
- 响应:
|
||||||
```json
|
```json
|
||||||
{ "status": "ok" }
|
{ "status": "ok" }
|
||||||
```
|
```
|
||||||
- PATCH `/codex-api-key`
|
- PATCH `/codex-api-key` — 修改其中一个(按 `index` 或 `match`)
|
||||||
- 请求:
|
- 请求(按索引):
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH -H 'Content-Type: application/json' \
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
-d '{"old":"sk-proj-1","new":"sk-proj-1b"}' \
|
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||||
http://localhost:8317/v0/management/codex-api-key
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
```
|
```
|
||||||
- 响应:
|
- 请求(按匹配):
|
||||||
```json
|
```bash
|
||||||
{ "status": "ok" }
|
curl -X PATCH -H 'Content-Type: application/json' \
|
||||||
```
|
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
- DELETE `/codex-api-key`
|
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||||
- 请求:
|
http://localhost:8317/v0/management/codex-api-key
|
||||||
```bash
|
```
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?value=sk-proj-2'
|
- 响应:
|
||||||
```
|
```json
|
||||||
- 响应:
|
{ "status": "ok" }
|
||||||
```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'
|
||||||
- GET `/request-log` — 获取布尔值
|
```
|
||||||
- 请求:
|
- 请求(按索引):
|
||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-log
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
|
||||||
```
|
```
|
||||||
- 响应:
|
- 响应:
|
||||||
```json
|
```json
|
||||||
{ "request-log": true }
|
{ "status": "ok" }
|
||||||
```
|
```
|
||||||
- PUT/PATCH `/request-log` — 设置布尔值
|
|
||||||
- 请求:
|
|
||||||
```bash
|
|
||||||
curl -X PUT -H 'Content-Type: application/json' \
|
|
||||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
|
||||||
-d '{"value":true}' \
|
|
||||||
http://localhost:8317/v0/management/request-log
|
|
||||||
```
|
|
||||||
- 响应:
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 请求重试次数
|
### 请求重试次数
|
||||||
- GET `/request-retry` — 获取整数
|
- GET `/request-retry` — 获取整数
|
||||||
@@ -467,7 +466,7 @@
|
|||||||
```
|
```
|
||||||
- 响应:
|
- 响应:
|
||||||
```json
|
```json
|
||||||
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] }
|
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
- GET `/auth-files/download?name=<file.json>` — 下载单个文件
|
- GET `/auth-files/download?name=<file.json>` — 下载单个文件
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -220,6 +220,7 @@ console.log(await claudeResponse.json());
|
|||||||
|
|
||||||
- gemini-2.5-pro
|
- gemini-2.5-pro
|
||||||
- gemini-2.5-flash
|
- gemini-2.5-flash
|
||||||
|
- gemini-2.5-flash-lite
|
||||||
- gpt-5
|
- gpt-5
|
||||||
- claude-opus-4-1-20250805
|
- claude-opus-4-1-20250805
|
||||||
- claude-opus-4-20250514
|
- claude-opus-4-20250514
|
||||||
@@ -254,6 +255,9 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
|
|||||||
| `debug` | boolean | false | Enable debug mode for verbose logging. |
|
| `debug` | boolean | false | Enable debug mode for verbose logging. |
|
||||||
| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. |
|
| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. |
|
||||||
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. |
|
| `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` | object | {} | List of Claude API keys. |
|
||||||
| `claude-api-key.api-key` | string | "" | Claude API key. |
|
| `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. |
|
| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. |
|
||||||
@@ -310,6 +314,11 @@ generative-language-api-key:
|
|||||||
- "AIzaSy...02"
|
- "AIzaSy...02"
|
||||||
- "AIzaSy...03"
|
- "AIzaSy...03"
|
||||||
- "AIzaSy...04"
|
- "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 keys
|
||||||
claude-api-key:
|
claude-api-key:
|
||||||
@@ -486,6 +495,69 @@ Run the following command to start the server:
|
|||||||
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 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
|
||||||
|
```
|
||||||
|
- **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
|
## Management API
|
||||||
|
|
||||||
see [MANAGEMENT_API.md](MANAGEMENT_API.md)
|
see [MANAGEMENT_API.md](MANAGEMENT_API.md)
|
||||||
|
|||||||
82
README_CN.md
82
README_CN.md
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
另外中文需要交流的用户可以加 QQ 群:188637136
|
另外中文需要交流的用户可以加 QQ 群:188637136
|
||||||
|
|
||||||
|
或 Telegram 群:https://t.me/CLIProxyAPI
|
||||||
|
|
||||||
# CLI 代理 API
|
# CLI 代理 API
|
||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
@@ -237,6 +239,7 @@ console.log(await claudeResponse.json());
|
|||||||
|
|
||||||
- gemini-2.5-pro
|
- gemini-2.5-pro
|
||||||
- gemini-2.5-flash
|
- gemini-2.5-flash
|
||||||
|
- gemini-2.5-flash-lite
|
||||||
- gpt-5
|
- gpt-5
|
||||||
- claude-opus-4-1-20250805
|
- claude-opus-4-1-20250805
|
||||||
- claude-opus-4-20250514
|
- claude-opus-4-20250514
|
||||||
@@ -271,6 +274,9 @@ console.log(await claudeResponse.json());
|
|||||||
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
|
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
|
||||||
| `api-keys` | string[] | [] | 可用于验证请求的API密钥列表。 |
|
| `api-keys` | string[] | [] | 可用于验证请求的API密钥列表。 |
|
||||||
| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
|
| `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` | object | {} | Claude API密钥列表。 |
|
||||||
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
|
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
|
||||||
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 |
|
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 |
|
||||||
@@ -328,11 +334,16 @@ generative-language-api-key:
|
|||||||
- "AIzaSy...03"
|
- "AIzaSy...03"
|
||||||
- "AIzaSy...04"
|
- "AIzaSy...04"
|
||||||
|
|
||||||
# Claude API keys
|
# Codex API 密钥
|
||||||
claude-api-key:
|
codex-api-key:
|
||||||
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
|
||||||
- api-key: "sk-atSM..."
|
- api-key: "sk-atSM..."
|
||||||
base-url: "https://www.example.com" # use the custom claude API endpoint
|
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 兼容提供商
|
||||||
openai-compatibility:
|
openai-compatibility:
|
||||||
@@ -499,6 +510,69 @@ docker run -it -rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /pat
|
|||||||
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 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
|
||||||
|
```
|
||||||
|
- **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 文档
|
## 管理 API 文档
|
||||||
|
|
||||||
请参见 [MANAGEMENT_API_CN.md](MANAGEMENT_API_CN.md)
|
请参见 [MANAGEMENT_API_CN.md](MANAGEMENT_API_CN.md)
|
||||||
|
|||||||
0
auths/.gitkeep
Normal file
0
auths/.gitkeep
Normal file
@@ -8,15 +8,22 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/cmd"
|
"github.com/luispater/CLIProxyAPI/internal/cmd"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
_ "github.com/luispater/CLIProxyAPI/internal/translator"
|
_ "github.com/luispater/CLIProxyAPI/internal/translator"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = "none"
|
||||||
|
BuildDate = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
// LogFormatter defines a custom log format for logrus.
|
// LogFormatter defines a custom log format for logrus.
|
||||||
// This formatter adds timestamp, log level, and source location information
|
// This formatter adds timestamp, log level, and source location information
|
||||||
// to each log entry for better debugging and monitoring.
|
// to each log entry for better debugging and monitoring.
|
||||||
@@ -36,7 +43,7 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
|||||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||||
var newLog string
|
var newLog string
|
||||||
// Customize the log format to include timestamp, level, caller file/line, and message.
|
// Customize the log format to include timestamp, level, caller file/line, and message.
|
||||||
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, path.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
|
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
|
||||||
|
|
||||||
b.WriteString(newLog)
|
b.WriteString(newLog)
|
||||||
return b.Bytes(), nil
|
return b.Bytes(), nil
|
||||||
@@ -58,6 +65,8 @@ func init() {
|
|||||||
// It parses command-line flags, loads configuration, and starts the appropriate
|
// It parses command-line flags, loads configuration, and starts the appropriate
|
||||||
// service based on the provided flags (login, codex-login, or server mode).
|
// service based on the provided flags (login, codex-login, or server mode).
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate)
|
||||||
|
|
||||||
// Command-line flags to control the application's behavior.
|
// Command-line flags to control the application's behavior.
|
||||||
var login bool
|
var login bool
|
||||||
var codexLogin bool
|
var codexLogin bool
|
||||||
@@ -96,7 +105,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get working directory: %v", err)
|
log.Fatalf("failed to get working directory: %v", err)
|
||||||
}
|
}
|
||||||
configFilePath = path.Join(wd, "config.yaml")
|
configFilePath = filepath.Join(wd, "config.yaml")
|
||||||
cfg, err = config.LoadConfig(configFilePath)
|
cfg, err = config.LoadConfig(configFilePath)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,11 +113,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the log level based on the configuration.
|
// Set the log level based on the configuration.
|
||||||
if cfg.Debug {
|
util.SetLogLevel(cfg)
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
} else {
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand the tilde (~) in the auth directory path to the user's home directory.
|
// Expand the tilde (~) in the auth directory path to the user's home directory.
|
||||||
if strings.HasPrefix(cfg.AuthDir, "~") {
|
if strings.HasPrefix(cfg.AuthDir, "~") {
|
||||||
@@ -120,7 +125,7 @@ func main() {
|
|||||||
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
|
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
parts[0] = home
|
parts[0] = home
|
||||||
cfg.AuthDir = path.Join(parts...)
|
cfg.AuthDir = filepath.Join(parts...)
|
||||||
} else {
|
} else {
|
||||||
// If the path is just "~", set it to the home directory.
|
// If the path is just "~", set it to the home directory.
|
||||||
cfg.AuthDir = home
|
cfg.AuthDir = home
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ generative-language-api-key:
|
|||||||
- "AIzaSy...03"
|
- "AIzaSy...03"
|
||||||
- "AIzaSy...04"
|
- "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 keys
|
||||||
claude-api-key:
|
claude-api-key:
|
||||||
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||||
|
|||||||
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
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
|
restart: unless-stopped
|
||||||
@@ -139,12 +139,13 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
// Main client rotation loop with quota management
|
// Main client rotation loop with quota management
|
||||||
// This loop implements a sophisticated load balancing and failover mechanism
|
// This loop implements a sophisticated load balancing and failover mechanism
|
||||||
outLoop:
|
outLoop:
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -185,6 +186,8 @@ outLoop:
|
|||||||
// This manages various error conditions and implements retry logic
|
// This manages various error conditions and implements retry logic
|
||||||
case errInfo, okError := <-errChan:
|
case errInfo, okError := <-errChan:
|
||||||
if okError {
|
if okError {
|
||||||
|
errorResponse = errInfo
|
||||||
|
h.LoggingAPIResponseError(cliCtx, errInfo)
|
||||||
// Special handling for quota exceeded errors
|
// Special handling for quota exceeded errors
|
||||||
// If configured, attempt to switch to a different project/client
|
// If configured, attempt to switch to a different project/client
|
||||||
switch errInfo.StatusCode {
|
switch errInfo.StatusCode {
|
||||||
@@ -221,4 +224,12 @@ outLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = fmt.Fprint(c.Writer, errorResponse.Error.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,10 +169,10 @@ func (h *GeminiCLIAPIHandler) handleInternalStreamGenerateContent(c *gin.Context
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
outLoop:
|
outLoop:
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -208,6 +208,9 @@ outLoop:
|
|||||||
// Handle errors from the backend.
|
// Handle errors from the backend.
|
||||||
case err, okError := <-errChan:
|
case err, okError := <-errChan:
|
||||||
if okError {
|
if okError {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -232,6 +235,13 @@ outLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = fmt.Fprint(c.Writer, errorResponse.Error.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInternalGenerateContent handles non-streaming content generation requests.
|
// handleInternalGenerateContent handles non-streaming content generation requests.
|
||||||
@@ -252,9 +262,9 @@ func (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJ
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -265,6 +275,9 @@ func (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJ
|
|||||||
|
|
||||||
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, "")
|
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -292,8 +305,15 @@ func (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJ
|
|||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.Writer.Write(resp)
|
_, _ = c.Writer.Write(resp)
|
||||||
cliCancel(resp)
|
cliCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = c.Writer.Write([]byte(errorResponse.Error.Error()))
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,10 +221,10 @@ func (h *GeminiAPIHandler) handleStreamGenerateContent(c *gin.Context, modelName
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
outLoop:
|
outLoop:
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -263,6 +263,9 @@ outLoop:
|
|||||||
// Handle errors from the backend.
|
// Handle errors from the backend.
|
||||||
case err, okError := <-errChan:
|
case err, okError := <-errChan:
|
||||||
if okError {
|
if okError {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -287,6 +290,13 @@ outLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = fmt.Fprint(c.Writer, errorResponse.Error.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCountTokens handles token counting requests for Gemini models.
|
// handleCountTokens handles token counting requests for Gemini models.
|
||||||
@@ -365,9 +375,9 @@ func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName strin
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -378,6 +388,9 @@ func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName strin
|
|||||||
|
|
||||||
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, alt)
|
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, alt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -405,8 +418,14 @@ func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName strin
|
|||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.Writer.Write(resp)
|
_, _ = c.Writer.Write(resp)
|
||||||
cliCancel(resp)
|
cliCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = c.Writer.Write([]byte(errorResponse.Error.Error()))
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,22 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// APIHandlerCancelFunc is a function type for canceling an API handler's context.
|
||||||
// It can optionally accept parameters, which are used for logging the response.
|
// It can optionally accept parameters, which are used for logging the response.
|
||||||
type APIHandlerCancelFunc func(params ...interface{})
|
type APIHandlerCancelFunc func(params ...interface{})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// List auth files
|
// List auth files
|
||||||
@@ -27,7 +28,16 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if info, errInfo := e.Info(); errInfo == nil {
|
if info, errInfo := e.Info(); errInfo == nil {
|
||||||
files = append(files, gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()})
|
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})
|
c.JSON(200, gin.H{"files": files})
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (h *Handler) GetConfig(c *gin.Context) {
|
||||||
|
c.JSON(200, h.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": 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 }) }
|
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
|
||||||
|
|||||||
@@ -387,9 +387,9 @@ func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -400,6 +400,9 @@ func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []
|
|||||||
|
|
||||||
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, "")
|
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -427,10 +430,16 @@ func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []
|
|||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.Writer.Write(resp)
|
_, _ = c.Writer.Write(resp)
|
||||||
cliCancel(resp)
|
cliCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = c.Writer.Write([]byte(errorResponse.Error.Error()))
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStreamingResponse handles streaming responses for Gemini models.
|
// handleStreamingResponse handles streaming responses for Gemini models.
|
||||||
@@ -471,10 +480,10 @@ func (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byt
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
outLoop:
|
outLoop:
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -511,6 +520,9 @@ outLoop:
|
|||||||
// Handle errors from the backend.
|
// Handle errors from the backend.
|
||||||
case err, okError := <-errChan:
|
case err, okError := <-errChan:
|
||||||
if okError {
|
if okError {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -535,6 +547,13 @@ outLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = fmt.Fprint(c.Writer, errorResponse.Error.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCompletionsNonStreamingResponse handles non-streaming completions responses.
|
// handleCompletionsNonStreamingResponse handles non-streaming completions responses.
|
||||||
@@ -562,9 +581,9 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -576,6 +595,9 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
|
|||||||
// Send the converted chat completions request
|
// Send the converted chat completions request
|
||||||
resp, err := cliClient.SendRawMessage(cliCtx, modelName, chatCompletionsJSON, "")
|
resp, err := cliClient.SendRawMessage(cliCtx, modelName, chatCompletionsJSON, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -597,10 +619,17 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
|
|||||||
// Convert chat completions response back to completions format
|
// Convert chat completions response back to completions format
|
||||||
completionsResp := convertChatCompletionsResponseToCompletions(resp)
|
completionsResp := convertChatCompletionsResponseToCompletions(resp)
|
||||||
_, _ = c.Writer.Write(completionsResp)
|
_, _ = c.Writer.Write(completionsResp)
|
||||||
cliCancel(completionsResp)
|
cliCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = c.Writer.Write([]byte(errorResponse.Error.Error()))
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCompletionsStreamingResponse handles streaming completions responses.
|
// handleCompletionsStreamingResponse handles streaming completions responses.
|
||||||
@@ -644,10 +673,10 @@ func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, ra
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
outLoop:
|
outLoop:
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -689,6 +718,9 @@ outLoop:
|
|||||||
// Handle errors from the backend.
|
// Handle errors from the backend.
|
||||||
case err, okError := <-errChan:
|
case err, okError := <-errChan:
|
||||||
if okError {
|
if okError {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -713,4 +745,11 @@ outLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = fmt.Fprint(c.Writer, errorResponse.Error.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, r
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -128,6 +128,9 @@ func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, r
|
|||||||
|
|
||||||
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, "")
|
resp, err := cliClient.SendRawMessage(cliCtx, modelName, rawJSON, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
|
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -155,10 +158,17 @@ func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, r
|
|||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.Writer.Write(resp)
|
_, _ = c.Writer.Write(resp)
|
||||||
cliCancel(resp)
|
cliCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = c.Writer.Write([]byte(errorResponse.Error.Error()))
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStreamingResponse handles streaming responses for Gemini models.
|
// handleStreamingResponse handles streaming responses for Gemini models.
|
||||||
@@ -199,10 +209,10 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var errorResponse *interfaces.ErrorMessage
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
outLoop:
|
outLoop:
|
||||||
for retryCount <= h.Cfg.RequestRetry {
|
for retryCount <= h.Cfg.RequestRetry {
|
||||||
var errorResponse *interfaces.ErrorMessage
|
|
||||||
cliClient, errorResponse = h.GetClient(modelName)
|
cliClient, errorResponse = h.GetClient(modelName)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
c.Status(errorResponse.StatusCode)
|
c.Status(errorResponse.StatusCode)
|
||||||
@@ -238,6 +248,8 @@ outLoop:
|
|||||||
// Handle errors from the backend.
|
// Handle errors from the backend.
|
||||||
case err, okError := <-errChan:
|
case err, okError := <-errChan:
|
||||||
if okError {
|
if okError {
|
||||||
|
errorResponse = err
|
||||||
|
h.LoggingAPIResponseError(cliCtx, err)
|
||||||
switch err.StatusCode {
|
switch err.StatusCode {
|
||||||
case 429:
|
case 429:
|
||||||
if h.Cfg.QuotaExceeded.SwitchProject {
|
if h.Cfg.QuotaExceeded.SwitchProject {
|
||||||
@@ -262,4 +274,12 @@ outLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errorResponse != nil {
|
||||||
|
c.Status(errorResponse.StatusCode)
|
||||||
|
_, _ = fmt.Fprint(c.Writer, errorResponse.Error.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
cliCancel(errorResponse.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/interfaces"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/logging"
|
"github.com/luispater/CLIProxyAPI/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -240,6 +241,16 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Log complete non-streaming response
|
||||||
return w.logger.LogRequest(
|
return w.logger.LogRequest(
|
||||||
w.requestInfo.URL,
|
w.requestInfo.URL,
|
||||||
@@ -251,6 +262,7 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
|||||||
w.body.Bytes(),
|
w.body.Bytes(),
|
||||||
apiRequestBody,
|
apiRequestBody,
|
||||||
apiResponseBody,
|
apiResponseBody,
|
||||||
|
slicesAPIResponseError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import (
|
|||||||
managementHandlers "github.com/luispater/CLIProxyAPI/internal/api/handlers/management"
|
managementHandlers "github.com/luispater/CLIProxyAPI/internal/api/handlers/management"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers/openai"
|
"github.com/luispater/CLIProxyAPI/internal/api/handlers/openai"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/api/middleware"
|
"github.com/luispater/CLIProxyAPI/internal/api/middleware"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/interfaces"
|
"github.com/luispater/CLIProxyAPI/internal/interfaces"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/logging"
|
"github.com/luispater/CLIProxyAPI/internal/logging"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,6 +151,8 @@ func (s *Server) setupRoutes() {
|
|||||||
mgmt := s.engine.Group("/v0/management")
|
mgmt := s.engine.Group("/v0/management")
|
||||||
mgmt.Use(s.mgmt.Middleware())
|
mgmt.Use(s.mgmt.Middleware())
|
||||||
{
|
{
|
||||||
|
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||||
|
|
||||||
mgmt.GET("/debug", s.mgmt.GetDebug)
|
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||||
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
||||||
@@ -275,7 +279,7 @@ 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)
|
||||||
@@ -292,7 +296,8 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - clients: The new slice of AI service clients
|
// - clients: The new slice of AI service clients
|
||||||
// - cfg: The new application configuration
|
// - cfg: The new application configuration
|
||||||
func (s *Server) UpdateClients(clients []interfaces.Client, cfg *config.Config) {
|
func (s *Server) UpdateClients(clients map[string]interfaces.Client, cfg *config.Config) {
|
||||||
|
clientSlice := s.clientsToSlice(clients)
|
||||||
// Update request logger enabled state if it has changed
|
// Update request logger enabled state if it has changed
|
||||||
if s.requestLogger != nil && s.cfg.RequestLog != cfg.RequestLog {
|
if s.requestLogger != nil && s.cfg.RequestLog != cfg.RequestLog {
|
||||||
s.requestLogger.SetEnabled(cfg.RequestLog)
|
s.requestLogger.SetEnabled(cfg.RequestLog)
|
||||||
@@ -301,20 +306,56 @@ func (s *Server) UpdateClients(clients []interfaces.Client, cfg *config.Config)
|
|||||||
|
|
||||||
// Update log level dynamically when debug flag changes
|
// Update log level dynamically when debug flag changes
|
||||||
if s.cfg.Debug != cfg.Debug {
|
if s.cfg.Debug != cfg.Debug {
|
||||||
if cfg.Debug {
|
util.SetLogLevel(cfg)
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
} else {
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
}
|
|
||||||
log.Debugf("debug mode updated from %t to %t", s.cfg.Debug, cfg.Debug)
|
log.Debugf("debug mode updated from %t to %t", s.cfg.Debug, cfg.Debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.cfg = cfg
|
s.cfg = cfg
|
||||||
s.handlers.UpdateClients(clients, cfg)
|
s.handlers.UpdateClients(clientSlice, cfg)
|
||||||
if s.mgmt != nil {
|
if s.mgmt != nil {
|
||||||
s.mgmt.SetConfig(cfg)
|
s.mgmt.SetConfig(cfg)
|
||||||
}
|
}
|
||||||
log.Infof("server clients and configuration updated: %d clients", len(clients))
|
|
||||||
|
// Count client types for detailed logging
|
||||||
|
authFiles := 0
|
||||||
|
glAPIKeyCount := 0
|
||||||
|
claudeAPIKeyCount := 0
|
||||||
|
codexAPIKeyCount := 0
|
||||||
|
openAICompatCount := 0
|
||||||
|
|
||||||
|
for _, c := range clientSlice {
|
||||||
|
switch cl := c.(type) {
|
||||||
|
case *client.GeminiCLIClient:
|
||||||
|
authFiles++
|
||||||
|
case *client.CodexClient:
|
||||||
|
if cl.GetAPIKey() == "" {
|
||||||
|
authFiles++
|
||||||
|
} else {
|
||||||
|
codexAPIKeyCount++
|
||||||
|
}
|
||||||
|
case *client.ClaudeClient:
|
||||||
|
if cl.GetAPIKey() == "" {
|
||||||
|
authFiles++
|
||||||
|
} else {
|
||||||
|
claudeAPIKeyCount++
|
||||||
|
}
|
||||||
|
case *client.QwenClient:
|
||||||
|
authFiles++
|
||||||
|
case *client.GeminiClient:
|
||||||
|
glAPIKeyCount++
|
||||||
|
case *client.OpenAICompatibilityClient:
|
||||||
|
openAICompatCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||||
|
len(clientSlice),
|
||||||
|
authFiles,
|
||||||
|
glAPIKeyCount,
|
||||||
|
claudeAPIKeyCount,
|
||||||
|
codexAPIKeyCount,
|
||||||
|
openAICompatCount,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// (management handlers moved to internal/api/handlers/management)
|
// (management handlers moved to internal/api/handlers/management)
|
||||||
@@ -384,3 +425,11 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client {
|
||||||
|
slice := make([]interfaces.Client, 0, len(clientMap))
|
||||||
|
for _, v := range clientMap {
|
||||||
|
slice = append(slice, v)
|
||||||
|
}
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
|
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
|
||||||
@@ -49,7 +49,7 @@ func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
|
|||||||
ts.Type = "claude"
|
ts.Type = "claude"
|
||||||
|
|
||||||
// Create directory structure if it doesn't exist
|
// Create directory structure if it doesn't exist
|
||||||
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %v", err)
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
||||||
@@ -43,7 +43,7 @@ type CodexTokenStorage struct {
|
|||||||
// - error: An error if the operation fails, nil otherwise
|
// - error: An error if the operation fails, nil otherwise
|
||||||
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
ts.Type = "codex"
|
ts.Type = "codex"
|
||||||
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %v", err)
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
@@ -250,11 +251,13 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
|||||||
// Check if browser is available
|
// Check if browser is available
|
||||||
if !browser.IsAvailable() {
|
if !browser.IsAvailable() {
|
||||||
log.Warn("No browser available on this system")
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
} else {
|
} else {
|
||||||
if err := browser.OpenURL(authURL); err != nil {
|
if err := browser.OpenURL(authURL); err != nil {
|
||||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
// Log platform info for debugging
|
// Log platform info for debugging
|
||||||
@@ -265,6 +268,7 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -46,7 +46,7 @@ type GeminiTokenStorage struct {
|
|||||||
// - error: An error if the operation fails, nil otherwise
|
// - error: An error if the operation fails, nil otherwise
|
||||||
func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
ts.Type = "gemini"
|
ts.Type = "gemini"
|
||||||
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %v", err)
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
|
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
|
||||||
@@ -41,7 +41,7 @@ type QwenTokenStorage struct {
|
|||||||
// - error: An error if the operation fails, nil otherwise
|
// - error: An error if the operation fails, nil otherwise
|
||||||
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
|
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
ts.Type = "qwen"
|
ts.Type = "qwen"
|
||||||
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %v", err)
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - An error if the URL cannot be opened, otherwise nil.
|
// - An error if the URL cannot be opened, otherwise nil.
|
||||||
func OpenURL(url string) error {
|
func OpenURL(url string) error {
|
||||||
log.Debugf("Attempting to open URL in browser: %s", url)
|
log.Infof("Attempting to open URL in browser: %s", url)
|
||||||
|
|
||||||
// Try using the open-golang library first
|
// Try using the open-golang library first
|
||||||
err := open.Run(url)
|
err := open.Run(url)
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
previewModels = map[string][]string{
|
previewModels = map[string][]string{
|
||||||
"gemini-2.5-pro": {"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"},
|
"gemini-2.5-pro": {"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"},
|
||||||
"gemini-2.5-flash": {"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"},
|
"gemini-2.5-flash": {"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"},
|
||||||
|
"gemini-2.5-flash-lite": {"gemini-2.5-flash-lite-preview-06-17"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ func (c *GeminiCLIClient) CanProvideModel(modelName string) bool {
|
|||||||
models := []string{
|
models := []string{
|
||||||
"gemini-2.5-pro",
|
"gemini-2.5-pro",
|
||||||
"gemini-2.5-flash",
|
"gemini-2.5-flash",
|
||||||
|
"gemini-2.5-flash-lite",
|
||||||
}
|
}
|
||||||
return util.InArray(models, modelName)
|
return util.InArray(models, modelName)
|
||||||
}
|
}
|
||||||
@@ -415,6 +417,7 @@ func (c *GeminiCLIClient) SendRawTokenCount(ctx context.Context, modelName strin
|
|||||||
if newModelName != "" {
|
if newModelName != "" {
|
||||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", modelName, newModelName)
|
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", modelName, newModelName)
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", newModelName)
|
rawJSON, _ = sjson.SetBytes(rawJSON, "model", newModelName)
|
||||||
|
modelName = newModelName
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type OpenAICompatibilityClient struct {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - *OpenAICompatibilityClient: A new OpenAI compatibility client instance.
|
// - *OpenAICompatibilityClient: A new OpenAI compatibility client instance.
|
||||||
// - error: An error if the client creation fails.
|
// - error: An error if the client creation fails.
|
||||||
func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenAICompatibility) (*OpenAICompatibilityClient, error) {
|
func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenAICompatibility, apiKeyIndex int) (*OpenAICompatibilityClient, error) {
|
||||||
if compatConfig == nil {
|
if compatConfig == nil {
|
||||||
return nil, fmt.Errorf("compatibility configuration is required")
|
return nil, fmt.Errorf("compatibility configuration is required")
|
||||||
}
|
}
|
||||||
@@ -53,10 +53,14 @@ func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenA
|
|||||||
return nil, fmt.Errorf("at least one API key is required for OpenAI compatibility provider: %s", compatConfig.Name)
|
return nil, fmt.Errorf("at least one API key is required for OpenAI compatibility provider: %s", compatConfig.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(compatConfig.APIKeys) <= apiKeyIndex {
|
||||||
|
return nil, fmt.Errorf("invalid API key index for OpenAI compatibility provider: %s", compatConfig.Name)
|
||||||
|
}
|
||||||
|
|
||||||
httpClient := util.SetProxy(cfg, &http.Client{})
|
httpClient := util.SetProxy(cfg, &http.Client{})
|
||||||
|
|
||||||
// Generate unique client ID
|
// Generate unique client ID
|
||||||
clientID := fmt.Sprintf("openai-compatibility-%s-%d", compatConfig.Name, time.Now().UnixNano())
|
clientID := fmt.Sprintf("openai-compatibility-%s-%d-%d", compatConfig.Name, apiKeyIndex, time.Now().UnixNano())
|
||||||
|
|
||||||
client := &OpenAICompatibilityClient{
|
client := &OpenAICompatibilityClient{
|
||||||
ClientBase: ClientBase{
|
ClientBase: ClientBase{
|
||||||
@@ -66,7 +70,7 @@ func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenA
|
|||||||
modelQuotaExceeded: make(map[string]*time.Time),
|
modelQuotaExceeded: make(map[string]*time.Time),
|
||||||
},
|
},
|
||||||
compatConfig: compatConfig,
|
compatConfig: compatConfig,
|
||||||
currentAPIKeyIndex: 0,
|
currentAPIKeyIndex: apiKeyIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize model registry
|
// Initialize model registry
|
||||||
@@ -134,8 +138,6 @@ func (c *OpenAICompatibilityClient) GetCurrentAPIKey() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := c.compatConfig.APIKeys[c.currentAPIKeyIndex]
|
key := c.compatConfig.APIKeys[c.currentAPIKeyIndex]
|
||||||
// Rotate to next key for load balancing
|
|
||||||
c.currentAPIKeyIndex = (c.currentAPIKeyIndex + 1) % len(c.compatConfig.APIKeys)
|
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,11 +87,13 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
// Check if browser is available
|
// Check if browser is available
|
||||||
if !browser.IsAvailable() {
|
if !browser.IsAvailable() {
|
||||||
log.Warn("No browser available on this system")
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(54545)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
} else {
|
} else {
|
||||||
if err = browser.OpenURL(authURL); err != nil {
|
if err = browser.OpenURL(authURL); err != nil {
|
||||||
authErr := claude.NewAuthenticationError(claude.ErrBrowserOpenFailed, err)
|
authErr := claude.NewAuthenticationError(claude.ErrBrowserOpenFailed, err)
|
||||||
log.Warn(claude.GetUserFriendlyMessage(authErr))
|
log.Warn(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(54545)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
// Log platform info for debugging
|
// Log platform info for debugging
|
||||||
@@ -101,6 +104,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(54545)
|
||||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,11 +95,13 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
// Check if browser is available
|
// Check if browser is available
|
||||||
if !browser.IsAvailable() {
|
if !browser.IsAvailable() {
|
||||||
log.Warn("No browser available on this system")
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(1455)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
} else {
|
} else {
|
||||||
if err = browser.OpenURL(authURL); err != nil {
|
if err = browser.OpenURL(authURL); err != nil {
|
||||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(1455)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
// Log platform info for debugging
|
// Log platform info for debugging
|
||||||
@@ -109,6 +112,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(1455)
|
||||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ import (
|
|||||||
// - configPath: The path to the configuration file for watching changes
|
// - configPath: The path to the configuration file for watching changes
|
||||||
func StartService(cfg *config.Config, configPath string) {
|
func StartService(cfg *config.Config, configPath string) {
|
||||||
// Create a pool of API clients, one for each token file found.
|
// Create a pool of API clients, one for each token file found.
|
||||||
cliClients := make([]interfaces.Client, 0)
|
cliClients := make(map[string]interfaces.Client)
|
||||||
|
successfulAuthCount := 0
|
||||||
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
|
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -88,7 +89,8 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
|
|
||||||
// Add the new client to the pool.
|
// Add the new client to the pool.
|
||||||
cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg)
|
cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg)
|
||||||
cliClients = append(cliClients, cliClient)
|
cliClients[path] = cliClient
|
||||||
|
successfulAuthCount++
|
||||||
}
|
}
|
||||||
} else if tokenType == "codex" {
|
} else if tokenType == "codex" {
|
||||||
var ts codex.CodexTokenStorage
|
var ts codex.CodexTokenStorage
|
||||||
@@ -102,7 +104,8 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
return errGetClient
|
return errGetClient
|
||||||
}
|
}
|
||||||
log.Info("Authentication successful.")
|
log.Info("Authentication successful.")
|
||||||
cliClients = append(cliClients, codexClient)
|
cliClients[path] = codexClient
|
||||||
|
successfulAuthCount++
|
||||||
}
|
}
|
||||||
} else if tokenType == "claude" {
|
} else if tokenType == "claude" {
|
||||||
var ts claude.ClaudeTokenStorage
|
var ts claude.ClaudeTokenStorage
|
||||||
@@ -111,7 +114,8 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
log.Info("Initializing claude authentication for token...")
|
log.Info("Initializing claude authentication for token...")
|
||||||
claudeClient := client.NewClaudeClient(cfg, &ts)
|
claudeClient := client.NewClaudeClient(cfg, &ts)
|
||||||
log.Info("Authentication successful.")
|
log.Info("Authentication successful.")
|
||||||
cliClients = append(cliClients, claudeClient)
|
cliClients[path] = claudeClient
|
||||||
|
successfulAuthCount++
|
||||||
}
|
}
|
||||||
} else if tokenType == "qwen" {
|
} else if tokenType == "qwen" {
|
||||||
var ts qwen.QwenTokenStorage
|
var ts qwen.QwenTokenStorage
|
||||||
@@ -120,7 +124,8 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
log.Info("Initializing qwen authentication for token...")
|
log.Info("Initializing qwen authentication for token...")
|
||||||
qwenClient := client.NewQwenClient(cfg, &ts)
|
qwenClient := client.NewQwenClient(cfg, &ts)
|
||||||
log.Info("Authentication successful.")
|
log.Info("Authentication successful.")
|
||||||
cliClients = append(cliClients, qwenClient)
|
cliClients[path] = qwenClient
|
||||||
|
successfulAuthCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,49 +135,24 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
log.Fatalf("Error walking auth directory: %v", err)
|
log.Fatalf("Error walking auth directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.GlAPIKey) > 0 {
|
apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := buildAPIKeyClients(cfg)
|
||||||
// Initialize clients with Generative Language API Keys if provided in configuration.
|
|
||||||
for i := 0; i < len(cfg.GlAPIKey); i++ {
|
|
||||||
httpClient := util.SetProxy(cfg, &http.Client{})
|
|
||||||
|
|
||||||
log.Debug("Initializing with Generative Language API Key...")
|
totalNewClients := len(cliClients) + len(apiKeyClients)
|
||||||
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
|
log.Infof("full client load complete - %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||||
cliClients = append(cliClients, cliClient)
|
totalNewClients,
|
||||||
}
|
successfulAuthCount,
|
||||||
}
|
glAPIKeyCount,
|
||||||
|
claudeAPIKeyCount,
|
||||||
|
codexAPIKeyCount,
|
||||||
|
openAICompatCount,
|
||||||
|
)
|
||||||
|
|
||||||
if len(cfg.ClaudeKey) > 0 {
|
// Combine file-based and API key-based clients for the initial server setup
|
||||||
// Initialize clients with Claude API Keys if provided in configuration.
|
allClients := clientsToSlice(cliClients)
|
||||||
for i := 0; i < len(cfg.ClaudeKey); i++ {
|
allClients = append(allClients, clientsToSlice(apiKeyClients)...)
|
||||||
log.Debug("Initializing with Claude API Key...")
|
|
||||||
cliClient := client.NewClaudeClientWithKey(cfg, i)
|
|
||||||
cliClients = append(cliClients, cliClient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.CodexKey) > 0 {
|
|
||||||
// Initialize clients with Codex API Keys if provided in configuration.
|
|
||||||
for i := 0; i < len(cfg.CodexKey); i++ {
|
|
||||||
log.Debug("Initializing with Codex API Key...")
|
|
||||||
cliClient := client.NewCodexClientWithKey(cfg, i)
|
|
||||||
cliClients = append(cliClients, cliClient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.OpenAICompatibility) > 0 {
|
|
||||||
// Initialize clients for OpenAI compatibility configurations
|
|
||||||
for _, compatConfig := range cfg.OpenAICompatibility {
|
|
||||||
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
|
|
||||||
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig)
|
|
||||||
if errClient != nil {
|
|
||||||
log.Fatalf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
|
|
||||||
}
|
|
||||||
cliClients = append(cliClients, compatClient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and start the API server with the pool of clients in a separate goroutine.
|
// Create and start the API server with the pool of clients in a separate goroutine.
|
||||||
apiServer := api.NewServer(cfg, cliClients, configPath)
|
apiServer := api.NewServer(cfg, allClients, configPath)
|
||||||
log.Infof("Starting API server on port %d", cfg.Port)
|
log.Infof("Starting API server on port %d", cfg.Port)
|
||||||
|
|
||||||
// Start the API server in a goroutine so it doesn't block the main thread.
|
// Start the API server in a goroutine so it doesn't block the main thread.
|
||||||
@@ -187,7 +167,7 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
log.Info("API server started successfully")
|
log.Info("API server started successfully")
|
||||||
|
|
||||||
// Setup file watcher for config and auth directory changes to enable hot-reloading.
|
// Setup file watcher for config and auth directory changes to enable hot-reloading.
|
||||||
fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients []interfaces.Client, newCfg *config.Config) {
|
fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients map[string]interfaces.Client, newCfg *config.Config) {
|
||||||
// Update the API server with new clients and configuration when files change.
|
// Update the API server with new clients and configuration when files change.
|
||||||
apiServer.UpdateClients(newClients, newCfg)
|
apiServer.UpdateClients(newClients, newCfg)
|
||||||
})
|
})
|
||||||
@@ -198,6 +178,7 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
// Set initial state for the watcher with current configuration and clients.
|
// Set initial state for the watcher with current configuration and clients.
|
||||||
fileWatcher.SetConfig(cfg)
|
fileWatcher.SetConfig(cfg)
|
||||||
fileWatcher.SetClients(cliClients)
|
fileWatcher.SetClients(cliClients)
|
||||||
|
fileWatcher.SetAPIKeyClients(apiKeyClients)
|
||||||
|
|
||||||
// Start the file watcher in a separate context.
|
// Start the file watcher in a separate context.
|
||||||
watcherCtx, watcherCancel := context.WithCancel(context.Background())
|
watcherCtx, watcherCancel := context.WithCancel(context.Background())
|
||||||
@@ -230,8 +211,9 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
|
|
||||||
// Function to check and refresh tokens for all client types before they expire.
|
// Function to check and refresh tokens for all client types before they expire.
|
||||||
checkAndRefresh := func() {
|
checkAndRefresh := func() {
|
||||||
for i := 0; i < len(cliClients); i++ {
|
clientSlice := clientsToSlice(cliClients)
|
||||||
if codexCli, ok := cliClients[i].(*client.CodexClient); ok {
|
for i := 0; i < len(clientSlice); i++ {
|
||||||
|
if codexCli, ok := clientSlice[i].(*client.CodexClient); ok {
|
||||||
if ts, isCodexTS := codexCli.TokenStorage().(*claude.ClaudeTokenStorage); isCodexTS {
|
if ts, isCodexTS := codexCli.TokenStorage().(*claude.ClaudeTokenStorage); isCodexTS {
|
||||||
if ts != nil && ts.Expire != "" {
|
if ts != nil && ts.Expire != "" {
|
||||||
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
||||||
@@ -242,7 +224,7 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if claudeCli, isOK := cliClients[i].(*client.ClaudeClient); isOK {
|
} else if claudeCli, isOK := clientSlice[i].(*client.ClaudeClient); isOK {
|
||||||
if ts, isCluadeTS := claudeCli.TokenStorage().(*claude.ClaudeTokenStorage); isCluadeTS {
|
if ts, isCluadeTS := claudeCli.TokenStorage().(*claude.ClaudeTokenStorage); isCluadeTS {
|
||||||
if ts != nil && ts.Expire != "" {
|
if ts != nil && ts.Expire != "" {
|
||||||
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
||||||
@@ -253,7 +235,7 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if qwenCli, isQwenOK := cliClients[i].(*client.QwenClient); isQwenOK {
|
} else if qwenCli, isQwenOK := clientSlice[i].(*client.QwenClient); isQwenOK {
|
||||||
if ts, isQwenTS := qwenCli.TokenStorage().(*qwen.QwenTokenStorage); isQwenTS {
|
if ts, isQwenTS := qwenCli.TokenStorage().(*qwen.QwenTokenStorage); isQwenTS {
|
||||||
if ts != nil && ts.Expire != "" {
|
if ts != nil && ts.Expire != "" {
|
||||||
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
||||||
@@ -306,3 +288,65 @@ func StartService(cfg *config.Config, configPath string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client {
|
||||||
|
s := make([]interfaces.Client, 0, len(clientMap))
|
||||||
|
for _, v := range clientMap {
|
||||||
|
s = append(s, v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAPIKeyClients creates clients from API keys in the config
|
||||||
|
func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
|
||||||
|
apiKeyClients := make(map[string]interfaces.Client)
|
||||||
|
glAPIKeyCount := 0
|
||||||
|
claudeAPIKeyCount := 0
|
||||||
|
codexAPIKeyCount := 0
|
||||||
|
openAICompatCount := 0
|
||||||
|
|
||||||
|
if len(cfg.GlAPIKey) > 0 {
|
||||||
|
for _, key := range cfg.GlAPIKey {
|
||||||
|
httpClient := util.SetProxy(cfg, &http.Client{})
|
||||||
|
log.Debug("Initializing with Generative Language API Key...")
|
||||||
|
cliClient := client.NewGeminiClient(httpClient, cfg, key)
|
||||||
|
apiKeyClients[cliClient.GetClientID()] = cliClient
|
||||||
|
glAPIKeyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.ClaudeKey) > 0 {
|
||||||
|
for i := range cfg.ClaudeKey {
|
||||||
|
log.Debug("Initializing with Claude API Key...")
|
||||||
|
cliClient := client.NewClaudeClientWithKey(cfg, i)
|
||||||
|
apiKeyClients[cliClient.GetClientID()] = cliClient
|
||||||
|
claudeAPIKeyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.CodexKey) > 0 {
|
||||||
|
for i := range cfg.CodexKey {
|
||||||
|
log.Debug("Initializing with Codex API Key...")
|
||||||
|
cliClient := client.NewCodexClientWithKey(cfg, i)
|
||||||
|
apiKeyClients[cliClient.GetClientID()] = cliClient
|
||||||
|
codexAPIKeyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.OpenAICompatibility) > 0 {
|
||||||
|
for _, compatConfig := range cfg.OpenAICompatibility {
|
||||||
|
for i := 0; i < len(compatConfig.APIKeys); i++ {
|
||||||
|
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
|
||||||
|
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
|
||||||
|
if errClient != nil {
|
||||||
|
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiKeyClients[compatClient.GetClientID()] = compatClient
|
||||||
|
openAICompatCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,46 +15,46 @@ import (
|
|||||||
// Config represents the application's configuration, loaded from a YAML file.
|
// Config represents the application's configuration, loaded from a YAML file.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Port is the network port on which the API server will listen.
|
// Port is the network port on which the API server will listen.
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port" json:"-"`
|
||||||
|
|
||||||
// AuthDir is the directory where authentication token files are stored.
|
// AuthDir is the directory where authentication token files are stored.
|
||||||
AuthDir string `yaml:"auth-dir"`
|
AuthDir string `yaml:"auth-dir" json:"-"`
|
||||||
|
|
||||||
// Debug enables or disables debug-level logging and other debug features.
|
// Debug enables or disables debug-level logging and other debug features.
|
||||||
Debug bool `yaml:"debug"`
|
Debug bool `yaml:"debug" json:"debug"`
|
||||||
|
|
||||||
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
||||||
ProxyURL string `yaml:"proxy-url"`
|
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||||
|
|
||||||
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||||
APIKeys []string `yaml:"api-keys"`
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||||
|
|
||||||
// QuotaExceeded defines the behavior when a quota is exceeded.
|
// QuotaExceeded defines the behavior when a quota is exceeded.
|
||||||
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded"`
|
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
||||||
|
|
||||||
// GlAPIKey is the API key for the generative language API.
|
// GlAPIKey is the API key for the generative language API.
|
||||||
GlAPIKey []string `yaml:"generative-language-api-key"`
|
GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"`
|
||||||
|
|
||||||
// RequestLog enables or disables detailed request logging functionality.
|
// RequestLog enables or disables detailed request logging functionality.
|
||||||
RequestLog bool `yaml:"request-log"`
|
RequestLog bool `yaml:"request-log" json:"request-log"`
|
||||||
|
|
||||||
// RequestRetry defines the retry times when the request failed.
|
// RequestRetry defines the retry times when the request failed.
|
||||||
RequestRetry int `yaml:"request-retry"`
|
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 defines a list of Claude API key configurations as specified in the YAML configuration file.
|
||||||
ClaudeKey []ClaudeKey `yaml:"claude-api-key"`
|
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.
|
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
||||||
CodexKey []CodexKey `yaml:"codex-api-key"`
|
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
||||||
|
|
||||||
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
|
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
|
||||||
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility"`
|
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"`
|
||||||
|
|
||||||
// AllowLocalhostUnauthenticated allows unauthenticated requests from localhost.
|
// AllowLocalhostUnauthenticated allows unauthenticated requests from localhost.
|
||||||
AllowLocalhostUnauthenticated bool `yaml:"allow-localhost-unauthenticated"`
|
AllowLocalhostUnauthenticated bool `yaml:"allow-localhost-unauthenticated" json:"allow-localhost-unauthenticated"`
|
||||||
|
|
||||||
// RemoteManagement nests management-related options under 'remote-management'.
|
// RemoteManagement nests management-related options under 'remote-management'.
|
||||||
RemoteManagement RemoteManagement `yaml:"remote-management"`
|
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoteManagement holds management API configuration under 'remote-management'.
|
// RemoteManagement holds management API configuration under 'remote-management'.
|
||||||
@@ -69,58 +69,58 @@ type RemoteManagement struct {
|
|||||||
// It provides configuration options for automatic failover mechanisms.
|
// It provides configuration options for automatic failover mechanisms.
|
||||||
type QuotaExceeded struct {
|
type QuotaExceeded struct {
|
||||||
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
|
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
|
||||||
SwitchProject bool `yaml:"switch-project"`
|
SwitchProject bool `yaml:"switch-project" json:"switch-project"`
|
||||||
|
|
||||||
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
|
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
|
||||||
SwitchPreviewModel bool `yaml:"switch-preview-model"`
|
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaudeKey represents the configuration for a Claude API key,
|
// ClaudeKey represents the configuration for a Claude API key,
|
||||||
// including the API key itself and an optional base URL for the API endpoint.
|
// including the API key itself and an optional base URL for the API endpoint.
|
||||||
type ClaudeKey struct {
|
type ClaudeKey struct {
|
||||||
// APIKey is the authentication key for accessing Claude API services.
|
// APIKey is the authentication key for accessing Claude API services.
|
||||||
APIKey string `yaml:"api-key"`
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
// BaseURL is the base URL for the Claude API endpoint.
|
// BaseURL is the base URL for the Claude API endpoint.
|
||||||
// If empty, the default Claude API URL will be used.
|
// If empty, the default Claude API URL will be used.
|
||||||
BaseURL string `yaml:"base-url"`
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CodexKey represents the configuration for a Codex API key,
|
// CodexKey represents the configuration for a Codex API key,
|
||||||
// including the API key itself and an optional base URL for the API endpoint.
|
// including the API key itself and an optional base URL for the API endpoint.
|
||||||
type CodexKey struct {
|
type CodexKey struct {
|
||||||
// APIKey is the authentication key for accessing Codex API services.
|
// APIKey is the authentication key for accessing Codex API services.
|
||||||
APIKey string `yaml:"api-key"`
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
// BaseURL is the base URL for the Codex API endpoint.
|
// BaseURL is the base URL for the Codex API endpoint.
|
||||||
// If empty, the default Codex API URL will be used.
|
// If empty, the default Codex API URL will be used.
|
||||||
BaseURL string `yaml:"base-url"`
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
||||||
// with external providers, allowing model aliases to be routed through OpenAI API format.
|
// with external providers, allowing model aliases to be routed through OpenAI API format.
|
||||||
type OpenAICompatibility struct {
|
type OpenAICompatibility struct {
|
||||||
// Name is the identifier for this OpenAI compatibility configuration.
|
// Name is the identifier for this OpenAI compatibility configuration.
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
||||||
BaseURL string `yaml:"base-url"`
|
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||||
|
|
||||||
// APIKeys are the authentication keys for accessing the external API services.
|
// APIKeys are the authentication keys for accessing the external API services.
|
||||||
APIKeys []string `yaml:"api-keys"`
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||||
|
|
||||||
// Models defines the model configurations including aliases for routing.
|
// Models defines the model configurations including aliases for routing.
|
||||||
Models []OpenAICompatibilityModel `yaml:"models"`
|
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
|
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
|
||||||
// including the actual model name and its alias for API routing.
|
// including the actual model name and its alias for API routing.
|
||||||
type OpenAICompatibilityModel struct {
|
type OpenAICompatibilityModel struct {
|
||||||
// Name is the actual model name used by the external provider.
|
// Name is the actual model name used by the external provider.
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
// Alias is the model name alias that clients will use to reference this model.
|
// Alias is the model name alias that clients will use to reference this model.
|
||||||
Alias string `yaml:"alias"`
|
Alias string `yaml:"alias" json:"alias"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig reads a YAML configuration file from the given path,
|
// LoadConfig reads a YAML configuration file from the given path,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequestLogger defines the interface for logging HTTP requests and responses.
|
// RequestLogger defines the interface for logging HTTP requests and responses.
|
||||||
@@ -34,7 +36,7 @@ type RequestLogger interface {
|
|||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - error: An error if logging fails, nil otherwise
|
// - 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) error
|
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.
|
// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks.
|
||||||
//
|
//
|
||||||
@@ -139,7 +141,7 @@ func (l *FileRequestLogger) SetEnabled(enabled bool) {
|
|||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - error: An error if logging fails, nil otherwise
|
// - 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) error {
|
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 {
|
if !l.enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -161,7 +163,7 @@ func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create log content
|
// Create log content
|
||||||
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders)
|
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders, apiResponseErrors)
|
||||||
|
|
||||||
// Write to file
|
// Write to file
|
||||||
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||||
@@ -310,7 +312,7 @@ func (l *FileRequestLogger) sanitizeForFilename(path string) string {
|
|||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - string: The formatted log content
|
// - 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) string {
|
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
|
var content strings.Builder
|
||||||
|
|
||||||
// Request info
|
// Request info
|
||||||
@@ -320,6 +322,13 @@ func (l *FileRequestLogger) formatLogContent(url, method string, headers map[str
|
|||||||
content.Write(apiRequest)
|
content.Write(apiRequest)
|
||||||
content.WriteString("\n\n")
|
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.WriteString("=== API RESPONSE ===\n")
|
||||||
content.Write(apiResponse)
|
content.Write(apiResponse)
|
||||||
content.WriteString("\n\n")
|
content.WriteString("\n\n")
|
||||||
|
|||||||
@@ -130,6 +130,20 @@ func GetGeminiCLIModels() []*ModelInfo {
|
|||||||
OutputTokenLimit: 65536,
|
OutputTokenLimit: 65536,
|
||||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "gemini-2.5-flash-lite",
|
||||||
|
Object: "model",
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
OwnedBy: "google",
|
||||||
|
Type: "gemini",
|
||||||
|
Name: "models/gemini-2.5-flash-lite",
|
||||||
|
Version: "2.5",
|
||||||
|
DisplayName: "Gemini 2.5 Flash Lite",
|
||||||
|
Description: "Our smallest and most cost effective model, built for at scale usage.",
|
||||||
|
InputTokenLimit: 1048576,
|
||||||
|
OutputTokenLimit: 65536,
|
||||||
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
out, _ = sjson.Set(out, "stop_sequences", stopSequences)
|
out, _ = sjson.Set(out, "stop_sequences", stopSequences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include thoughts configuration for reasoning process visibility
|
||||||
|
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
|
if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() {
|
||||||
|
if includeThoughts.Type == gjson.True {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// System instruction conversion to Claude Code format
|
// System instruction conversion to Claude Code format
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
|
|||||||
}
|
}
|
||||||
case "thinking_delta":
|
case "thinking_delta":
|
||||||
// Thinking/reasoning content delta for models with reasoning capabilities
|
// Thinking/reasoning content delta for models with reasoning capabilities
|
||||||
if text := delta.Get("text"); text.Exists() && text.String() != "" {
|
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
|
||||||
thinkingPart := `{"thought":true,"text":""}`
|
thinkingPart := `{"thought":true,"text":""}`
|
||||||
thinkingPart, _ = sjson.Set(thinkingPart, "text", text.String())
|
thinkingPart, _ = sjson.Set(thinkingPart, "text", text.String())
|
||||||
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", thinkingPart)
|
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", thinkingPart)
|
||||||
@@ -411,7 +411,7 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
|||||||
}
|
}
|
||||||
case "thinking_delta":
|
case "thinking_delta":
|
||||||
// Process reasoning/thinking content
|
// Process reasoning/thinking content
|
||||||
if text := delta.Get("text"); text.Exists() && text.String() != "" {
|
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
|
||||||
partJSON := `{"thought":true,"text":""}`
|
partJSON := `{"thought":true,"text":""}`
|
||||||
partJSON, _ = sjson.Set(partJSON, "text", text.String())
|
partJSON, _ = sjson.Set(partJSON, "text", text.String())
|
||||||
part := gjson.Parse(partJSON).Value().(map[string]interface{})
|
part := gjson.Parse(partJSON).Value().(map[string]interface{})
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
|
|
||||||
root := gjson.ParseBytes(rawJSON)
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
|
if v := root.Get("reasoning_effort"); v.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
|
||||||
|
switch v.String() {
|
||||||
|
case "none":
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||||
|
case "low":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 1024)
|
||||||
|
case "medium":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 8192)
|
||||||
|
case "high":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 24576)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper for generating tool call IDs in the form: toolu_<alphanum>
|
// Helper for generating tool call IDs in the form: toolu_<alphanum>
|
||||||
// This ensures unique identifiers for tool calls in the Claude Code format
|
// This ensures unique identifiers for tool calls in the Claude Code format
|
||||||
genToolCallID := func() string {
|
genToolCallID := func() string {
|
||||||
|
|||||||
@@ -128,10 +128,11 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
|
|||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []string{template}
|
return []string{}
|
||||||
|
|
||||||
case "content_block_delta":
|
case "content_block_delta":
|
||||||
// Handle content delta (text, tool use arguments, or reasoning content)
|
// Handle content delta (text, tool use arguments, or reasoning content)
|
||||||
|
hasContent := false
|
||||||
if delta := root.Get("delta"); delta.Exists() {
|
if delta := root.Get("delta"); delta.Exists() {
|
||||||
deltaType := delta.Get("type").String()
|
deltaType := delta.Get("type").String()
|
||||||
|
|
||||||
@@ -140,8 +141,14 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
|
|||||||
// Text content delta - send incremental text updates
|
// Text content delta - send incremental text updates
|
||||||
if text := delta.Get("text"); text.Exists() {
|
if text := delta.Get("text"); text.Exists() {
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.content", text.String())
|
template, _ = sjson.Set(template, "choices.0.delta.content", text.String())
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
case "thinking_delta":
|
||||||
|
// Accumulate reasoning/thinking content
|
||||||
|
if thinking := delta.Get("thinking"); thinking.Exists() {
|
||||||
|
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", thinking.String())
|
||||||
|
hasContent = true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "input_json_delta":
|
case "input_json_delta":
|
||||||
// Tool use input delta - accumulate arguments for tool calls
|
// Tool use input delta - accumulate arguments for tool calls
|
||||||
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
|
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
|
||||||
@@ -156,7 +163,11 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
|
|||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []string{template}
|
if hasContent {
|
||||||
|
return []string{template}
|
||||||
|
} else {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
case "content_block_stop":
|
case "content_block_stop":
|
||||||
// End of content block - output complete tool call if it's a tool_use block
|
// End of content block - output complete tool call if it's a tool_use block
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
|||||||
|
|
||||||
root := gjson.ParseBytes(rawJSON)
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
|
if v := root.Get("reasoning.effort"); v.Exists() {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
|
||||||
|
switch v.String() {
|
||||||
|
case "none":
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||||
|
case "minimal":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 1024)
|
||||||
|
case "low":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 4096)
|
||||||
|
case "medium":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 8192)
|
||||||
|
case "high":
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", 24576)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper for generating tool call IDs when missing
|
// Helper for generating tool call IDs when missing
|
||||||
genToolCallID := func() string {
|
genToolCallID := func() string {
|
||||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ package claude
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/misc"
|
"github.com/luispater/CLIProxyAPI/internal/misc"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -94,7 +96,17 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
// Handle tool use content by creating function call message.
|
// Handle tool use content by creating function call message.
|
||||||
functionCallMessage := `{"type":"function_call"}`
|
functionCallMessage := `{"type":"function_call"}`
|
||||||
functionCallMessage, _ = sjson.Set(functionCallMessage, "call_id", messageContentResult.Get("id").String())
|
functionCallMessage, _ = sjson.Set(functionCallMessage, "call_id", messageContentResult.Get("id").String())
|
||||||
functionCallMessage, _ = sjson.Set(functionCallMessage, "name", messageContentResult.Get("name").String())
|
{
|
||||||
|
// Shorten tool name if needed based on declared tools
|
||||||
|
name := messageContentResult.Get("name").String()
|
||||||
|
toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
|
||||||
|
if short, ok := toolMap[name]; ok {
|
||||||
|
name = short
|
||||||
|
} else {
|
||||||
|
name = shortenNameIfNeeded(name)
|
||||||
|
}
|
||||||
|
functionCallMessage, _ = sjson.Set(functionCallMessage, "name", name)
|
||||||
|
}
|
||||||
functionCallMessage, _ = sjson.Set(functionCallMessage, "arguments", messageContentResult.Get("input").Raw)
|
functionCallMessage, _ = sjson.Set(functionCallMessage, "arguments", messageContentResult.Get("input").Raw)
|
||||||
template, _ = sjson.SetRaw(template, "input.-1", functionCallMessage)
|
template, _ = sjson.SetRaw(template, "input.-1", functionCallMessage)
|
||||||
} else if contentType == "tool_result" {
|
} else if contentType == "tool_result" {
|
||||||
@@ -130,10 +142,29 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
template, _ = sjson.SetRaw(template, "tools", `[]`)
|
template, _ = sjson.SetRaw(template, "tools", `[]`)
|
||||||
template, _ = sjson.Set(template, "tool_choice", `auto`)
|
template, _ = sjson.Set(template, "tool_choice", `auto`)
|
||||||
toolResults := toolsResult.Array()
|
toolResults := toolsResult.Array()
|
||||||
|
// Build short name map from declared tools
|
||||||
|
var names []string
|
||||||
|
for i := 0; i < len(toolResults); i++ {
|
||||||
|
n := toolResults[i].Get("name").String()
|
||||||
|
if n != "" {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortMap := buildShortNameMap(names)
|
||||||
for i := 0; i < len(toolResults); i++ {
|
for i := 0; i < len(toolResults); i++ {
|
||||||
toolResult := toolResults[i]
|
toolResult := toolResults[i]
|
||||||
tool := toolResult.Raw
|
tool := toolResult.Raw
|
||||||
tool, _ = sjson.Set(tool, "type", "function")
|
tool, _ = sjson.Set(tool, "type", "function")
|
||||||
|
// Apply shortened name if needed
|
||||||
|
if v := toolResult.Get("name"); v.Exists() {
|
||||||
|
name := v.String()
|
||||||
|
if short, ok := shortMap[name]; ok {
|
||||||
|
name = short
|
||||||
|
} else {
|
||||||
|
name = shortenNameIfNeeded(name)
|
||||||
|
}
|
||||||
|
tool, _ = sjson.Set(tool, "name", name)
|
||||||
|
}
|
||||||
tool, _ = sjson.SetRaw(tool, "parameters", toolResult.Get("input_schema").Raw)
|
tool, _ = sjson.SetRaw(tool, "parameters", toolResult.Get("input_schema").Raw)
|
||||||
tool, _ = sjson.Delete(tool, "input_schema")
|
tool, _ = sjson.Delete(tool, "input_schema")
|
||||||
tool, _ = sjson.Delete(tool, "parameters.$schema")
|
tool, _ = sjson.Delete(tool, "parameters.$schema")
|
||||||
@@ -170,3 +201,97 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
|
|
||||||
return []byte(template)
|
return []byte(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shortenNameIfNeeded applies a simple shortening rule for a single name.
|
||||||
|
func shortenNameIfNeeded(name string) string {
|
||||||
|
const limit = 64
|
||||||
|
if len(name) <= limit {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "mcp__") {
|
||||||
|
idx := strings.LastIndex(name, "__")
|
||||||
|
if idx > 0 {
|
||||||
|
cand := "mcp__" + name[idx+2:]
|
||||||
|
if len(cand) > limit {
|
||||||
|
return cand[:limit]
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildShortNameMap ensures uniqueness of shortened names within a request.
|
||||||
|
func buildShortNameMap(names []string) map[string]string {
|
||||||
|
const limit = 64
|
||||||
|
used := map[string]struct{}{}
|
||||||
|
m := map[string]string{}
|
||||||
|
|
||||||
|
baseCandidate := func(n string) string {
|
||||||
|
if len(n) <= limit {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "mcp__") {
|
||||||
|
idx := strings.LastIndex(n, "__")
|
||||||
|
if idx > 0 {
|
||||||
|
cand := "mcp__" + n[idx+2:]
|
||||||
|
if len(cand) > limit {
|
||||||
|
cand = cand[:limit]
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
makeUnique := func(cand string) string {
|
||||||
|
if _, ok := used[cand]; !ok {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
base := cand
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
suffix := "~" + strconv.Itoa(i)
|
||||||
|
allowed := limit - len(suffix)
|
||||||
|
if allowed < 0 {
|
||||||
|
allowed = 0
|
||||||
|
}
|
||||||
|
tmp := base
|
||||||
|
if len(tmp) > allowed {
|
||||||
|
tmp = tmp[:allowed]
|
||||||
|
}
|
||||||
|
tmp = tmp + suffix
|
||||||
|
if _, ok := used[tmp]; !ok {
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range names {
|
||||||
|
cand := baseCandidate(n)
|
||||||
|
uniq := makeUnique(cand)
|
||||||
|
used[uniq] = struct{}{}
|
||||||
|
m[n] = uniq
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReverseMapFromClaudeOriginalToShort builds original->short map, used to map tool_use names to short.
|
||||||
|
func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string {
|
||||||
|
tools := gjson.GetBytes(original, "tools")
|
||||||
|
m := map[string]string{}
|
||||||
|
if !tools.IsArray() {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
arr := tools.Array()
|
||||||
|
for i := 0; i < len(arr); i++ {
|
||||||
|
n := arr[i].Get("name").String()
|
||||||
|
if n != "" {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
m = buildShortNameMap(names)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,7 +122,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
|||||||
template = `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
|
template = `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
|
||||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||||
template, _ = sjson.Set(template, "content_block.id", itemResult.Get("call_id").String())
|
template, _ = sjson.Set(template, "content_block.id", itemResult.Get("call_id").String())
|
||||||
template, _ = sjson.Set(template, "content_block.name", itemResult.Get("name").String())
|
{
|
||||||
|
// Restore original tool name if shortened
|
||||||
|
name := itemResult.Get("name").String()
|
||||||
|
rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
|
||||||
|
if orig, ok := rev[name]; ok {
|
||||||
|
name = orig
|
||||||
|
}
|
||||||
|
template, _ = sjson.Set(template, "content_block.name", name)
|
||||||
|
}
|
||||||
|
|
||||||
output = "event: content_block_start\n"
|
output = "event: content_block_start\n"
|
||||||
output += fmt.Sprintf("data: %s\n\n", template)
|
output += fmt.Sprintf("data: %s\n\n", template)
|
||||||
@@ -171,3 +179,27 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
|||||||
func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, _ []byte, _ *any) string {
|
func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, _ []byte, _ *any) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools.
|
||||||
|
func buildReverseMapFromClaudeOriginalShortToOriginal(original []byte) map[string]string {
|
||||||
|
tools := gjson.GetBytes(original, "tools")
|
||||||
|
rev := map[string]string{}
|
||||||
|
if !tools.IsArray() {
|
||||||
|
return rev
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
arr := tools.Array()
|
||||||
|
for i := 0; i < len(arr); i++ {
|
||||||
|
n := arr[i].Get("name").String()
|
||||||
|
if n != "" {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
m := buildShortNameMap(names)
|
||||||
|
for orig, short := range m {
|
||||||
|
rev[short] = orig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rev
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/misc"
|
"github.com/luispater/CLIProxyAPI/internal/misc"
|
||||||
@@ -46,6 +47,27 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
|
|
||||||
root := gjson.ParseBytes(rawJSON)
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
|
// Pre-compute tool name shortening map from declared functionDeclarations
|
||||||
|
shortMap := map[string]string{}
|
||||||
|
if tools := root.Get("tools"); tools.IsArray() {
|
||||||
|
var names []string
|
||||||
|
tarr := tools.Array()
|
||||||
|
for i := 0; i < len(tarr); i++ {
|
||||||
|
fns := tarr[i].Get("functionDeclarations")
|
||||||
|
if !fns.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, fn := range fns.Array() {
|
||||||
|
if v := fn.Get("name"); v.Exists() {
|
||||||
|
names = append(names, v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
shortMap = buildShortNameMap(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// helper for generating paired call IDs in the form: call_<alphanum>
|
// helper for generating paired call IDs in the form: call_<alphanum>
|
||||||
// Gemini uses sequential pairing across possibly multiple in-flight
|
// Gemini uses sequential pairing across possibly multiple in-flight
|
||||||
// functionCalls, so we keep a FIFO queue of generated call IDs and
|
// functionCalls, so we keep a FIFO queue of generated call IDs and
|
||||||
@@ -124,7 +146,13 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
if fc := p.Get("functionCall"); fc.Exists() {
|
if fc := p.Get("functionCall"); fc.Exists() {
|
||||||
fn := `{"type":"function_call"}`
|
fn := `{"type":"function_call"}`
|
||||||
if name := fc.Get("name"); name.Exists() {
|
if name := fc.Get("name"); name.Exists() {
|
||||||
fn, _ = sjson.Set(fn, "name", name.String())
|
n := name.String()
|
||||||
|
if short, ok := shortMap[n]; ok {
|
||||||
|
n = short
|
||||||
|
} else {
|
||||||
|
n = shortenNameIfNeeded(n)
|
||||||
|
}
|
||||||
|
fn, _ = sjson.Set(fn, "name", n)
|
||||||
}
|
}
|
||||||
if args := fc.Get("args"); args.Exists() {
|
if args := fc.Get("args"); args.Exists() {
|
||||||
fn, _ = sjson.Set(fn, "arguments", args.Raw)
|
fn, _ = sjson.Set(fn, "arguments", args.Raw)
|
||||||
@@ -185,7 +213,13 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
tool := `{}`
|
tool := `{}`
|
||||||
tool, _ = sjson.Set(tool, "type", "function")
|
tool, _ = sjson.Set(tool, "type", "function")
|
||||||
if v := fn.Get("name"); v.Exists() {
|
if v := fn.Get("name"); v.Exists() {
|
||||||
tool, _ = sjson.Set(tool, "name", v.String())
|
name := v.String()
|
||||||
|
if short, ok := shortMap[name]; ok {
|
||||||
|
name = short
|
||||||
|
} else {
|
||||||
|
name = shortenNameIfNeeded(name)
|
||||||
|
}
|
||||||
|
tool, _ = sjson.Set(tool, "name", name)
|
||||||
}
|
}
|
||||||
if v := fn.Get("description"); v.Exists() {
|
if v := fn.Get("description"); v.Exists() {
|
||||||
tool, _ = sjson.Set(tool, "description", v.String())
|
tool, _ = sjson.Set(tool, "description", v.String())
|
||||||
@@ -227,3 +261,76 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
|
|
||||||
return []byte(out)
|
return []byte(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shortenNameIfNeeded applies the simple shortening rule for a single name.
|
||||||
|
func shortenNameIfNeeded(name string) string {
|
||||||
|
const limit = 64
|
||||||
|
if len(name) <= limit {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "mcp__") {
|
||||||
|
idx := strings.LastIndex(name, "__")
|
||||||
|
if idx > 0 {
|
||||||
|
cand := "mcp__" + name[idx+2:]
|
||||||
|
if len(cand) > limit {
|
||||||
|
return cand[:limit]
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildShortNameMap ensures uniqueness of shortened names within a request.
|
||||||
|
func buildShortNameMap(names []string) map[string]string {
|
||||||
|
const limit = 64
|
||||||
|
used := map[string]struct{}{}
|
||||||
|
m := map[string]string{}
|
||||||
|
|
||||||
|
baseCandidate := func(n string) string {
|
||||||
|
if len(n) <= limit {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "mcp__") {
|
||||||
|
idx := strings.LastIndex(n, "__")
|
||||||
|
if idx > 0 {
|
||||||
|
cand := "mcp__" + n[idx+2:]
|
||||||
|
if len(cand) > limit {
|
||||||
|
cand = cand[:limit]
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
makeUnique := func(cand string) string {
|
||||||
|
if _, ok := used[cand]; !ok {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
base := cand
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
suffix := "~" + strconv.Itoa(i)
|
||||||
|
allowed := limit - len(suffix)
|
||||||
|
if allowed < 0 {
|
||||||
|
allowed = 0
|
||||||
|
}
|
||||||
|
tmp := base
|
||||||
|
if len(tmp) > allowed {
|
||||||
|
tmp = tmp[:allowed]
|
||||||
|
}
|
||||||
|
tmp = tmp + suffix
|
||||||
|
if _, ok := used[tmp]; !ok {
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range names {
|
||||||
|
cand := baseCandidate(n)
|
||||||
|
uniq := makeUnique(cand)
|
||||||
|
used[uniq] = struct{}{}
|
||||||
|
m[n] = uniq
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
|||||||
if itemType == "function_call" {
|
if itemType == "function_call" {
|
||||||
// Create function call part
|
// Create function call part
|
||||||
functionCall := `{"functionCall":{"name":"","args":{}}}`
|
functionCall := `{"functionCall":{"name":"","args":{}}}`
|
||||||
functionCall, _ = sjson.Set(functionCall, "functionCall.name", itemResult.Get("name").String())
|
{
|
||||||
|
// Restore original tool name if shortened
|
||||||
|
n := itemResult.Get("name").String()
|
||||||
|
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
|
||||||
|
if orig, ok := rev[n]; ok {
|
||||||
|
n = orig
|
||||||
|
}
|
||||||
|
functionCall, _ = sjson.Set(functionCall, "functionCall.name", n)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse and set arguments
|
// Parse and set arguments
|
||||||
argsStr := itemResult.Get("arguments").String()
|
argsStr := itemResult.Get("arguments").String()
|
||||||
@@ -250,7 +258,14 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
|
|||||||
hasToolCall = true
|
hasToolCall = true
|
||||||
functionCall := map[string]interface{}{
|
functionCall := map[string]interface{}{
|
||||||
"functionCall": map[string]interface{}{
|
"functionCall": map[string]interface{}{
|
||||||
"name": value.Get("name").String(),
|
"name": func() string {
|
||||||
|
n := value.Get("name").String()
|
||||||
|
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
|
||||||
|
if orig, ok := rev[n]; ok {
|
||||||
|
return orig
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}(),
|
||||||
"args": map[string]interface{}{},
|
"args": map[string]interface{}{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -292,6 +307,35 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildReverseMapFromGeminiOriginal builds a map[short]original from original Gemini request tools.
|
||||||
|
func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
|
||||||
|
tools := gjson.GetBytes(original, "tools")
|
||||||
|
rev := map[string]string{}
|
||||||
|
if !tools.IsArray() {
|
||||||
|
return rev
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
tarr := tools.Array()
|
||||||
|
for i := 0; i < len(tarr); i++ {
|
||||||
|
fns := tarr[i].Get("functionDeclarations")
|
||||||
|
if !fns.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, fn := range fns.Array() {
|
||||||
|
if v := fn.Get("name"); v.Exists() {
|
||||||
|
names = append(names, v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
m := buildShortNameMap(names)
|
||||||
|
for orig, short := range m {
|
||||||
|
rev[short] = orig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rev
|
||||||
|
}
|
||||||
|
|
||||||
// mustMarshalJSON marshals a value to JSON, panicking on error.
|
// mustMarshalJSON marshals a value to JSON, panicking on error.
|
||||||
func mustMarshalJSON(v interface{}) string {
|
func mustMarshalJSON(v interface{}) string {
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ package chat_completions
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/luispater/CLIProxyAPI/internal/misc"
|
"github.com/luispater/CLIProxyAPI/internal/misc"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
@@ -67,6 +70,31 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
|||||||
// Model
|
// Model
|
||||||
out, _ = sjson.Set(out, "model", modelName)
|
out, _ = sjson.Set(out, "model", modelName)
|
||||||
|
|
||||||
|
// Build tool name shortening map from original tools (if any)
|
||||||
|
originalToolNameMap := map[string]string{}
|
||||||
|
{
|
||||||
|
tools := gjson.GetBytes(rawJSON, "tools")
|
||||||
|
if tools.IsArray() && len(tools.Array()) > 0 {
|
||||||
|
// Collect original tool names
|
||||||
|
var names []string
|
||||||
|
arr := tools.Array()
|
||||||
|
for i := 0; i < len(arr); i++ {
|
||||||
|
t := arr[i]
|
||||||
|
if t.Get("type").String() == "function" {
|
||||||
|
fn := t.Get("function")
|
||||||
|
if fn.Exists() {
|
||||||
|
if v := fn.Get("name"); v.Exists() {
|
||||||
|
names = append(names, v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
originalToolNameMap = buildShortNameMap(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract system instructions from first system message (string or text object)
|
// Extract system instructions from first system message (string or text object)
|
||||||
messages := gjson.GetBytes(rawJSON, "messages")
|
messages := gjson.GetBytes(rawJSON, "messages")
|
||||||
instructions := misc.CodexInstructions
|
instructions := misc.CodexInstructions
|
||||||
@@ -177,7 +205,15 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
|||||||
funcCall := `{}`
|
funcCall := `{}`
|
||||||
funcCall, _ = sjson.Set(funcCall, "type", "function_call")
|
funcCall, _ = sjson.Set(funcCall, "type", "function_call")
|
||||||
funcCall, _ = sjson.Set(funcCall, "call_id", tc.Get("id").String())
|
funcCall, _ = sjson.Set(funcCall, "call_id", tc.Get("id").String())
|
||||||
funcCall, _ = sjson.Set(funcCall, "name", tc.Get("function.name").String())
|
{
|
||||||
|
name := tc.Get("function.name").String()
|
||||||
|
if short, ok := originalToolNameMap[name]; ok {
|
||||||
|
name = short
|
||||||
|
} else {
|
||||||
|
name = shortenNameIfNeeded(name)
|
||||||
|
}
|
||||||
|
funcCall, _ = sjson.Set(funcCall, "name", name)
|
||||||
|
}
|
||||||
funcCall, _ = sjson.Set(funcCall, "arguments", tc.Get("function.arguments").String())
|
funcCall, _ = sjson.Set(funcCall, "arguments", tc.Get("function.arguments").String())
|
||||||
out, _ = sjson.SetRaw(out, "input.-1", funcCall)
|
out, _ = sjson.SetRaw(out, "input.-1", funcCall)
|
||||||
}
|
}
|
||||||
@@ -249,7 +285,13 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
|||||||
fn := t.Get("function")
|
fn := t.Get("function")
|
||||||
if fn.Exists() {
|
if fn.Exists() {
|
||||||
if v := fn.Get("name"); v.Exists() {
|
if v := fn.Get("name"); v.Exists() {
|
||||||
item, _ = sjson.Set(item, "name", v.Value())
|
name := v.String()
|
||||||
|
if short, ok := originalToolNameMap[name]; ok {
|
||||||
|
name = short
|
||||||
|
} else {
|
||||||
|
name = shortenNameIfNeeded(name)
|
||||||
|
}
|
||||||
|
item, _ = sjson.Set(item, "name", name)
|
||||||
}
|
}
|
||||||
if v := fn.Get("description"); v.Exists() {
|
if v := fn.Get("description"); v.Exists() {
|
||||||
item, _ = sjson.Set(item, "description", v.Value())
|
item, _ = sjson.Set(item, "description", v.Value())
|
||||||
@@ -273,3 +315,81 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
|||||||
out, _ = sjson.Set(out, "store", store)
|
out, _ = sjson.Set(out, "store", store)
|
||||||
return []byte(out)
|
return []byte(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shortenNameIfNeeded applies the simple shortening rule for a single name.
|
||||||
|
// If the name length exceeds 64, it will try to preserve the "mcp__" prefix and last segment.
|
||||||
|
// Otherwise it truncates to 64 characters.
|
||||||
|
func shortenNameIfNeeded(name string) string {
|
||||||
|
const limit = 64
|
||||||
|
if len(name) <= limit {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "mcp__") {
|
||||||
|
// Keep prefix and last segment after '__'
|
||||||
|
idx := strings.LastIndex(name, "__")
|
||||||
|
if idx > 0 {
|
||||||
|
candidate := "mcp__" + name[idx+2:]
|
||||||
|
if len(candidate) > limit {
|
||||||
|
return candidate[:limit]
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildShortNameMap generates unique short names (<=64) for the given list of names.
|
||||||
|
// It preserves the "mcp__" prefix with the last segment when possible and ensures uniqueness
|
||||||
|
// by appending suffixes like "~1", "~2" if needed.
|
||||||
|
func buildShortNameMap(names []string) map[string]string {
|
||||||
|
const limit = 64
|
||||||
|
used := map[string]struct{}{}
|
||||||
|
m := map[string]string{}
|
||||||
|
|
||||||
|
baseCandidate := func(n string) string {
|
||||||
|
if len(n) <= limit {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "mcp__") {
|
||||||
|
idx := strings.LastIndex(n, "__")
|
||||||
|
if idx > 0 {
|
||||||
|
cand := "mcp__" + n[idx+2:]
|
||||||
|
if len(cand) > limit {
|
||||||
|
cand = cand[:limit]
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
makeUnique := func(cand string) string {
|
||||||
|
if _, ok := used[cand]; !ok {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
base := cand
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
suffix := "~" + strconv.Itoa(i)
|
||||||
|
allowed := limit - len(suffix)
|
||||||
|
if allowed < 0 {
|
||||||
|
allowed = 0
|
||||||
|
}
|
||||||
|
tmp := base
|
||||||
|
if len(tmp) > allowed {
|
||||||
|
tmp = tmp[:allowed]
|
||||||
|
}
|
||||||
|
tmp = tmp + suffix
|
||||||
|
if _, ok := used[tmp]; !ok {
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range names {
|
||||||
|
cand := baseCandidate(n)
|
||||||
|
uniq := makeUnique(cand)
|
||||||
|
used[uniq] = struct{}{}
|
||||||
|
m[n] = uniq
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ var (
|
|||||||
|
|
||||||
// ConvertCliToOpenAIParams holds parameters for response conversion.
|
// ConvertCliToOpenAIParams holds parameters for response conversion.
|
||||||
type ConvertCliToOpenAIParams struct {
|
type ConvertCliToOpenAIParams struct {
|
||||||
ResponseID string
|
ResponseID string
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
Model string
|
Model string
|
||||||
|
FunctionCallIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the
|
// ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the
|
||||||
@@ -43,9 +44,10 @@ type ConvertCliToOpenAIParams struct {
|
|||||||
func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
|
func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
|
||||||
if *param == nil {
|
if *param == nil {
|
||||||
*param = &ConvertCliToOpenAIParams{
|
*param = &ConvertCliToOpenAIParams{
|
||||||
Model: modelName,
|
Model: modelName,
|
||||||
CreatedAt: 0,
|
CreatedAt: 0,
|
||||||
ResponseID: "",
|
ResponseID: "",
|
||||||
|
FunctionCallIndex: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,18 +110,36 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
|
|||||||
template, _ = sjson.Set(template, "choices.0.delta.content", deltaResult.String())
|
template, _ = sjson.Set(template, "choices.0.delta.content", deltaResult.String())
|
||||||
}
|
}
|
||||||
} else if dataType == "response.completed" {
|
} else if dataType == "response.completed" {
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", "stop")
|
finishReason := "stop"
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "stop")
|
if (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 {
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason)
|
||||||
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason)
|
||||||
} else if dataType == "response.output_item.done" {
|
} else if dataType == "response.output_item.done" {
|
||||||
functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
|
functionCallItemTemplate := `{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`
|
||||||
itemResult := rootResult.Get("item")
|
itemResult := rootResult.Get("item")
|
||||||
if itemResult.Exists() {
|
if itemResult.Exists() {
|
||||||
if itemResult.Get("type").String() != "function_call" {
|
if itemResult.Get("type").String() != "function_call" {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// set the index
|
||||||
|
(*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++
|
||||||
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex)
|
||||||
|
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
|
||||||
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
|
||||||
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", itemResult.Get("name").String())
|
|
||||||
|
// Restore original tool name if it was shortened
|
||||||
|
name := itemResult.Get("name").String()
|
||||||
|
// Build reverse map on demand from original request tools
|
||||||
|
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
|
||||||
|
if orig, ok := rev[name]; ok {
|
||||||
|
name = orig
|
||||||
|
}
|
||||||
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", name)
|
||||||
|
|
||||||
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", itemResult.Get("arguments").String())
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", itemResult.Get("arguments").String())
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
||||||
@@ -244,7 +264,12 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
|
|||||||
}
|
}
|
||||||
|
|
||||||
if nameResult := outputItem.Get("name"); nameResult.Exists() {
|
if nameResult := outputItem.Get("name"); nameResult.Exists() {
|
||||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", nameResult.String())
|
n := nameResult.String()
|
||||||
|
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
|
||||||
|
if orig, ok := rev[n]; ok {
|
||||||
|
n = orig
|
||||||
|
}
|
||||||
|
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
if argsResult := outputItem.Get("arguments"); argsResult.Exists() {
|
if argsResult := outputItem.Get("arguments"); argsResult.Exists() {
|
||||||
@@ -289,3 +314,34 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name
|
||||||
|
// from the original OpenAI-style request JSON using the same shortening logic.
|
||||||
|
func buildReverseMapFromOriginalOpenAI(original []byte) map[string]string {
|
||||||
|
tools := gjson.GetBytes(original, "tools")
|
||||||
|
rev := map[string]string{}
|
||||||
|
if tools.IsArray() && len(tools.Array()) > 0 {
|
||||||
|
var names []string
|
||||||
|
arr := tools.Array()
|
||||||
|
for i := 0; i < len(arr); i++ {
|
||||||
|
t := arr[i]
|
||||||
|
if t.Get("type").String() != "function" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn := t.Get("function")
|
||||||
|
if !fn.Exists() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v := fn.Get("name"); v.Exists() {
|
||||||
|
names = append(names, v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
m := buildShortNameMap(names)
|
||||||
|
for orig, short := range m {
|
||||||
|
rev[short] = orig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rev
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package gemini
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -248,14 +249,253 @@ func parseArgsToMap(argsStr string) map[string]interface{} {
|
|||||||
if trimmed == "" || trimmed == "{}" {
|
if trimmed == "" || trimmed == "{}" {
|
||||||
return map[string]interface{}{}
|
return map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First try strict JSON
|
||||||
var out map[string]interface{}
|
var out map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(trimmed), &out); err == nil {
|
if errUnmarshal := json.Unmarshal([]byte(trimmed), &out); errUnmarshal == nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tolerant parse: handle streams where values are barewords (e.g., 北京, celsius)
|
||||||
|
tolerant := tolerantParseJSONMap(trimmed)
|
||||||
|
if len(tolerant) > 0 {
|
||||||
|
return tolerant
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: return empty object when parsing fails
|
// Fallback: return empty object when parsing fails
|
||||||
return map[string]interface{}{}
|
return map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tolerantParseJSONMap attempts to parse a JSON-like object string into a map, tolerating
|
||||||
|
// bareword values (unquoted strings) commonly seen during streamed tool calls.
|
||||||
|
// Example input: {"location": 北京, "unit": celsius}
|
||||||
|
func tolerantParseJSONMap(s string) map[string]interface{} {
|
||||||
|
// Ensure we operate within the outermost braces if present
|
||||||
|
start := strings.Index(s, "{")
|
||||||
|
end := strings.LastIndex(s, "}")
|
||||||
|
if start == -1 || end == -1 || start >= end {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
content := s[start+1 : end]
|
||||||
|
|
||||||
|
runes := []rune(content)
|
||||||
|
n := len(runes)
|
||||||
|
i := 0
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
|
for i < n {
|
||||||
|
// Skip whitespace and commas
|
||||||
|
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t' || runes[i] == ',') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect quoted key
|
||||||
|
if runes[i] != '"' {
|
||||||
|
// Unable to parse this segment reliably; skip to next comma
|
||||||
|
for i < n && runes[i] != ',' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON string for key
|
||||||
|
keyToken, nextIdx := parseJSONStringRunes(runes, i)
|
||||||
|
if nextIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
keyName := jsonStringTokenToRawString(keyToken)
|
||||||
|
i = nextIdx
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= n || runes[i] != ':' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++ // skip ':'
|
||||||
|
// Skip whitespace
|
||||||
|
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse value (string, number, object/array, bareword)
|
||||||
|
var value interface{}
|
||||||
|
switch runes[i] {
|
||||||
|
case '"':
|
||||||
|
// JSON string
|
||||||
|
valToken, ni := parseJSONStringRunes(runes, i)
|
||||||
|
if ni == -1 {
|
||||||
|
// Malformed; treat as empty string
|
||||||
|
value = ""
|
||||||
|
i = n
|
||||||
|
} else {
|
||||||
|
value = jsonStringTokenToRawString(valToken)
|
||||||
|
i = ni
|
||||||
|
}
|
||||||
|
case '{', '[':
|
||||||
|
// Bracketed value: attempt to capture balanced structure
|
||||||
|
seg, ni := captureBracketed(runes, i)
|
||||||
|
if ni == -1 {
|
||||||
|
i = n
|
||||||
|
} else {
|
||||||
|
var anyVal interface{}
|
||||||
|
if errUnmarshal := json.Unmarshal([]byte(seg), &anyVal); errUnmarshal == nil {
|
||||||
|
value = anyVal
|
||||||
|
} else {
|
||||||
|
value = seg
|
||||||
|
}
|
||||||
|
i = ni
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Bare token until next comma or end
|
||||||
|
j := i
|
||||||
|
for j < n && runes[j] != ',' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(string(runes[i:j]))
|
||||||
|
// Interpret common JSON atoms and numbers; otherwise treat as string
|
||||||
|
if token == "true" {
|
||||||
|
value = true
|
||||||
|
} else if token == "false" {
|
||||||
|
value = false
|
||||||
|
} else if token == "null" {
|
||||||
|
value = nil
|
||||||
|
} else if numVal, ok := tryParseNumber(token); ok {
|
||||||
|
value = numVal
|
||||||
|
} else {
|
||||||
|
value = token
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
|
||||||
|
result[keyName] = value
|
||||||
|
|
||||||
|
// Skip trailing whitespace and optional comma before next pair
|
||||||
|
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i < n && runes[i] == ',' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJSONStringRunes returns the JSON string token (including quotes) and the index just after it.
|
||||||
|
func parseJSONStringRunes(runes []rune, start int) (string, int) {
|
||||||
|
if start >= len(runes) || runes[start] != '"' {
|
||||||
|
return "", -1
|
||||||
|
}
|
||||||
|
i := start + 1
|
||||||
|
escaped := false
|
||||||
|
for i < len(runes) {
|
||||||
|
r := runes[i]
|
||||||
|
if r == '\\' && !escaped {
|
||||||
|
escaped = true
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '"' && !escaped {
|
||||||
|
return string(runes[start : i+1]), i + 1
|
||||||
|
}
|
||||||
|
escaped = false
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return string(runes[start:]), -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonStringTokenToRawString converts a JSON string token (including quotes) to a raw Go string value.
|
||||||
|
func jsonStringTokenToRawString(token string) string {
|
||||||
|
var s string
|
||||||
|
if errUnmarshal := json.Unmarshal([]byte(token), &s); errUnmarshal == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// Fallback: strip surrounding quotes if present
|
||||||
|
if len(token) >= 2 && token[0] == '"' && token[len(token)-1] == '"' {
|
||||||
|
return token[1 : len(token)-1]
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureBracketed captures a balanced JSON object/array starting at index i.
|
||||||
|
// Returns the segment string and the index just after it; -1 if malformed.
|
||||||
|
func captureBracketed(runes []rune, i int) (string, int) {
|
||||||
|
if i >= len(runes) {
|
||||||
|
return "", -1
|
||||||
|
}
|
||||||
|
startRune := runes[i]
|
||||||
|
var endRune rune
|
||||||
|
if startRune == '{' {
|
||||||
|
endRune = '}'
|
||||||
|
} else if startRune == '[' {
|
||||||
|
endRune = ']'
|
||||||
|
} else {
|
||||||
|
return "", -1
|
||||||
|
}
|
||||||
|
depth := 0
|
||||||
|
j := i
|
||||||
|
inStr := false
|
||||||
|
escaped := false
|
||||||
|
for j < len(runes) {
|
||||||
|
r := runes[j]
|
||||||
|
if inStr {
|
||||||
|
if r == '\\' && !escaped {
|
||||||
|
escaped = true
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '"' && !escaped {
|
||||||
|
inStr = false
|
||||||
|
} else {
|
||||||
|
escaped = false
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '"' {
|
||||||
|
inStr = true
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == startRune {
|
||||||
|
depth++
|
||||||
|
} else if r == endRune {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return string(runes[i : j+1]), j + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
return string(runes[i:]), -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryParseNumber attempts to parse a string as an int or float.
|
||||||
|
func tryParseNumber(s string) (interface{}, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Try integer
|
||||||
|
if i64, errParseInt := strconv.ParseInt(s, 10, 64); errParseInt == nil {
|
||||||
|
return i64, true
|
||||||
|
}
|
||||||
|
if u64, errParseUInt := strconv.ParseUint(s, 10, 64); errParseUInt == nil {
|
||||||
|
return u64, true
|
||||||
|
}
|
||||||
|
if f64, errParseFloat := strconv.ParseFloat(s, 64); errParseFloat == nil {
|
||||||
|
return f64, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
// ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response.
|
// ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package util provides utility functions for the CLI Proxy API server.
|
// Package util provides utility functions for the CLI Proxy API server.
|
||||||
// It includes helper functions for proxy configuration, HTTP client setup,
|
// It includes helper functions for proxy configuration, HTTP client setup,
|
||||||
// and other common operations used across the application.
|
// log level management, and other common operations used across the application.
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
135
internal/util/ssh_helper.go
Normal file
135
internal/util/ssh_helper.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Package util provides helper functions for SSH tunnel instructions and network-related tasks.
|
||||||
|
// This includes detecting the appropriate IP address and printing commands
|
||||||
|
// to help users connect to the local server from a remote machine.
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ipServices = []string{
|
||||||
|
"https://api.ipify.org",
|
||||||
|
"https://ifconfig.me/ip",
|
||||||
|
"https://icanhazip.com",
|
||||||
|
"https://ipinfo.io/ip",
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPublicIP attempts to retrieve the public IP address from a list of external services.
|
||||||
|
// It iterates through the ipServices and returns the first successful response.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The public IP address as a string
|
||||||
|
// - error: An error if all services fail, nil otherwise
|
||||||
|
func getPublicIP() (string, error) {
|
||||||
|
for _, service := range ipServices {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", service, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to create request to %s: %v", service, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to get public IP from %s: %v", service, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||||
|
log.Warnf("Failed to close response body from %s: %v", service, closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Debugf("bad status code from %s: %d", service, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to read response body from %s: %v", service, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(ip)), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("all IP services failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOutboundIP retrieves the preferred outbound IP address of this machine.
|
||||||
|
// It uses a UDP connection to a public DNS server to determine the local IP
|
||||||
|
// address that would be used for outbound traffic.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The outbound IP address as a string
|
||||||
|
// - error: An error if the IP address cannot be determined, nil otherwise
|
||||||
|
func getOutboundIP() (string, error) {
|
||||||
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := conn.Close(); closeErr != nil {
|
||||||
|
log.Warnf("Failed to close UDP connection: %v", closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
localAddr, ok := conn.LocalAddr().(*net.UDPAddr)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("could not assert UDP address type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return localAddr.IP.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIPAddress attempts to find the best-available IP address.
|
||||||
|
// It first tries to get the public IP address, and if that fails,
|
||||||
|
// it falls back to getting the local outbound IP address.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The determined IP address (preferring public IPv4)
|
||||||
|
func GetIPAddress() string {
|
||||||
|
publicIP, err := getPublicIP()
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("Public IP detected: %s", publicIP)
|
||||||
|
return publicIP
|
||||||
|
}
|
||||||
|
log.Warnf("Failed to get public IP, falling back to outbound IP: %v", err)
|
||||||
|
outboundIP, err := getOutboundIP()
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("Outbound IP detected: %s", outboundIP)
|
||||||
|
return outboundIP
|
||||||
|
}
|
||||||
|
log.Errorf("Failed to get any IP address: %v", err)
|
||||||
|
return "127.0.0.1" // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintSSHTunnelInstructions detects the IP address and prints SSH tunnel instructions
|
||||||
|
// for the user to connect to the local OAuth callback server from a remote machine.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - port: The local port number for the SSH tunnel
|
||||||
|
func PrintSSHTunnelInstructions(port int) {
|
||||||
|
ipAddress := GetIPAddress()
|
||||||
|
border := "================================================================================"
|
||||||
|
log.Infof("To authenticate from a remote machine, an SSH tunnel may be required.")
|
||||||
|
fmt.Println(border)
|
||||||
|
fmt.Println(" Run one of the following commands on your local machine (NOT the server):")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" # Standard SSH command (assumes SSH port 22):\n")
|
||||||
|
fmt.Printf(" ssh -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" # If using an SSH key (assumes SSH port 22):\n")
|
||||||
|
fmt.Printf(" ssh -i <path_to_your_key> -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" NOTE: If your server's SSH port is not 22, please modify the '-p 22' part accordingly.")
|
||||||
|
fmt.Println(border)
|
||||||
|
}
|
||||||
23
internal/util/util.go
Normal file
23
internal/util/util.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetLogLevel configures the logrus log level based on the configuration.
|
||||||
|
// It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.
|
||||||
|
func SetLogLevel(cfg *config.Config) {
|
||||||
|
currentLevel := log.GetLevel()
|
||||||
|
var newLevel log.Level
|
||||||
|
if cfg.Debug {
|
||||||
|
newLevel = log.DebugLevel
|
||||||
|
} else {
|
||||||
|
newLevel = log.InfoLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentLevel != newLevel {
|
||||||
|
log.SetLevel(newLevel)
|
||||||
|
log.Infof("log level changed from %s to %s (debug=%t)", currentLevel, newLevel, cfg.Debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,12 @@ package watcher
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -34,14 +35,16 @@ type Watcher struct {
|
|||||||
configPath string
|
configPath string
|
||||||
authDir string
|
authDir string
|
||||||
config *config.Config
|
config *config.Config
|
||||||
clients []interfaces.Client
|
clients map[string]interfaces.Client
|
||||||
|
apiKeyClients map[string]interfaces.Client // New field for caching API key clients
|
||||||
clientsMutex sync.RWMutex
|
clientsMutex sync.RWMutex
|
||||||
reloadCallback func([]interfaces.Client, *config.Config)
|
reloadCallback func(map[string]interfaces.Client, *config.Config)
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
|
lastAuthHashes map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWatcher creates a new file watcher instance
|
// NewWatcher creates a new file watcher instance
|
||||||
func NewWatcher(configPath, authDir string, reloadCallback func([]interfaces.Client, *config.Config)) (*Watcher, error) {
|
func NewWatcher(configPath, authDir string, reloadCallback func(map[string]interfaces.Client, *config.Config)) (*Watcher, error) {
|
||||||
watcher, errNewWatcher := fsnotify.NewWatcher()
|
watcher, errNewWatcher := fsnotify.NewWatcher()
|
||||||
if errNewWatcher != nil {
|
if errNewWatcher != nil {
|
||||||
return nil, errNewWatcher
|
return nil, errNewWatcher
|
||||||
@@ -52,6 +55,9 @@ func NewWatcher(configPath, authDir string, reloadCallback func([]interfaces.Cli
|
|||||||
authDir: authDir,
|
authDir: authDir,
|
||||||
reloadCallback: reloadCallback,
|
reloadCallback: reloadCallback,
|
||||||
watcher: watcher,
|
watcher: watcher,
|
||||||
|
clients: make(map[string]interfaces.Client),
|
||||||
|
apiKeyClients: make(map[string]interfaces.Client),
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,13 +95,20 @@ func (w *Watcher) SetConfig(cfg *config.Config) {
|
|||||||
w.config = cfg
|
w.config = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetClients updates the current client list
|
// SetClients sets the file-based clients.
|
||||||
func (w *Watcher) SetClients(clients []interfaces.Client) {
|
func (w *Watcher) SetClients(clients map[string]interfaces.Client) {
|
||||||
w.clientsMutex.Lock()
|
w.clientsMutex.Lock()
|
||||||
defer w.clientsMutex.Unlock()
|
defer w.clientsMutex.Unlock()
|
||||||
w.clients = clients
|
w.clients = clients
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAPIKeyClients sets the API key-based clients.
|
||||||
|
func (w *Watcher) SetAPIKeyClients(apiKeyClients map[string]interfaces.Client) {
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
defer w.clientsMutex.Unlock()
|
||||||
|
w.apiKeyClients = apiKeyClients
|
||||||
|
}
|
||||||
|
|
||||||
// processEvents handles file system events
|
// processEvents handles file system events
|
||||||
func (w *Watcher) processEvents(ctx context.Context) {
|
func (w *Watcher) processEvents(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
@@ -119,7 +132,6 @@ func (w *Watcher) processEvents(ctx context.Context) {
|
|||||||
// handleEvent processes individual file system events
|
// handleEvent processes individual file system events
|
||||||
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name)
|
log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name)
|
||||||
|
|
||||||
// Handle config file changes
|
// Handle config file changes
|
||||||
@@ -130,13 +142,14 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle auth directory changes (only for .json files)
|
// Handle auth directory changes incrementally
|
||||||
// Simplified: reload on any change to .json files in auth directory
|
|
||||||
if strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") {
|
if strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") {
|
||||||
log.Infof("auth file changed (%s): %s, reloading clients", event.Op.String(), filepath.Base(event.Name))
|
log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
|
||||||
log.Debugf("auth file change details - operation: %s, file: %s, timestamp: %s",
|
if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write {
|
||||||
event.Op.String(), filepath.Base(event.Name), now.Format("2006-01-02 15:04:05.000"))
|
w.addOrUpdateClient(event.Name)
|
||||||
w.reloadClients()
|
} else if event.Op&fsnotify.Remove == fsnotify.Remove {
|
||||||
|
w.removeClient(event.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +168,14 @@ func (w *Watcher) reloadConfig() {
|
|||||||
w.config = newConfig
|
w.config = newConfig
|
||||||
w.clientsMutex.Unlock()
|
w.clientsMutex.Unlock()
|
||||||
|
|
||||||
|
// Always apply the current log level based on the latest config.
|
||||||
|
// This ensures logrus reflects the desired level even if change detection misses.
|
||||||
|
util.SetLogLevel(newConfig)
|
||||||
|
// Additional debug for visibility when the flag actually changes.
|
||||||
|
if oldConfig != nil && oldConfig.Debug != newConfig.Debug {
|
||||||
|
log.Debugf("log level updated - debug mode changed from %t to %t", oldConfig.Debug, newConfig.Debug)
|
||||||
|
}
|
||||||
|
|
||||||
// Log configuration changes in debug mode
|
// Log configuration changes in debug mode
|
||||||
if oldConfig != nil {
|
if oldConfig != nil {
|
||||||
log.Debugf("config changes detected:")
|
log.Debugf("config changes detected:")
|
||||||
@@ -201,13 +222,14 @@ func (w *Watcher) reloadConfig() {
|
|||||||
w.reloadClients()
|
w.reloadClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadClients reloads all authentication clients
|
// reloadClients performs a full scan and reload of all clients.
|
||||||
func (w *Watcher) reloadClients() {
|
func (w *Watcher) reloadClients() {
|
||||||
log.Debugf("starting client reload process")
|
log.Debugf("starting full client reload process")
|
||||||
|
|
||||||
w.clientsMutex.RLock()
|
w.clientsMutex.RLock()
|
||||||
cfg := w.config
|
cfg := w.config
|
||||||
oldClientCount := len(w.clients)
|
oldFileClientCount := len(w.clients)
|
||||||
|
oldAPIKeyClientCount := len(w.apiKeyClients)
|
||||||
w.clientsMutex.RUnlock()
|
w.clientsMutex.RUnlock()
|
||||||
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
@@ -215,213 +237,328 @@ func (w *Watcher) reloadClients() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("scanning auth directory: %s", cfg.AuthDir)
|
// Unregister all old API key clients before creating new ones
|
||||||
|
log.Debugf("unregistering %d old API key clients", oldAPIKeyClientCount)
|
||||||
// Create new client list
|
for _, oldClient := range w.apiKeyClients {
|
||||||
newClients := make([]interfaces.Client, 0)
|
if u, ok := oldClient.(interface{ UnregisterClient() }); ok {
|
||||||
authFileCount := 0
|
|
||||||
successfulAuthCount := 0
|
|
||||||
|
|
||||||
if strings.HasPrefix(cfg.AuthDir, "~") {
|
|
||||||
home, errUserHomeDir := os.UserHomeDir()
|
|
||||||
if errUserHomeDir != nil {
|
|
||||||
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
|
|
||||||
}
|
|
||||||
// Reconstruct the path by replacing the tilde with the user's home directory.
|
|
||||||
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
|
|
||||||
if len(parts) > 1 {
|
|
||||||
parts[0] = home
|
|
||||||
cfg.AuthDir = path.Join(parts...)
|
|
||||||
} else {
|
|
||||||
// If the path is just "~", set it to the home directory.
|
|
||||||
cfg.AuthDir = home
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load clients from auth directory
|
|
||||||
errWalk := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
log.Debugf("error accessing path %s: %v", path, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process only JSON files in the auth directory
|
|
||||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
|
|
||||||
authFileCount++
|
|
||||||
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
|
|
||||||
|
|
||||||
data, errReadFile := os.ReadFile(path)
|
|
||||||
if errReadFile != nil {
|
|
||||||
return errReadFile
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenType := "gemini"
|
|
||||||
typeResult := gjson.GetBytes(data, "type")
|
|
||||||
if typeResult.Exists() {
|
|
||||||
tokenType = typeResult.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the token storage file
|
|
||||||
if tokenType == "gemini" {
|
|
||||||
var ts gemini.GeminiTokenStorage
|
|
||||||
if err = json.Unmarshal(data, &ts); err == nil {
|
|
||||||
// For each valid token, create an authenticated client
|
|
||||||
clientCtx := context.Background()
|
|
||||||
log.Debugf(" initializing gemini authentication for token from %s...", filepath.Base(path))
|
|
||||||
geminiAuth := gemini.NewGeminiAuth()
|
|
||||||
httpClient, errGetClient := geminiAuth.GetAuthenticatedClient(clientCtx, &ts, cfg)
|
|
||||||
if errGetClient != nil {
|
|
||||||
log.Errorf(" failed to get authenticated client for token %s: %v", path, errGetClient)
|
|
||||||
return nil // Continue processing other files
|
|
||||||
}
|
|
||||||
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
|
|
||||||
|
|
||||||
// Add the new client to the pool
|
|
||||||
cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg)
|
|
||||||
newClients = append(newClients, cliClient)
|
|
||||||
successfulAuthCount++
|
|
||||||
} else {
|
|
||||||
log.Errorf(" failed to decode token file %s: %v", path, err)
|
|
||||||
}
|
|
||||||
} else if tokenType == "codex" {
|
|
||||||
var ts codex.CodexTokenStorage
|
|
||||||
if err = json.Unmarshal(data, &ts); err == nil {
|
|
||||||
// For each valid token, create an authenticated client
|
|
||||||
log.Debugf(" initializing codex authentication for token from %s...", filepath.Base(path))
|
|
||||||
codexClient, errGetClient := client.NewCodexClient(cfg, &ts)
|
|
||||||
if errGetClient != nil {
|
|
||||||
log.Errorf(" failed to get authenticated client for token %s: %v", path, errGetClient)
|
|
||||||
return nil // Continue processing other files
|
|
||||||
}
|
|
||||||
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
|
|
||||||
|
|
||||||
// Add the new client to the pool
|
|
||||||
newClients = append(newClients, codexClient)
|
|
||||||
successfulAuthCount++
|
|
||||||
} else {
|
|
||||||
log.Errorf(" failed to decode token file %s: %v", path, err)
|
|
||||||
}
|
|
||||||
} else if tokenType == "claude" {
|
|
||||||
var ts claude.ClaudeTokenStorage
|
|
||||||
if err = json.Unmarshal(data, &ts); err == nil {
|
|
||||||
// For each valid token, create an authenticated client
|
|
||||||
log.Debugf(" initializing claude authentication for token from %s...", filepath.Base(path))
|
|
||||||
claudeClient := client.NewClaudeClient(cfg, &ts)
|
|
||||||
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
|
|
||||||
|
|
||||||
// Add the new client to the pool
|
|
||||||
newClients = append(newClients, claudeClient)
|
|
||||||
successfulAuthCount++
|
|
||||||
} else {
|
|
||||||
log.Errorf(" failed to decode token file %s: %v", path, err)
|
|
||||||
}
|
|
||||||
} else if tokenType == "qwen" {
|
|
||||||
var ts qwen.QwenTokenStorage
|
|
||||||
if err = json.Unmarshal(data, &ts); err == nil {
|
|
||||||
// For each valid token, create an authenticated client
|
|
||||||
log.Debugf(" initializing qwen authentication for token from %s...", filepath.Base(path))
|
|
||||||
qwenClient := client.NewQwenClient(cfg, &ts)
|
|
||||||
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
|
|
||||||
|
|
||||||
// Add the new client to the pool
|
|
||||||
newClients = append(newClients, qwenClient)
|
|
||||||
successfulAuthCount++
|
|
||||||
} else {
|
|
||||||
log.Errorf(" failed to decode token file %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if errWalk != nil {
|
|
||||||
log.Errorf("error walking auth directory: %v", errWalk)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("auth directory scan complete - found %d .json files, %d successful authentications", authFileCount, successfulAuthCount)
|
|
||||||
|
|
||||||
// Add clients for Generative Language API keys if configured
|
|
||||||
glAPIKeyCount := 0
|
|
||||||
if len(cfg.GlAPIKey) > 0 {
|
|
||||||
log.Debugf("processing %d Generative Language API Keys", len(cfg.GlAPIKey))
|
|
||||||
for i := 0; i < len(cfg.GlAPIKey); i++ {
|
|
||||||
httpClient := util.SetProxy(cfg, &http.Client{})
|
|
||||||
|
|
||||||
log.Debugf("Initializing with Generative Language API Key %d...", i+1)
|
|
||||||
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
|
|
||||||
newClients = append(newClients, cliClient)
|
|
||||||
glAPIKeyCount++
|
|
||||||
}
|
|
||||||
log.Debugf("Successfully initialized %d Generative Language API Key clients", glAPIKeyCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeAPIKeyCount := 0
|
|
||||||
if len(cfg.ClaudeKey) > 0 {
|
|
||||||
log.Debugf("processing %d Claude API Keys", len(cfg.ClaudeKey))
|
|
||||||
for i := 0; i < len(cfg.ClaudeKey); i++ {
|
|
||||||
log.Debugf("Initializing with Claude API Key %d...", i+1)
|
|
||||||
cliClient := client.NewClaudeClientWithKey(cfg, i)
|
|
||||||
newClients = append(newClients, cliClient)
|
|
||||||
claudeAPIKeyCount++
|
|
||||||
}
|
|
||||||
log.Debugf("Successfully initialized %d Claude API Key clients", claudeAPIKeyCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
codexAPIKeyCount := 0
|
|
||||||
if len(cfg.CodexKey) > 0 {
|
|
||||||
log.Debugf("processing %d Codex API Keys", len(cfg.CodexKey))
|
|
||||||
for i := 0; i < len(cfg.CodexKey); i++ {
|
|
||||||
log.Debugf("Initializing with Codex API Key %d...", i+1)
|
|
||||||
cliClient := client.NewCodexClientWithKey(cfg, i)
|
|
||||||
newClients = append(newClients, cliClient)
|
|
||||||
codexAPIKeyCount++
|
|
||||||
}
|
|
||||||
log.Debugf("Successfully initialized %d Codex API Key clients", codexAPIKeyCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add clients for OpenAI compatibility providers if configured
|
|
||||||
openAICompatCount := 0
|
|
||||||
if len(cfg.OpenAICompatibility) > 0 {
|
|
||||||
log.Debugf("processing %d OpenAI-compatibility providers", len(cfg.OpenAICompatibility))
|
|
||||||
for i := 0; i < len(cfg.OpenAICompatibility); i++ {
|
|
||||||
compat := cfg.OpenAICompatibility[i]
|
|
||||||
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compat)
|
|
||||||
if errClient != nil {
|
|
||||||
log.Errorf(" failed to create OpenAI-compatibility client for %s: %v", compat.Name, errClient)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newClients = append(newClients, compatClient)
|
|
||||||
openAICompatCount++
|
|
||||||
}
|
|
||||||
log.Debugf("Successfully initialized %d OpenAI-compatibility clients", openAICompatCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unregister old clients from the model registry if supported
|
|
||||||
w.clientsMutex.RLock()
|
|
||||||
for i := 0; i < len(w.clients); i++ {
|
|
||||||
if u, ok := any(w.clients[i]).(interface{ UnregisterClient() }); ok {
|
|
||||||
u.UnregisterClient()
|
u.UnregisterClient()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.clientsMutex.RUnlock()
|
|
||||||
|
|
||||||
// Update the client list
|
// Create new API key clients based on the new config
|
||||||
|
newAPIKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := buildAPIKeyClients(cfg)
|
||||||
|
log.Debugf("created %d new API key clients", len(newAPIKeyClients))
|
||||||
|
|
||||||
|
// Load file-based clients
|
||||||
|
newFileClients, successfulAuthCount := w.loadFileClients(cfg)
|
||||||
|
log.Debugf("loaded %d new file-based clients", len(newFileClients))
|
||||||
|
|
||||||
|
// Unregister all old file-based clients
|
||||||
|
log.Debugf("unregistering %d old file-based clients", oldFileClientCount)
|
||||||
|
for _, oldClient := range w.clients {
|
||||||
|
if u, ok := any(oldClient).(interface{ UnregisterClient() }); ok {
|
||||||
|
u.UnregisterClient()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update client maps
|
||||||
w.clientsMutex.Lock()
|
w.clientsMutex.Lock()
|
||||||
w.clients = newClients
|
w.clients = newFileClients
|
||||||
|
w.apiKeyClients = newAPIKeyClients
|
||||||
|
|
||||||
|
// Rebuild auth file hash cache for current clients
|
||||||
|
w.lastAuthHashes = make(map[string]string, len(newFileClients))
|
||||||
|
for path := range newFileClients {
|
||||||
|
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
}
|
||||||
w.clientsMutex.Unlock()
|
w.clientsMutex.Unlock()
|
||||||
|
|
||||||
log.Infof("client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d OpenAI-compat)",
|
totalNewClients := len(newFileClients) + len(newAPIKeyClients)
|
||||||
oldClientCount,
|
|
||||||
len(newClients),
|
log.Infof("full client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||||
|
oldFileClientCount+oldAPIKeyClientCount,
|
||||||
|
totalNewClients,
|
||||||
successfulAuthCount,
|
successfulAuthCount,
|
||||||
glAPIKeyCount,
|
glAPIKeyCount,
|
||||||
claudeAPIKeyCount,
|
claudeAPIKeyCount,
|
||||||
|
codexAPIKeyCount,
|
||||||
openAICompatCount,
|
openAICompatCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Trigger the callback to update the server
|
// Trigger the callback to update the server
|
||||||
if w.reloadCallback != nil {
|
if w.reloadCallback != nil {
|
||||||
log.Debugf("triggering server update callback")
|
log.Debugf("triggering server update callback")
|
||||||
w.reloadCallback(newClients, cfg)
|
combinedClients := w.buildCombinedClientMap()
|
||||||
|
w.reloadCallback(combinedClients, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createClientFromFile creates a single client instance from a given token file path.
|
||||||
|
func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfaces.Client, error) {
|
||||||
|
data, errReadFile := os.ReadFile(path)
|
||||||
|
if errReadFile != nil {
|
||||||
|
return nil, errReadFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file is empty, it's likely an intermediate state (e.g., after touch, before write).
|
||||||
|
// Silently ignore it and wait for a subsequent write event with content.
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, nil // Not an error, just nothing to process yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenType := "gemini"
|
||||||
|
typeResult := gjson.GetBytes(data, "type")
|
||||||
|
if typeResult.Exists() {
|
||||||
|
tokenType = typeResult.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tokenType == "gemini" {
|
||||||
|
var ts gemini.GeminiTokenStorage
|
||||||
|
if err = json.Unmarshal(data, &ts); err == nil {
|
||||||
|
clientCtx := context.Background()
|
||||||
|
geminiAuth := gemini.NewGeminiAuth()
|
||||||
|
httpClient, errGetClient := geminiAuth.GetAuthenticatedClient(clientCtx, &ts, cfg)
|
||||||
|
if errGetClient != nil {
|
||||||
|
return nil, errGetClient
|
||||||
|
}
|
||||||
|
return client.NewGeminiCLIClient(httpClient, &ts, cfg), nil
|
||||||
|
}
|
||||||
|
} else if tokenType == "codex" {
|
||||||
|
var ts codex.CodexTokenStorage
|
||||||
|
if err = json.Unmarshal(data, &ts); err == nil {
|
||||||
|
return client.NewCodexClient(cfg, &ts)
|
||||||
|
}
|
||||||
|
} else if tokenType == "claude" {
|
||||||
|
var ts claude.ClaudeTokenStorage
|
||||||
|
if err = json.Unmarshal(data, &ts); err == nil {
|
||||||
|
return client.NewClaudeClient(cfg, &ts), nil
|
||||||
|
}
|
||||||
|
} else if tokenType == "qwen" {
|
||||||
|
var ts qwen.QwenTokenStorage
|
||||||
|
if err = json.Unmarshal(data, &ts); err == nil {
|
||||||
|
return client.NewQwenClient(cfg, &ts), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientsToSlice converts the client map to a slice.
|
||||||
|
func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client {
|
||||||
|
s := make([]interfaces.Client, 0, len(clientMap))
|
||||||
|
for _, v := range clientMap {
|
||||||
|
s = append(s, v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// addOrUpdateClient handles the addition or update of a single client.
|
||||||
|
func (w *Watcher) addOrUpdateClient(path string) {
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
|
||||||
|
cfg := w.config
|
||||||
|
if cfg == nil {
|
||||||
|
log.Error("config is nil, cannot add or update client")
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file to check for emptiness and calculate hash
|
||||||
|
data, errRead := os.ReadFile(path)
|
||||||
|
if errRead != nil {
|
||||||
|
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
// Empty file: ignore (wait for a subsequent WRITE)
|
||||||
|
log.Debugf("ignoring empty auth file: %s", filepath.Base(path))
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate a hash of the current content and compare with the cache
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
curHash := hex.EncodeToString(sum[:])
|
||||||
|
if prev, ok := w.lastAuthHashes[path]; ok && prev == curHash {
|
||||||
|
log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(path))
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an old client exists, unregister it first
|
||||||
|
if oldClient, ok := w.clients[path]; ok {
|
||||||
|
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
|
||||||
|
log.Debugf("unregistering old client for updated file: %s", filepath.Base(path))
|
||||||
|
u.UnregisterClient()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new client (reads the file again internally; this is acceptable as the files are small and it keeps the change minimal)
|
||||||
|
newClient, err := w.createClientFromFile(path, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to create/update client for %s: %v", filepath.Base(path), err)
|
||||||
|
// If creation fails, ensure the old client is removed from the map; don't update hash, let a subsequent change retry
|
||||||
|
delete(w.clients, path)
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newClient == nil {
|
||||||
|
// This branch should not be reached normally (empty files are handled above); a fallback
|
||||||
|
log.Debugf("ignoring auth file with no client created: %s", filepath.Base(path))
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update client and hash cache
|
||||||
|
log.Debugf("successfully created/updated client for %s", filepath.Base(path))
|
||||||
|
w.clients[path] = newClient
|
||||||
|
w.lastAuthHashes[path] = curHash
|
||||||
|
|
||||||
|
w.clientsMutex.Unlock() // Unlock before the callback
|
||||||
|
|
||||||
|
if w.reloadCallback != nil {
|
||||||
|
log.Debugf("triggering server update callback after add/update")
|
||||||
|
combinedClients := w.buildCombinedClientMap()
|
||||||
|
w.reloadCallback(combinedClients, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeClient handles the removal of a single client.
|
||||||
|
func (w *Watcher) removeClient(path string) {
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
|
||||||
|
cfg := w.config
|
||||||
|
var clientRemoved bool
|
||||||
|
|
||||||
|
// Unregister client if it exists
|
||||||
|
if oldClient, ok := w.clients[path]; ok {
|
||||||
|
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
|
||||||
|
log.Debugf("unregistering client for removed file: %s", filepath.Base(path))
|
||||||
|
u.UnregisterClient()
|
||||||
|
}
|
||||||
|
delete(w.clients, path)
|
||||||
|
delete(w.lastAuthHashes, path)
|
||||||
|
log.Debugf("removed client for %s", filepath.Base(path))
|
||||||
|
clientRemoved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
w.clientsMutex.Unlock() // Release the lock before the callback
|
||||||
|
|
||||||
|
if clientRemoved && w.reloadCallback != nil {
|
||||||
|
log.Debugf("triggering server update callback after removal")
|
||||||
|
combinedClients := w.buildCombinedClientMap()
|
||||||
|
w.reloadCallback(combinedClients, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCombinedClientMap merges file-based clients with API key clients from the cache.
|
||||||
|
func (w *Watcher) buildCombinedClientMap() map[string]interfaces.Client {
|
||||||
|
w.clientsMutex.RLock()
|
||||||
|
defer w.clientsMutex.RUnlock()
|
||||||
|
|
||||||
|
combined := make(map[string]interfaces.Client)
|
||||||
|
|
||||||
|
// Add file-based clients
|
||||||
|
for k, v := range w.clients {
|
||||||
|
combined[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cached API key-based clients
|
||||||
|
for k, v := range w.apiKeyClients {
|
||||||
|
combined[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFileClients scans the auth directory and creates clients from .json files.
|
||||||
|
func (w *Watcher) loadFileClients(cfg *config.Config) (map[string]interfaces.Client, int) {
|
||||||
|
newClients := make(map[string]interfaces.Client)
|
||||||
|
authFileCount := 0
|
||||||
|
successfulAuthCount := 0
|
||||||
|
|
||||||
|
authDir := cfg.AuthDir
|
||||||
|
if strings.HasPrefix(authDir, "~") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to get home directory: %v", err)
|
||||||
|
return newClients, 0
|
||||||
|
}
|
||||||
|
authDir = filepath.Join(home, authDir[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
errWalk := filepath.Walk(authDir, func(path string, info fs.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("error accessing path %s: %v", path, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
|
||||||
|
authFileCount++
|
||||||
|
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
|
||||||
|
if cliClient, errCreate := w.createClientFromFile(path, cfg); errCreate == nil && cliClient != nil {
|
||||||
|
newClients[path] = cliClient
|
||||||
|
successfulAuthCount++
|
||||||
|
} else if errCreate != nil {
|
||||||
|
log.Errorf("failed to create client from file %s: %v", path, errCreate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if errWalk != nil {
|
||||||
|
log.Errorf("error walking auth directory: %v", errWalk)
|
||||||
|
}
|
||||||
|
log.Debugf("auth directory scan complete - found %d .json files, %d successful authentications", authFileCount, successfulAuthCount)
|
||||||
|
return newClients, successfulAuthCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAPIKeyClients creates clients from API keys in the config.
|
||||||
|
func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
|
||||||
|
apiKeyClients := make(map[string]interfaces.Client)
|
||||||
|
glAPIKeyCount := 0
|
||||||
|
claudeAPIKeyCount := 0
|
||||||
|
codexAPIKeyCount := 0
|
||||||
|
openAICompatCount := 0
|
||||||
|
|
||||||
|
if len(cfg.GlAPIKey) > 0 {
|
||||||
|
for _, key := range cfg.GlAPIKey {
|
||||||
|
httpClient := util.SetProxy(cfg, &http.Client{})
|
||||||
|
cliClient := client.NewGeminiClient(httpClient, cfg, key)
|
||||||
|
apiKeyClients[cliClient.GetClientID()] = cliClient
|
||||||
|
glAPIKeyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cfg.ClaudeKey) > 0 {
|
||||||
|
for i := range cfg.ClaudeKey {
|
||||||
|
cliClient := client.NewClaudeClientWithKey(cfg, i)
|
||||||
|
apiKeyClients[cliClient.GetClientID()] = cliClient
|
||||||
|
claudeAPIKeyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cfg.CodexKey) > 0 {
|
||||||
|
for i := range cfg.CodexKey {
|
||||||
|
cliClient := client.NewCodexClientWithKey(cfg, i)
|
||||||
|
apiKeyClients[cliClient.GetClientID()] = cliClient
|
||||||
|
codexAPIKeyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cfg.OpenAICompatibility) > 0 {
|
||||||
|
for _, compatConfig := range cfg.OpenAICompatibility {
|
||||||
|
for i := 0; i < len(compatConfig.APIKeys); i++ {
|
||||||
|
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
|
||||||
|
if errClient != nil {
|
||||||
|
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compatConfig.Name, errClient)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiKeyClients[compatClient.GetClientID()] = compatClient
|
||||||
|
openAICompatCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user