chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:43 +08:00
commit 0e2cc6b102
927 changed files with 260929 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Git and GitHub folders
.git
.github
# Docker and CI/CD related files
docker-compose.yml
.dockerignore
.gitignore
.goreleaser.yml
Dockerfile
# Documentation and license
docs
README.md
README_CN.md
LICENSE
# Runtime data folders (should be mounted as volumes)
auths
logs
conv
config.yaml
# Development/editor
bin
.vscode
.claude
.codex
.codex-worktrees
.gemini
.serena
.agent
.agents
.antigravitycli
.opencode
.idea
.junie
.worktrees
.bmad
_bmad
_bmad-output
+5
View File
@@ -0,0 +1,5 @@
# Cluster JWT example.
# After deploying https://github.com/router-for-me/CLIProxyAPIHome, get the JWT value with:
# curl -sS -X POST "http://<home-host>:8327/v0/management/certificates/clients" -H "X-MANAGEMENT-KEY: <management-key>" | jq -r '.home_jwt'
# Then paste it into HOME_JWT here or export it before starting Compose.
HOME_JWT=your-home-jwt-here
+34
View File
@@ -0,0 +1,34 @@
# Example environment configuration for CLIProxyAPI.
# Copy this file to `.env` and uncomment the variables you need.
#
# NOTE: Environment variables are only required when using remote storage options.
# For local file-based storage (default), no environment variables need to be set.
# ------------------------------------------------------------------------------
# Management Web UI
# ------------------------------------------------------------------------------
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
# ------------------------------------------------------------------------------
# Postgres Token Store (optional)
# ------------------------------------------------------------------------------
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
# PGSTORE_SCHEMA=public
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
# ------------------------------------------------------------------------------
# Git-Backed Config Store (optional)
# ------------------------------------------------------------------------------
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
# GITSTORE_GIT_USERNAME=git-user
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
# ------------------------------------------------------------------------------
# Object Store Token Store (optional)
# ------------------------------------------------------------------------------
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
# OBJECTSTORE_BUCKET=cli-proxy-config
# OBJECTSTORE_ACCESS_KEY=your_access_key
# OBJECTSTORE_SECRET_KEY=your_secret_key
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore
+1
View File
@@ -0,0 +1 @@
github: [router-for-me]
+44
View File
@@ -0,0 +1,44 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Is it a request payload issue?**
[ ] Yes, this is a request payload issue. I am using a client/cURL to send a request payload, but I received an unexpected error.
[ ] No, it's another issue.
**If it's a request payload issue, you MUST know**
Our team doesn't have any GODs or ORACLEs or MIND READERs. Please make sure to attach the request log or curl payload.
**Describe the bug**
A clear and concise description of what the bug is.
**CLI Type**
What type of CLI account do you use? (gemini, codex, claude code or openai-compatibility)
**Model Name**
What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
**LLM Client**
What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
**Request Information**
The best way is to paste the cURL command of the HTTP request here.
Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**OS Type**
- OS: [e.g. macOS]
- Version [e.g. 15.6.0]
**Additional context**
Add any other context about the problem here.
+81
View File
@@ -0,0 +1,81 @@
name: agents-md-guard
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-when-agents-md-changed:
runs-on: ubuntu-latest
steps:
- name: Detect AGENTS.md changes and close PR
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const touchesAgentsMd = (path) =>
typeof path === "string" &&
(path === "AGENTS.md" || path.endsWith("/AGENTS.md"));
const touched = files.filter(
(f) => touchesAgentsMd(f.filename) || touchesAgentsMd(f.previous_filename),
);
if (touched.length === 0) {
core.info("No AGENTS.md changes detected.");
return;
}
const changedList = touched
.map((f) =>
f.previous_filename && f.previous_filename !== f.filename
? `- ${f.previous_filename} -> ${f.filename}`
: `- ${f.filename}`,
)
.join("\n");
const body = [
"This repository does not allow modifying `AGENTS.md` in pull requests.",
"",
"Detected changes:",
changedList,
"",
"Please revert these changes and open a new PR without touching `AGENTS.md`.",
].join("\n");
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
} catch (error) {
core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`);
}
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
state: "closed",
});
core.setFailed("PR modifies AGENTS.md");
@@ -0,0 +1,73 @@
name: auto-retarget-main-pr-to-dev
on:
pull_request_target:
types:
- opened
- reopened
- edited
branches:
- main
permissions:
contents: read
issues: write
pull-requests: write
jobs:
retarget:
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- name: Retarget PR base to dev
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const prNumber = pr.number;
const { owner, repo } = context.repo;
const baseRef = pr.base?.ref;
const headRef = pr.head?.ref;
const desiredBase = "dev";
if (baseRef !== "main") {
core.info(`PR #${prNumber} base is ${baseRef}; nothing to do.`);
return;
}
if (headRef === desiredBase) {
core.info(`PR #${prNumber} is ${desiredBase} -> main; skipping retarget.`);
return;
}
core.info(`Retargeting PR #${prNumber} base from ${baseRef} to ${desiredBase}.`);
try {
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
base: desiredBase,
});
} catch (error) {
core.setFailed(`Failed to retarget PR #${prNumber} to ${desiredBase}: ${error.message}`);
return;
}
const body = [
`This pull request targeted \`${baseRef}\`.`,
"",
`The base branch has been automatically changed to \`${desiredBase}\`.`,
].join("\n");
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
} catch (error) {
core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`);
}
+147
View File
@@ -0,0 +1,147 @@
name: docker-image
on:
push:
tags:
- v*
env:
APP_NAME: CLIProxyAPI
DOCKERHUB_REPO: eceasy/cli-proxy-api
jobs:
docker_amd64:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Refresh models catalog
run: |
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Build Metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $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 (amd64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
build-args: |
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_DATE=${{ env.BUILD_DATE }}
tags: |
${{ env.DOCKERHUB_REPO }}:latest-amd64
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}-amd64
docker_arm64:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Refresh models catalog
run: |
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Build Metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $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 (arm64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
push: true
build-args: |
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_DATE=${{ env.BUILD_DATE }}
tags: |
${{ env.DOCKERHUB_REPO }}:latest-arm64
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}-arm64
docker_manifest:
runs-on: ubuntu-latest
needs:
- docker_amd64
- docker_arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Build Metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $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: Create and push multi-arch manifests
run: |
docker buildx imagetools create \
--tag "${DOCKERHUB_REPO}:latest" \
"${DOCKERHUB_REPO}:latest-amd64" \
"${DOCKERHUB_REPO}:latest-arm64"
docker buildx imagetools create \
--tag "${DOCKERHUB_REPO}:${VERSION}" \
"${DOCKERHUB_REPO}:${VERSION}-amd64" \
"${DOCKERHUB_REPO}:${VERSION}-arm64"
- name: Cleanup temporary tags
continue-on-error: true
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
set -euo pipefail
namespace="${DOCKERHUB_REPO%%/*}"
repo_name="${DOCKERHUB_REPO#*/}"
token="$(
curl -fsSL \
-H 'Content-Type: application/json' \
-d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}" \
'https://hub.docker.com/v2/users/login/' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
)"
delete_tag() {
local tag="$1"
local url="https://hub.docker.com/v2/repositories/${namespace}/${repo_name}/tags/${tag}/"
local http_code
http_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE -H "Authorization: JWT ${token}" "${url}" || true)"
if [ "${http_code}" = "204" ] || [ "${http_code}" = "404" ]; then
echo "Docker Hub tag removed (or missing): ${DOCKERHUB_REPO}:${tag} (HTTP ${http_code})"
return 0
fi
echo "Docker Hub tag delete failed: ${DOCKERHUB_REPO}:${tag} (HTTP ${http_code})"
return 0
}
delete_tag "latest-amd64"
delete_tag "latest-arm64"
delete_tag "${VERSION}-amd64"
delete_tag "${VERSION}-arm64"
+28
View File
@@ -0,0 +1,28 @@
name: translator-path-guard
on:
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
ensure-no-translator-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect internal/translator changes
id: changed-files
uses: tj-actions/changed-files@v45
with:
files: |
internal/translator/**
- name: Fail when restricted paths change
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "Changes under internal/translator are not allowed in pull requests."
echo "You need to create an issue for our maintenance team to make the necessary changes."
exit 1
+27
View File
@@ -0,0 +1,27 @@
name: pr-test-build
on:
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Refresh models catalog
run: |
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build
run: |
go build -o test-output ./cmd/server
rm -f test-output
+677
View File
@@ -0,0 +1,677 @@
name: release
on:
push:
# run only against tags
tags:
- '*'
permissions:
contents: write
env:
GH_REPO: ${{ github.repository }}
GO_VERSION: '1.26.4'
jobs:
prepare-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
release_notes_file="$(mktemp)"
generated_notes_file="$(mktemp)"
changelog_entries_file="$(mktemp)"
changelog_notes_file="$(mktemp)"
updated_notes_file="$(mktemp)"
trap 'rm -f "$release_notes_file" "$generated_notes_file" "$changelog_entries_file" "$changelog_notes_file" "$updated_notes_file"' EXIT
cat > "$release_notes_file" <<'EOF'
<!-- cliproxyapi-linux-release-assets:start -->
## Linux release assets
- `CLIProxyAPI_<version>_linux_<arch>.tar.gz` is the default Linux build. It supports dynamic library plugins and is built against a GLIBC 2.17 baseline.
- `CLIProxyAPI_<version>_linux_<arch>_no-plugin.tar.gz` is the portable Linux build for musl-based or older systems such as OpenWrt. It does not support dynamic library plugins.
## FreeBSD release assets
- `CLIProxyAPI_<version>_freebsd_aarch64_no-plugin.tar.gz` is the FreeBSD arm64 build. It is built without CGO and does not support dynamic library plugins.
<!-- cliproxyapi-linux-release-assets:end -->
EOF
git fetch --force --tags
previous_tag=""
if previous_tag_value="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null)"; then
previous_tag="$previous_tag_value"
changelog_range="${previous_tag}..${GITHUB_REF_NAME}"
else
changelog_range="$GITHUB_REF_NAME"
fi
git log --reverse --pretty=format:'- %s (%h)' "$changelog_range" |
grep -Ev '^- (docs:|test:)' > "$changelog_entries_file" || true
gh api "repos/${GH_REPO}/releases/generate-notes" \
-f tag_name="$GITHUB_REF_NAME" \
--jq .body > "$generated_notes_file"
if [[ ! -s "$generated_notes_file" ]]; then
if [[ -n "$previous_tag" ]]; then
printf '**Full Changelog**: https://github.com/%s/compare/%s...%s\n' "$GH_REPO" "$previous_tag" "$GITHUB_REF_NAME" > "$generated_notes_file"
else
printf '**Full Changelog**: https://github.com/%s/commits/%s\n' "$GH_REPO" "$GITHUB_REF_NAME" > "$generated_notes_file"
fi
fi
{
if [[ -s "$changelog_entries_file" ]]; then
printf '## Changelog\n\n'
cat "$changelog_entries_file"
printf '\n\n'
fi
cat "$generated_notes_file"
} > "$changelog_notes_file"
{
cat "$release_notes_file"
printf '\n'
cat "$changelog_notes_file"
} > "$updated_notes_file"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release edit "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$updated_notes_file"
else
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$updated_notes_file"
fi
build-hosted:
name: build ${{ matrix.target }}
needs: prepare-release
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- target: darwin-amd64
runner: macos-15-intel
goos: darwin
goarch: amd64
asset_arch: amd64
archive_format: tar.gz
- target: darwin-arm64
runner: macos-15
goos: darwin
goarch: arm64
asset_arch: aarch64
archive_format: tar.gz
- target: windows-amd64
runner: windows-latest
goos: windows
goarch: amd64
asset_arch: amd64
archive_format: zip
- target: windows-arm64
runner: windows-11-arm
goos: windows
goarch: arm64
asset_arch: aarch64
archive_format: zip
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Refresh models catalog
shell: bash
run: |
set -euo pipefail
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Fetch tags
shell: bash
run: git fetch --force --tags
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.target }}-${{ hashFiles('go.sum') }}
restore-keys: |
go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.target }}-
go-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Build Metadata
shell: bash
run: |
set -euo pipefail
echo "RELEASE_VERSION=${GITHUB_REF_NAME#v}" >> "$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 archive
shell: bash
env:
TARGET: ${{ matrix.target }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
ASSET_ARCH: ${{ matrix.asset_arch }}
ARCHIVE_FORMAT: ${{ matrix.archive_format }}
run: |
set -euo pipefail
binary_name="cli-proxy-api"
if [[ "$GOOS" == "windows" ]]; then
binary_name="cli-proxy-api.exe"
fi
archive_dir="dist/${TARGET}/archive"
archive_name="CLIProxyAPI_${RELEASE_VERSION}_${GOOS}_${ASSET_ARCH}.${ARCHIVE_FORMAT}"
rm -rf "dist/${TARGET}"
mkdir -p "$archive_dir"
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-ldflags="-s -w -X main.Version=${RELEASE_VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE}" \
-o "$archive_dir/$binary_name" ./cmd/server/
cp LICENSE README.md README_CN.md config.example.yaml "$archive_dir/"
if [[ "$ARCHIVE_FORMAT" == "zip" ]]; then
powershell -NoProfile -Command "Compress-Archive -Path '${archive_dir}/*' -DestinationPath 'dist/${archive_name}' -Force"
else
tar -C "$archive_dir" -czf "dist/$archive_name" "$binary_name" LICENSE README.md README_CN.md config.example.yaml
fi
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: dist/CLIProxyAPI_*
if-no-files-found: error
- name: Upload release assets
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(dist/CLIProxyAPI_*.tar.gz dist/CLIProxyAPI_*.zip)
if [[ ${#assets[@]} -eq 0 ]]; then
printf 'expected archive assets, found %s\n' "${#assets[@]}" >&2
printf '%s\n' "${assets[@]}" >&2
exit 1
fi
gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber
- name: Refresh release checksums
continue-on-error: true
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
gh release download "$GITHUB_REF_NAME" \
--pattern 'CLIProxyAPI_*.tar.gz' \
--pattern 'CLIProxyAPI_*.zip' \
--dir "$tmp_dir" \
--clobber
(
cd "$tmp_dir"
shopt -s nullglob
archives=(CLIProxyAPI_*.tar.gz CLIProxyAPI_*.zip)
if [[ ${#archives[@]} -eq 0 ]]; then
echo "No release archives found"
exit 0
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${archives[@]}" | sort -k2 > checksums.txt
else
shasum -a 256 "${archives[@]}" | sort -k2 > checksums.txt
fi
)
gh release upload "$GITHUB_REF_NAME" "$tmp_dir/checksums.txt" --clobber
build-linux-glibc:
name: build linux-${{ matrix.goarch }} glibc
needs: prepare-release
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- target: linux-amd64
runner: ubuntu-latest
goarch: amd64
asset_arch: amd64
manylinux_image: quay.io/pypa/manylinux2014_x86_64
- target: linux-arm64
runner: ubuntu-24.04-arm
goarch: arm64
asset_arch: aarch64
manylinux_image: quay.io/pypa/manylinux2014_aarch64
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Refresh models catalog
shell: bash
run: |
set -euo pipefail
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Fetch tags
shell: bash
run: git fetch --force --tags
- name: Generate Build Metadata
shell: bash
run: |
set -euo pipefail
echo "RELEASE_VERSION=${GITHUB_REF_NAME#v}" >> "$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 archive
shell: bash
env:
TARGET: ${{ matrix.target }}
GOARCH: ${{ matrix.goarch }}
ASSET_ARCH: ${{ matrix.asset_arch }}
MANYLINUX_IMAGE: ${{ matrix.manylinux_image }}
run: |
set -euo pipefail
archive_dir="dist/${TARGET}/archive"
archive_name="CLIProxyAPI_${RELEASE_VERSION}_linux_${ASSET_ARCH}.tar.gz"
rm -rf "dist/${TARGET}"
mkdir -p "$archive_dir"
docker run --rm \
-v "$PWD:/src" \
-w /src \
-e GO_VERSION \
-e GOARCH \
-e RELEASE_VERSION \
-e COMMIT \
-e BUILD_DATE \
"$MANYLINUX_IMAGE" \
bash -euo pipefail -c '
go_archive="go${GO_VERSION}.linux-${GOARCH}.tar.gz"
curl -fsSL "https://go.dev/dl/${go_archive}" -o "/tmp/${go_archive}"
rm -rf /usr/local/go
tar -C /usr/local -xzf "/tmp/${go_archive}"
export PATH="/usr/local/go/bin:${PATH}"
CGO_ENABLED=1 GOOS=linux GOARCH="${GOARCH}" go build -buildvcs=false \
-ldflags="-s -w -X main.Version=${RELEASE_VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE}" \
-o "'"$archive_dir"'/cli-proxy-api" ./cmd/server/
glibc_versions="$(readelf --version-info "'"$archive_dir"'/cli-proxy-api" | sed -n "s/.*Name: GLIBC_\([0-9.]*\).*/\1/p" | sort -Vu)"
if [[ -n "${glibc_versions}" ]]; then
printf "GLIBC versions:\n%s\n" "${glibc_versions}"
max_glibc="$(printf "%s\n" "${glibc_versions}" | sort -V | tail -n 1)"
if [[ "$(printf "2.17\n%s\n" "${max_glibc}" | sort -V | tail -n 1)" != "2.17" ]]; then
printf "linux ${GOARCH} binary requires GLIBC_%s, expected GLIBC_2.17 or older\n" "${max_glibc}" >&2
exit 1
fi
fi
'
cp LICENSE README.md README_CN.md config.example.yaml "$archive_dir/"
tar -C "$archive_dir" -czf "dist/$archive_name" cli-proxy-api LICENSE README.md README_CN.md config.example.yaml
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: dist/CLIProxyAPI_*
if-no-files-found: error
- name: Upload release assets
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(dist/CLIProxyAPI_*.tar.gz)
if [[ ${#assets[@]} -eq 0 ]]; then
printf 'expected archive assets, found %s\n' "${#assets[@]}" >&2
printf '%s\n' "${assets[@]}" >&2
exit 1
fi
gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber
- name: Refresh release checksums
continue-on-error: true
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
gh release download "$GITHUB_REF_NAME" \
--pattern 'CLIProxyAPI_*.tar.gz' \
--pattern 'CLIProxyAPI_*.zip' \
--dir "$tmp_dir" \
--clobber
(
cd "$tmp_dir"
shopt -s nullglob
archives=(CLIProxyAPI_*.tar.gz CLIProxyAPI_*.zip)
if [[ ${#archives[@]} -eq 0 ]]; then
echo "No release archives found"
exit 0
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${archives[@]}" | sort -k2 > checksums.txt
else
shasum -a 256 "${archives[@]}" | sort -k2 > checksums.txt
fi
)
gh release upload "$GITHUB_REF_NAME" "$tmp_dir/checksums.txt" --clobber
build-linux-no-plugin:
name: build linux-${{ matrix.goarch }} no-plugin
needs: prepare-release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: linux-amd64-no-plugin
goarch: amd64
asset_arch: amd64
- target: linux-arm64-no-plugin
goarch: arm64
asset_arch: aarch64
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Refresh models catalog
shell: bash
run: |
set -euo pipefail
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Fetch tags
shell: bash
run: git fetch --force --tags
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-linux-no-plugin-${{ matrix.goarch }}-${{ hashFiles('go.sum') }}
restore-keys: |
go-linux-no-plugin-${{ matrix.goarch }}-
go-linux-no-plugin-
- name: Generate Build Metadata
shell: bash
run: |
set -euo pipefail
echo "RELEASE_VERSION=${GITHUB_REF_NAME#v}" >> "$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 archive
shell: bash
env:
TARGET: ${{ matrix.target }}
GOARCH: ${{ matrix.goarch }}
ASSET_ARCH: ${{ matrix.asset_arch }}
run: |
set -euo pipefail
archive_dir="dist/${TARGET}/archive"
archive_name="CLIProxyAPI_${RELEASE_VERSION}_linux_${ASSET_ARCH}_no-plugin.tar.gz"
rm -rf "dist/${TARGET}"
mkdir -p "$archive_dir"
CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" go build -buildvcs=false \
-ldflags="-s -w -X main.Version=${RELEASE_VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE}" \
-o "$archive_dir/cli-proxy-api" ./cmd/server/
if readelf -l "$archive_dir/cli-proxy-api" | grep -q 'Requesting program interpreter'; then
readelf -l "$archive_dir/cli-proxy-api" >&2
echo "no-plugin linux binary must not require a dynamic interpreter" >&2
exit 1
fi
cp LICENSE README.md README_CN.md config.example.yaml "$archive_dir/"
tar -C "$archive_dir" -czf "dist/$archive_name" cli-proxy-api LICENSE README.md README_CN.md config.example.yaml
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: dist/CLIProxyAPI_*
if-no-files-found: error
- name: Upload release assets
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(dist/CLIProxyAPI_*.tar.gz)
if [[ ${#assets[@]} -eq 0 ]]; then
printf 'expected archive assets, found %s\n' "${#assets[@]}" >&2
printf '%s\n' "${assets[@]}" >&2
exit 1
fi
gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber
- name: Refresh release checksums
continue-on-error: true
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
gh release download "$GITHUB_REF_NAME" \
--pattern 'CLIProxyAPI_*.tar.gz' \
--pattern 'CLIProxyAPI_*.zip' \
--dir "$tmp_dir" \
--clobber
(
cd "$tmp_dir"
shopt -s nullglob
archives=(CLIProxyAPI_*.tar.gz CLIProxyAPI_*.zip)
if [[ ${#archives[@]} -eq 0 ]]; then
echo "No release archives found"
exit 0
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${archives[@]}" | sort -k2 > checksums.txt
else
shasum -a 256 "${archives[@]}" | sort -k2 > checksums.txt
fi
)
gh release upload "$GITHUB_REF_NAME" "$tmp_dir/checksums.txt" --clobber
build-freebsd:
name: build ${{ matrix.target }}
needs: prepare-release
runs-on: ubuntu-latest
env:
TARGET: ${{ matrix.target }}
GOARCH: ${{ matrix.goarch }}
ASSET_ARCH: ${{ matrix.asset_arch }}
ASSET_SUFFIX: ${{ matrix.asset_suffix }}
strategy:
fail-fast: false
matrix:
include:
- target: freebsd-amd64
goarch: amd64
asset_arch: amd64
asset_suffix: ''
cgo_enabled: true
- target: freebsd-arm64-no-plugin
goarch: arm64
asset_arch: aarch64
asset_suffix: _no-plugin
cgo_enabled: false
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Refresh models catalog
run: |
git fetch --depth 1 https://github.com/router-for-me/models.git main
git show FETCH_HEAD:models.json > internal/registry/models/models.json
- name: Fetch tags
run: git fetch --force --tags
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-freebsd-${{ matrix.goarch }}-${{ hashFiles('go.sum') }}
restore-keys: |
go-freebsd-${{ matrix.goarch }}-
go-freebsd-
- name: Generate Build Metadata
id: metadata
run: |
set -euo pipefail
release_version="${GITHUB_REF_NAME#v}"
commit="$(git rev-parse --short HEAD)"
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV"
echo "COMMIT=$commit" >> "$GITHUB_ENV"
echo "BUILD_DATE=$build_date" >> "$GITHUB_ENV"
echo "release_version=$release_version" >> "$GITHUB_OUTPUT"
echo "commit=$commit" >> "$GITHUB_OUTPUT"
echo "build_date=$build_date" >> "$GITHUB_OUTPUT"
- name: Prepare FreeBSD output
shell: bash
run: |
set -euo pipefail
rm -rf "dist/${TARGET}"
- name: Install FreeBSD cross-build dependencies
if: ${{ matrix.cgo_enabled }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld wget
- name: Build FreeBSD binary with CGO
if: ${{ matrix.cgo_enabled }}
timeout-minutes: 45
uses: go-cross/cgo-actions@v1
with:
dir: .
packages: ./cmd/server/
targets: ${{ env.TARGET }}
out-dir: dist/${{ env.TARGET }}/bin
output: cli-proxy-api
flags: >-
-ldflags=-s -w
-X main.Version=${{ steps.metadata.outputs.release_version }}
-X main.Commit=${{ steps.metadata.outputs.commit }}
-X main.BuildDate=${{ steps.metadata.outputs.build_date }}
- name: Build FreeBSD no-plugin binary
if: ${{ !matrix.cgo_enabled }}
shell: bash
run: |
set -euo pipefail
mkdir -p "dist/${TARGET}/bin"
CGO_ENABLED=0 GOOS=freebsd GOARCH="$GOARCH" go build -buildvcs=false \
-ldflags="-s -w -X main.Version=${RELEASE_VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE}" \
-o "dist/${TARGET}/bin/cli-proxy-api" ./cmd/server/
- name: Package FreeBSD archive
shell: bash
run: |
set -euo pipefail
archive_dir="dist/${TARGET}/archive"
archive_name="CLIProxyAPI_${RELEASE_VERSION}_freebsd_${ASSET_ARCH}${ASSET_SUFFIX}.tar.gz"
mkdir -p "$archive_dir"
echo "Packaging ${archive_name}"
cp "dist/${TARGET}/bin/cli-proxy-api" "$archive_dir/cli-proxy-api"
cp LICENSE README.md README_CN.md config.example.yaml "$archive_dir/"
tar -C "$archive_dir" -czf "dist/$archive_name" cli-proxy-api LICENSE README.md README_CN.md config.example.yaml
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: dist/CLIProxyAPI_*
if-no-files-found: error
- name: Upload release assets
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(dist/CLIProxyAPI_*.tar.gz)
if [[ ${#assets[@]} -eq 0 ]]; then
printf 'expected archive assets, found %s\n' "${#assets[@]}" >&2
printf '%s\n' "${assets[@]}" >&2
exit 1
fi
gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber
- name: Refresh release checksums
continue-on-error: true
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
gh release download "$GITHUB_REF_NAME" \
--pattern 'CLIProxyAPI_*.tar.gz' \
--pattern 'CLIProxyAPI_*.zip' \
--dir "$tmp_dir" \
--clobber
(
cd "$tmp_dir"
shopt -s nullglob
archives=(CLIProxyAPI_*.tar.gz CLIProxyAPI_*.zip)
if [[ ${#archives[@]} -eq 0 ]]; then
echo "No release archives found"
exit 0
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${archives[@]}" | sort -k2 > checksums.txt
else
shasum -a 256 "${archives[@]}" | sort -k2 > checksums.txt
fi
)
gh release upload "$GITHUB_REF_NAME" "$tmp_dir/checksums.txt" --clobber
publish-checksums:
runs-on: ubuntu-latest
if: always()
needs:
- build-hosted
- build-linux-glibc
- build-linux-no-plugin
- build-freebsd
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Publish final checksums
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cd dist
shopt -s nullglob
archives=(CLIProxyAPI_*.tar.gz CLIProxyAPI_*.zip)
if [[ ${#archives[@]} -eq 0 ]]; then
echo "No release archives found"
exit 0
fi
sha256sum "${archives[@]}" | sort -k2 > checksums.txt
gh release upload "$GITHUB_REF_NAME" checksums.txt --clobber
+58
View File
@@ -0,0 +1,58 @@
# Binaries
cli-proxy-api
*.exe
# Configuration
config.yaml
.env
# Generated content
bin/*
logs/*
conv/*
temp/*
refs/*
plugins/*
examples/plugin/bin/*
# Storage backends
pgstore/*
gitstore/*
objectstore/*
# Static assets
static/*
# Authentication data
auths/*
/auths
!auths/.gitkeep
# Documentation
docs/*
AGENTS.md
CLAUDE.md
GEMINI.md
# Tooling metadata
.vscode/*
.worktrees/
.codex/*
.claude/*
.claude
.gemini/*
.serena/*
.agent/*
.agents
.agents/*
.opencode/*
.idea/*
.beads/*
.bmad/*
_bmad/*
_bmad-output/*
# macOS
.DS_Store
._*
.gocache/
+37
View File
@@ -0,0 +1,37 @@
FROM golang:1.26-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential git && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=1 GOOS=linux go build -buildvcs=false -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends tzdata ca-certificates && rm -rf /var/lib/apt/lists/*
RUN mkdir /CLIProxyAPI
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
COPY config.example.yaml /CLIProxyAPI/config.example.yaml
WORKDIR /CLIProxyAPI
EXPOSE 8317
ENV TZ=Asia/Shanghai
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
CMD ["./CLIProxyAPI"]
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025-2005.9 Luis Pater
Copyright (c) 2025.9-present Router-For.ME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+258
View File
@@ -0,0 +1,258 @@
# CLI Proxy API
English | [中文](README_CN.md) | [日本語](README_JA.md)
A proxy server that provides OpenAI/Gemini/Claude/Codex/Grok compatible API interfaces for CLI.
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
## Sponsor
[![https://www.packyapi.com/register?aff=cliproxyapi](./assets/packycode-en.png)](https://www.packyapi.com/register?aff=cliproxyapi)
Thanks to PackyCode for sponsoring this project!
PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more.
PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.
---
<table>
<tbody>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via <a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of <b>10% of the official GPT subscription price (90% OFF)</b>!</td>
</tr>
<tr>
<td width="180"><a href="https://visioncoder.cn/"><img src="./assets/visioncoder.png" alt="VisionCoder" width="150"></a></td>
<td>Thanks to VisionCoder for supporting this project. <a href="https://visioncoder.cn/">VisionCoder Developer Platform</a> is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. Additionally, VisionCoder now offers retail channels for <b>Claude Max 200 and GPT Pro 200 premium accounts</b>, providing users with instant access to top-tier AI computing power and features.</td>
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=CLIProxyAPI"><img src="./assets/apikey.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay platform dedicated to providing stable, efficient, and low-cost AI model API access for enterprises and individual developers. The platform supports popular mainstream models such as Claude, OpenAI, and Gemini, with prices as low as 7% of the official price. Register through this project's <a href="https://apikey.fun/register?aff=CLIProxyAPI">exclusive link</a> to enjoy a special <b>permanent 5% top-up discount</b>.</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=FivD"><img src="./assets/runapi.png" alt="RunAPI" width="150"></a></td>
<td>RunAPI is an efficient and stable API platform—an alternative to OpenRouter. A single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, Grok, and more, at prices as low as 10% of the original (up to 90% off), with exceptional stability. It's seamlessly compatible with tools like Claude Code, OpenClaw, and others. RunAPI offers an exclusive perk for CPA users: <a href="https://runapi.co/register?aff=FivD">register</a> and contact an administrator to claim ¥7 in free credit.</td>
</tr>
<tr>
<td width="180"><a href="https://catapi.ai/sign-up"><img src="./assets/catapi.png" alt="CatAPI" width="150"></a></td>
<td>Cat API is an AI model aggregation platform built for individual developers and teams, integrating leading large language models into a single simple, stable, and easy-to-use entry point. It provides an API fully compatible with OpenAI, Claude, and Gemini that plugs seamlessly into mainstream AI IDEs and coding tools such as Claude Code, Cursor, Windsurf, Cline, Roo Code, Continue, Codex, and Trae, and features dedicated CN2 high-speed routing for low-latency, highly reliable access. <a href="https://catapi.ai/sign-up">Sign up</a> to claim 1$ in free credits.</td>
</tr>
<tr>
<td width="180"><a href="https://t.me/CyberWlD/218"><img src="./assets/cyberpay.jpg" alt="CyberPay" width="150"></a></td>
<td>CyberPay was founded in 2021. We are committed to providing stable, efficient, and secure payment settlement solutions for AI industry merchants. Working with us helps your website platform solve Alipay and WeChat payment collection needs. We support business cooperation for selling GPT, Gemini, Claude, and Codex accounts, relay platforms, and other related services, helping merchants address payment collection challenges. <a href="https://t.me/CyberWlD/218">Contact us</a> to start your path to growth.</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo"><img src="./assets/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
<td>Thanks to <a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo">Claude API</a> for sponsoring this project! Claude API is an official-channel API provider focused on Claude models. Built on Anthropic official keys and AWS Bedrock official channels, it provides a stable integration experience for Claude Code and Agent applications, supports the full Claude model family, and preserves official capabilities such as Tool Use and long context. The service is not reverse-engineered and does not downgrade model capabilities, making it suitable for heavy Claude Code users, Agent engineers, and enterprise technical teams. Register through the <a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo">Exclusive link</a> and contact customer support to claim free test credits. Invoicing and team onboarding are also supported.</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf"><img src="./assets/code0.png" alt="code0" width="150"></a></td>
<td>Thanks to <a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf">Code0</a> for sponsoring this project! code0.ai is an AI coding workspace for developers and technical teams, bringing together mainstream Agent coding capabilities such as Claude Code and Codex. It supports common development scenarios including code generation, project understanding, debugging, code review, and documentation. It is suitable for independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register through the <a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf">Exclusive link</a> and contact customer support to claim free test credits and experience a more efficient AI coding workflow.</td>
</tr>
<tr>
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY"><img src="./assets/fennoai.png" alt="FennoAI" width="150"></a></td>
<td>Thanks to <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY">Fenno.ai</a> for sponsoring this project! Fenno.ai is a stable and efficient API relay service provider currently focused on Codex relay services. It is compatible with OpenAI and Anthropic protocols and can flexibly connect to mainstream coding tools such as Codex, Claude Code, and OpenCode. It can reliably support enterprise-grade demand of hundreds of billions of tokens per day, with B2B settlement and invoicing for domestic and overseas entities. Fenno.ai offers an exclusive benefit for CLIProxyAPI users: subscribe to the great-value Coding Plan with <b>9.9 yuan / $150 quota</b> through <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY">this link</a>, and invite friends to earn up to 20% rewards.</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/7zUJri"><img src="./assets/qiniucloud.png" alt="Qiniu Cloud AI" width="150"></a></td>
<td>Thanks to <a href="https://s.qiniu.com/7zUJri">Qiniu Cloud AI</a> for sponsoring this project! Qiniu Cloud AI is an enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK). It provides one-stop access to 150+ mainstream global models, is compatible with protocols from major global model providers, and covers full-modal processing capabilities for text, image, audio, video, and files. It serves more than 1.69 million enterprise and developer users. Exclusive benefits: enterprise users can claim 12 million free tokens, and invite friends to earn up to tens of billions of tokens.</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
</tr>
<tr>
<td width="180"><a href="https://www.fastaitoken.com/"><img src="./assets/fastaitoken.png" alt="FastAIToken" width="150"></a></td>
<td>Thanks to <a href="https://www.fastaitoken.com/">FastAIToken</a> for sponsoring this project! FastAIToken is an AI API aggregation platform built for developers, focused on speed and stability. It supports leading AI models including OpenAI, Claude, Gemini, and more. With a 1:1 recharge ratio (¥1 = $1 in API credits), developers can access the world's top AI models at lower cost and with greater convenience. <a href="https://t.me/+stwq0MLi0PtkZTZl">Telegram Support Group</a><br/>The platform offers multiple channels to suit different needs: an ultra-low-cost 0.02× OpenAI promotional tier (limited time), OpenAI channels starting from 0.25×, 0.7× Claude with 95% fixed cache, and 1.2× Claude Max channels. It also provides a public status page displaying real-time availability, latency, and operational status for every channel, ensuring transparent and reliable service. In addition, FastAIToken offers 24/7 human technical support (no bots) for rapid response to developers' needs. For enterprise customers, dedicated SLA-backed channel pools are available with guaranteed stability, contract support, invoicing, and dedicated maintenance.</td>
</tr>
</tbody>
</table>
## Overview
- OpenAI/Gemini/Claude/Grok compatible API endpoints for CLI models
- OpenAI Codex support (GPT models) via OAuth login
- Claude Code support via OAuth login
- Grok Build support via OAuth login
- Streaming, non-streaming, and WebSocket responses where supported
- Function calling/tools support
- Multimodal input support (text and images)
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Grok)
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Grok)
- Generative Language API Key support
- AI Studio Build multi-account load balancing
- Claude Code multi-account load balancing
- OpenAI Codex multi-account load balancing
- Grok Build multi-account load balancing
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
## Getting Started
CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
## Management API
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
## Usage Statistics
Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) no longer ship built-in usage statistics. If you need usage statistics, use:
### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics.
### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
Full CLIProxyAPI management center with request-level monitoring and cost estimates. CPA-Manager tracks collected requests by account, model, channel, latency, status, and token usage; estimates cost with editable model prices and one-click LiteLLM price sync; persists events in SQLite; and provides Codex account-pool operations with batch inspection, quota detection, unhealthy account discovery, cleanup suggestions, and one-click execution for day-to-day multi-account maintenance.
## SDK Docs
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
- Access: [docs/sdk-access.md](docs/sdk-access.md)
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
- Custom Provider Example: `examples/custom-provider`
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Who is with us?
Those projects are based on CLIProxyAPI:
### [vibeproxy](https://github.com/automazeio/vibeproxy)
Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
A cross-platform desktop and web app to translate and validate SRT subtitles using your existing LLM subscriptions (Gemini, ChatGPT, Claude, etc.) via CLIProxyAPI - no API keys needed.
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
### [Quotio](https://github.com/nguyenphutrong/quotio)
Native macOS menu bar app that unifies Claude, Gemini, OpenAI, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas via CLIProxyAPI. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed.
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
A lightweight web admin panel for CLIProxyAPI with health checks, resource monitoring, real-time logs, auto-update, request statistics and pricing display. Supports one-click installation and systemd service.
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
A Windows tray application implemented using PowerShell scripts, without relying on any third-party libraries. The main features include: automatic creation of shortcuts, silent running, password management, channel switching (Main / Plus), and automatic downloading and updating.
### [霖君](https://github.com/wangdabaoqq/LinJun)
霖君 is a cross-platform desktop application for managing AI programming assistants, supporting macOS, Windows, and Linux systems. Unified management of Claude Code, Gemini, OpenAI Codex, and other AI coding tools, with local proxy for multi-account quota tracking and one-click configuration.
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
A modern web-based management dashboard for CLIProxyAPI built with Next.js, React, and PostgreSQL. Features real-time log streaming, structured configuration editing, API key management, OAuth provider integration for Claude/Gemini/Codex, usage analytics, container management, and config sync with OpenCode via companion plugin - no manual YAML editing needed.
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
Browser extension for one-stop management of New API-compatible relay site accounts, featuring balance and usage dashboards, auto check-in, one-click key export to common apps, in-page API availability testing, and channel/model sync and redirection. It integrates with CLIProxyAPI through the Management API for one-click provider import and config sync.
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
Shadow AI is an AI assistant tool designed specifically for restricted environments. It provides a stealthy operation
mode without windows or traces, and enables cross-device AI Q&A interaction and control via the local area network (
LAN). Essentially, it is an automated collaboration layer of "screen/audio capture + AI inference + low-friction delivery",
helping users to immersively use AI assistants across applications on controlled devices or in restricted environments.
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a native GUI. Connects Claude, ChatGPT, Gemini, GitHub Copilot, and custom OpenAI-compatible endpoints with usage analytics, request monitoring, and auto-configuration for popular coding tools - no API keys needed.
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-account codex 5h/7d quota windows, plan-based sorting, status coloring, and multi-account summary analytics.
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
Native macOS SwiftUI app for monitoring ChatGPT/Codex account quotas in CLIProxyAPI pools. Displays account availability, Plus-base capacity, 5-hour and weekly quota bars, plan weights, and restore forecasts through the Management API.
### [Panopticon](https://github.com/eltmon/panopticon-cli)
Multi-agent orchestration for AI coding assistants. Runs CLIProxyAPI as a local sidecar so its agents can drive GPT models through a ChatGPT subscription, pointing Claude Code at an Anthropic-compatible endpoint with no OpenAI API key required.
### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
Windows desktop UI that manages CLIProxyAPI and Perplexity WebUI Scraper from a single interface, inspired by Quotio and VibeProxy. Connect OAuth providers (Claude, Gemini, Codex, Kimi, Antigravity), custom API keys, and Perplexity session accounts, then point any coding agent at the local endpoint.
### [Quotio Desktop](https://github.com/xiaocoss/quotio-desktop)
Cross-platform (Tauri) port of Quotio for Windows, macOS and Linux. Manages a pool of AI accounts (Codex, Claude Code, GitHub Copilot, Gemini, Antigravity, Kiro, Cursor, Trae, GLM) through CLIProxyAPI, with per-account 5-hour/weekly quota bars, Codex rate-limit reset credits with one-click reset, smart scheduling, usage statistics, and multi-instance Codex — no API keys needed.
### [Universal Chat Provider](https://github.com/maxdewald/vscode-universal-chat-provider)
VS Code extension that brings your Claude, ChatGPT/Codex, Antigravity, Grok, and Kimi subscriptions into GitHub Copilot Chat as native language models — and can power your Git commit messages, chat titles, and summaries too. Runs CLIProxyAPI in a fully managed background lifecycle (download, verify, supervise) shared across all windows, so it's zero-setup. No API keys needed, just OAuth.
### [CPA-Tray-Powershell](https://github.com/IQ-Director/CPA-Tray-Powershell)
A PowerShell-based Windows system tray launcher for CLIProxyAPI. It supports running in the background without a console window, opening the management page, keeping the backend running after the management window closes, and reopening the page from the tray. It also supports checking for CLIProxyAPI updates on startup, SHA-256 verification with rollback, one-click CLIProxyAPI restart and update, PID-validated process management, and safe service shutdown.
> [!NOTE]
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
## More choices
Those projects are ports of CLIProxyAPI or inspired by it:
### [9Router](https://github.com/decolua/9router)
A Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.
### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)
Never stop coding. Smart routing to FREE & low-cost AI models with automatic fallback.
OmniRoute is an AI gateway for multi-provider LLMs: an OpenAI-compatible endpoint with smart routing, load balancing, retries, and fallbacks. Add policies, rate limits, caching, and observability for reliable, cost-aware inference.
### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
A public CLIProxyAPI-compatible fork and bundled management panel. It keeps upstream-style usage while restoring built-in usage statistics, adding cache hit rate, first-byte latency, TPS tracking, and Docker-oriented self-hosted installation docs.
### [Codex Switch](https://github.com/9ycrooked/CodexSwitch)
This is a tool built with Tauri 2 + Vue 3 for managing multiple OpenAI Codex desktop accounts. Switch between saved ChatGPT/Codex certification profiles, check 5-hour and weekly quota usage in real time, verify token health, view active account details, and import or save auth.json files without manual copying.
> [!NOTE]
> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`router-for-me/CLIProxyAPI`
- 原始仓库:https://github.com/router-for-me/CLIProxyAPI
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+264
View File
@@ -0,0 +1,264 @@
# CLI 代理 API
[English](README.md) | 中文 | [日本語](README_JA.md)
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex/Grok 兼容 API 接口的代理服务器。
现已支持通过 OAuth 登录接入 OpenAI CodexGPT 系列)和 Claude Code。
您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses/Gemini/Claude 兼容的客户端和SDK进行访问。
## 赞助商
[![https://www.packyapi.com/register?aff=cliproxyapi](./assets/packycode-cn.png)](https://www.packyapi.com/register?aff=cliproxyapi)
感谢 PackyCode 对本项目的赞助!
PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。
PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi" target="_blank">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。
---
<table>
<tbody>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF" target="_blank">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github" target="_blank">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT <b>官网订阅一折</b> 的震撼价格!</td>
</tr>
<tr>
<td width="180"><a href="https://visioncoder.cn/"><img src="./assets/visioncoder.png" alt="VisionCoder" width="150"></a></td>
<td>感谢 VisionCoder 对本项目的支持。<a href="https://visioncoder.cn/">VisionCoder 开发平台</a> 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。此外,VisionCoder 还提供 <b>Claude Max 200 与 GPT Pro 200 高级成品号</b>的独家售卖渠道,助力体验全网顶配 AI 的算力与体验。</td>
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=CLIProxyAPI"><img src="./assets/apikey.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助本项目!APIKEY.FUN 是一家专业的企业级 AI 中转站,致力于为企业和个人开发者提供稳定、高效、低成本的 AI 模型 API 接入服务。平台支持 Claude、OpenAI、Gemini 等主流热门模型,价格低至官方原价的 7%。通过本项目<a href="https://apikey.fun/register?aff=CLIProxyAPI">专属链接</a>注册,还可享受最高 <b>充值永久 95 折</b> 专属优惠。</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=FivD"><img src="./assets/runapi.png" alt="RunAPI" width="150"></a></td>
<td>RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CPA的用户提供专属福利:<a href="https://runapi.co/register?aff=FivD">注册</a>联系管理员即可领取¥7的免费额度</td>
</tr>
<tr>
<td width="180"><a href="https://catapi.ai/sign-up"><img src="./assets/catapi.png" alt="CatAPI" width="150"></a></td>
<td>Cat API 是一家面向个人开发者与团队的 AI 大模型聚合平台,致力于将主流大模型能力整合到一个简单、稳定、易用的入口中。平台提供完全兼容 OpenAI、Claude、Gemini 的 API,可无缝接入 Claude Code、Cursor、Windsurf、Cline、Roo Code、Continue、Codex、Trae 等主流 AI IDE 与编程工具,并主打 CN2 高速线路,为用户带来低延迟、高稳定的访问体验。<a href="https://catapi.ai/sign-up">注册</a>即可领取 1$ 的免费额度。</td>
</tr>
<tr>
<td width="180"><a href="https://t.me/CyberWlD/218"><img src="./assets/cyberpay.jpg" alt="CyberPay" width="150"></a></td>
<td>赛博支付(CyberPay)成立于2021年。我们致力于为AI从业者商家提供稳定、高效、安全的支付结算解决方案。与我们合作即可使您的网站平台解决用户支付宝/微信收款问题。承接售卖GPT 、Gemini、Claude、Codex账号与中转站等各类业务合作,解决各位商家收款困难痛点。<a href="https://t.me/CyberWlD/218">联系我们</a>开启您的致富通道。</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo"><img src="./assets/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
<td>感谢 <a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo">Claude API</a> 赞助本项目!Claude API 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo">专属链接</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf"><img src="./assets/code0.png" alt="code0" width="150"></a></td>
<td>感谢 <a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf">Code0</a> 赞助本项目!code0.ai 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf">专属链接</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。</td>
</tr>
<tr>
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY"><img src="./assets/fennoai.png" alt="FennoAI" width="150"></a></td>
<td>感谢 <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY">Fenno.ai</a> 赞助本项目!Fenno.ai 是一家稳定、高效的API 中转服务商,目前主要提供 Codex 中转服务,兼容OpenAI 及 Anthropic 协议,可灵活接入 Codex、Claude Code、OpenCode等主流编程工具,可稳定支撑千亿Token/日的企业级调用需求,支持国内及海外主体公对公结算、开票。Fenno.ai 为 CLIProxyAPI 的用户提供了专属福利:通过<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY">此链接</a>即可订阅<b>9.9 元/150刀额度</b>的超值Coding Plan,邀请好友最高可享20%奖励,多邀多得!</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/7zUJri"><img src="./assets/qiniucloud.png" alt="七牛云AI" width="150"></a></td>
<td>感谢 <a href="https://s.qiniu.com/7zUJri">七牛云AI</a> 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型MaaS平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过 169 万企业及开发者用户。专属福利:企业用户免费领 <b>1200万 Token</b>,邀请好友最高得百亿 Token。</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
</tr>
<tr>
<td width="180"><a href="https://www.fastaitoken.com/"><img src="./assets/fastaitoken.png" alt="FastAIToken" width="150"></a></td>
<td>感谢 <a href="https://www.fastaitoken.com/">FastAIToken</a> 对本项目的赞助! FastAIToken 是面向开发者的 AI API 聚合平台,追求极速、稳定。支持 OpenAI、Claude、Gemini 等主流大模型,充值 1:1,1 元 = 1 美元 API 额度,让开发者以更低成本、更便捷地使用全球领先的大模型服务,QQ服务群1054566214。<br/>平台提供多种渠道自由选择:超级低价的0.02x OpenAI 福利分组(限时)、低至 0.25x OpenAI 分组、0.7x Claude 95%固定缓存、1.2x Claude Max 渠道;同时提供公开状态页,实时展示各分组的可用率、延迟及运行状态,服务透明可靠,并提供 7×24 小时真人技术支持(非机器人),快速响应开发者需求。针对企业用户可以构建SLA专线号池,包稳定,可签合同开票专人维护。</td>
</tr>
</tbody>
</table>
## 功能特性
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex/Grok 兼容的 API 端点
- 新增 OpenAI CodexGPT 系列)支持(OAuth 登录)
- 新增 Claude Code 支持(OAuth 登录)
- 新增 Grok Build 支持(OAuth 登录)
- 支持流式、非流式响应,以及受支持场景下的 WebSocket 响应
- 函数调用/工具支持
- 多模态输入(文本、图片)
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Grok
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Grok
- 支持 Gemini AIStudio API 密钥
- 支持 AI Studio Build 多账户轮询
- 支持 Claude Code 多账户轮询
- 支持 OpenAI Codex 多账户轮询
- 支持 Grok Build 多账户轮询
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`
## 新手入门
CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
## 管理 API 文档
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
## 使用量统计
自v6.10.0版本以后,CLIProxyAPI及 [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 项目不再预置数据统计功能,如果有数据统计需求的请使用以下项目:
### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。
### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。
## SDK 文档
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
- 自定义 Provider 示例:`examples/custom-provider`
## 贡献
欢迎贡献!请随时提交 Pull Request。
1. Fork 仓库
2. 创建您的功能分支(`git checkout -b feature/amazing-feature`
3. 提交您的更改(`git commit -m 'Add some amazing feature'`
4. 推送到分支(`git push origin feature/amazing-feature`
5. 打开 Pull Request
## 谁与我们在一起?
这些项目基于 CLIProxyAPI:
### [vibeproxy](https://github.com/automazeio/vibeproxy)
一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
一款跨平台的桌面和 Web 应用程序,可通过 CLIProxyAPI 使用您现有的 LLM 订阅(Gemini、ChatGPT、Claude, etc.)来翻译和验证 SRT 字幕 - 无需 API 密钥。
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
### [Quotio](https://github.com/nguyenphutrong/quotio)
原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
原生 Windows CLIProxyAPI 分支,集成 TUI、系统托盘及多服务商 OAuth 认证,专为 AI 编程工具打造,无需 API 密钥。
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
一款 VSCode 扩展,提供了在 VSCode 中快速切换 Claude Code 模型的功能,内置 CLIProxyAPI 作为其后端,支持后台自动启动和关闭。
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI 监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
面向 CLIProxyAPI 的 Web 管理面板,提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示,支持一键安装与 systemd 服务。
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
Windows 托盘应用,基于 PowerShell 脚本实现,不依赖任何第三方库。主要功能包括:自动创建快捷方式、静默运行、密码管理、通道切换(Main / Plus)以及自动下载与更新。
### [霖君](https://github.com/wangdabaoqq/LinJun)
霖君是一款用于管理AI编程助手的跨平台桌面应用,支持macOS、Windows、Linux系统。统一管理Claude Code、Gemini、OpenAI Codex等AI编程工具,本地代理实现多账户配额跟踪和一键配置。
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
一个面向 CLIProxyAPI 的现代化 Web 管理仪表盘,基于 Next.js、React 和 PostgreSQL 构建。支持实时日志流、结构化配置编辑、API Key 管理、Claude/Gemini/Codex 的 OAuth 提供方集成、使用量分析、容器管理,并可通过配套插件与 OpenCode 同步配置,无需手动编辑 YAML。
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
用于一站式管理 New API 兼容中转站账号的浏览器扩展,提供余额与用量看板、自动签到、密钥一键导出到常用应用、网页内 API 可用性测试,以及渠道与模型同步和重定向。支持通过 CLIProxyAPI Management API 一键导入 Provider 与同步配置。
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口、无痕迹的隐蔽运行方式,并通过局域网实现跨设备的 AI 问答交互与控制。本质上是一个「屏幕/音频采集 + AI 推理 + 低摩擦投送」的自动化协作层,帮助用户在受控设备/受限环境下沉浸式跨应用地使用 AI 助手。
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
跨平台桌面应用(macOS、Windows、Linux),以原生 GUI 封装 CLIProxyAPI。支持连接 Claude、ChatGPT、Gemini、GitHub Copilot 及自定义 OpenAI 兼容端点,具备使用分析、请求监控和热门编程工具自动配置功能,无需 API 密钥。
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
上手即用的面向 CLIProxyAPI 跨平台配额查询工具,支持按账号展示 codex 5h/7d 配额窗口、按计划排序、状态着色及多账号汇总分析。
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。
### [Panopticon](https://github.com/eltmon/panopticon-cli)
面向 AI 编程助手的多智能体编排工具。它将 CLIProxyAPI 作为本地 sidecar 运行,使其智能体可以通过 ChatGPT 订阅驱动 GPT 模型,并将 Claude Code 指向 Anthropic 兼容端点,无需 OpenAI API 密钥。
### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
Windows 桌面 UI,通过单一界面管理 CLIProxyAPI 和 Perplexity WebUI Scraper,灵感来自 Quotio 和 VibeProxy。连接 OAuth 提供商(Claude、Gemini、Codex、Kimi、Antigravity)、自定义 API 密钥和 Perplexity 会话账号,然后将任意编程智能体指向本地端点。
### [Quotio Desktop](https://github.com/xiaocoss/quotio-desktop)
Quotio 的跨平台(Tauri)移植版,支持 Windows / macOS / Linux。通过 CLIProxyAPI 管理多账号代理池(Codex、Claude Code、GitHub Copilot、Gemini、Antigravity、Kiro、Cursor、Trae、GLM),提供每账号 5 小时 / 每周额度进度条、Codex 主动重置次数与一键重置、智能调度、用量统计及 Codex 多开实例,无需 API 密钥。
### [Universal Chat Provider](https://github.com/maxdewald/vscode-universal-chat-provider)
VS Code 扩展,可将你的 Claude、ChatGPT/Codex、Antigravity、Grok 和 Kimi 订阅作为原生语言模型接入 GitHub Copilot Chat,并且也可用于生成 Git 提交信息、聊天标题和摘要。它以完全托管的后台生命周期运行 CLIProxyAPI(下载、验证、监督),并在所有窗口间共享,因此无需配置。无需 API 密钥,只需 OAuth。
### [CPA-Tray-Powershell](https://github.com/IQ-Director/CPA-Tray-Powershell)
基于 PowerShell 的 Windows CLIProxyAPI 托盘启动工具。支持无终端窗口后台运行、打开管理页面、关闭管理窗口后保持后端运行,并可通过托盘重新打开页面;同时支持启动时自动检查 CLIProxyAPI 更新、SHA-256 校验与失败回滚、一键重启并更新 CLIProxyAPI、基于 PID 校验的进程管理以及安全停止服务。
> [!NOTE]
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
## 更多选择
以下项目是 CLIProxyAPI 的移植版或受其启发:
### [9Router](https://github.com/decolua/9router)
基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)
代码不止,创新不停。智能路由至免费及低成本 AI 模型,并支持自动故障转移。
OmniRoute 是一个面向多供应商大语言模型的 AI 网关:它提供兼容 OpenAI 的端点,具备智能路由、负载均衡、重试及回退机制。通过添加策略、速率限制、缓存和可观测性,确保推理过程既可靠又具备成本意识。
### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
一个公开的 CLIProxyAPI 兼容二开版本和配套管理面板,尽量保持与上游一致的使用方式,同时恢复内置使用量统计,并补充缓存命中率、首字响应时间、TPS 记录和面向 Docker 自托管的安装说明。
### [Codex Switch](https://github.com/9ycrooked/CodexSwitch)
这是一个使用 Tauri 2 + Vue 3 构建的工具,用于管理多个 OpenAI Codex 桌面账户。它可以在已保存的 ChatGPT/Codex 认证配置之间切换,实时查看 5 小时和每周配额使用情况,验证 token 健康状态,查看当前账户详情,并在无需手动复制的情况下导入或保存 auth.json 文件。
> [!NOTE]
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
## 许可证
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
## 写给所有中国网友的
QQ 群:188637136(满)、1081218164
Telegram 群:https://t.me/CLIProxyAPI
+255
View File
@@ -0,0 +1,255 @@
# CLI Proxy API
[English](README.md) | [中文](README_CN.md) | 日本語
CLI向けのOpenAI/Gemini/Claude/Codex/Grok互換APIインターフェースを提供するプロキシサーバーです。
OAuth経由でOpenAI CodexGPTモデル)およびClaude Codeもサポートしています。
ローカルまたはマルチアカウントのCLIアクセスを、OpenAIResponses含む)/Gemini/Claude互換のクライアントやSDKで利用できます。
## スポンサー
[![https://www.packyapi.com/register?aff=cliproxyapi](./assets/packycode-en.png)](https://www.packyapi.com/register?aff=cliproxyapi)
PackyCodeのスポンサーシップに感謝します!
PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。
PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:<a href="https://www.packyapi.com/register?aff=cliproxyapi">こちらのリンク</a>から登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。
---
<table>
<tbody>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">こちらのリンク</a>から登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます!</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを <b>公式サイト価格の約1割(90% OFF)</b> という驚異的な価格でご利用いただけます!</td>
</tr>
<tr>
<td width="180"><a href="https://visioncoder.cn/"><img src="./assets/visioncoder.png" alt="VisionCoder" width="150"></a></td>
<td>VisionCoderのご支援に感謝します。<a href="https://visioncoder.cn/">VisionCoder 開発プラットフォーム</a> は、信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどの主要AIモデルを提供し、開発者やチームがより簡単にAI機能を統合して生産性を向上できるよう支援します。さらに、VisionCoderは <b>Claude Max 200 と GPT Pro 200 高級即納アカウント</b> の独占販売チャネルを提供しており、最高クラスのAI算力と体験を手軽に体験できます。</td>
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=CLIProxyAPI"><img src="./assets/apikey.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUNのスポンサーシップに感謝します!APIKEY.FUNはプロフェッショナルなエンタープライズ向けAIリレーサービスで、企業および個人開発者に安定・高効率・低コストなAIモデルAPI接続サービスを提供しています。Claude、OpenAI、Geminiなどの主要人気モデルに対応し、価格は公式価格の7%から利用できます。本プロジェクトの<a href="https://apikey.fun/register?aff=CLIProxyAPI">専用リンク</a>から登録すると、さらに<b>チャージが永続的に5%割引</b>となる特別優待を受けられます。</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=FivD"><img src="./assets/runapi.png" alt="RunAPI" width="150"></a></td>
<td>RunAPIは高効率で安定したAPIプラットフォームで、OpenRouterの代替として利用できます。1つのAPI KeyでOpenAI、Claude、Gemini、DeepSeek、Grokなど150以上の主要モデルにアクセスでき、価格は公式価格の10%から、非常に安定しており、Claude Code、OpenClawなどのツールとシームレスに互換性があります。RunAPIはCPAユーザー向けに特別特典を提供しています:<a href="https://runapi.co/register?aff=FivD">登録</a>後に管理者へ連絡すると、7元分の無料クレジットを受け取れます。</td>
</tr>
<tr>
<td width="180"><a href="https://catapi.ai/sign-up"><img src="./assets/catapi.png" alt="CatAPI" width="150"></a></td>
<td>Cat APIは、個人開発者やチーム向けのAI大規模モデル集約プラットフォームです。主要な大規模モデルの機能を、シンプルで安定した使いやすい入口に統合することを目指しています。OpenAI、Claude、Geminiと完全互換のAPIを提供し、Claude Code、Cursor、Windsurf、Cline、Roo Code、Continue、Codex、Traeなどの主要なAI IDEやプログラミングツールへシームレスに接続できます。また、CN2高速回線を主な特徴としており、低遅延で高安定なアクセス体験を提供します。<a href="https://catapi.ai/sign-up">登録</a>すると、1$の無料クレジットを受け取れます。</td>
</tr>
<tr>
<td width="180"><a href="https://t.me/CyberWlD/218"><img src="./assets/cyberpay.jpg" alt="CyberPay" width="150"></a></td>
<td>CyberPay(サイバー決済)は2021年に設立されました。AI業界の事業者向けに、安定・高効率・安全な決済精算ソリューションを提供することに取り組んでいます。私たちと連携することで、WebサイトやプラットフォームでのAlipay/WeChat決済の受け取り課題を解決できます。GPT、Gemini、Claude、Codexアカウントやリレープラットフォームなど、各種事業提携にも対応し、事業者の決済回収に関する課題を解決します。<a href="https://t.me/CyberWlD/218">お問い合わせ</a>ください。</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo"><img src="./assets/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
<td>本プロジェクトは <a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo">Claude API</a> にご支援いただいています!Claude API は Claude モデルに特化した公式チャネルの API プロバイダーです。Anthropic 公式 Key と AWS Bedrock の公式チャネルを基盤に、Claude Code と Agent アプリケーション向けに安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長いコンテキストなどの公式機能も維持されています。リバースエンジニアリングではなく、モデル性能のダウングレードもありません。Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに適しています。<a href="https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo">専用リンク</a> から登録後、カスタマーサポートに連絡すると無料テストクレジットを受け取れます。請求書発行やチーム導入の相談にも対応しています。</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf"><img src="./assets/code0.png" alt="code0" width="150"></a></td>
<td>本プロジェクトは <a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf">Code0</a> にご支援いただいています!code0.ai は、開発者と技術チーム向けの AI コーディングワークスペースです。Claude Code や Codex などの主要な Agent 型コーディング機能を統合し、コード生成、プロジェクト理解、デバッグ、コードレビュー、ドキュメント作成など、日常的な開発シーンをサポートします。個人開発者、Agent エンジニア、オープンソースメンテナー、企業の開発チームに適しており、請求書発行やチーム導入にも対応しています。<a href="https://code0.ai/agent/register/slxVMR3uVBoRgNBf">専用リンク</a> から登録後、カスタマーサポートに連絡すると無料テストクレジットを受け取れます。より効率的な AI コーディングワークフローをぜひ体験してください。</td>
</tr>
<tr>
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY"><img src="./assets/fennoai.png" alt="FennoAI" width="150"></a></td>
<td>本プロジェクトは <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY">Fenno.ai</a> にご支援いただいています!Fenno.ai は安定した高効率な API リレーサービスプロバイダーで、現在は主に Codex リレーサービスを提供しています。OpenAI および Anthropic プロトコルに対応し、Codex、Claude Code、OpenCode などの主要なコーディングツールへ柔軟に接続できます。1日あたり数千億 token 規模のエンタープライズ利用を安定して支え、国内および海外法人向けのB2B決済と請求書発行にも対応しています。Fenno.ai は CLIProxyAPI ユーザー向けの特典として、<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=DQFAMNB6CBLY">こちらのリンク</a>から <b>9.9元 / 150ドル分のクォータ</b> のお得な Coding Plan を購読でき、友人招待では最大20%の報酬を受け取れます。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/7zUJri"><img src="./assets/qiniucloud.png" alt="Qiniu Cloud AI" width="150"></a></td>
<td>本プロジェクトは <a href="https://s.qiniu.com/7zUJri">七牛雲AI</a> にご支援いただいています!七牛雲AI は七牛雲(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル150以上をワンストップで呼び出せ、世界の主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバーしています。169万を超える企業および開発者ユーザーにサービスを提供しています。専用特典:企業ユーザーは <b>1,200万 Token</b> を無料で受け取れ、友人招待で最大100億 Tokenを獲得できます。</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
<td>Cubenceのスポンサーシップに感謝します!Cubenceは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。Cubenceは当ソフトウェアのユーザーに特別割引を提供しています:<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">こちらのリンク</a>から登録し、チャージ時にプロモーションコード「CLIPROXYAPI」を入力すると10%割引になります。</td>
</tr>
<tr>
<td width="180"><a href="https://www.fastaitoken.com/"><img src="./assets/fastaitoken.png" alt="FastAIToken" width="150"></a></td>
<td><a href="https://www.fastaitoken.com/">FastAIToken</a> のスポンサーシップに感謝します!FastAIToken は開発者向けの AI API 集約プラットフォームで、速度と安定性を重視しています。OpenAI、Claude、Gemini などの主要 AI モデルに対応し、チャージ比率は 1:1(1元 = 1ドル分の API クレジット)のため、開発者はより低コストで便利に世界トップクラスの AI モデルを利用できます。<a href="https://t.me/+stwq0MLi0PtkZTZl">Telegram サポートグループ</a><br/>プラットフォームでは用途に応じて複数のチャネルを選択できます:超低価格の 0.02× OpenAI プロモーション枠(期間限定)、0.25× からの OpenAI チャネル、95% 固定キャッシュの 0.7× Claude、1.2× Claude Max チャネル。また、各チャネルの稼働率、遅延、運用状況をリアルタイム表示する公開ステータスページも提供しており、透明で信頼性の高いサービスを実現しています。さらに FastAIToken は 24時間365日の真人テクニカルサポート(ボットではありません)を提供し、開発者のニーズに迅速に対応します。エンタープライズ顧客向けには、安定性を保証する SLA 対応の専用チャネルプールを提供し、契約対応、請求書発行、専任保守にも対応しています。</td>
</tr>
</tbody>
</table>
## 概要
- CLIモデル向けのOpenAI/Gemini/Claude/Grok互換APIエンドポイント
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
- OAuthログインによるClaude Codeサポート
- OAuthログインによるGrok Buildサポート
- ストリーミング、非ストリーミング、および対応環境でのWebSocketレスポンス
- 関数呼び出し/ツールのサポート
- マルチモーダル入力サポート(テキストと画像)
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude、Grok
- シンプルなCLI認証フロー(Gemini、OpenAI、Claude、Grok
- Generative Language APIキーのサポート
- AI Studioビルドのマルチアカウント負荷分散
- Claude Codeのマルチアカウント負荷分散
- OpenAI Codexのマルチアカウント負荷分散
- Grok Buildのマルチアカウント負荷分散
- 設定によるOpenAI互換アップストリームプロバイダー(例:OpenRouter)
- プロキシ埋め込み用の再利用可能なGo SDK(`docs/sdk-usage.md`を参照)
## はじめに
CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
## 管理API
[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
## 使用量統計
v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) プロジェクトには使用量統計機能がプリセットされなくなりました。使用量統計が必要な場合は、次のプロジェクトをご利用ください:
### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。
### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。
## SDKドキュメント
- 使い方:[docs/sdk-usage.md](docs/sdk-usage.md)
- 上級(エグゼキューターとトランスレーター):[docs/sdk-advanced.md](docs/sdk-advanced.md)
- アクセス:[docs/sdk-access.md](docs/sdk-access.md)
- ウォッチャー:[docs/sdk-watcher.md](docs/sdk-watcher.md)
- カスタムプロバイダーの例:`examples/custom-provider`
## コントリビューション
コントリビューションを歓迎します!お気軽にPull Requestを送ってください。
1. リポジトリをフォーク
2. フィーチャーブランチを作成(`git checkout -b feature/amazing-feature`
3. 変更をコミット(`git commit -m 'Add some amazing feature'`
4. ブランチにプッシュ(`git push origin feature/amazing-feature`
5. Pull Requestを作成
## 関連プロジェクト
CLIProxyAPIをベースにした以下のプロジェクトがあります:
### [vibeproxy](https://github.com/automazeio/vibeproxy)
macOSネイティブのメニューバーアプリで、Claude CodeとChatGPTのサブスクリプションをAIコーディングツールで使用可能 - APIキー不要
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
CLIProxyAPI経由で既存のLLMサブスクリプション(Gemini、ChatGPT、Claude, etc.)を使用してSRT字幕を翻訳および検証する、クロスプラットフォームのデスクトップおよびWebアプリ - APIキー不要。
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
CLIProxyAPI OAuthを使用して複数のClaudeアカウントや代替モデル(Gemini、Codex、Antigravity)を即座に切り替えるCLIラッパー - APIキー不要
### [Quotio](https://github.com/nguyenphutrong/quotio)
Claude、Gemini、OpenAI、Antigravityのサブスクリプションを統合し、リアルタイムのクォータ追跡とスマート自動フェイルオーバーを備えたmacOSネイティブのメニューバーアプリ。Claude Code、OpenCode、Droidなどのコーディングツール向け - APIキー不要
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
TUI、システムトレイ、マルチプロバイダーOAuthを備えたWindows向けCLIProxyAPIフォーク - AIコーディングツール用、APIキー不要
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
Claude Codeモデルを素早く切り替えるVSCode拡張機能。バックエンドとしてCLIProxyAPIを統合し、バックグラウンドでの自動ライフサイクル管理を搭載
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
CLIProxyAPIを使用してAIコーディングアシスタントのクォータを監視するTauri + React製のWindowsデスクトップアプリ。Gemini、Claude、OpenAI Codex、Antigravityアカウントの使用量をリアルタイムダッシュボード、システムトレイ統合、ワンクリックプロキシコントロールで追跡 - APIキー不要
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
CLIProxyAPI向けの軽量Web管理パネル。ヘルスチェック、リソース監視、リアルタイムログ、自動更新、リクエスト統計、料金表示機能を搭載。ワンクリックインストールとsystemdサービスに対応
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
PowerShellスクリプトで実装されたWindowsトレイアプリケーション。サードパーティライブラリに依存せず、ショートカットの自動作成、サイレント実行、パスワード管理、チャネル切り替え(Main / Plus)、自動ダウンロードおよび自動更新に対応
### [霖君](https://github.com/wangdabaoqq/LinJun)
霖君はAIプログラミングアシスタントを管理するクロスプラットフォームデスクトップアプリケーションで、macOS、Windows、Linuxシステムに対応。Claude Code、Gemini、OpenAI Codexなどのコーディングツールを統合管理し、ローカルプロキシによるマルチアカウントクォータ追跡とワンクリック設定が可能
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
Next.js、React、PostgreSQLで構築されたCLIProxyAPI用のモダンなWebベース管理ダッシュボード。リアルタイムログストリーミング、構造化された設定編集、APIキー管理、Claude/Gemini/Codex向けOAuthプロバイダー統合、使用量分析、コンテナ管理、コンパニオンプラグインによるOpenCodeとの設定同期機能を搭載 - 手動でのYAML編集は不要
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
New API互換リレーサイトアカウントをワンストップで管理するブラウザ拡張機能。残高と使用量のダッシュボード、自動チェックイン、一般的なアプリへのワンクリックキーエクスポート、ページ内API可用性テスト、チャネル/モデルの同期とリダイレクト機能を搭載。Management APIを通じてCLIProxyAPIと統合し、ワンクリックでプロバイダーのインポートと設定同期が可能
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
Shadow AIは制限された環境向けに特別に設計されたAIアシスタントツールです。ウィンドウや痕跡のないステルス動作モードを提供し、LAN(ローカルエリアネットワーク)を介したクロスデバイスAI質疑応答のインタラクションと制御を可能にします。本質的には「画面/音声キャプチャ + AI推論 + 低摩擦デリバリー」の自動化コラボレーションレイヤーであり、制御されたデバイスや制限された環境でアプリケーション横断的にAIアシスタントを没入的に使用できるようユーザーを支援します。
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォームデスクトップアプリ(macOS、Windows、Linux)。Claude、ChatGPT、Gemini、GitHub Copilot、カスタムOpenAI互換エンドポイントに対応し、使用状況分析、リクエスト監視、人気コーディングツールの自動設定機能を搭載 - APIキー不要
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォータ確認ツール。アカウントごとの codex 5h/7d クォータ表示、プラン別ソート、ステータス色分け、複数アカウントの集計分析に対応。
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。
### [Panopticon](https://github.com/eltmon/panopticon-cli)
AIコーディングアシスタント向けのマルチエージェントオーケストレーションツール。CLIProxyAPIをローカルsidecarとして実行することで、エージェントがChatGPTサブスクリプション経由でGPTモデルを利用できるようにし、Claude CodeをAnthropic互換エンドポイントへ向けるため、OpenAI APIキーは不要です。
### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
CLIProxyAPIとPerplexity WebUI Scraperをひとつのインターフェースで管理するWindowsデスクトップUI。QuotioとVibeProxyにインスパイアされ、OAuthプロバイダー(Claude、Gemini、Codex、Kimi、Antigravity)、カスタムAPIキー、Perplexityセッションアカウントを接続し、任意のコーディングエージェントをローカルエンドポイントに向けることができます。
### [Quotio Desktop](https://github.com/xiaocoss/quotio-desktop)
Quotio のクロスプラットフォーム(Tauri)移植版(Windows / macOS / Linux 対応)。CLIProxyAPI 経由で複数の AI アカウント(Codex、Claude Code、GitHub Copilot、Gemini、Antigravity、Kiro、Cursor、Trae、GLM)のプールを管理し、アカウントごとの 5 時間 / 週間クォータバー、Codex のリセットクレジットとワンクリックリセット、スマートスケジューリング、使用統計、Codex マルチインスタンスに対応。API キー不要。
### [Universal Chat Provider](https://github.com/maxdewald/vscode-universal-chat-provider)
Claude、ChatGPT/Codex、Antigravity、Grok、Kimi のサブスクリプションを GitHub Copilot Chat のネイティブ言語モデルとして利用できる VS Code 拡張機能です。Git のコミットメッセージ、チャットタイトル、要約の生成にも使えます。CLIProxyAPI を完全管理されたバックグラウンドライフサイクル(ダウンロード、検証、監視)で実行し、すべてのウィンドウで共有するため、セットアップは不要です。API キーは不要で、OAuth だけで利用できます。
### [CPA-Tray-Powershell](https://github.com/IQ-Director/CPA-Tray-Powershell)
PowerShellベースのWindows向けCLIProxyAPIシステムトレイランチャー。コンソールウィンドウを表示せずにバックグラウンドで実行し、管理ページを開き、管理ウィンドウを閉じた後もバックエンドを維持してトレイからページを再表示できます。起動時のCLIProxyAPI更新確認、SHA-256検証と失敗時のロールバック、ワンクリックでのCLIProxyAPI再起動と更新、PID検証に基づくプロセス管理、安全なサービス停止にも対応しています。
> [!NOTE]
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
## その他の選択肢
以下のプロジェクトはCLIProxyAPIの移植版またはそれに触発されたものです:
### [9Router](https://github.com/decolua/9router)
CLIProxyAPIに触発されたNext.js実装。インストールと使用が簡単で、フォーマット変換(OpenAI/Claude/Gemini/Ollama)、自動フォールバック付きコンボシステム、指数バックオフ付きマルチアカウント管理、Next.js Webダッシュボード、CLIツール(Cursor、Claude Code、Cline、RooCode)のサポートをゼロから構築 - APIキー不要
### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)
コーディングを止めない。無料および低コストのAIモデルへのスマートルーティングと自動フォールバック。
OmniRouteはマルチプロバイダーLLM向けのAIゲートウェイです:スマートルーティング、負荷分散、リトライ、フォールバックを備えたOpenAI互換エンドポイント。ポリシー、レート制限、キャッシュ、可観測性を追加して、信頼性が高くコストを意識した推論を実現します。
### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
上流に近い使い方を維持する公開CLIProxyAPI互換フォーク兼管理パネルです。内蔵の使用量統計を復元し、キャッシュヒット率、初回バイト待ち時間、TPSの記録、Docker向けのセルフホスト手順を追加しています。
### [Codex Switch](https://github.com/9ycrooked/CodexSwitch)
Tauri 2 + Vue 3で構築された、複数のOpenAI Codexデスクトップアカウントを管理するためのツールです。保存済みのChatGPT/Codex認証プロファイルを切り替え、5時間および週次クォータ使用量をリアルタイムで確認し、tokenの状態を検証し、現在のアカウント詳細を表示し、手動コピーなしでauth.jsonファイルをインポートまたは保存できます。
> [!NOTE]
> CLIProxyAPIの移植版またはそれに触発されたプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
## ライセンス
本プロジェクトはMITライセンスの下でライセンスされています - 詳細は[LICENSE](LICENSE)ファイルを参照してください。
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+305
View File
@@ -0,0 +1,305 @@
// Command fetch_antigravity_models connects to the Antigravity API using the
// stored auth credentials and saves the dynamically fetched model list to a
// JSON file for inspection or offline use.
//
// Usage:
//
// go run ./cmd/fetch_antigravity_models [flags]
//
// Flags:
//
// --auths-dir <path> Directory containing auth JSON files (default: config auth-dir)
// --config <path> Config file path (default: "config.yaml")
// --output <path> Output JSON file path (default: "antigravity_models.json")
// --pretty Pretty-print the output JSON (default: true)
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
const (
antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
antigravityBaseURLProd = "https://cloudcode-pa.googleapis.com"
antigravityModelsPath = "/v1internal:fetchAvailableModels"
)
func init() {
logging.SetupBaseLogger()
log.SetLevel(log.InfoLevel)
}
// modelOutput wraps the fetched model list with fetch metadata.
type modelOutput struct {
Models []modelEntry `json:"models"`
}
// modelEntry contains only the fields we want to keep for static model definitions.
type modelEntry struct {
ID string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Description string `json:"description"`
ContextLength int `json:"context_length,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
}
func main() {
var authsDir string
var configPath string
var outputPath string
var pretty bool
flag.StringVar(&authsDir, "auths-dir", "", "Directory containing auth JSON files (overrides config auth-dir)")
flag.StringVar(&configPath, "config", "", "Configure File Path")
flag.StringVar(&outputPath, "output", "antigravity_models.json", "Output JSON file path")
flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
flag.Parse()
authsDirOverridden := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "auths-dir" {
authsDirOverridden = true
}
})
wd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
os.Exit(1)
}
if strings.TrimSpace(configPath) == "" {
configPath = filepath.Join(wd, "config.yaml")
}
cfg, err := config.LoadConfigOptional(configPath, false)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to load config file %s: %v\n", configPath, err)
os.Exit(1)
}
if cfg == nil {
cfg = &config.Config{}
}
if !authsDirOverridden {
authsDir = cfg.AuthDir
} else if strings.TrimSpace(authsDir) != "" && !strings.HasPrefix(strings.TrimSpace(authsDir), "~") && !filepath.IsAbs(authsDir) {
authsDir = filepath.Join(wd, authsDir)
}
if authsDir, err = util.ResolveAuthDir(authsDir); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to resolve auth directory: %v\n", err)
os.Exit(1)
}
if !filepath.IsAbs(outputPath) {
outputPath = filepath.Join(wd, outputPath)
}
fmt.Printf("Scanning auth files in: %s\n", authsDir)
// Load all auth records from the directory.
fileStore := sdkauth.NewFileTokenStore()
fileStore.SetBaseDir(authsDir)
ctx := context.Background()
auths, err := fileStore.List(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
os.Exit(1)
}
if len(auths) == 0 {
fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
os.Exit(1)
}
// Find the first enabled antigravity auth.
var chosen *coreauth.Auth
for _, a := range auths {
if a == nil || a.Disabled {
continue
}
if strings.EqualFold(strings.TrimSpace(a.Provider), "antigravity") {
chosen = a
break
}
}
if chosen == nil {
fmt.Fprintf(os.Stderr, "error: no enabled antigravity auth found in %s\n", authsDir)
os.Exit(1)
}
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
// Fetch models from the upstream Antigravity API.
fmt.Println("Fetching Antigravity model list from upstream...")
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
models := fetchModels(fetchCtx, chosen)
if len(models) == 0 {
fmt.Fprintln(os.Stderr, "warning: no models returned (API may be unavailable or token expired)")
} else {
fmt.Printf("Fetched %d models.\n", len(models))
}
// Build the output payload.
out := modelOutput{
Models: models,
}
// Marshal to JSON.
var raw []byte
if pretty {
raw, err = json.MarshalIndent(out, "", " ")
} else {
raw, err = json.Marshal(out)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to marshal JSON: %v\n", err)
os.Exit(1)
}
if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
os.Exit(1)
}
fmt.Printf("Model list saved to: %s\n", outputPath)
}
func fetchModels(ctx context.Context, auth *coreauth.Auth) []modelEntry {
accessToken := metaStringValue(auth.Metadata, "access_token")
if accessToken == "" {
fmt.Fprintln(os.Stderr, "error: no access token found in auth")
return nil
}
baseURLs := []string{antigravityBaseURLProd, antigravityBaseURLDaily, antigravitySandboxBaseURLDaily}
for _, baseURL := range baseURLs {
modelsURL := baseURL + antigravityModelsPath
var payload []byte
if auth != nil && auth.Metadata != nil {
if pid, ok := auth.Metadata["project_id"].(string); ok && strings.TrimSpace(pid) != "" {
payload = []byte(fmt.Sprintf(`{"project": "%s"}`, strings.TrimSpace(pid)))
}
}
if len(payload) == 0 {
payload = []byte(`{}`)
}
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, strings.NewReader(string(payload)))
if errReq != nil {
continue
}
httpReq.Close = true
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
httpReq.Header.Set("User-Agent", misc.AntigravityUserAgent())
httpClient := &http.Client{Timeout: 30 * time.Second}
if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
httpClient.Transport = transport
}
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
continue
}
bodyBytes, errRead := io.ReadAll(httpResp.Body)
httpResp.Body.Close()
if errRead != nil {
continue
}
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
continue
}
result := gjson.GetBytes(bodyBytes, "models")
if !result.Exists() {
continue
}
var models []modelEntry
for originalName, modelData := range result.Map() {
modelID := strings.TrimSpace(originalName)
if modelID == "" {
continue
}
// Skip internal/experimental models
switch modelID {
case "chat_20706", "chat_23310", "tab_flash_lite_preview", "tab_jump_flash_lite_preview", "gemini-2.5-flash-thinking", "gemini-2.5-pro":
continue
}
displayName := modelData.Get("displayName").String()
if displayName == "" {
displayName = modelID
}
entry := modelEntry{
ID: modelID,
Object: "model",
OwnedBy: "antigravity",
Type: "antigravity",
DisplayName: displayName,
Name: modelID,
Description: displayName,
}
if maxTok := modelData.Get("maxTokens").Int(); maxTok > 0 {
entry.ContextLength = int(maxTok)
}
if maxOut := modelData.Get("maxOutputTokens").Int(); maxOut > 0 {
entry.MaxCompletionTokens = int(maxOut)
}
models = append(models, entry)
}
return models
}
return nil
}
func metaStringValue(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok {
return ""
}
switch val := v.(type) {
case string:
return val
default:
return ""
}
}
+333
View File
@@ -0,0 +1,333 @@
// Command fetch_codex_models connects to the Codex API using stored auth
// credentials and saves the dynamically fetched Codex client model catalog to a
// JSON file for inspection or offline use.
//
// Usage:
//
// go run ./cmd/fetch_codex_models [flags]
//
// Flags:
//
// --auths-dir <path> Directory containing auth JSON files (default: config auth-dir)
// --config <path> Config file path (default: "config.yaml")
// --output <path> Output JSON file path (default: "codex_models.json")
// --client-version <ver> Codex client_version query value (default: "0.133.0")
// --pretty Pretty-print the output JSON (default: true)
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
)
const (
codexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
codexModelsPath = "/models"
defaultClientVersion = "0.144.1"
defaultCodexUserAgent = "codex_cli_rs/0.144.1 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9"
defaultCodexOriginator = "codex_cli_rs"
accessTokenRefreshLeeway = 30 * time.Second
)
func init() {
logging.SetupBaseLogger()
log.SetLevel(log.InfoLevel)
}
func main() {
var authsDir string
var configPath string
var outputPath string
var clientVersion string
var pretty bool
flag.StringVar(&authsDir, "auths-dir", "", "Directory containing auth JSON files (overrides config auth-dir)")
flag.StringVar(&configPath, "config", "", "Configure File Path")
flag.StringVar(&outputPath, "output", "codex_models.json", "Output JSON file path")
flag.StringVar(&clientVersion, "client-version", defaultClientVersion, "Codex client_version query value")
flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
flag.Parse()
authsDirOverridden := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "auths-dir" {
authsDirOverridden = true
}
})
wd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
os.Exit(1)
}
if strings.TrimSpace(configPath) == "" {
configPath = filepath.Join(wd, "config.yaml")
}
cfg, err := config.LoadConfigOptional(configPath, false)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to load config file %s: %v\n", configPath, err)
os.Exit(1)
}
if cfg == nil {
cfg = &config.Config{}
}
if !authsDirOverridden {
authsDir = cfg.AuthDir
} else if strings.TrimSpace(authsDir) != "" && !strings.HasPrefix(strings.TrimSpace(authsDir), "~") && !filepath.IsAbs(authsDir) {
authsDir = filepath.Join(wd, authsDir)
}
if authsDir, err = util.ResolveAuthDir(authsDir); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to resolve auth directory: %v\n", err)
os.Exit(1)
}
if !filepath.IsAbs(outputPath) {
outputPath = filepath.Join(wd, outputPath)
}
fmt.Printf("Scanning auth files in: %s\n", authsDir)
fileStore := sdkauth.NewFileTokenStore()
fileStore.SetBaseDir(authsDir)
ctx := context.Background()
auths, err := fileStore.List(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
os.Exit(1)
}
if len(auths) == 0 {
fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
os.Exit(1)
}
chosen := findCodexAuth(auths)
if chosen == nil {
fmt.Fprintf(os.Stderr, "error: no enabled codex auth found in %s\n", authsDir)
os.Exit(1)
}
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
accessToken, refreshed, err := ensureAccessToken(ctx, fileStore, chosen)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to prepare codex access token: %v\n", err)
os.Exit(1)
}
if refreshed {
fmt.Println("Refreshed Codex access token.")
}
fmt.Println("Fetching Codex model list from upstream...")
raw, count, err := fetchModels(ctx, chosen, accessToken, clientVersion)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to fetch codex models: %v\n", err)
os.Exit(1)
}
fmt.Printf("Fetched %d models.\n", count)
if pretty {
raw, err = prettyJSON(raw)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to format JSON: %v\n", err)
os.Exit(1)
}
}
if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
os.Exit(1)
}
fmt.Printf("Model list saved to: %s\n", outputPath)
}
func findCodexAuth(auths []*coreauth.Auth) *coreauth.Auth {
for _, auth := range auths {
if auth == nil || auth.Disabled {
continue
}
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
continue
}
if metaStringValue(auth.Metadata, "access_token") == "" && metaStringValue(auth.Metadata, "refresh_token") == "" {
continue
}
return auth
}
return nil
}
func ensureAccessToken(ctx context.Context, store *sdkauth.FileTokenStore, auth *coreauth.Auth) (string, bool, error) {
accessToken := metaStringValue(auth.Metadata, "access_token")
if accessToken != "" {
if expiresAt, ok := auth.ExpirationTime(); !ok || time.Now().Add(accessTokenRefreshLeeway).Before(expiresAt) {
return accessToken, false, nil
}
}
refreshToken := metaStringValue(auth.Metadata, "refresh_token")
if refreshToken == "" {
if accessToken != "" {
return accessToken, false, nil
}
return "", false, fmt.Errorf("missing access_token and refresh_token")
}
svc := codexauth.NewCodexAuthWithProxyURL(nil, auth.ProxyURL)
tokenData, errRefresh := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
if errRefresh != nil {
return "", false, errRefresh
}
if strings.TrimSpace(tokenData.AccessToken) == "" {
return "", false, fmt.Errorf("refresh response did not include access_token")
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["id_token"] = tokenData.IDToken
auth.Metadata["access_token"] = tokenData.AccessToken
if tokenData.RefreshToken != "" {
auth.Metadata["refresh_token"] = tokenData.RefreshToken
}
if tokenData.AccountID != "" {
auth.Metadata["account_id"] = tokenData.AccountID
}
if tokenData.Email != "" {
auth.Metadata["email"] = tokenData.Email
}
auth.Metadata["expired"] = tokenData.Expire
auth.Metadata["type"] = "codex"
auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
if _, errSave := store.Save(ctx, auth); errSave != nil {
return "", false, fmt.Errorf("failed to save refreshed auth: %w", errSave)
}
return tokenData.AccessToken, true, nil
}
func fetchModels(ctx context.Context, auth *coreauth.Auth, accessToken, clientVersion string) ([]byte, int, error) {
modelsURL, errURL := codexModelsURL(clientVersion)
if errURL != nil {
return nil, 0, errURL
}
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
if errReq != nil {
return nil, 0, errReq
}
httpReq.Close = true
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
httpReq.Header.Set("Originator", defaultCodexOriginator)
httpReq.Header.Set("User-Agent", defaultCodexUserAgent)
if accountID := metaStringValue(auth.Metadata, "account_id"); accountID != "" {
httpReq.Header.Set("Chatgpt-Account-Id", accountID)
}
if auth != nil {
util.ApplyCustomHeadersFromAttrs(httpReq, auth.Attributes)
}
httpClient := &http.Client{}
if auth != nil {
if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
httpClient.Transport = transport
}
}
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
return nil, 0, errDo
}
bodyBytes, errRead := io.ReadAll(httpResp.Body)
if errClose := httpResp.Body.Close(); errClose != nil && errRead == nil {
errRead = errClose
}
if errRead != nil {
return nil, 0, errRead
}
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
return nil, 0, fmt.Errorf("models request failed with status %d: %s", httpResp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
count, errCount := countModels(bodyBytes)
if errCount != nil {
return nil, 0, errCount
}
return bodyBytes, count, nil
}
func codexModelsURL(clientVersion string) (string, error) {
u, err := url.Parse(codexModelsBaseURL + codexModelsPath)
if err != nil {
return "", err
}
if strings.TrimSpace(clientVersion) != "" {
q := u.Query()
q.Set("client_version", strings.TrimSpace(clientVersion))
u.RawQuery = q.Encode()
}
return u.String(), nil
}
func countModels(raw []byte) (int, error) {
var payload struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return 0, fmt.Errorf("failed to parse response JSON: %w", err)
}
if payload.Models == nil {
return 0, fmt.Errorf("response JSON does not contain models array")
}
return len(payload.Models), nil
}
func prettyJSON(raw []byte) ([]byte, error) {
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
return nil, err
}
buf.WriteByte('\n')
return buf.Bytes(), nil
}
func metaStringValue(m map[string]any, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok {
return ""
}
switch val := v.(type) {
case string:
return strings.TrimSpace(val)
default:
return ""
}
}
+773
View File
@@ -0,0 +1,773 @@
// Package main provides the entry point for the CLI Proxy API server.
// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
"github.com/router-for-me/CLIProxyAPI/v7/internal/store"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
"github.com/router-for-me/CLIProxyAPI/v7/internal/tui"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
DefaultConfigPath = ""
)
// init initializes the shared logger setup.
func init() {
logging.SetupBaseLogger()
buildinfo.Version = Version
buildinfo.Commit = Commit
buildinfo.BuildDate = BuildDate
}
func shouldEnableExampleAPIKeySafeMode(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool {
if cfg == nil || commandMode || homeMode || cloudConfigMissing {
return false
}
if tuiMode && !standalone {
return false
}
return safemode.HasExampleAPIKeys(cfg.APIKeys)
}
// main is the entry point of the application.
// It parses command-line flags, loads configuration, and starts the appropriate
// service based on the provided flags (login, codex-login, or server mode).
func main() {
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
// Command-line flags to control the application's behavior.
var codexLogin bool
var codexDeviceLogin bool
var claudeLogin bool
var noBrowser bool
var oauthCallbackPort int
var antigravityLogin bool
var kimiLogin bool
var xaiLogin bool
var vertexImport string
var vertexImportPrefix string
var configPath string
var password string
var homeJWT string
var homeDisableClusterDiscovery bool
var tuiMode bool
var standalone bool
var localModel bool
// Define command-line flags for different operation modes.
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow")
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
flag.BoolVar(&xaiLogin, "xai-login", false, "Login to xAI using OAuth")
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
flag.StringVar(&vertexImportPrefix, "vertex-import-prefix", "", "Prefix for Vertex model namespacing (use with -vertex-import)")
flag.StringVar(&password, "password", "", "")
flag.StringVar(&homeJWT, "home-jwt", "", "Home control plane JWT for mTLS certificate bootstrap and connection")
flag.BoolVar(&homeDisableClusterDiscovery, "home-disable-cluster-discovery", false, "Disable Home CLUSTER NODES discovery and keep using the configured -home-jwt address")
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalog only, skip remote model fetching")
flag.CommandLine.Usage = func() {
out := flag.CommandLine.Output()
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
flag.CommandLine.VisitAll(func(f *flag.Flag) {
if f.Name == "password" {
return
}
s := fmt.Sprintf(" -%s", f.Name)
name, unquoteUsage := flag.UnquoteUsage(f)
if name != "" {
s += " " + name
}
if len(s) <= 4 {
s += " "
} else {
s += "\n "
}
if unquoteUsage != "" {
s += unquoteUsage
}
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
s += fmt.Sprintf(" (default %s)", f.DefValue)
}
_, _ = fmt.Fprint(out, s+"\n")
})
}
pluginHost := pluginhost.New()
if bootstrapCfg := loadPluginBootstrapConfig(pluginBootstrapConfigPath(os.Args[1:], DefaultConfigPath)); bootstrapCfg != nil {
pluginHost.ApplyConfig(context.Background(), bootstrapCfg)
pluginHost.RegisterCommandLineFlags(context.Background(), flag.CommandLine)
}
// Parse the command-line flags.
flag.Parse()
// Core application variables.
var err error
var cfg *config.Config
var isCloudDeploy bool
var configLoadedFromHome bool
var homeClient *home.Client
var homePluginSyncReport homeplugins.SyncReport
var (
usePostgresStore bool
pgStoreDSN string
pgStoreSchema string
pgStoreLocalPath string
pgStoreInst *store.PostgresStore
useGitStore bool
gitStoreRemoteURL string
gitStoreUser string
gitStorePassword string
gitStoreBranch string
gitStoreLocalPath string
gitStoreInst *store.GitTokenStore
gitStoreRoot string
useObjectStore bool
objectStoreEndpoint string
objectStoreAccess string
objectStoreSecret string
objectStoreBucket string
objectStoreLocalPath string
objectStoreInst *store.ObjectTokenStore
)
wd, err := os.Getwd()
if err != nil {
log.Errorf("failed to get working directory: %v", err)
return
}
// Load environment variables from .env if present.
if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
if !errors.Is(errLoad, os.ErrNotExist) {
log.WithError(errLoad).Warn("failed to load .env file")
}
}
lookupEnv := func(keys ...string) (string, bool) {
for _, key := range keys {
if value, ok := os.LookupEnv(key); ok {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed, true
}
}
}
return "", false
}
writableBase := util.WritablePath()
if strings.TrimSpace(homeJWT) == "" {
if v, ok := lookupEnv("HOME_JWT", "home_jwt"); ok {
homeJWT = v
}
}
if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
usePostgresStore = true
pgStoreDSN = value
}
if usePostgresStore {
if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
pgStoreSchema = value
}
if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
pgStoreLocalPath = value
}
if pgStoreLocalPath == "" {
if writableBase != "" {
pgStoreLocalPath = writableBase
} else {
pgStoreLocalPath = wd
}
}
useGitStore = false
}
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
useGitStore = true
gitStoreRemoteURL = value
}
if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
gitStoreUser = value
}
if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
gitStorePassword = value
}
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
gitStoreLocalPath = value
}
if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok {
gitStoreBranch = value
}
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
useObjectStore = true
objectStoreEndpoint = value
}
if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
objectStoreAccess = value
}
if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
objectStoreSecret = value
}
if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
objectStoreBucket = value
}
if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
objectStoreLocalPath = value
}
// Check for cloud deploy mode only on first execution
// Read env var name in uppercase: DEPLOY
deployEnv := os.Getenv("DEPLOY")
if deployEnv == "cloud" {
isCloudDeploy = true
}
// Determine and load the configuration file.
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
var configFilePath string
if strings.TrimSpace(homeJWT) != "" {
configLoadedFromHome = true
ctxHome, cancelHome := context.WithTimeout(context.Background(), 30*time.Second)
homeCfg, errHomeCfg := home.ConfigFromJWT(ctxHome, homeJWT)
cancelHome()
if errHomeCfg != nil {
log.Errorf("invalid -home-jwt: %v", errHomeCfg)
return
}
if homeDisableClusterDiscovery {
homeCfg.DisableClusterDiscovery = true
}
homeClient = home.New(homeCfg)
defer homeClient.Close()
ctxHomeConfig, cancelHomeConfig := context.WithTimeout(context.Background(), 30*time.Second)
raw, errGetConfig := homeClient.GetConfig(ctxHomeConfig)
cancelHomeConfig()
if errGetConfig != nil {
log.Errorf("failed to fetch config from home: %v", errGetConfig)
return
}
parsed, errParseConfig := config.ParseConfigBytes(raw)
if errParseConfig != nil {
log.Errorf("failed to parse config payload from home: %v", errParseConfig)
return
}
if parsed == nil {
parsed = &config.Config{}
}
parsed.Home = homeCfg
parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
parsed.UsageStatisticsEnabled = true
ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
var errHomePlugins error
homePluginSyncReport, errHomePlugins = homeplugins.SyncWithReport(ctxHomePlugins, parsed, pluginHost)
cancelHomePlugins()
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, homeCfg.NodeID, homePluginSyncReport)
if errHomePlugins != nil {
log.Errorf("failed to fetch plugins from home: %v", errHomePlugins)
}
if errReportPlugins != nil {
log.Warnf("failed to report home plugin sync status: %v", errReportPlugins)
}
if errHomePlugins != nil {
return
}
cfg = parsed
// Keep a non-empty config path for downstream components (log paths, management assets, etc),
// but do not require the file to exist when loading config from home.
if strings.TrimSpace(configPath) != "" {
configFilePath = configPath
} else {
configFilePath = filepath.Join(wd, "config.yaml")
}
// Local stores are intentionally disabled when config is loaded from home.
usePostgresStore = false
useObjectStore = false
useGitStore = false
} else if usePostgresStore {
if pgStoreLocalPath == "" {
pgStoreLocalPath = wd
}
pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
DSN: pgStoreDSN,
Schema: pgStoreSchema,
SpoolDir: pgStoreLocalPath,
})
cancel()
if err != nil {
log.Errorf("failed to initialize postgres token store: %v", err)
return
}
examplePath := filepath.Join(wd, "config.example.yaml")
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
cancel()
log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
return
}
cancel()
configFilePath = pgStoreInst.ConfigPath()
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
if err == nil {
cfg.AuthDir = pgStoreInst.AuthDir()
log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
}
} else if useObjectStore {
if objectStoreLocalPath == "" {
if writableBase != "" {
objectStoreLocalPath = writableBase
} else {
objectStoreLocalPath = wd
}
}
objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
useSSL := true
if strings.Contains(resolvedEndpoint, "://") {
parsed, errParse := url.Parse(resolvedEndpoint)
if errParse != nil {
log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
return
}
switch strings.ToLower(parsed.Scheme) {
case "http":
useSSL = false
case "https":
useSSL = true
default:
log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
return
}
if parsed.Host == "" {
log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
return
}
resolvedEndpoint = parsed.Host
if parsed.Path != "" && parsed.Path != "/" {
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
}
}
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
objCfg := store.ObjectStoreConfig{
Endpoint: resolvedEndpoint,
Bucket: objectStoreBucket,
AccessKey: objectStoreAccess,
SecretKey: objectStoreSecret,
LocalRoot: objectStoreRoot,
UseSSL: useSSL,
PathStyle: true,
}
objectStoreInst, err = store.NewObjectTokenStore(objCfg)
if err != nil {
log.Errorf("failed to initialize object token store: %v", err)
return
}
examplePath := filepath.Join(wd, "config.example.yaml")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
cancel()
log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
return
}
cancel()
configFilePath = objectStoreInst.ConfigPath()
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
if err == nil {
if cfg == nil {
cfg = &config.Config{}
}
cfg.AuthDir = objectStoreInst.AuthDir()
log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
}
} else if useGitStore {
if gitStoreLocalPath == "" {
if writableBase != "" {
gitStoreLocalPath = writableBase
} else {
gitStoreLocalPath = wd
}
}
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
authDir := filepath.Join(gitStoreRoot, "auths")
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch)
gitStoreInst.SetBaseDir(authDir)
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
log.Errorf("failed to prepare git token store: %v", errRepo)
return
}
configFilePath = gitStoreInst.ConfigPath()
if configFilePath == "" {
configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
}
if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
examplePath := filepath.Join(wd, "config.example.yaml")
if _, errExample := os.Stat(examplePath); errExample != nil {
log.Errorf("failed to find template config file: %v", errExample)
return
}
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
return
}
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
log.Errorf("failed to commit initial git-backed config: %v", errCommit)
return
}
log.Infof("git-backed config initialized from template: %s", configFilePath)
} else if statErr != nil {
log.Errorf("failed to inspect git-backed config: %v", statErr)
return
}
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
if err == nil {
cfg.AuthDir = gitStoreInst.AuthDir()
log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
}
} else if configPath != "" {
configFilePath = configPath
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
} else {
wd, err = os.Getwd()
if err != nil {
log.Errorf("failed to get working directory: %v", err)
return
}
configFilePath = filepath.Join(wd, "config.yaml")
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
}
if err != nil {
log.Errorf("failed to load config: %v", err)
return
}
if cfg == nil {
cfg = &config.Config{}
}
// In cloud deploy mode, check if we have a valid configuration
var configFileExists bool
if isCloudDeploy {
if configLoadedFromHome && cfg != nil {
configFileExists = cfg.Port != 0
} else {
if info, errStat := os.Stat(configFilePath); errStat != nil {
// Don't mislead: API server will not start until configuration is provided.
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
configFileExists = false
} else if info.IsDir() {
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
configFileExists = false
} else if cfg.Port == 0 {
// LoadConfigOptional returns empty config when file is empty or invalid.
// Config file exists but is empty or invalid; treat as missing config
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
configFileExists = false
} else {
log.Info("Cloud deploy mode: Configuration file detected; starting service")
configFileExists = true
}
}
}
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
if err = logging.ConfigureLogOutput(cfg); err != nil {
log.Errorf("failed to configure log output: %v", err)
return
}
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
// Set the log level based on the configuration.
util.SetLogLevel(cfg)
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
return
} else {
cfg.AuthDir = resolvedAuthDir
}
managementasset.SetCurrentConfig(cfg)
// Create login options to be used in authentication flows.
options := &cmd.LoginOptions{
NoBrowser: noBrowser,
CallbackPort: oauthCallbackPort,
}
commandMode := vertexImport != "" || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || xaiLogin
cloudConfigMissing := isCloudDeploy && !configFileExists
homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled)
exampleAPIKeySafeMode := shouldEnableExampleAPIKeySafeMode(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode)
serverOptions := []api.ServerOption(nil)
if exampleAPIKeySafeMode {
matches := safemode.ExampleAPIKeys(cfg.APIKeys)
log.WithField("api_keys", strings.Join(matches, ",")).Error("unsafe example API key configured; proxy API endpoints disabled until api-keys is updated")
serverOptions = append(serverOptions, api.WithExampleAPIKeySafeMode())
}
// Register the shared token store once so all components use the same persistence backend.
if usePostgresStore {
sdkAuth.RegisterTokenStore(pgStoreInst)
} else if useObjectStore {
sdkAuth.RegisterTokenStore(objectStoreInst)
} else if useGitStore {
sdkAuth.RegisterTokenStore(gitStoreInst)
} else {
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
}
// Register built-in access providers before constructing services.
configaccess.Register(&cfg.SDKConfig)
pluginHost.ApplyConfig(context.Background(), cfg)
if configLoadedFromHome {
errHomePluginLoad := homeplugins.MarkLoadResults(&homePluginSyncReport, pluginHost)
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, cfg.Home.NodeID, homePluginSyncReport)
if errHomePluginLoad != nil {
log.Errorf("failed to load home plugins: %v", errHomePluginLoad)
}
if errReportPlugins != nil {
log.Warnf("failed to report home plugin load status: %v", errReportPlugins)
}
if errHomePluginLoad != nil {
return
}
}
if pluginHost.HasTriggeredCommandLineFlags() {
if exitCode, handled := pluginHost.ExecuteCommandLine(context.Background(), os.Args[0], os.Args[1:], configFilePath, flag.CommandLine); handled {
if exitCode != 0 {
os.Exit(exitCode)
}
return
}
}
// Handle different command modes based on the provided flags.
if vertexImport != "" {
// Handle Vertex service account import
cmd.DoVertexImport(cfg, vertexImport, vertexImportPrefix)
} else if antigravityLogin {
// Handle Antigravity login
cmd.DoAntigravityLogin(cfg, options)
} else if codexLogin {
// Handle Codex login
cmd.DoCodexLogin(cfg, options)
} else if codexDeviceLogin {
// Handle Codex device-code login
cmd.DoCodexDeviceLogin(cfg, options)
} else if claudeLogin {
// Handle Claude login
cmd.DoClaudeLogin(cfg, options)
} else if kimiLogin {
cmd.DoKimiLogin(cfg, options)
} else if xaiLogin {
cmd.DoXAILogin(cfg, options)
} else {
// In cloud deploy mode without config file, just wait for shutdown signals
if isCloudDeploy && !configFileExists {
// No config file available, just wait for shutdown
cmd.WaitForCloudDeploy()
return
}
if localModel && (!tuiMode || standalone) {
log.Info("Local model mode: using embedded model catalog, remote model updates disabled")
}
if tuiMode {
if standalone {
// Standalone mode: start an embedded local server and connect TUI client to it.
managementasset.StartAutoUpdater(context.Background(), configFilePath)
misc.StartAntigravityVersionUpdater(context.Background())
if !localModel && !cfg.Home.Enabled {
registry.StartModelsUpdater(context.Background())
} else if cfg.Home.Enabled {
log.Info("Home mode: remote model updates disabled")
}
hook := tui.NewLogHook(2000)
hook.SetFormatter(&logging.LogFormatter{})
log.AddHook(hook)
origStdout := os.Stdout
origStderr := os.Stderr
origLogOutput := log.StandardLogger().Out
log.SetOutput(io.Discard)
devNull, errOpenDevNull := os.Open(os.DevNull)
if errOpenDevNull == nil {
os.Stdout = devNull
os.Stderr = devNull
}
restoreIO := func() {
os.Stdout = origStdout
os.Stderr = origStderr
log.SetOutput(origLogOutput)
if devNull != nil {
_ = devNull.Close()
}
}
localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano())
if password == "" {
password = localMgmtPassword
}
cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
client := tui.NewClient(cfg.Port, password)
ready := false
backoff := 100 * time.Millisecond
for i := 0; i < 30; i++ {
if _, errGetConfig := client.GetConfig(); errGetConfig == nil {
ready = true
break
}
time.Sleep(backoff)
if backoff < time.Second {
backoff = time.Duration(float64(backoff) * 1.5)
}
}
if !ready {
restoreIO()
cancel()
<-done
fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n")
return
}
if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {
restoreIO()
fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
} else {
restoreIO()
}
cancel()
<-done
} else {
// Default TUI mode: pure management client.
// The proxy server must already be running.
if errRun := tui.Run(cfg.Port, password, nil, os.Stdout); errRun != nil {
fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
}
}
} else {
// Start the main proxy service
managementasset.StartAutoUpdater(context.Background(), configFilePath)
misc.StartAntigravityVersionUpdater(context.Background())
if !localModel && !cfg.Home.Enabled {
registry.StartModelsUpdater(context.Background())
} else if cfg.Home.Enabled {
log.Info("Home mode: remote model updates disabled")
}
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
}
}
}
func pluginBootstrapConfigPath(args []string, defaultPath string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--":
return defaultPluginBootstrapConfigPath(defaultPath)
case arg == "-config" || arg == "--config":
if i+1 < len(args) {
return args[i+1]
}
return defaultPluginBootstrapConfigPath(defaultPath)
case strings.HasPrefix(arg, "-config="):
return strings.TrimPrefix(arg, "-config=")
case strings.HasPrefix(arg, "--config="):
return strings.TrimPrefix(arg, "--config=")
}
}
return defaultPluginBootstrapConfigPath(defaultPath)
}
func defaultPluginBootstrapConfigPath(defaultPath string) string {
if strings.TrimSpace(defaultPath) != "" {
return defaultPath
}
wd, errGetwd := os.Getwd()
if errGetwd != nil {
return "config.yaml"
}
return filepath.Join(wd, "config.yaml")
}
func loadPluginBootstrapConfig(path string) *config.Config {
raw, errReadFile := os.ReadFile(path)
if errReadFile != nil {
if !errors.Is(errReadFile, os.ErrNotExist) {
log.Warnf("failed to read plugin bootstrap config: %v", errReadFile)
}
cfg := &config.Config{}
cfg.NormalizePluginsConfig()
return cfg
}
if len(strings.TrimSpace(string(raw))) == 0 {
cfg := &config.Config{}
cfg.NormalizePluginsConfig()
return cfg
}
cfg, errParseConfig := config.ParseConfigBytes(raw)
if errParseConfig != nil {
log.Warnf("failed to parse plugin bootstrap config: %v", errParseConfig)
cfg = &config.Config{}
cfg.NormalizePluginsConfig()
return cfg
}
return cfg
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestShouldEnableExampleAPIKeySafeMode(t *testing.T) {
cfgWithExampleKey := &config.Config{
SDKConfig: config.SDKConfig{
APIKeys: []string{"real-key", " your-api-key-1 "},
},
}
cfgWithRealKey := &config.Config{
SDKConfig: config.SDKConfig{
APIKeys: []string{"real-key"},
},
}
tests := []struct {
name string
cfg *config.Config
commandMode bool
tuiMode bool
standalone bool
cloudConfigMissing bool
homeMode bool
want bool
}{
{
name: "normal server with example key",
cfg: cfgWithExampleKey,
want: true,
},
{
name: "standalone tui with example key",
cfg: cfgWithExampleKey,
tuiMode: true,
standalone: true,
want: true,
},
{
name: "pure tui client is not blocked",
cfg: cfgWithExampleKey,
tuiMode: true,
standalone: false,
commandMode: false,
want: false,
},
{
name: "one-shot command is not blocked",
cfg: cfgWithExampleKey,
commandMode: true,
want: false,
},
{
name: "home mode is not blocked",
cfg: cfgWithExampleKey,
homeMode: true,
want: false,
},
{
name: "cloud standby without config is not blocked",
cfg: cfgWithExampleKey,
cloudConfigMissing: true,
want: false,
},
{
name: "normal server with real key",
cfg: cfgWithRealKey,
want: false,
},
{
name: "nil config",
cfg: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldEnableExampleAPIKeySafeMode(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
if got != tt.want {
t.Fatalf("shouldEnableExampleAPIKeySafeMode() = %t, want %t", got, tt.want)
}
})
}
}
+501
View File
@@ -0,0 +1,501 @@
# Server host/interface to bind to. Default is empty ("") to bind all interfaces (IPv4 + IPv6).
# Use "127.0.0.1" or "localhost" to restrict access to local machine only.
host: ""
# Server port
port: 8317
# TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key.
tls:
enable: false
cert: ""
key: ""
# Management API settings
remote-management:
# Whether to allow remote (non-localhost) management access.
# When false, only localhost can access management endpoints (a key is still required).
allow-remote: false
# Management key. If a plaintext value is provided here, it will be hashed on startup.
# All management requests (even from localhost) require this key.
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
secret-key: ""
# Disable the bundled management control panel asset download and HTTP route when true.
disable-control-panel: false
# Disable automatic periodic background updates of the management panel from GitHub (default: false).
# When enabled, the panel is only downloaded on first access if missing, and never auto-updated afterward.
# disable-auto-update-panel: false
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
# Authentication directory (supports ~ for home directory)
auth-dir: "~/.cli-proxy-api"
# API keys for authentication
api-keys:
- "your-api-key-1"
- "your-api-key-2"
- "your-api-key-3"
# Enable debug logging
debug: false
# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.
pprof:
enable: false
addr: "127.0.0.1:8316"
# Standard dynamic library plugins are trusted in-process code. They are disabled by default.
# Build Go examples with go build -buildmode=c-shared for the target GOOS/GOARCH.
# Other languages can implement the same C ABI and JSON method protocol.
# Plugin executors require a matching auth record with the same provider key.
# If the same provider is configured as OpenAI-compatible, the native executor wins.
# Plugin command-line flags and Management API routes are optional capabilities.
# Existing native flags/routes and higher-priority plugin flags/routes cannot be replaced.
# Plugin list Management API reads Logo and ConfigFields from plugin metadata for management UI display.
# Per-plugin enabled only controls plugins.configs.<pluginID>.enabled and does not implicitly change global plugins.enabled.
plugins:
enabled: false
dir: "plugins"
# Additional plugin store registries. The built-in official registry is always included.
# store-sources:
# - "https://example.com/cliproxy-plugins/registry.json"
# Optional plugin store auth rules. Values are read from environment variables;
# tokens are not written into plugin manifests or node status.
# store-auth:
# - match: "https://example.com/cliproxy-plugins/"
# apply-to: ["registry", "artifact"]
# type: bearer
# token-env: "CLIPROXY_PLUGIN_STORE_TOKEN"
configs:
example:
enabled: true
priority: 1
config1: true
config2: "string"
config3: 3
mode: "safe" # enum example: safe, fast
# When true, disable high-overhead request logging and HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false
# When true, write application logs to rotating files instead of stdout
logging-to-file: false
# Maximum total size (MB) of log files under the logs directory. When exceeded, the oldest log
# files are deleted until within the limit. Set to 0 to disable.
logs-max-total-size-mb: 0
# Maximum number of error log files retained when request logging is disabled.
# When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup.
error-logs-max-files: 10
# When false, disable in-memory usage statistics aggregation
usage-statistics-enabled: false
# How long (in seconds) usage queue items are retained in memory for the Management API.
# The local Redis RESP usage output is disabled.
# Default: 60. Max: 3600.
redis-usage-queue-retention-seconds: 60
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
proxy-url: ""
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
force-model-prefix: false
# When true, forward filtered upstream response headers to downstream clients.
# Default is false (disabled).
passthrough-headers: false
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
request-retry: 3
# Maximum number of different credentials to try for one failed request.
# Set to 0 to keep legacy behavior (try all available credentials).
max-retry-credentials: 0
# Maximum wait time in seconds for a cooled-down credential before triggering a retry.
max-retry-interval: 30
# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
disable-cooling: false
# When true, persist per-auth cooldown status as .cds files next to auth files.
# Default is false; when false, cooldown status is kept in memory only.
save-cooldown-status: false
# Cooldown duration in seconds for transient upstream errors (408/500/502/503/504).
# Set to 0 to keep the legacy 60-second cooldown; set to -1 to disable transient error cooldowns.
transient-error-cooldown-seconds: 0
# When true, globally disable Claude request cloaking (the Claude Code CLI disguise and
# system prompt replacement), so the original system prompt is passed through to Claude as-is.
# Individual credentials can still override this: a claude-api-key entry via its "cloak.mode",
# or a Claude OAuth/token file via a "cloak_mode" value. Default false keeps the per-client
# "auto" behavior (cloak only non-Claude-Code clients).
disable-claude-cloak-mode: false
# disable-image-generation supports: false (default), true, "chat", or "passthrough".
# - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits).
# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled.
# - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints.
disable-image-generation: false
# Base model used by the legacy hosted image_generation tool path when a Codex image request is not proxied directly through the Image API.
# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".
# gpt-image-2-base-model: "gpt-5.4-mini"
# How long video IDs returned by /openai/v1/videos and xAI video creation stay bound
# to the credential that created them. Default: 3h.
video-result-auth-cache-ttl: "3h"
# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).
# When > 0, overrides the default worker count (16).
# auth-auto-refresh-workers: 16
# Quota exceeded behavior
quota-exceeded:
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
antigravity-credits: true # Whether to use credits as last-resort fallback when all free-tier auths are exhausted for Claude models
# Routing strategy for selecting credentials when multiple match.
routing:
strategy: "round-robin" # round-robin (default), fill-first
# Enable universal session-sticky routing for all clients.
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
# X-Session-ID, Session_id (Codex), X-Client-Request-Id (PI), conversation_id,
# or first few messages hash.
# Automatic failover is always enabled when bound auth becomes unavailable.
session-affinity: false # default: false
# How long session-to-auth bindings are retained. Default: 1h
session-affinity-ttl: "1h"
# Codex provider behavior.
codex:
# When true, and routing.strategy is fill-first or routing.session-affinity is true,
# remap Codex prompt_cache_key and installation identity per selected auth.
# Some superstitious users believe request tracking identifiers can be used
# as evidence for TOS enforcement bans; this option only satisfies those odd concerns.
identity-confuse: false
# When true, enable authentication for the WebSocket API (/v1/ws).
ws-auth: true
# When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.
nonstream-keepalive-interval: 0
# Streaming behavior (SSE keep-alives + safe bootstrap retries).
# streaming:
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
# Signature cache validation for thinking blocks (Antigravity/Claude).
# When true (default), cached signatures are preferred and validated.
# When false, client signatures are used directly after normalization (bypass mode for testing).
# antigravity-signature-cache-enabled: true
# Bypass mode signature validation strictness (only applies when signature cache is disabled).
# When true, validates full Claude protobuf tree (Field 2 -> Field 1 structure).
# When false (default), only checks R/E prefix + base64 + first byte 0x12.
# antigravity-signature-bypass-strict: false
# Gemini API keys
# gemini-api-key:
# - api-key: "AIzaSy...01"
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://generativelanguage.googleapis.com"
# headers:
# X-Custom-Header: "custom-value"
# proxy-url: "socks5://proxy.example.com:1080"
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# models:
# - name: "gemini-2.5-flash" # upstream model name
# alias: "gemini-flash" # client alias mapped to the upstream model
# display-name: "Gemini Flash" # optional catalog display name
# excluded-models:
# - "gemini-2.5-pro" # exclude specific models from this provider (exact match)
# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)
# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
# - api-key: "AIzaSy...02"
# Native Interactions API keys
# These keys are used only for direct /v1beta/interactions execution. Regular gemini-api-key entries still
# send Gemini generateContent/streamGenerateContent requests when the client enters through the interactions API.
# interactions-api-key:
# - api-key: "AIzaSy...03"
# prefix: "native" # optional: require calls like "native/gemini-3-pro-preview" to target this credential
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://generativelanguage.googleapis.com"
# headers:
# X-Custom-Header: "custom-value"
# proxy-url: "socks5://proxy.example.com:1080"
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# models:
# - name: "gemini-2.5-flash" # upstream model name
# alias: "native-gemini-flash" # client alias mapped to the upstream model
# excluded-models:
# - "gemini-2.5-pro"
# Codex API keys
# codex-api-key:
# - api-key: "sk-atSM..."
# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://www.example.com" # use the custom codex API endpoint
# headers:
# X-Custom-Header: "custom-value"
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# models:
# - name: "gpt-5-codex" # upstream model name
# alias: "codex-latest" # client alias mapped to the upstream model
# display-name: "Codex Latest" # optional catalog display name
# force-mapping: true # optional: rewrite response model fields back to the alias
# excluded-models:
# - "gpt-5.1" # exclude specific models (exact match)
# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)
# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)
# Claude API keys
# claude-api-key:
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
# - api-key: "sk-atSM..."
# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://www.example.com" # use the custom claude API endpoint
# headers:
# X-Custom-Header: "custom-value"
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# models:
# - name: "claude-3-5-sonnet-20241022" # upstream model name
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
# display-name: "Claude Sonnet" # optional catalog display name
# force-mapping: true # optional: rewrite response model fields back to the alias
# excluded-models:
# - "claude-opus-4-5-20251101" # exclude specific models (exact match)
# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)
# - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
# rebuild-mid-system-message: false # optional: default is false; when true, move messages with role "system" into the top-level Claude system field
# cloak: # optional: request cloaking for non-Claude-Code clients
# mode: "auto" # "auto" (default): cloak only when client is not Claude Code
# # "always": always apply cloaking
# # "never": never apply cloaking
# # This "cloak" block applies to this claude-api-key entry only. For Claude OAuth
# # credentials, set the same options in the auth/token JSON file via "cloak_mode" /
# # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id". The top-level
# # "disable-claude-cloak-mode: true" disables cloaking for all Claude credentials at once.
# strict-mode: false # false (default): prepend Claude Code prompt to user system messages
# # true: strip all user system messages, keep only Claude Code prompt
# sensitive-words: # optional: words to obfuscate with zero-width characters
# - "API"
# - "proxy"
# cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request
# experimental-cch-signing: false # optional: default is false; when true, sign the final /v1/messages body using the current Claude Code cch algorithm
# # keep this disabled unless you explicitly need the behavior, so upstream seed changes fall back to legacy proxy behavior
# Default headers for Claude API requests. Update when Claude Code releases new versions.
# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks
# when the client omits them, while OS/arch remain runtime-derived. When
# stabilize-device-profile is enabled, OS/arch stay pinned to the baseline values below,
# while user-agent/package-version/runtime-version seed a software fingerprint that can
# still upgrade to newer official Claude client versions.
# claude-header-defaults:
# user-agent: "claude-cli/2.1.44 (external, sdk-cli)"
# package-version: "0.74.0"
# runtime-version: "v24.3.0"
# os: "MacOS"
# arch: "arm64"
# timeout: "600"
# stabilize-device-profile: false # optional, default false; set true to enable per-auth/API-key fingerprint pinning
# Default headers for Codex OAuth model requests.
# These are used only for file-backed/OAuth Codex requests when the client
# does not send the header. `user-agent` applies to HTTP and websocket requests;
# `beta-features` only applies to websocket requests. They do not apply to codex-api-key entries.
# codex-header-defaults:
# user-agent: "codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0"
# beta-features: "multi_agent"
# OpenAI compatibility providers
# openai-compatibility:
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
# disabled: false # optional: set to true to disable this provider without removing it
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
# disable-cooling: false # optional: per-provider override for auth/model cooldown scheduling
# headers:
# X-Custom-Header: "custom-value"
# api-key-entries:
# - api-key: "sk-or-v1-...b780"
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# - api-key: "sk-or-v1-...b781" # without proxy-url
# models: # The models supported by the provider.
# - name: "moonshotai/kimi-k2:free" # The actual model name.
# alias: "kimi-k2" # The alias used in the API.
# display-name: "Kimi K2" # optional catalog display name
# image: false # optional: set true to allow this model on /v1/images/generations and /v1/images/edits (not chat/responses image input)
# input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients
# output-modalities: [text] # optional: declare output modalities when known
# thinking: # optional: omit to default to levels ["low","medium","high"]
# levels: ["low", "medium", "high"]
# # You may repeat the same alias to build an internal model pool.
# # The client still sees only one alias in the model list.
# # Requests to that alias will round-robin across the upstream names below,
# # and if the chosen upstream fails before producing output, the request will
# # continue with the next upstream model in the same alias pool.
# - name: "deepseek-v3.1"
# alias: "claude-opus-4.66"
# - name: "glm-5"
# alias: "claude-opus-4.66"
# - name: "kimi-k2.5"
# alias: "claude-opus-4.66"
# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
# vertex-api-key:
# - api-key: "vk-123..." # x-goog-api-key header
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
# base-url: "https://example.com/api" # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# headers:
# X-Custom-Header: "custom-value"
# models: # optional: map aliases to upstream model names
# - name: "gemini-2.5-flash" # upstream model name
# alias: "vertex-flash" # client-visible alias
# display-name: "Vertex Flash" # optional catalog display name
# - name: "gemini-2.5-pro"
# alias: "vertex-pro"
# excluded-models: # optional: models to exclude from listing
# - "imagen-3.0-generate-002"
# - "imagen-*"
# Global OAuth model name aliases (per channel)
# These aliases rename model IDs for both model listing and request routing.
# Supported channels: vertex, aistudio, antigravity, claude, codex, kimi, xai.
# NOTE: Aliases do not apply to gemini-api-key, interactions-api-key, codex-api-key, claude-api-key, openai-compatibility, or vertex-api-key.
# NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping
# client-visible names can become ambiguous across providers. For strict backend pinning, use
# unique aliases/prefixes or avoid overlapping names.
# You can repeat the same name with different aliases to expose multiple client model names.
# Optional per-entry flags:
# fork: true # keep the upstream model and also expose the alias as a separate client-visible model
# force-mapping: true # optional: rewrite upstream response model fields back to the client-visible alias (example below uses antigravity only)
# Per-auth OAuth aliases can also be stored in an OAuth auth JSON file as "model-aliases".
# They apply only to that selected auth and take precedence over global aliases for the same client-visible alias.
# Example auth JSON:
# {
# "type": "codex",
# "email": "user@example.com",
# "model-aliases": [
# {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"},
# {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4"}
# ]
# }
# oauth-model-alias:
# vertex:
# - name: "gemini-2.5-pro"
# alias: "g2.5p"
# aistudio:
# - name: "gemini-2.5-pro"
# alias: "g2.5p"
# antigravity:
# - name: "gemini-pro-agent" # upstream Antigravity model id
# alias: "gemini-3.1-pro-preview" # client-visible id (Gemini 3.1 Pro Preview)
# fork: true
# force-mapping: true
# claude:
# - name: "claude-sonnet-4-5-20250929"
# alias: "cs4.5"
# codex:
# - name: "gpt-5"
# alias: "g5"
# kimi:
# - name: "kimi-k2.5"
# alias: "k2.5"
# xai:
# - name: "grok-4.3"
# alias: "grok-latest"
# sample-provider: # plugin provider keys are supported for OAuth plugins
# - name: "sample-model-latest"
# alias: "sample-latest"
# OAuth provider excluded models
# oauth-excluded-models:
# vertex:
# - "gemini-3-pro-preview"
# aistudio:
# - "gemini-3-pro-preview"
# antigravity:
# - "gemini-3-pro-preview"
# claude:
# - "claude-3-5-haiku-20241022"
# codex:
# - "gpt-5-codex-mini"
# kimi:
# - "kimi-k2-thinking"
# xai:
# - "grok-3-mini"
# Optional payload configuration
# payload:
# default: # Default rules only set parameters when they are missing in the payload.
# - models:
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
# from-protocol: "responses" # restricts the rule to the source protocol, options: openai, responses, gemini, claude
# headers: # all configured request headers must match; values support "*" wildcards
# X-Client-Tier: "tenant-*-region-*"
# match: # all payload JSON paths must equal the configured values
# - "metadata.client": "codex"
# not-match: # payload JSON paths must not equal the configured values
# - "metadata.mode": "dev"
# exist: # all payload JSON paths must exist and not be null
# - "tools.#(type==\"web_search\").type"
# not-exist: # all payload JSON paths must be missing or null
# - "metadata.disable_payload"
# params: # JSON path (gjson/sjson syntax) -> value
# "generationConfig.thinkingConfig.thinkingBudget": 32768
# default-raw: # Default raw rules set parameters using raw JSON when missing (must be valid JSON).
# - models:
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
# params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
# "generationConfig.responseJsonSchema": "{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}"
# override: # Override rules always set parameters, overwriting any existing values.
# - models:
# - name: "gpt-5.4-fast"
# protocol: "codex"
# - name: "gpt-5.5-fast"
# protocol: "codex"
# params:
# service_tier: priority
# - models:
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
# params: # JSON path (gjson/sjson syntax) -> value
# "reasoning.effort": "high"
# override-raw: # Override raw rules always set parameters using raw JSON (must be valid JSON).
# - models:
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
# params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
# "response_format": "{\"type\":\"json_schema\",\"json_schema\":{\"name\":\"answer\",\"schema\":{\"type\":\"object\"}}}"
# filter: # Filter rules remove specified parameters from the payload.
# - models:
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
# params: # JSON paths (gjson/sjson syntax) to remove from the payload
# - "generationConfig.thinkingConfig.thinkingBudget"
# - "generationConfig.responseJsonSchema"
+53
View 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
}
}
+63
View File
@@ -0,0 +1,63 @@
#!/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.
set -euo pipefail
if [[ "${1:-}" != "" ]]; then
echo "Error: unknown option '${1}'."
echo "Usage: ./docker-build.sh"
exit 1
fi
# --- 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
+29
View File
@@ -0,0 +1,29 @@
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-cluster
environment:
HOME_JWT: ${HOME_JWT:-}
ports:
- "8317:8317"
volumes:
- ./home:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs
command: >
sh -eu -c '
if [ -z "$$HOME_JWT" ]; then
echo "HOME_JWT is required" >&2
exit 1
fi
exec ./CLIProxyAPI -home-jwt "$$HOME_JWT"
'
restart: unless-stopped
+28
View File
@@ -0,0 +1,28 @@
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
# env_file:
# - .env
environment:
DEPLOY: ${DEPLOY:-}
ports:
- "8317:8317"
- "8085:8085"
- "1455:1455"
- "54545:54545"
- "51121:51121"
- "11451:11451"
volumes:
- ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml
- ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
- ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
restart: unless-stopped
+225
View File
@@ -0,0 +1,225 @@
// Package main demonstrates how to create a custom AI provider executor
// and integrate it with the CLI Proxy API server. This example shows how to:
// - Create a custom executor that implements the Executor interface
// - Register custom translators for request/response transformation
// - Integrate the custom provider with the SDK server
// - Register custom models in the model registry
//
// This example uses a simple echo service (httpbin.org) as the upstream API
// for demonstration purposes. In a real implementation, you would replace
// this with your actual AI service provider.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/logging"
sdktr "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
const (
// providerKey is the identifier for our custom provider.
providerKey = "myprov"
// fOpenAI represents the OpenAI chat format.
fOpenAI = sdktr.Format("openai.chat")
// fMyProv represents our custom provider's chat format.
fMyProv = sdktr.Format("myprov.chat")
)
// init registers trivial translators for demonstration purposes.
// In a real implementation, you would implement proper request/response
// transformation logic between OpenAI format and your provider's format.
func init() {
sdktr.Register(fOpenAI, fMyProv,
func(model string, raw []byte, stream bool) []byte { return raw },
sdktr.ResponseTransform{
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) [][]byte {
return [][]byte{raw}
},
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []byte {
return raw
},
},
)
}
// MyExecutor is a minimal provider implementation for demonstration purposes.
// It implements the Executor interface to handle requests to a custom AI provider.
type MyExecutor struct{}
// Identifier returns the unique identifier for this executor.
func (MyExecutor) Identifier() string { return providerKey }
// PrepareRequest optionally injects credentials to raw HTTP requests.
// This method is called before each request to allow the executor to modify
// the HTTP request with authentication headers or other necessary modifications.
//
// Parameters:
// - req: The HTTP request to prepare
// - a: The authentication information
//
// Returns:
// - error: An error if request preparation fails
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
if req == nil || a == nil {
return nil
}
if a.Attributes != nil {
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
req.Header.Set("Authorization", "Bearer "+ak)
}
}
return nil
}
func buildHTTPClient(a *coreauth.Auth) *http.Client {
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
return http.DefaultClient
}
u, err := url.Parse(a.ProxyURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return http.DefaultClient
}
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
}
func upstreamEndpoint(a *coreauth.Auth) string {
if a != nil && a.Attributes != nil {
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
return ep
}
}
// Demo echo endpoint; replace with your upstream.
return "https://httpbin.org/post"
}
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
client := buildHTTPClient(a)
endpoint := upstreamEndpoint(a)
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
if errNew != nil {
return clipexec.Response{}, errNew
}
httpReq.Header.Set("Content-Type", "application/json")
// Inject credentials via PrepareRequest hook.
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
return clipexec.Response{}, errPrep
}
resp, errDo := client.Do(httpReq)
if errDo != nil {
return clipexec.Response{}, errDo
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose)
}
}()
body, _ := io.ReadAll(resp.Body)
return clipexec.Response{Payload: body}, nil
}
func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, fmt.Errorf("myprov executor: request is nil")
}
if ctx == nil {
ctx = req.Context()
}
httpReq := req.WithContext(ctx)
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
return nil, errPrep
}
client := buildHTTPClient(a)
return client.Do(httpReq)
}
func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
return clipexec.Response{}, errors.New("count tokens not implemented")
}
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (*clipexec.StreamResult, error) {
ch := make(chan clipexec.StreamChunk, 1)
go func() {
defer close(ch)
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
}()
return &clipexec.StreamResult{Chunks: ch}, nil
}
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
return a, nil
}
func main() {
cfg, err := config.LoadConfig("config.yaml")
if err != nil {
panic(err)
}
tokenStore := sdkAuth.GetTokenStore()
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
dirSetter.SetBaseDir(cfg.AuthDir)
}
core := coreauth.NewManager(tokenStore, nil, nil)
core.RegisterExecutor(MyExecutor{})
hooks := cliproxy.Hooks{
OnAfterStart: func(s *cliproxy.Service) {
// Register demo models for the custom provider so they appear in /v1/models.
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
for _, a := range core.List() {
if strings.EqualFold(a.Provider, providerKey) {
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
}
}
},
}
svc, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithCoreAuthManager(core).
WithServerOptions(
// Optional: add a simple middleware + custom request logger
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
return logging.NewFileRequestLoggerWithOptions(true, "logs", filepath.Dir(cfgPath), cfg.ErrorLogsMaxFiles)
}),
).
WithHooks(hooks).
Build()
if err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {
panic(errRun)
}
_ = os.Stderr // keep os import used (demo only)
_ = time.Second
}
+140
View File
@@ -0,0 +1,140 @@
// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest
// to execute arbitrary HTTP requests with provider credentials injected.
//
// This example registers a minimal custom executor that injects an Authorization
// header from auth.Attributes["api_key"], then performs two requests against
// httpbin.org to show the injected headers.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
)
const providerKey = "echo"
// EchoExecutor is a minimal provider implementation for demonstration purposes.
type EchoExecutor struct{}
func (EchoExecutor) Identifier() string { return providerKey }
func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
if req == nil || auth == nil {
return nil
}
if auth.Attributes != nil {
if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
return nil
}
func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, fmt.Errorf("echo executor: request is nil")
}
if ctx == nil {
ctx = req.Context()
}
httpReq := req.WithContext(ctx)
if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {
return nil, errPrep
}
return http.DefaultClient.Do(httpReq)
}
func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
return clipexec.Response{}, errors.New("echo executor: Execute not implemented")
}
func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (*clipexec.StreamResult, error) {
return nil, errors.New("echo executor: ExecuteStream not implemented")
}
func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
return nil, errors.New("echo executor: Refresh not implemented")
}
func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented")
}
func main() {
log.SetLevel(log.InfoLevel)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
core := coreauth.NewManager(nil, nil, nil)
core.RegisterExecutor(EchoExecutor{})
auth := &coreauth.Auth{
ID: "demo-echo",
Provider: providerKey,
Attributes: map[string]string{
"api_key": "demo-api-key",
},
}
// Example 1: Build a prepared request and execute it using your own http.Client.
reqPrepared, errReqPrepared := core.NewHttpRequest(
ctx,
auth,
http.MethodGet,
"https://httpbin.org/anything",
nil,
http.Header{"X-Example": []string{"prepared"}},
)
if errReqPrepared != nil {
panic(errReqPrepared)
}
respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)
if errDoPrepared != nil {
panic(errDoPrepared)
}
defer func() {
if errClose := respPrepared.Body.Close(); errClose != nil {
log.Errorf("close response body error: %v", errClose)
}
}()
bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)
if errReadPrepared != nil {
panic(errReadPrepared)
}
fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared)
// Example 2: Execute a raw request via core.HttpRequest (auto inject + do).
rawBody := []byte(`{"hello":"world"}`)
rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody))
if errRawReq != nil {
panic(errRawReq)
}
rawReq.Header.Set("Content-Type", "application/json")
rawReq.Header.Set("X-Example", "executed")
respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)
if errDoExec != nil {
panic(errDoExec)
}
defer func() {
if errClose := respExec.Body.Close(); errClose != nil {
log.Errorf("close response body error: %v", errClose)
}
}()
bodyExec, errReadExec := io.ReadAll(respExec.Body)
if errReadExec != nil {
panic(errReadExec)
}
fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec)
}
+48
View File
@@ -0,0 +1,48 @@
EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback host-callback-auth-files host-model-callback claude-web-search-router
LANGUAGES := go c rust
BIN_DIR := $(CURDIR)/bin
BUILD_DIR := $(BIN_DIR)/build
UNAME_S := $(shell uname -s)
ifeq ($(OS),Windows_NT)
PLUGIN_EXT := dll
RUST_DYLIB_PREFIX :=
RUST_DYLIB_EXT := dll
else ifeq ($(UNAME_S),Darwin)
PLUGIN_EXT := dylib
RUST_DYLIB_PREFIX := lib
RUST_DYLIB_EXT := dylib
else
PLUGIN_EXT := so
RUST_DYLIB_PREFIX := lib
RUST_DYLIB_EXT := so
endif
.PHONY: build list clean
build: $(foreach example,$(EXAMPLES),$(foreach lang,$(LANGUAGES),$(BIN_DIR)/$(example)-$(lang).$(PLUGIN_EXT)))
list:
@$(foreach example,$(EXAMPLES),$(foreach lang,$(LANGUAGES),echo $(example)/$(lang);))
clean:
rm -rf $(BIN_DIR)
$(BIN_DIR):
mkdir -p $(BIN_DIR)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(BIN_DIR)/%-go.$(PLUGIN_EXT): %/go/main.go %/go/go.mod | $(BIN_DIR)
cd $*/go && go build -buildmode=c-shared -o $(abspath $@) .
rm -f $(BIN_DIR)/$*-go.h
$(BIN_DIR)/%-c.$(PLUGIN_EXT): %/c/CMakeLists.txt %/c/src/plugin.c | $(BIN_DIR) $(BUILD_DIR)
cmake -S $*/c -B $(BUILD_DIR)/$*/c -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$(BIN_DIR)
cmake --build $(BUILD_DIR)/$*/c
$(BIN_DIR)/%-rust.$(PLUGIN_EXT): %/rust/Cargo.toml %/rust/Cargo.lock %/rust/src/lib.rs | $(BIN_DIR) $(BUILD_DIR)
cd $*/rust && CARGO_TARGET_DIR=$(abspath $(BUILD_DIR)/$*/rust) cargo build --release --locked
cp "$(BUILD_DIR)/$*/rust/release/$(RUST_DYLIB_PREFIX)cliproxy_$(subst -,_,$*)_rust.$(RUST_DYLIB_EXT)" "$@"
+109
View File
@@ -0,0 +1,109 @@
# Standard Dynamic Library Plugin Examples
This directory contains standard dynamic library plugin examples for the CLIProxyAPI C ABI.
## Layout
- `simple/`- : Go-only plugin resource that calls host auth file callbacks (, , , ).
- : full provider-native skeleton that declares every supported capability.
- `model/`: model capability only.
- `auth/`: auth provider capability only.
- `frontend-auth/`: frontend auth provider capability only.
- `frontend-auth-exclusive/`: frontend auth provider that becomes the only request authentication provider when selected.
- `executor/`: executor capability only.
- `protocol-format/`: minimal executor focused on input/output format declarations.
- `request-translator/`: request translation capability only.
- `request-normalizer/`: request normalization capability only.
- `codex-service-tier/`: Go-only request normalizer that sets Codex `gpt-5.5` requests to the priority service tier when enabled.
- `scheduler/`: Go-only scheduler that can select a configured auth ID, delegate to a built-in scheduler, or deny picks.
- `claude-web-search-router/`: ModelRouter + executor for Claude Code built-in `web_search` (antigravity / codex / xai / Tavily). See `claude-web-search-router/README.md`.
- `response-translator/`: response translation capability only.
- `response-normalizer/`: response normalization capability only.
- `thinking/`: thinking applier capability only.
- `usage/`: usage observer capability only.
- `cli/`: command-line capability only.
- `management-api/`: Management API and resource capability only.
- `host-callback/`: minimal plugin resource that demonstrates host callbacks.
- `host-callback-auth-files/`: Go-only plugin resource that calls host auth file callbacks.
- `host-model-callback/`: Go-only plugin resource that calls the host model execution callbacks.
Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need.
## Codex Service Tier
`codex-service-tier` declares the request normalization capability. When `fast` is `true`, it sets `service_tier` to `priority` for requests where `req.ToFormat` is `codex` and `req.Model` is `gpt-5.5`.
```yaml
plugins:
configs:
codex-service-tier:
enabled: true
priority: 1
fast: false
```
## Host Auth Files Callback
`host-callback-auth-files` declares the Management API capability and exposes a browser resource named `Host Auth Files`. The resource demonstrates `host.auth.list`, `host.auth.get` (physical JSON file), `host.auth.get_runtime`, and `host.auth.save`.
```yaml
plugins:
configs:
host-callback-auth-files:
enabled: true
priority: 1
```
See `host-callback-auth-files/README.md` for URL examples.
## Host Model Callback
`host-model-callback` declares the Management API capability and exposes a browser resource named `Host Model Callback`. The resource calls `host.model.execute` for non-streaming requests and `host.model.execute_stream` plus `host.model.stream_read` for streaming requests. It demonstrates explicit stream close with `host.model.stream_close` and an `implicit_close=true` option for RPC-scope host cleanup.
When the resource forwards its `host_callback_id`, CPA identifies the plugin that initiated the host model callback and skips that same plugin's interceptors for the nested execution. This makes host model callbacks non-recursive for the caller while allowing other plugins to intercept the nested request.
```yaml
plugins:
configs:
host-model-callback:
enabled: true
priority: 1
```
The default example model is `gpt-5.5`, but the request succeeds only when the current CPA model and auth configuration can route that model.
## Scheduler
`scheduler` declares the scheduler capability. It can select a configured auth ID from the candidate list, delegate to the built-in `fill-first` or `round-robin` scheduler, or reject picks when `deny` is `true`.
```yaml
plugins:
configs:
scheduler:
enabled: true
priority: 1
auth_id: ""
delegate: ""
deny: false
```
`auth_id` selects a matching candidate when `delegate` is empty. `delegate` accepts `""`, `fill-first`, or `round-robin`; other non-empty values leave the pick unhandled. `deny` returns a scheduler error.
## Build All Examples
```bash
make -C examples/plugin list
make -C examples/plugin build
```
Artifacts are written to `examples/plugin/bin`.
## Notes
`protocol-format` uses a minimal executor because format declarations belong to executor capabilities.
`host-callback` uses a minimal plugin resource because host callbacks are invoked from plugin methods and are not standalone capabilities.
Menu resources returned by `management.register` through the `resources` field are exposed by CPA under `/v0/resource/plugins/<pluginID>/...`. Authenticated plugin Management API routes remain under `/v0/management/...`.
+108
View File
@@ -0,0 +1,108 @@
- :仅 Go 实现的插件资源,演示 host 凭证文件回调(、、、)。
- # 标准动态库插件示例
本目录包含 CLIProxyAPI C ABI 的标准动态库插件示例。
## 目录布局
- `simple/`:声明全部支持能力的完整骨架示例。
- `model/`:只演示模型能力。
- `auth/`:只演示认证提供方能力。
- `frontend-auth/`:只演示前端认证提供方能力。
- `frontend-auth-exclusive/`:演示被选中后成为唯一请求认证方式的前端认证提供方。
- `executor/`:只演示执行器能力。
- `protocol-format/`:使用最小执行器重点演示输入和输出格式声明。
- `request-translator/`:只演示请求转换能力。
- `request-normalizer/`:只演示请求规整能力。
- `codex-service-tier/`:仅 Go 实现的请求规整插件,启用后会将 Codex `gpt-5.5` 请求设置为 priority service tier。
- `scheduler/`:仅 Go 实现的调度插件,可选择指定 auth ID、委托内置调度器或拒绝调度。
- `response-translator/`:只演示响应转换能力。
- `response-normalizer/`:只演示响应规整能力。
- `thinking/`:只演示 Thinking 处理能力。
- `usage/`:只演示 Usage 观察能力。
- `cli/`:只演示命令行扩展能力。
- `management-api/`:只演示 Management API 和资源扩展能力。
- `host-callback/`:使用最小插件资源演示宿主回调。
- `host-callback-auth-files/`:仅 Go 实现的插件资源,演示 host 凭证文件回调。
- `host-model-callback/`:仅 Go 实现的插件资源,演示调用宿主模型执行回调。
多数标准能力示例都包含 `go/``c/``rust/` 三个子目录。专用示例可能只提供所需的实现语言。
## Codex Service Tier
`codex-service-tier` 声明请求规整能力。当 `fast``true` 时,如果 `req.ToFormat``codex``req.Model``gpt-5.5`,它会将 `service_tier` 设置为 `priority`
```yaml
plugins:
configs:
codex-service-tier:
enabled: true
priority: 1
fast: false
```
## Host Auth Files 回调
`host-callback-auth-files` 声明 Management API 能力,并暴露名为 `Host Auth Files` 的浏览器资源,演示 `host.auth.list``host.auth.get`(物理 JSON 文件)、`host.auth.get_runtime``host.auth.save`
```yaml
plugins:
configs:
host-callback-auth-files:
enabled: true
priority: 1
```
详见 `host-callback-auth-files/README.md`
## Host Model Callback
`host-model-callback` 声明 Management API 能力,并暴露名为 `Host Model Callback` 的浏览器资源。该资源在非流式请求中调用 `host.model.execute`,在流式请求中调用 `host.model.execute_stream``host.model.stream_read`。它演示了通过 `host.model.stream_close` 显式关闭流,也提供 `implicit_close=true` 用于演示 RPC 作用域结束时的宿主隐式清理。
当该资源转发自身收到的 `host_callback_id` 时,CPA 会识别发起宿主模型回调的插件,并在嵌套模型执行中跳过同一个插件的拦截器。因此宿主模型回调不会递归调用发起插件自身,但其他已启用插件仍可拦截这次嵌套请求。
```yaml
plugins:
configs:
host-model-callback:
enabled: true
priority: 1
```
默认示例模型是 `gpt-5.5`,但请求能否成功取决于当前 CPA 模型和认证配置是否可以路由该模型。
## Scheduler
`scheduler` 声明调度能力。它可以从候选列表中选择配置的 auth ID,委托内置的 `fill-first``round-robin` 调度器,或在 `deny``true` 时拒绝调度。
```yaml
plugins:
configs:
scheduler:
enabled: true
priority: 1
auth_id: ""
delegate: ""
deny: false
```
`auth_id` 会在 `delegate` 为空时选择匹配候选。`delegate` 支持 `""``fill-first``round-robin`;其他非空值会让本插件不处理本次调度。`deny` 会返回调度错误。
## 构建全部示例
```bash
make -C examples/plugin list
make -C examples/plugin build
```
构建产物会写入 `examples/plugin/bin`
## 说明
`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。
`host-callback` 使用最小插件资源承载,因为宿主回调只能从插件方法内部发起,不是独立能力。
`management.register` 通过 `resources` 字段返回的菜单资源会由 CPA 暴露在 `/v0/resource/plugins/<pluginID>/...` 下。需要认证的插件自有 Management API 路由仍保留在 `/v0/management/...` 下。
+8
View File
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
project(cliproxy_auth_c C)
add_library(cliproxy_auth_c SHARED src/plugin.c)
set_target_properties(cliproxy_auth_c PROPERTIES
OUTPUT_NAME "auth-c"
PREFIX ""
)
+129
View File
@@ -0,0 +1,129 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#define CLIPROXY_EXPORT __declspec(dllexport)
#else
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
#endif
#define ABI_VERSION 1
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
static const cliproxy_host_api* stored_host = NULL;
static void write_response(cliproxy_buffer* response, const char* text) {
if (response == NULL || text == NULL) {
return;
}
size_t len = strlen(text);
void* ptr = malloc(len);
if (ptr == NULL) {
response->ptr = NULL;
response->len = 0;
return;
}
memcpy(ptr, text, len);
response->ptr = ptr;
response->len = len;
}
static void call_host(const char* method, const char* payload) {
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
return;
}
cliproxy_buffer response = {0};
const uint8_t* request = (const uint8_t*)payload;
size_t request_len = payload == NULL ? 0 : strlen(payload);
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
stored_host->free_buffer(response.ptr, response.len);
}
}
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (response != NULL) {
response->ptr = NULL;
response->len = 0;
}
if (method == NULL) {
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
return 1;
}
if (strcmp(method, "plugin.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}");
return 0;
}
if (strcmp(method, "plugin.reconfigure") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}");
return 0;
}
if (strcmp(method, "auth.identifier") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-auth-c\"}}");
return 0;
}
if (strcmp(method, "auth.parse") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}}}}");
return 0;
}
if (strcmp(method, "auth.login.start") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-auth-c\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}");
return 0;
}
if (strcmp(method, "auth.login.poll") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}}}}");
return 0;
}
if (strcmp(method, "auth.refresh") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}}");
return 0;
}
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
(void)request;
(void)request_len;
return 0;
}
static void plugin_free(void* ptr, size_t len) {
(void)len;
free(ptr);
}
static void plugin_shutdown(void) {}
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
if (plugin == NULL) {
return 1;
}
stored_host = host;
plugin->abi_version = ABI_VERSION;
plugin->call = plugin_call;
plugin->free_buffer = plugin_free;
plugin->shutdown = plugin_shutdown;
return 0;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/auth/go
go 1.26
+181
View File
@@ -0,0 +1,181 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"encoding/json"
"net/http"
"time"
"unsafe"
)
const abiVersion uint32 = 1
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
C.store_host_api(host)
plugin.abi_version = C.uint32_t(abiVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
raw, errHandle := handleMethod(C.GoString(method))
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
_ = request
_ = requestLen
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
_ = len
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string) ([]byte, error) {
_ = http.StatusOK
_ = time.Second
switch method {
case "plugin.register":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}")
case "plugin.reconfigure":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}")
case "auth.identifier":
return okEnvelopeJSON("{\"identifier\":\"example-auth-go\"}")
case "auth.parse":
return okEnvelopeJSON("{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}}}")
case "auth.login.start":
return okEnvelopeJSON("{\"Provider\":\"example-auth-go\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}")
case "auth.login.poll":
return okEnvelopeJSON("{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}}}")
case "auth.refresh":
return okEnvelopeJSON("{\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}")
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func okEnvelopeJSON(result string) ([]byte, error) {
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
func callHost(method string, payload []byte) {
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var req *C.uint8_t
if len(payload) > 0 {
req = (*C.uint8_t)(C.CBytes(payload))
defer C.free(unsafe.Pointer(req))
}
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
C.free_host_buffer(response.ptr, response.len)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-auth-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-auth-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -0,0 +1,127 @@
use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;
const ABI_VERSION: u32 = 1;
#[repr(C)]
pub struct CliproxyBuffer {
ptr: *mut u8,
len: usize,
}
type HostCall = unsafe extern "C" fn(*mut std::ffi::c_void, *const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type HostFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginCall = unsafe extern "C" fn(*const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type PluginFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginShutdown = unsafe extern "C" fn();
#[repr(C)]
pub struct CliproxyHostApi {
abi_version: u32,
host_ctx: *mut std::ffi::c_void,
call: Option<HostCall>,
free_buffer: Option<HostFree>,
}
#[repr(C)]
pub struct CliproxyPluginApi {
abi_version: u32,
call: Option<PluginCall>,
free_buffer: Option<PluginFree>,
shutdown: Option<PluginShutdown>,
}
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
#[no_mangle]
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
if plugin.is_null() {
return 1;
}
unsafe {
STORED_HOST = host;
(*plugin).abi_version = ABI_VERSION;
(*plugin).call = Some(plugin_call);
(*plugin).free_buffer = Some(plugin_free);
(*plugin).shutdown = Some(plugin_shutdown);
}
0
}
unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, request_len: usize, response: *mut CliproxyBuffer) -> i32 {
if !response.is_null() {
(*response).ptr = ptr::null_mut();
(*response).len = 0;
}
if method.is_null() {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
return 1;
}
let method = match CStr::from_ptr(method).to_str() {
Ok(value) => value,
Err(_) => {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is not utf-8"}}"#);
return 1;
}
};
let _ = request;
let _ = request_len;
match method {
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}"); 0 },"auth.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-auth-rust\"}}"); 0 },"auth.parse" => { write_response(response, "{\"ok\":true,\"result\":{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-rust\",\"ID\":\"example-auth-rust\",\"FileName\":\"example-auth-rust.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLXJ1c3QiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-rust\"}}}}"); 0 },"auth.login.start" => { write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-auth-rust\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}"); 0 },"auth.login.poll" => { write_response(response, "{\"ok\":true,\"result\":{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-rust\",\"ID\":\"example-auth-rust\",\"FileName\":\"example-auth-rust.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLXJ1c3QiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-rust\"}}}}"); 0 },"auth.refresh" => { write_response(response, "{\"ok\":true,\"result\":{\"Auth\":{\"Provider\":\"example-auth-rust\",\"ID\":\"example-auth-rust\",\"FileName\":\"example-auth-rust.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLXJ1c3QiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-rust\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}}"); 0 },
_ => {
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
0
}
}
}
unsafe extern "C" fn plugin_free(ptr: *mut std::ffi::c_void, len: usize) {
if !ptr.is_null() {
let _ = Vec::from_raw_parts(ptr as *mut u8, len, len);
}
}
unsafe extern "C" fn plugin_shutdown() {}
fn write_response(response: *mut CliproxyBuffer, text: &str) {
if response.is_null() {
return;
}
let mut bytes = text.as_bytes().to_vec();
let len = bytes.len();
let ptr = bytes.as_mut_ptr();
std::mem::forget(bytes);
unsafe {
(*response).ptr = ptr;
(*response).len = len;
}
}
#[allow(dead_code)]
fn call_host(method: &str, payload: &str) {
unsafe {
if STORED_HOST.is_null() {
return;
}
let host = &*STORED_HOST;
let Some(call) = host.call else {
return;
};
let mut method_bytes = method.as_bytes().to_vec();
method_bytes.push(0);
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
let rc = call(
host.host_ctx,
method_bytes.as_ptr() as *const c_char,
payload.as_ptr(),
payload.len(),
&mut response,
);
if rc == 0 && !response.ptr.is_null() {
if let Some(free_buffer) = host.free_buffer {
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
}
}
}
}
@@ -0,0 +1,175 @@
# Claude Code Web Search Router (ModelRouter example)
This plugin demonstrates **ModelRouter** on Claude Code built-in `web_search` requests (see `temp/1.json` in the repo root for a captured request/response).
## What it detects
- Inbound protocol `claude` / `anthropic`
- `tools[]` with `type` `web_search_20250305` or `web_search_20260209`
- Optional Claude Code heuristics: system text like “web search tool use”, or user text
`Perform a web search for the query: …`
## Routes (`route` config)
| Value | Behavior |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fallback` (**default**) | Plugin **executor** runs **antigravity → codex → xai → tavily** (built-ins via `host.model.*`, Tavily in-plugin). On **429/503/502**, tries the next backend in the same request. Backends that fail often are **deprioritized on later requests** (in-memory penalty; no extra config). |
| `antigravity_google` / `codex_web_search` / `xai_web_search` / `tavily` | Same orchestration for that backends chain member(s): execution retry + penalty apply when multiple backends are eligible. |
| `default_provider` | `default_provider` + optional `default_provider_model` via built-in AuthManager (not orchestrated). |
Routing for `fallback` requires at least one runnable backend (providers in `AvailableProviders` where needed, resolvable antigravity model, or `tavily_api_keys`).
### xAI web search notes (aligned with upstream docs)
- **Model**: xAI documents `grok-4.3` for server-side `web_search`. This example sets `TargetModel` to **`grok-4.3`** when `xai_model` is empty (do not forward `claude-sonnet-4-6` to xAI).
- **Request shape**: Responses API `input` + `tools[]` with `"type": "web_search"`. Optional `filters.allowed_domains` / `filters.excluded_domains` (max 5 each, mutually exclusive).
- **Claude mapping today**: `internal/translator/codex/claude` copies Claude `allowed_domains``filters.allowed_domains`. Claude `blocked_domains` is **not** mapped to `excluded_domains` yet.
- **Executor**: `xai_executor` normalizes tools (drops unsupported `external_web_access` if present) and posts to `/responses`.
- **Response**: Citations / server tool metadata come back through OpenAI Responses SSE and are converted toward Claude `server_tool_use` / `web_search_tool_result` where the response translator supports it.
## Configuration
Plugin config lives under `plugins.configs.claude-web-search-router` (key must match the plugin name). Load the shared library via `plugins.path`.
### Recommended: fallback chain (default)
Tries **antigravity → codex → xai → tavily**; configure `tavily_api_keys` so the last step can succeed when built-in providers are missing or unavailable.
```yaml
plugins:
path:
- /absolute/path/to/examples/plugin/bin/claude-web-search-router-go.dylib
configs:
claude-web-search-router:
enabled: true
priority: 20
route: fallback
antigravity_model: "" # empty: registry lookup, then first supports_web_search
codex_model: "gpt-5.4-mini"
xai_model: "grok-4.3"
tavily_api_keys:
- "tvly-xxxxxxxx"
# - "tvly-yyyyyyyy" # optional: round-robin
require_web_search_only: true
```
Omit `route` to use the same default (`fallback`).
### Minimal fallback (Tavily as last resort only)
```yaml
plugins:
configs:
claude-web-search-router:
enabled: true
priority: 20
route: fallback
tavily_api_keys:
- "tvly-xxxxxxxx"
require_web_search_only: true
```
### Single backend (no fallback)
**Antigravity only:**
```yaml
plugins:
configs:
claude-web-search-router:
enabled: true
priority: 20
route: antigravity_google
antigravity_model: "gemini-3.1-flash-lite"
require_web_search_only: true
```
**Codex only:**
```yaml
plugins:
configs:
claude-web-search-router:
enabled: true
priority: 20
route: codex_web_search
codex_model: "gpt-5.4-mini"
require_web_search_only: true
```
**xAI only:**
```yaml
plugins:
configs:
claude-web-search-router:
enabled: true
priority: 20
route: xai_web_search
xai_model: "grok-4.3"
require_web_search_only: true
```
**Tavily only (plugin executor):**
```yaml
plugins:
configs:
claude-web-search-router:
enabled: true
priority: 20
route: tavily
tavily_api_keys:
- "tvly-xxxxxxxx"
require_web_search_only: true
```
**Built-in provider via `default_provider`:**
```yaml
plugins:
configs:
claude-web-search-router:
enabled: true
priority: 20
route: default_provider
default_provider: claude
default_provider_model: ""
require_web_search_only: true
```
### Disable or relax detection
```yaml
plugins:
configs:
claude-web-search-router:
enabled: false # plugin declines; host may use default Claude path
# Or keep enabled but allow mixed tool lists:
claude-web-search-router:
enabled: true
route: fallback
require_web_search_only: false
```
### Config field reference
| Field | Description |
| ----- | ----------- |
| `enabled` | `false``Handled: false` for all web_search matches |
| `priority` | Host plugin order for ModelRouter (higher runs earlier; see main repo plugins docs) |
| `route` | `fallback` (default), `antigravity_google`, `codex_web_search`, `xai_web_search`, `tavily`, `default_provider` |
| `antigravity_model` | Antigravity execution model; never the client Claude model name |
| `codex_model` | Codex model; empty → `gpt-5.4-mini` |
| `xai_model` | xAI model; empty → `grok-4.3` |
| `default_provider` / `default_provider_model` | Used when `route=default_provider` |
| `tavily_api_keys` | Required for `route=tavily` or fallback last step |
| `require_web_search_only` | `true` matches Claude Codestyle exclusive `web_search` tools |
## Build
```bash
make -C examples/plugin bin/claude-web-search-router-go.dylib
```
Use `.so` on Linux and `.dll` on Windows. Point `plugins.path` at the built artifact.
@@ -0,0 +1,173 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type claudeStreamBuilder struct {
model string
messageID string
toolUseID string
index int
inputTokens int
}
func newClaudeStreamBuilder(model string) *claudeStreamBuilder {
model = strings.TrimSpace(model)
if model == "" {
model = "claude-sonnet-4-6"
}
now := time.Now().UnixNano()
return &claudeStreamBuilder{
model: model,
messageID: fmt.Sprintf("msg_%x", now),
toolUseID: fmt.Sprintf("srvtoolu_%d", now),
inputTokens: 85,
}
}
func (b *claudeStreamBuilder) buildStreamWithQuery(query string, hits []claudeWebSearchHit, answer string) []byte {
var chunks []string
chunks = append(chunks, b.event("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": b.messageID, "type": "message", "role": "assistant", "content": []any{},
"model": b.model, "stop_reason": nil, "stop_sequence": nil,
"usage": map[string]any{"input_tokens": b.inputTokens, "output_tokens": 0},
},
}))
chunks = append(chunks, b.blockStart(b.index, map[string]any{
"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]any{},
}))
partial, _ := json.Marshal(map[string]string{"query": query})
chunks = append(chunks, b.event("content_block_delta", map[string]any{
"type": "content_block_delta", "index": b.index,
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(partial)},
}))
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
b.index++
resultContent := webSearchResultBlocks(hits)
chunks = append(chunks, b.blockStart(b.index, map[string]any{
"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": resultContent,
}))
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
b.index++
text := composeAnswerText(answer, hits)
outputTokens := estimateTokens(text)
chunks = append(chunks, b.blockStart(b.index, map[string]any{"type": "text", "text": ""}))
chunks = append(chunks, b.event("content_block_delta", map[string]any{
"type": "content_block_delta", "index": b.index,
"delta": map[string]any{"type": "text_delta", "text": text},
}))
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
chunks = append(chunks, b.event("message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]any{
"input_tokens": b.inputTokens, "output_tokens": outputTokens,
"server_tool_use": map[string]any{"web_search_requests": 1},
},
}))
chunks = append(chunks, b.event("message_stop", map[string]any{"type": "message_stop"}))
return []byte(strings.Join(chunks, ""))
}
func (b *claudeStreamBuilder) buildMessageJSON(query string, hits []claudeWebSearchHit, answer string) []byte {
text := composeAnswerText(answer, hits)
content := []map[string]any{
{"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]string{"query": query}},
{"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": webSearchResultBlocks(hits)},
{"type": "text", "text": text},
}
out := map[string]any{
"id": b.messageID, "type": "message", "role": "assistant", "model": b.model,
"content": content, "stop_reason": "end_turn", "stop_sequence": nil,
"usage": map[string]any{
"input_tokens": b.inputTokens, "output_tokens": estimateTokens(text),
"server_tool_use": map[string]any{"web_search_requests": 1},
},
}
raw, _ := json.Marshal(out)
return raw
}
func webSearchResultBlocks(hits []claudeWebSearchHit) []map[string]any {
resultContent := make([]map[string]any, 0, len(hits))
for _, hit := range hits {
title := hit.Title
if title == "" {
title = hostFromURL(hit.URL)
}
resultContent = append(resultContent, map[string]any{
"type": "web_search_result", "title": title, "url": hit.URL, "page_age": nil,
})
}
return resultContent
}
func (b *claudeStreamBuilder) event(eventType string, data map[string]any) string {
raw, _ := json.Marshal(data)
return fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(raw))
}
func (b *claudeStreamBuilder) blockStart(index int, block map[string]any) string {
return b.event("content_block_start", map[string]any{
"type": "content_block_start", "index": index, "content_block": block,
})
}
func composeAnswerText(answer string, hits []claudeWebSearchHit) string {
if strings.TrimSpace(answer) != "" {
return answer
}
if len(hits) == 0 {
return "No web search results were returned."
}
var buf strings.Builder
for i, hit := range hits {
if i > 0 {
buf.WriteString("\n\n")
}
if hit.Title != "" {
buf.WriteString(hit.Title)
buf.WriteString("\n")
}
if hit.URL != "" {
buf.WriteString(hit.URL)
buf.WriteString("\n")
}
if hit.Snippet != "" {
buf.WriteString(hit.Snippet)
}
}
return buf.String()
}
func hostFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
withoutScheme := raw
if idx := strings.Index(raw, "://"); idx >= 0 {
withoutScheme = raw[idx+3:]
}
if slash := strings.Index(withoutScheme, "/"); slash >= 0 {
return withoutScheme[:slash]
}
return withoutScheme
}
func estimateTokens(text string) int {
n := len([]rune(text)) / 4
if n < 1 {
return 1
}
return n
}
@@ -0,0 +1,22 @@
package main
import "testing"
func TestConfigurePreservesDefaultBooleansWhenConfigIsPartial(t *testing.T) {
raw := mustJSON(t, lifecycleRequest{ConfigYAML: []byte("route: codex_web_search\n")})
if errConfigure := configure(raw); errConfigure != nil {
t.Fatalf("configure() error = %v", errConfigure)
}
cfg := loadedConfig()
if !cfg.Enabled {
t.Fatal("Enabled = false, want default true")
}
if !cfg.RequireWebSearchOnly {
t.Fatal("RequireWebSearchOnly = false, want default true")
}
if cfg.Route != string(backendCodexWebSearch) {
t.Fatalf("Route = %q, want codex_web_search", cfg.Route)
}
}
@@ -0,0 +1,183 @@
package main
import (
"strings"
"github.com/tidwall/gjson"
)
const (
claudeWebSearchToolTypeA = "web_search_20250305"
claudeWebSearchToolTypeB = "web_search_20260209"
)
// isClaudeSourceFormat reports whether the inbound protocol is Claude / Anthropic Messages.
func isClaudeSourceFormat(source string) bool {
switch strings.ToLower(strings.TrimSpace(source)) {
case "claude", "anthropic":
return true
default:
return false
}
}
func isClaudeTypedWebSearchToolType(toolType string) bool {
return toolType == claudeWebSearchToolTypeA || toolType == claudeWebSearchToolTypeB
}
func hasClaudeTypedWebSearchTool(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
for _, tool := range tools.Array() {
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
return true
}
}
return false
}
func hasOnlyClaudeTypedWebSearchTools(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
hasWebSearch := false
for _, tool := range tools.Array() {
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
hasWebSearch = true
continue
}
if tool.Get("type").String() != "" || tool.Get("name").String() != "" {
return false
}
}
return hasWebSearch
}
func looksLikeClaudeCodeWebSearchAssistant(body []byte) bool {
system := gjson.GetBytes(body, "system")
if system.IsArray() {
for _, block := range system.Array() {
text := strings.ToLower(block.Get("text").String())
if strings.Contains(text, "web search tool use") ||
strings.Contains(text, "performing a web search") {
return true
}
}
}
if system.Type == gjson.String {
text := strings.ToLower(system.String())
if strings.Contains(text, "web search tool use") {
return true
}
}
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return false
}
for _, message := range messages.Array() {
if message.Get("role").String() != "user" {
continue
}
text := strings.ToLower(extractClaudeMessageText(message.Get("content")))
if strings.HasPrefix(text, "perform a web search for the query:") {
return true
}
}
return false
}
func isClaudeCodeBuiltinWebSearchRequest(body []byte, requireWebSearchOnly bool) bool {
if !hasClaudeTypedWebSearchTool(body) {
return false
}
if requireWebSearchOnly && !hasOnlyClaudeTypedWebSearchTools(body) {
return false
}
return looksLikeClaudeCodeWebSearchAssistant(body) || hasOnlyClaudeTypedWebSearchTools(body)
}
func extractClaudeWebSearchQuery(body []byte) string {
if q := extractQueryFromPerformPrefix(body); q != "" {
return q
}
return extractQueryFromUserMessages(body)
}
func extractQueryFromPerformPrefix(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return ""
}
const prefix = "perform a web search for the query:"
for _, message := range messages.Array() {
if message.Get("role").String() != "user" {
continue
}
text := strings.TrimSpace(extractClaudeMessageText(message.Get("content")))
lower := strings.ToLower(text)
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(text[len(prefix):])
}
}
return ""
}
func extractQueryFromUserMessages(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return ""
}
arr := messages.Array()
for i := len(arr) - 1; i >= 0; i-- {
message := arr[i]
role := message.Get("role").String()
if role != "" && role != "user" {
continue
}
if query := strings.TrimSpace(extractClaudeMessageText(message.Get("content"))); query != "" {
return query
}
}
return ""
}
func extractClaudeMessageText(content gjson.Result) string {
if content.Type == gjson.String {
return content.String()
}
if !content.IsArray() {
return ""
}
var parts []string
for _, block := range content.Array() {
if block.Get("type").String() != "text" {
continue
}
if text := strings.TrimSpace(block.Get("text").String()); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
}
func extractClaudeWebSearchMaxUses(body []byte, defaultMax int) int {
if defaultMax <= 0 {
defaultMax = 5
}
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return defaultMax
}
for _, tool := range tools.Array() {
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
continue
}
if maxUses := int(tool.Get("max_uses").Int()); maxUses > 0 {
return maxUses
}
}
return defaultMax
}
@@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDetectClaudeCodeWebSearchFromFixture(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "temp", "1.json")
raw, errRead := os.ReadFile(root)
if errRead != nil {
t.Skipf("fixture not found: %v", errRead)
}
// Fixture is HTTP capture; extract JSON request body between first blank line after headers.
body := extractHTTPJSONBody(raw)
if len(body) == 0 {
t.Fatal("empty JSON body in fixture")
}
if !hasClaudeTypedWebSearchTool(body) {
t.Fatal("fixture should declare web_search_20250305")
}
if !looksLikeClaudeCodeWebSearchAssistant(body) {
t.Fatal("fixture should match Claude Code web search assistant heuristics")
}
if !isClaudeCodeBuiltinWebSearchRequest(body, true) {
t.Fatal("expected match with require_web_search_only=true")
}
query := extractClaudeWebSearchQuery(body)
if query == "" {
t.Fatal("expected non-empty search query")
}
if want := "北京天气 2026年6月16日"; query != want {
t.Fatalf("query = %q, want %q", query, want)
}
}
func extractHTTPJSONBody(raw []byte) []byte {
text := string(raw)
idx := 0
for {
next := findDoubleNewline(text, idx)
if next < 0 {
return nil
}
rest := trimLeft(text[next:])
if len(rest) > 0 && rest[0] == '{' {
return []byte(rest)
}
idx = next + 1
}
}
func findDoubleNewline(s string, from int) int {
for i := from; i+1 < len(s); i++ {
if s[i] == '\n' && s[i+1] == '\n' {
return i + 2
}
if s[i] == '\r' && i+3 < len(s) && s[i+1] == '\n' && s[i+2] == '\r' && s[i+3] == '\n' {
return i + 4
}
}
return -1
}
func trimLeft(s string) string {
for len(s) > 0 && (s[0] == '\r' || s[0] == '\n' || s[0] == ' ') {
s = s[1:]
}
return s
}
@@ -0,0 +1,52 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type streamOrchestrationRunner func(context.Context, pluginapi.ExecutorRequest, string, string) error
type pluginStreamCloser func(string, string)
func executeStream(raw []byte) ([]byte, error) {
var req rpcExecutorRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
return startExecutorStream(req, runWebSearchStreamOrchestration, closePluginStream)
}
func startExecutorStream(req rpcExecutorRequest, runner streamOrchestrationRunner, closeStream pluginStreamCloser) ([]byte, error) {
streamID := strings.TrimSpace(req.StreamID)
if streamID == "" {
return errorEnvelope("executor_error", "stream_id is required for executor.execute_stream"), nil
}
if runner == nil {
return errorEnvelope("executor_error", "stream orchestration runner is unavailable"), nil
}
if closeStream == nil {
closeStream = func(string, string) {}
}
go func() {
defer func() {
if recovered := recover(); recovered != nil {
closeStream(streamID, fmt.Sprintf("stream orchestration panic: %v", recovered))
}
}()
errRun := runner(context.Background(), req.ExecutorRequest, req.HostCallbackID, streamID)
if errRun != nil {
closeStream(streamID, errRun.Error())
return
}
closeStream(streamID, "")
}()
return okEnvelope(map[string]any{
"headers": http.Header{"Content-Type": []string{"text/event-stream"}},
})
}
@@ -0,0 +1,334 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type executionPlan struct {
backend routeBackend
model string
}
func buildExecutionPlans(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
return buildExecutionPlansInternal(cfg, req, true)
}
func buildExecutionPlansForExecute(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
route := strings.TrimSpace(cfg.Route)
if isFallbackRoute(route) {
return buildExecutionPlansInternal(cfg, req, false)
}
return executionPlansForExecuteRoute(cfg, req, route)
}
// executionPlansForExecuteRoute builds plans for plugin executor without requiring
// ModelRouteRequest.AvailableProviders (host does not pass it on executor.execute_stream).
func executionPlansForExecuteRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
backend := routeBackend(strings.TrimSpace(route))
if !backendRunnableLenient(backend, cfg, req) {
return nil
}
var plans []executionPlan
switch backend {
case backendAntigravityGoogle:
model := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
if model == "" {
return nil
}
plans = append(plans, executionPlan{backend: backend, model: model})
case backendCodexWebSearch:
plans = append(plans, executionPlan{backend: backend, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
case backendXAIWebSearch:
plans = append(plans, executionPlan{backend: backend, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
case backendTavily:
if !newTavilyClient(cfg.TavilyAPIKeys).available() {
return nil
}
plans = append(plans, executionPlan{backend: backend})
default:
return nil
}
return plans
}
func buildExecutionPlansInternal(cfg pluginConfig, req pluginapi.ModelRouteRequest, requireProviders bool) []executionPlan {
var plans []executionPlan
for _, backend := range defaultWebSearchFallbackChain() {
if requireProviders {
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
continue
}
} else if !backendRunnableLenient(backend, cfg, req) {
continue
}
switch backend {
case backendAntigravityGoogle:
plans = append(plans, executionPlan{
backend: backend,
model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel),
})
case backendCodexWebSearch:
plans = append(plans, executionPlan{
backend: backend,
model: resolveCodexWebSearchTargetModel(cfg.CodexModel),
})
case backendXAIWebSearch:
plans = append(plans, executionPlan{
backend: backend,
model: resolveXAIWebSearchTargetModel(cfg.XAIModel),
})
case backendTavily:
plans = append(plans, executionPlan{backend: backend})
default:
continue
}
}
return plans
}
func backendRunnableLenient(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) bool {
switch backend {
case backendTavily:
return newTavilyClient(cfg.TavilyAPIKeys).available()
case backendAntigravityGoogle:
return resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel) != ""
case backendCodexWebSearch, backendXAIWebSearch:
return true
default:
return false
}
}
func executionPlansForRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
if isFallbackRoute(route) {
return buildExecutionPlans(cfg, req)
}
backend := routeBackend(strings.TrimSpace(route))
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
return nil
}
var plans []executionPlan
for _, b := range []routeBackend{backend} {
if !backendRunnableLenient(b, cfg, req) {
continue
}
switch b {
case backendAntigravityGoogle:
plans = append(plans, executionPlan{backend: b, model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)})
case backendCodexWebSearch:
plans = append(plans, executionPlan{backend: b, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
case backendXAIWebSearch:
plans = append(plans, executionPlan{backend: b, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
case backendTavily:
plans = append(plans, executionPlan{backend: b})
}
}
return plans
}
func claudeRequestBody(exec pluginapi.ExecutorRequest) []byte {
if len(exec.OriginalRequest) > 0 {
return exec.OriginalRequest
}
return exec.Payload
}
func runWebSearchWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: strings.TrimSpace(exec.Model),
Body: claudeRequestBody(exec),
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
}
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), false)
}
// runWebSearchStreamWithExecutionFallback buffers the full host stream (non-streaming RPC path only).
func runWebSearchStreamWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: strings.TrimSpace(exec.Model),
Body: claudeRequestBody(exec),
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
}
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), true)
}
func runOrderedExecutionPlans(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string, cfg pluginConfig, plans []executionPlan, stream bool) ([]byte, http.Header, error) {
if len(plans) == 0 {
return nil, nil, fmt.Errorf("web search execution: no backend available")
}
backends := make([]routeBackend, 0, len(plans))
for _, p := range plans {
backends = append(backends, p.backend)
}
ordered := sortBackendsByPenalty(backends)
planByBackend := make(map[routeBackend]executionPlan, len(plans))
for _, p := range plans {
planByBackend[p.backend] = p
}
body := claudeRequestBody(exec)
var lastErr error
for _, backend := range ordered {
plan := planByBackend[backend]
switch backend {
case backendTavily:
var payload []byte
var headers http.Header
var errRun error
if stream {
payload, headers, errRun = runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
} else {
payload, headers, errRun = runTavilyClaudeWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
}
if errRun != nil {
lastErr = errRun
continue
}
recordBackendSuccess(backend)
return payload, headers, nil
default:
payload, status, errRun := hostModelExecuteClaude(ctx, hostCallbackID, plan.model, body, stream)
if errRun != nil {
lastErr = errRun
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
recordBackendFailure(backend)
}
continue
}
if isRetryableHTTPStatus(status) {
recordBackendFailure(backend)
lastErr = fmt.Errorf("host model status %d", status)
continue
}
recordBackendSuccess(backend)
headers := http.Header{"Content-Type": []string{"application/json"}}
if stream {
headers = http.Header{"Content-Type": []string{"text/event-stream"}}
}
return payload, headers, nil
}
}
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, fmt.Errorf("web search execution: all backends failed")
}
func availableProvidersFromMetadata(meta map[string]any) []string {
if meta == nil {
return nil
}
raw, ok := meta["available_providers"]
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, okItem := item.(string); okItem {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func hostModelExecuteClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, stream bool) ([]byte, int, error) {
if stream {
return hostModelStreamClaude(ctx, hostCallbackID, execModel, body)
}
raw, errCall := callHost(pluginabi.MethodHostModelExecute, hostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "claude",
ExitProtocol: "claude",
Model: execModel,
Stream: false,
Body: body,
},
HostCallbackID: hostCallbackID,
})
if errCall != nil {
return nil, hostHTTPStatusFromError(errCall), errCall
}
var resp pluginapi.HostModelExecutionResponse
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
return nil, 0, errDecode
}
if resp.StatusCode >= 400 {
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
}
return resp.Body, resp.StatusCode, nil
}
func hostModelStreamClaude(ctx context.Context, hostCallbackID, execModel string, body []byte) ([]byte, int, error) {
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "claude",
ExitProtocol: "claude",
Model: execModel,
Stream: true,
Body: body,
},
HostCallbackID: hostCallbackID,
})
if errCall != nil {
return nil, hostHTTPStatusFromError(errCall), errCall
}
var resp pluginapi.HostModelStreamResponse
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
return nil, 0, errDecode
}
if resp.StatusCode >= 400 {
_ = closeHostModelStream(resp.StreamID)
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
}
if strings.TrimSpace(resp.StreamID) == "" {
return nil, 0, fmt.Errorf("host model stream: empty stream_id")
}
defer func() { _ = closeHostModelStream(resp.StreamID) }()
var buf bytes.Buffer
for {
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
if errRead != nil {
return nil, hostHTTPStatusFromError(errRead), errRead
}
var chunk pluginapi.HostModelStreamReadResponse
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
return nil, 0, errDecode
}
if chunk.Error != "" {
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
return nil, code, fmt.Errorf("%s", chunk.Error)
}
if len(chunk.Payload) > 0 {
buf.Write(chunk.Payload)
}
if chunk.Done {
break
}
}
return buf.Bytes(), http.StatusOK, nil
}
func closeHostModelStream(streamID string) error {
_, errCall := callHost(pluginabi.MethodHostModelStreamClose, pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
return errCall
}
@@ -0,0 +1,28 @@
package main
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestBuildExecutionPlansForExecuteRespectsRouteTavily(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendTavily),
TavilyAPIKeys: []string{"tvly-test"},
})
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: "claude-sonnet-4-6",
AvailableProviders: []string{"antigravity", "codex", "xai"},
}
plans := buildExecutionPlansForExecute(cfg, req)
if len(plans) != 1 {
t.Fatalf("plans len = %d, want 1 for route=tavily", len(plans))
}
if plans[0].backend != backendTavily {
t.Fatalf("backend = %q, want tavily", plans[0].backend)
}
}
@@ -0,0 +1,107 @@
package main
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
// defaultWebSearchFallbackChain is the ordered backend try list when route=fallback.
func defaultWebSearchFallbackChain() []routeBackend {
return []routeBackend{
backendAntigravityGoogle,
backendCodexWebSearch,
backendXAIWebSearch,
backendTavily,
}
}
func isFallbackRoute(route string) bool {
r := strings.ToLower(strings.TrimSpace(route))
return r == "" || r == string(backendFallback)
}
// tryRouteBackend returns a handled ModelRouteResponse and true when this backend can serve the request.
func tryRouteBackend(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
switch backend {
case backendTavily:
client := newTavilyClient(cfg.TavilyAPIKeys)
if !client.available() {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "tavily_unavailable"}, false
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetSelf,
Reason: "claude_code_web_search_tavily",
}, true
case backendAntigravityGoogle:
if !hasProvider(req.AvailableProviders, "antigravity") {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "antigravity_unavailable"}, false
}
targetModel := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
if targetModel == "" {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "antigravity_web_search_model_unresolved"}, false
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: "antigravity",
TargetModel: targetModel,
Reason: "claude_code_web_search_antigravity_google",
}, true
case backendCodexWebSearch:
if !hasProvider(req.AvailableProviders, "codex") {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "codex_unavailable"}, false
}
targetModel := resolveCodexWebSearchTargetModel(cfg.CodexModel)
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: "codex",
TargetModel: targetModel,
Reason: "claude_code_web_search_codex",
}, true
case backendXAIWebSearch:
if !hasProvider(req.AvailableProviders, "xai") {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "xai_unavailable"}, false
}
targetModel := resolveXAIWebSearchTargetModel(cfg.XAIModel)
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: "xai",
TargetModel: targetModel,
Reason: "claude_code_web_search_xai",
}, true
case backendDefaultProvider:
provider := cfg.DefaultProvider
if provider == "" || !hasProvider(req.AvailableProviders, provider) {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "default_provider_unavailable"}, false
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: provider,
TargetModel: cfg.DefaultProviderModel,
Reason: "claude_code_web_search_default_provider",
}, true
default:
return pluginapi.ModelRouteResponse{Handled: false}, false
}
}
func routeWithFallback(cfg pluginConfig, req pluginapi.ModelRouteRequest) pluginapi.ModelRouteResponse {
return routeWithExecutionOrchestration(cfg, req, string(backendFallback))
}
func routeWithExecutionOrchestration(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) pluginapi.ModelRouteResponse {
plans := executionPlansForRoute(cfg, req, route)
if len(plans) == 0 {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "web_search_fallback_exhausted"}
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetSelf,
Reason: "claude_code_web_search_orchestrated",
}
}
@@ -0,0 +1,138 @@
package main
import (
"encoding/json"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func claudeWebSearchRouteBody(t *testing.T) []byte {
t.Helper()
body := []byte(`{
"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}],
"system":[{"type":"text","text":"You have access to the web search tool use."}],
"messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: test"}]}]
}`)
return body
}
func decodeModelRouteResponse(t *testing.T, raw []byte) pluginapi.ModelRouteResponse {
t.Helper()
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatal(err)
}
var resp pluginapi.ModelRouteResponse
if err := json.Unmarshal(env.Result, &resp); err != nil {
t.Fatal(err)
}
return resp
}
func TestRouteWithFallbackAntigravityFirst(t *testing.T) {
reg := registry.GetGlobalRegistry()
const clientID = "test-fallback-antigravity"
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
{ID: "gem-fallback-test", SupportsWebSearch: true},
})
t.Cleanup(func() { reg.UnregisterClient(clientID) })
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
RequestedModel: "claude-sonnet-4-6",
AvailableProviders: []string{"antigravity", "codex", "xai"},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
t.Fatalf("resp = %#v", resp)
}
}
func TestRouteWithFallbackSkipsAntigravityToCodex(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
RequestedModel: "claude-sonnet-4-6",
AvailableProviders: []string{"codex", "xai"},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
t.Fatalf("resp = %#v", resp)
}
}
func TestRouteWithFallbackToTavily(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
TavilyAPIKeys: []string{"tvly-test"},
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
AvailableProviders: []string{},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
t.Fatalf("resp = %#v", resp)
}
}
func TestRouteWithFallbackExhausted(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
AvailableProviders: []string{},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if resp.Handled {
t.Fatalf("expected declined, got %#v", resp)
}
if resp.Reason == "" || resp.Reason[:len("web_search_fallback_exhausted")] != "web_search_fallback_exhausted" {
t.Fatalf("reason = %q", resp.Reason)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return raw
}
@@ -0,0 +1,18 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/claude-web-search-router/go
go 1.26.0
require (
github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
github.com/tidwall/gjson v1.18.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.38.0 // indirect
)
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
@@ -0,0 +1,24 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,482 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"gopkg.in/yaml.v3"
)
const pluginIdentifier = "claude-web-search-router"
type routeBackend string
const (
backendFallback routeBackend = "fallback"
backendAntigravityGoogle routeBackend = "antigravity_google"
backendCodexWebSearch routeBackend = "codex_web_search"
backendXAIWebSearch routeBackend = "xai_web_search"
backendTavily routeBackend = "tavily"
backendDefaultProvider routeBackend = "default_provider"
)
var currentConfig atomic.Value
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type lifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
}
type pluginConfig struct {
Enabled bool `yaml:"enabled"`
Route string `yaml:"route"`
AntigravityModel string `yaml:"antigravity_model"`
CodexModel string `yaml:"codex_model"`
XAIModel string `yaml:"xai_model"`
DefaultProvider string `yaml:"default_provider"`
DefaultProviderModel string `yaml:"default_provider_model"`
TavilyAPIKeys []string `yaml:"tavily_api_keys"`
RequireWebSearchOnly bool `yaml:"require_web_search_only"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities registrationCapability `json:"capabilities"`
}
type registrationCapability struct {
ModelRouter bool `json:"model_router"`
Executor bool `json:"executor"`
ExecutorModelScope string `json:"executor_model_scope"`
ExecutorInputFormats []string `json:"executor_input_formats"`
ExecutorOutputFormats []string `json:"executor_output_formats"`
}
type rpcExecutorRequest struct {
pluginapi.ExecutorRequest
StreamID string `json:"stream_id,omitempty"`
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcModelRouteRequest struct {
pluginapi.ModelRouteRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
C.store_host_api(host)
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, _ C.size_t) {
if ptr != nil {
C.free(ptr)
}
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
if errConfigure := configure(request); errConfigure != nil {
return nil, errConfigure
}
return okEnvelope(pluginRegistration())
case pluginabi.MethodModelRoute:
return routeModel(request)
case pluginabi.MethodExecutorIdentifier:
return okEnvelope(map[string]string{"identifier": pluginIdentifier})
case pluginabi.MethodExecutorExecute:
return execute(request)
case pluginabi.MethodExecutorExecuteStream:
return executeStream(request)
case pluginabi.MethodExecutorCountTokens:
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"input_tokens":0}`)})
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func configure(raw []byte) error {
var req lifecycleRequest
if len(raw) > 0 {
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return errUnmarshal
}
}
cfg := defaultPluginConfig()
if len(req.ConfigYAML) > 0 {
decoded, errDecode := decodeConfig(req.ConfigYAML)
if errDecode != nil {
return errDecode
}
cfg = decoded
}
currentConfig.Store(cfg)
return nil
}
func defaultPluginConfig() pluginConfig {
return pluginConfig{
Enabled: true,
Route: string(backendFallback),
RequireWebSearchOnly: true,
}
}
func decodeConfig(raw []byte) (pluginConfig, error) {
cfg := defaultPluginConfig()
if errUnmarshal := yaml.Unmarshal(raw, &cfg); errUnmarshal != nil {
return pluginConfig{}, errUnmarshal
}
cfg.Route = strings.TrimSpace(cfg.Route)
cfg.AntigravityModel = strings.TrimSpace(cfg.AntigravityModel)
cfg.CodexModel = strings.TrimSpace(cfg.CodexModel)
cfg.XAIModel = strings.TrimSpace(cfg.XAIModel)
cfg.DefaultProvider = strings.ToLower(strings.TrimSpace(cfg.DefaultProvider))
cfg.DefaultProviderModel = strings.TrimSpace(cfg.DefaultProviderModel)
return cfg, nil
}
func loadedConfig() pluginConfig {
raw := currentConfig.Load()
if cfg, ok := raw.(pluginConfig); ok {
return cfg
}
return defaultPluginConfig()
}
func pluginRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: "claude-web-search-router",
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
ConfigFields: []pluginapi.ConfigField{
{Name: "enabled", Type: pluginapi.ConfigFieldTypeBoolean, Description: "When false, the router declines all Claude web_search requests."},
{Name: "route", Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{
string(backendFallback), string(backendAntigravityGoogle), string(backendCodexWebSearch),
string(backendXAIWebSearch), string(backendTavily), string(backendDefaultProvider),
}, Description: "Backend for Claude Code web_search. fallback (default): antigravity → codex → xai → tavily."},
{Name: "antigravity_model", Type: pluginapi.ConfigFieldTypeString, Description: "Antigravity googleSearch model (empty: registry lookup, then first supports_web_search)."},
{Name: "codex_model", Type: pluginapi.ConfigFieldTypeString, Description: "Codex Responses model for web_search (empty defaults to gpt-5.4, never client Claude model)."},
{Name: "xai_model", Type: pluginapi.ConfigFieldTypeString, Description: "xAI Responses model with web_search (empty uses grok-4.3, not the client Claude model)."},
{Name: "default_provider", Type: pluginapi.ConfigFieldTypeString, Description: "Built-in provider key when route=default_provider."},
{Name: "default_provider_model", Type: pluginapi.ConfigFieldTypeString, Description: "Optional execution model on default_provider route."},
{Name: "tavily_api_keys", Type: pluginapi.ConfigFieldTypeArray, Description: "Tavily API keys (round-robin) when route=tavily."},
{Name: "require_web_search_only", Type: pluginapi.ConfigFieldTypeBoolean, Description: "Require tools to be exclusively typed web_search (matches antigravity-only path)."},
},
},
Capabilities: registrationCapability{
ModelRouter: true,
Executor: true,
ExecutorModelScope: string(pluginapi.ExecutorModelScopeStatic),
ExecutorInputFormats: []string{"claude"},
ExecutorOutputFormats: []string{"claude"},
},
}
}
func routeModel(raw []byte) ([]byte, error) {
var req rpcModelRouteRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
cfg := loadedConfig()
if !cfg.Enabled {
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
if !isClaudeSourceFormat(req.SourceFormat) {
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
if !isClaudeCodeBuiltinWebSearchRequest(req.Body, cfg.RequireWebSearchOnly) {
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
route := strings.TrimSpace(cfg.Route)
if isFallbackRoute(route) {
return okEnvelope(routeWithFallback(cfg, req.ModelRouteRequest))
}
if plans := executionPlansForRoute(cfg, req.ModelRouteRequest, route); len(plans) > 0 {
return okEnvelope(pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetSelf,
Reason: "claude_code_web_search_orchestrated",
})
}
backend := routeBackend(route)
resp, ok := tryRouteBackend(backend, cfg, req.ModelRouteRequest)
if ok {
return okEnvelope(resp)
}
if strings.TrimSpace(resp.Reason) != "" {
return okEnvelope(resp)
}
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
func hasProvider(providers []string, key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, p := range providers {
if strings.ToLower(strings.TrimSpace(p)) == key {
return true
}
}
return false
}
func execute(raw []byte) ([]byte, error) {
var req rpcExecutorRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
body, headers, errRun := runWebSearchWithExecutionFallback(context.Background(), req.ExecutorRequest, req.HostCallbackID)
if errRun != nil {
return errorEnvelope("executor_error", errRun.Error()), nil
}
return okEnvelope(pluginapi.ExecutorResponse{Payload: body, Headers: headers})
}
func runTavilyClaude(ctx context.Context, req pluginapi.ExecutorRequest) ([]byte, http.Header, error) {
return runTavilyClaudeWithClient(ctx, req, newTavilyClient(loadedConfig().TavilyAPIKeys))
}
func runTavilyClaudeWithClient(ctx context.Context, req pluginapi.ExecutorRequest, client *tavilyClient) ([]byte, http.Header, error) {
query := extractClaudeWebSearchQuery(req.OriginalRequest)
if query == "" {
query = extractClaudeWebSearchQuery(req.Payload)
}
maxResults := extractClaudeWebSearchMaxUses(req.OriginalRequest, 5)
hits, answer, errSearch := client.search(ctx, query, maxResults)
if errSearch != nil {
return nil, nil, errSearch
}
model := strings.TrimSpace(req.Model)
builder := newClaudeStreamBuilder(model)
payload := builder.buildMessageJSON(query, hits, answer)
headers := http.Header{"Content-Type": []string{"application/json"}}
return payload, headers, nil
}
func runTavilyClaudeStream(ctx context.Context, req pluginapi.ExecutorRequest) ([]byte, http.Header, error) {
return runTavilyClaudeStreamWithClient(ctx, req, newTavilyClient(loadedConfig().TavilyAPIKeys))
}
func runTavilyClaudeStreamWithClient(ctx context.Context, req pluginapi.ExecutorRequest, client *tavilyClient) ([]byte, http.Header, error) {
query := extractClaudeWebSearchQuery(req.OriginalRequest)
if query == "" {
query = extractClaudeWebSearchQuery(req.Payload)
}
maxResults := extractClaudeWebSearchMaxUses(req.OriginalRequest, 5)
hits, answer, errSearch := client.search(ctx, query, maxResults)
if errSearch != nil {
return nil, nil, errSearch
}
model := strings.TrimSpace(req.Model)
builder := newClaudeStreamBuilder(model)
payload := builder.buildStreamWithQuery(query, hits, answer)
headers := http.Header{"Content-Type": []string{"text/event-stream"}}
return payload, headers, nil
}
type hostModelExecutionRequest struct {
pluginapi.HostModelExecutionRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
func callHost(method string, payload any) (json.RawMessage, error) {
rawPayload, errMarshal := json.Marshal(payload)
if errMarshal != nil {
return nil, fmt.Errorf("marshal host callback %s: %w", method, errMarshal)
}
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var requestPtr *C.uint8_t
if len(rawPayload) > 0 {
cPayload := C.CBytes(rawPayload)
if cPayload == nil {
return nil, fmt.Errorf("allocate host callback %s", method)
}
defer C.free(cPayload)
requestPtr = (*C.uint8_t)(cPayload)
}
callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response)
var rawResponse []byte
if response.ptr != nil && response.len > 0 {
rawResponse = C.GoBytes(response.ptr, C.int(response.len))
}
if response.ptr != nil {
C.free_host_buffer(response.ptr, response.len)
}
if len(rawResponse) == 0 {
return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode))
}
var env envelope
if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil {
return nil, fmt.Errorf("decode host envelope %s: %w", method, errUnmarshal)
}
if !env.OK {
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return nil, fmt.Errorf("host callback %s failed", method)
}
if callCode != 0 {
return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode))
}
return append(json.RawMessage(nil), env.Result...), nil
}
func hostHTTPStatusFromError(err error) int {
if err == nil {
return 0
}
msg := err.Error()
for _, code := range []int{429, 503, 502} {
if strings.Contains(msg, fmt.Sprintf("%d", code)) {
return code
}
}
return 0
}
func isRetryableHTTPStatus(code int) bool {
return code == 429 || code == 503 || code == 502
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
@@ -0,0 +1,51 @@
package main
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
const (
// Default Codex model for Claude web_search → Codex Responses (override with codex_model).
defaultCodexWebSearchModel = "gpt-5.4-mini"
// Default xAI model for server-side web_search per https://docs.x.ai/developers/tools/web-search
defaultXAIWebSearchModel = "grok-4.3"
)
// resolveAntigravityWebSearchTargetModel picks an Antigravity model that can run native googleSearch.
// Config antigravity_model wins; otherwise registry.AntigravityWebSearchModelFor(requested) or the
// first available antigravity model with SupportsWebSearch.
func resolveAntigravityWebSearchTargetModel(configured, requested string) string {
if m := strings.TrimSpace(configured); m != "" {
return m
}
if m := registry.AntigravityWebSearchModelFor(strings.TrimSpace(requested)); m != "" {
return m
}
for _, model := range registry.GetGlobalRegistry().GetAvailableModelsByProvider("antigravity") {
if model == nil || !model.SupportsWebSearch {
continue
}
if id := strings.TrimSpace(model.ID); id != "" {
return id
}
}
return ""
}
// resolveCodexWebSearchTargetModel never forwards the client Claude model to Codex.
func resolveCodexWebSearchTargetModel(configured string) string {
if m := strings.TrimSpace(configured); m != "" {
return m
}
return defaultCodexWebSearchModel
}
// resolveXAIWebSearchTargetModel never forwards the client Claude model to xAI Responses.
func resolveXAIWebSearchTargetModel(configured string) string {
if m := strings.TrimSpace(configured); m != "" {
return m
}
return defaultXAIWebSearchModel
}
@@ -0,0 +1,43 @@
package main
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
func TestResolveCodexWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
got := resolveCodexWebSearchTargetModel("")
if got != defaultCodexWebSearchModel {
t.Fatalf("empty config = %q, want %q", got, defaultCodexWebSearchModel)
}
if got := resolveCodexWebSearchTargetModel("gpt-5.5"); got != "gpt-5.5" {
t.Fatalf("configured = %q", got)
}
}
func TestResolveXAIWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
got := resolveXAIWebSearchTargetModel("")
if got != defaultXAIWebSearchModel {
t.Fatalf("empty config = %q, want %q", got, defaultXAIWebSearchModel)
}
}
func TestResolveAntigravityWebSearchTargetModelConfiguredWins(t *testing.T) {
if got := resolveAntigravityWebSearchTargetModel("my-gemini", "claude-sonnet-4-6"); got != "my-gemini" {
t.Fatalf("configured = %q", got)
}
}
func TestResolveAntigravityWebSearchTargetModelFromRegistry(t *testing.T) {
reg := registry.GetGlobalRegistry()
const clientID = "test-claude-web-search-router-antigravity"
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
{ID: "gemini-web-search-test", SupportsWebSearch: true},
})
t.Cleanup(func() { reg.UnregisterClient(clientID) })
got := resolveAntigravityWebSearchTargetModel("", "claude-sonnet-4-6")
if got != "gemini-web-search-test" {
t.Fatalf("fallback = %q, want gemini-web-search-test", got)
}
}
@@ -0,0 +1,57 @@
package main
import (
"sort"
"sync"
)
const (
penaltyBumpOn429503 = 5
penaltyDecaySuccess = 1
)
var backendPenalties = struct {
sync.Mutex
scores map[routeBackend]int
}{
scores: make(map[routeBackend]int),
}
func recordBackendFailure(backend routeBackend) {
backendPenalties.Lock()
defer backendPenalties.Unlock()
backendPenalties.scores[backend] += penaltyBumpOn429503
}
func recordBackendSuccess(backend routeBackend) {
backendPenalties.Lock()
defer backendPenalties.Unlock()
score := backendPenalties.scores[backend] - penaltyDecaySuccess
if score < 0 {
score = 0
}
backendPenalties.scores[backend] = score
}
func penaltyScore(backend routeBackend) int {
backendPenalties.Lock()
defer backendPenalties.Unlock()
return backendPenalties.scores[backend]
}
func sortBackendsByPenalty(backends []routeBackend) []routeBackend {
if len(backends) <= 1 {
return append([]routeBackend(nil), backends...)
}
out := append([]routeBackend(nil), backends...)
sort.SliceStable(out, func(i, j int) bool {
return penaltyScore(out[i]) < penaltyScore(out[j])
})
return out
}
func resetBackendPenaltiesForTest() {
backendPenalties.Lock()
defer backendPenalties.Unlock()
backendPenalties.scores = make(map[routeBackend]int)
}
@@ -0,0 +1,18 @@
package main
import "testing"
func TestSortBackendsByPenaltyDeprioritizesFailures(t *testing.T) {
resetBackendPenaltiesForTest()
t.Cleanup(resetBackendPenaltiesForTest)
recordBackendFailure(backendAntigravityGoogle)
recordBackendFailure(backendAntigravityGoogle)
ordered := sortBackendsByPenalty([]routeBackend{
backendAntigravityGoogle,
backendCodexWebSearch,
backendXAIWebSearch,
})
if ordered[0] != backendCodexWebSearch {
t.Fatalf("ordered = %v, want codex first after antigravity penalty", ordered)
}
}
@@ -0,0 +1,180 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type rpcStreamEmitRequest struct {
StreamID string `json:"stream_id"`
Payload []byte `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
}
type rpcStreamCloseRequest struct {
StreamID string `json:"stream_id"`
Error string `json:"error,omitempty"`
}
func emitPluginStreamChunk(streamID string, payload []byte) error {
if strings.TrimSpace(streamID) == "" {
return fmt.Errorf("plugin stream id is required")
}
_, errCall := callHost(pluginabi.MethodHostStreamEmit, rpcStreamEmitRequest{
StreamID: streamID,
Payload: payload,
})
return errCall
}
func closePluginStream(streamID, errMsg string) {
if strings.TrimSpace(streamID) == "" {
return
}
_, _ = callHost(pluginabi.MethodHostStreamClose, rpcStreamCloseRequest{
StreamID: streamID,
Error: strings.TrimSpace(errMsg),
})
}
func looksLikeOpenAIResponsesSSE(payload []byte) bool {
if len(payload) == 0 {
return false
}
s := string(payload)
if strings.Contains(s, "event: message_start") {
return false
}
return strings.Contains(s, "event: response.") ||
strings.Contains(s, `"type":"response.`) ||
strings.Contains(s, `"type": "response.`)
}
func runWebSearchStreamOrchestration(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: strings.TrimSpace(exec.Model),
Body: claudeRequestBody(exec),
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
}
return runOrderedExecutionPlansStream(ctx, exec, hostCallbackID, pluginStreamID, cfg, buildExecutionPlansForExecute(cfg, req))
}
func runOrderedExecutionPlansStream(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string, cfg pluginConfig, plans []executionPlan) error {
if len(plans) == 0 {
return fmt.Errorf("web search execution: no backend available")
}
backends := make([]routeBackend, 0, len(plans))
for _, p := range plans {
backends = append(backends, p.backend)
}
ordered := sortBackendsByPenalty(backends)
planByBackend := make(map[routeBackend]executionPlan, len(plans))
for _, p := range plans {
planByBackend[p.backend] = p
}
body := claudeRequestBody(exec)
var lastErr error
for _, backend := range ordered {
plan := planByBackend[backend]
switch backend {
case backendTavily:
payload, _, errRun := runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
if errRun != nil {
lastErr = errRun
continue
}
if errEmit := emitPluginStreamChunk(pluginStreamID, payload); errEmit != nil {
return errEmit
}
recordBackendSuccess(backend)
return nil
default:
status, errRun := hostModelStreamForwardClaude(ctx, hostCallbackID, plan.model, body, pluginStreamID)
if errRun != nil {
lastErr = errRun
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
recordBackendFailure(backend)
}
continue
}
if isRetryableHTTPStatus(status) {
recordBackendFailure(backend)
lastErr = fmt.Errorf("host model status %d", status)
continue
}
recordBackendSuccess(backend)
return nil
}
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("web search execution: all backends failed")
}
func hostModelStreamForwardClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, pluginStreamID string) (int, error) {
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "claude",
ExitProtocol: "claude",
Model: execModel,
Stream: true,
Body: body,
},
HostCallbackID: hostCallbackID,
})
if errCall != nil {
return hostHTTPStatusFromError(errCall), errCall
}
var resp pluginapi.HostModelStreamResponse
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
return 0, errDecode
}
if resp.StatusCode >= 400 {
_ = closeHostModelStream(resp.StreamID)
return resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
}
if strings.TrimSpace(resp.StreamID) == "" {
return 0, fmt.Errorf("host model stream: empty stream_id")
}
defer func() { _ = closeHostModelStream(resp.StreamID) }()
firstPayload := true
for {
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
if errRead != nil {
return hostHTTPStatusFromError(errRead), errRead
}
var chunk pluginapi.HostModelStreamReadResponse
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
return 0, errDecode
}
if chunk.Error != "" {
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
return code, fmt.Errorf("%s", chunk.Error)
}
if len(chunk.Payload) > 0 {
if firstPayload && looksLikeOpenAIResponsesSSE(chunk.Payload) {
return 0, fmt.Errorf("host model stream returned OpenAI Responses SSE instead of Claude Messages SSE")
}
firstPayload = false
if errEmit := emitPluginStreamChunk(pluginStreamID, bytes.Clone(chunk.Payload)); errEmit != nil {
return 0, errEmit
}
}
if chunk.Done {
break
}
}
return http.StatusOK, nil
}
@@ -0,0 +1,71 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestLooksLikeOpenAIResponsesSSE(t *testing.T) {
if !looksLikeOpenAIResponsesSSE([]byte("event: response.created\ndata: {\"type\":\"response.created\"}\n\n")) {
t.Fatal("expected OpenAI Responses SSE detection")
}
if looksLikeOpenAIResponsesSSE([]byte("event: message_start\ndata: {\"type\":\"message_start\"}\n\n")) {
t.Fatal("expected Claude Messages SSE to not match Responses detector")
}
if looksLikeOpenAIResponsesSSE(nil) {
t.Fatal("empty payload should not match")
}
}
func TestStartExecutorStreamRunsOrchestrationAfterRPCReturns(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
closed := make(chan string, 1)
req := rpcExecutorRequest{
ExecutorRequest: pluginapi.ExecutorRequest{Stream: true},
StreamID: "stream-1",
HostCallbackID: "callback-1",
}
raw, errStart := startExecutorStream(req, func(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
if hostCallbackID != "callback-1" || pluginStreamID != "stream-1" {
t.Errorf("runner ids = %q/%q, want callback-1/stream-1", hostCallbackID, pluginStreamID)
}
close(started)
<-release
return nil
}, func(streamID, errMsg string) {
closed <- streamID + "|" + errMsg
})
if errStart != nil {
t.Fatalf("startExecutorStream() error = %v", errStart)
}
if !strings.Contains(string(raw), "text/event-stream") {
t.Fatalf("response does not include stream headers: %s", raw)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("orchestration did not start")
}
select {
case got := <-closed:
t.Fatalf("stream closed before orchestration finished: %q", got)
default:
}
close(release)
select {
case got := <-closed:
if got != "stream-1|" {
t.Fatalf("close call = %q, want stream-1|", got)
}
case <-time.After(time.Second):
t.Fatal("stream was not closed after orchestration finished")
}
}
@@ -0,0 +1,144 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
)
const tavilySearchURL = "https://api.tavily.com/search"
type tavilyClient struct {
keys []string
idx atomic.Uint64
http *http.Client
baseURL string // empty → https://api.tavily.com/search
}
func newTavilyClient(keys []string) *tavilyClient {
return newTavilyClientWithOptions(keys, nil, "")
}
func newTavilyClientWithOptions(keys []string, httpClient *http.Client, baseURL string) *tavilyClient {
trimmed := make([]string, 0, len(keys))
for _, key := range keys {
if k := strings.TrimSpace(key); k != "" {
trimmed = append(trimmed, k)
}
}
if httpClient == nil {
httpClient = &http.Client{}
}
return &tavilyClient{
keys: trimmed,
http: httpClient,
baseURL: strings.TrimSpace(baseURL),
}
}
func (c *tavilyClient) searchEndpoint() string {
if c != nil && c.baseURL != "" {
return c.baseURL
}
return tavilySearchURL
}
func (c *tavilyClient) available() bool {
return c != nil && len(c.keys) > 0
}
func (c *tavilyClient) nextKey() string {
if len(c.keys) == 0 {
return ""
}
n := c.idx.Add(1)
return c.keys[int(n-1)%len(c.keys)]
}
type tavilySearchRequest struct {
APIKey string `json:"api_key"`
Query string `json:"query"`
SearchDepth string `json:"search_depth,omitempty"`
MaxResults int `json:"max_results,omitempty"`
IncludeAnswer bool `json:"include_answer,omitempty"`
}
type tavilySearchResponse struct {
Answer string `json:"answer"`
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
} `json:"results"`
}
type claudeWebSearchHit struct {
Title string
URL string
Snippet string
}
func (c *tavilyClient) search(ctx context.Context, query string, maxResults int) ([]claudeWebSearchHit, string, error) {
if !c.available() {
return nil, "", fmt.Errorf("tavily_api_keys is empty")
}
query = strings.TrimSpace(query)
if query == "" {
return nil, "", fmt.Errorf("web search query is empty")
}
if maxResults <= 0 {
maxResults = 5
}
payload, errMarshal := json.Marshal(tavilySearchRequest{
APIKey: c.nextKey(),
Query: query,
SearchDepth: "basic",
MaxResults: maxResults,
IncludeAnswer: true,
})
if errMarshal != nil {
return nil, "", errMarshal
}
req, errNew := http.NewRequestWithContext(ctx, http.MethodPost, c.searchEndpoint(), bytes.NewReader(payload))
if errNew != nil {
return nil, "", errNew
}
req.Header.Set("Content-Type", "application/json")
resp, errDo := c.http.Do(req)
if errDo != nil {
return nil, "", errDo
}
defer func() { _ = resp.Body.Close() }()
body, errRead := io.ReadAll(resp.Body)
if errRead != nil {
return nil, "", errRead
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("tavily http %d: %s", resp.StatusCode, truncate(string(body), 512))
}
var parsed tavilySearchResponse
if errDecode := json.Unmarshal(body, &parsed); errDecode != nil {
return nil, "", errDecode
}
hits := make([]claudeWebSearchHit, 0, len(parsed.Results))
for _, r := range parsed.Results {
hits = append(hits, claudeWebSearchHit{
Title: strings.TrimSpace(r.Title),
URL: strings.TrimSpace(r.URL),
Snippet: strings.TrimSpace(r.Content),
})
}
return hits, strings.TrimSpace(parsed.Answer), nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
@@ -0,0 +1,217 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"github.com/tidwall/gjson"
)
func TestTavilyClientSearchMockAPI(t *testing.T) {
var gotBody tavilySearchRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("content-type = %q", ct)
}
raw, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatal(errRead)
}
if errDecode := json.Unmarshal(raw, &gotBody); errDecode != nil {
t.Fatal(errDecode)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"query": "北京天气",
"answer": "明天晴。",
"results": [
{"title": "Example Weather", "url": "https://example.com/w", "content": "snippet one"}
]
}`))
}))
defer server.Close()
client := newTavilyClientWithOptions([]string{"tvly-test-key"}, server.Client(), server.URL)
hits, answer, errSearch := client.search(context.Background(), "北京天气", 3)
if errSearch != nil {
t.Fatalf("search() error = %v", errSearch)
}
if gotBody.APIKey != "tvly-test-key" {
t.Fatalf("api_key = %q", gotBody.APIKey)
}
if gotBody.Query != "北京天气" {
t.Fatalf("query = %q", gotBody.Query)
}
if gotBody.MaxResults != 3 {
t.Fatalf("max_results = %d, want 3", gotBody.MaxResults)
}
if !gotBody.IncludeAnswer {
t.Fatal("include_answer should be true")
}
if answer != "明天晴。" {
t.Fatalf("answer = %q", answer)
}
if len(hits) != 1 || hits[0].URL != "https://example.com/w" {
t.Fatalf("hits = %#v", hits)
}
}
func TestTavilyClientSearchEmptyKeys(t *testing.T) {
client := newTavilyClient(nil)
_, _, err := client.search(context.Background(), "q", 5)
if err == nil || !strings.Contains(err.Error(), "tavily_api_keys") {
t.Fatalf("err = %v", err)
}
}
func TestTavilyClientSearchHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"bad key"}`))
}))
defer server.Close()
client := newTavilyClientWithOptions([]string{"bad"}, server.Client(), server.URL)
_, _, err := client.search(context.Background(), "q", 5)
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("err = %v", err)
}
}
func TestTavilyClientRoundRobinKeys(t *testing.T) {
var keys []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body tavilySearchRequest
_ = json.NewDecoder(r.Body).Decode(&body)
keys = append(keys, body.APIKey)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer server.Close()
client := newTavilyClientWithOptions([]string{"k1", "k2"}, server.Client(), server.URL)
for i := 0; i < 4; i++ {
if _, _, err := client.search(context.Background(), "q", 1); err != nil {
t.Fatal(err)
}
}
if len(keys) != 4 || keys[0] != "k1" || keys[1] != "k2" || keys[2] != "k1" || keys[3] != "k2" {
t.Fatalf("key rotation = %v", keys)
}
}
func TestRunTavilyClaudeStreamWithMock(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"answer": "2026年6月16日北京多雨。",
"results": [
{"title": "bjmy.gov.cn", "url": "https://www.bjmy.gov.cn/x", "content": "预报"}
]
}`))
}))
defer server.Close()
claudeBody := []byte(`{
"model": "claude-sonnet-4-6",
"stream": true,
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}],
"messages": [{"role": "user", "content": [{"type": "text", "text": "Perform a web search for the query: 北京天气 2026年6月16日"}]}]
}`)
client := newTavilyClientWithOptions([]string{"tvly-mock"}, server.Client(), server.URL)
payload, headers, errRun := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
Model: "claude-sonnet-4-6",
Stream: true,
OriginalRequest: claudeBody,
}, client)
if errRun != nil {
t.Fatalf("runTavilyClaudeStreamWithClient() error = %v", errRun)
}
if headers.Get("Content-Type") != "text/event-stream" {
t.Fatalf("content-type = %q", headers.Get("Content-Type"))
}
text := string(payload)
for _, needle := range []string{
"event: message_start",
`"type":"server_tool_use"`,
`"name":"web_search"`,
`"type":"web_search_tool_result"`,
`"type":"web_search_result"`,
`https://www.bjmy.gov.cn/x`,
`"web_search_requests":1`,
"event: message_stop",
"北京天气 2026年6月16日",
"2026年6月16日北京多雨",
} {
if !strings.Contains(text, needle) {
t.Fatalf("SSE missing %q in:\n%s", needle, text)
}
}
}
func TestRunTavilyClaudeJSONWithMock(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"answer":"ok","results":[{"title":"T","url":"https://t.example","content":"c"}]}`))
}))
defer server.Close()
claudeBody := []byte(`{
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
"messages": [{"role": "user", "content": "Perform a web search for the query: test query"}]
}`)
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
payload, _, errRun := runTavilyClaudeWithClient(context.Background(), pluginapi.ExecutorRequest{
Model: "claude-sonnet-4-6",
OriginalRequest: claudeBody,
}, client)
if errRun != nil {
t.Fatal(errRun)
}
root := gjson.ParseBytes(payload)
if root.Get("type").String() != "message" {
t.Fatalf("type = %s", root.Get("type").String())
}
if root.Get("content.0.type").String() != "server_tool_use" {
t.Fatalf("content.0 = %s", root.Get("content.0.type").String())
}
if root.Get("content.1.type").String() != "web_search_tool_result" {
t.Fatalf("content.1 = %s", root.Get("content.1.type").String())
}
if root.Get("content.2.text").String() != "ok" {
t.Fatalf("text = %s", root.Get("content.2.text").String())
}
if root.Get("usage.server_tool_use.web_search_requests").Int() != 1 {
t.Fatalf("web_search_requests = %d", root.Get("usage.server_tool_use.web_search_requests").Int())
}
}
func TestExecuteStreamRPCWithMockTavily(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"answer":"rpc-ok","results":[]}`))
}))
defer server.Close()
currentConfig.Store(pluginConfig{
Route: string(backendTavily),
TavilyAPIKeys: []string{"k"},
})
// Override client by patching: executeStream uses loadedConfig keys + real URL.
// Test runTavilyClaudeStreamWithClient directly instead; for execute() we need config + mock URL.
// Use executor path with injected client via runTavilyClaudeStreamWithClient already covered.
_ = server
claudeBody := []byte(`{"messages":[{"role":"user","content":"Perform a web search for the query: q"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`)
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
body, _, err := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
Model: "m", Stream: true, OriginalRequest: claudeBody,
}, client)
if err != nil || !strings.Contains(string(body), "rpc-ok") {
t.Fatalf("err=%v body=%s", err, body)
}
}
+8
View File
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
project(cliproxy_cli_c C)
add_library(cliproxy_cli_c SHARED src/plugin.c)
set_target_properties(cliproxy_cli_c PROPERTIES
OUTPUT_NAME "cli-c"
PREFIX ""
)
+117
View File
@@ -0,0 +1,117 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#define CLIPROXY_EXPORT __declspec(dllexport)
#else
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
#endif
#define ABI_VERSION 1
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
static const cliproxy_host_api* stored_host = NULL;
static void write_response(cliproxy_buffer* response, const char* text) {
if (response == NULL || text == NULL) {
return;
}
size_t len = strlen(text);
void* ptr = malloc(len);
if (ptr == NULL) {
response->ptr = NULL;
response->len = 0;
return;
}
memcpy(ptr, text, len);
response->ptr = ptr;
response->len = len;
}
static void call_host(const char* method, const char* payload) {
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
return;
}
cliproxy_buffer response = {0};
const uint8_t* request = (const uint8_t*)payload;
size_t request_len = payload == NULL ? 0 : strlen(payload);
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
stored_host->free_buffer(response.ptr, response.len);
}
}
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (response != NULL) {
response->ptr = NULL;
response->len = 0;
}
if (method == NULL) {
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
return 1;
}
if (strcmp(method, "plugin.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}");
return 0;
}
if (strcmp(method, "plugin.reconfigure") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}");
return 0;
}
if (strcmp(method, "command_line.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"example-cli-c-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}}");
return 0;
}
if (strcmp(method, "command_line.execute") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Stdout\":\"ImV4YW1wbGUtY2xpLWMgY29tbWFuZCBleGVjdXRlZFxcbiI=\",\"ExitCode\":0}}");
return 0;
}
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
(void)request;
(void)request_len;
return 0;
}
static void plugin_free(void* ptr, size_t len) {
(void)len;
free(ptr);
}
static void plugin_shutdown(void) {}
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
if (plugin == NULL) {
return 1;
}
stored_host = host;
plugin->abi_version = ABI_VERSION;
plugin->call = plugin_call;
plugin->free_buffer = plugin_free;
plugin->shutdown = plugin_shutdown;
return 0;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/cli/go
go 1.26
+175
View File
@@ -0,0 +1,175 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"encoding/json"
"net/http"
"time"
"unsafe"
)
const abiVersion uint32 = 1
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
C.store_host_api(host)
plugin.abi_version = C.uint32_t(abiVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
raw, errHandle := handleMethod(C.GoString(method))
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
_ = request
_ = requestLen
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
_ = len
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string) ([]byte, error) {
_ = http.StatusOK
_ = time.Second
switch method {
case "plugin.register":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}")
case "plugin.reconfigure":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}")
case "command_line.register":
return okEnvelopeJSON("{\"Flags\":[{\"Name\":\"example-cli-go-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}")
case "command_line.execute":
return okEnvelopeJSON("{\"Stdout\":\"ImV4YW1wbGUtY2xpLWdvIGNvbW1hbmQgZXhlY3V0ZWRcXG4i\",\"ExitCode\":0}")
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func okEnvelopeJSON(result string) ([]byte, error) {
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
func callHost(method string, payload []byte) {
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var req *C.uint8_t
if len(payload) > 0 {
req = (*C.uint8_t)(C.CBytes(payload))
defer C.free(unsafe.Pointer(req))
}
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
C.free_host_buffer(response.ptr, response.len)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-cli-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-cli-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -0,0 +1,127 @@
use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;
const ABI_VERSION: u32 = 1;
#[repr(C)]
pub struct CliproxyBuffer {
ptr: *mut u8,
len: usize,
}
type HostCall = unsafe extern "C" fn(*mut std::ffi::c_void, *const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type HostFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginCall = unsafe extern "C" fn(*const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type PluginFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginShutdown = unsafe extern "C" fn();
#[repr(C)]
pub struct CliproxyHostApi {
abi_version: u32,
host_ctx: *mut std::ffi::c_void,
call: Option<HostCall>,
free_buffer: Option<HostFree>,
}
#[repr(C)]
pub struct CliproxyPluginApi {
abi_version: u32,
call: Option<PluginCall>,
free_buffer: Option<PluginFree>,
shutdown: Option<PluginShutdown>,
}
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
#[no_mangle]
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
if plugin.is_null() {
return 1;
}
unsafe {
STORED_HOST = host;
(*plugin).abi_version = ABI_VERSION;
(*plugin).call = Some(plugin_call);
(*plugin).free_buffer = Some(plugin_free);
(*plugin).shutdown = Some(plugin_shutdown);
}
0
}
unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, request_len: usize, response: *mut CliproxyBuffer) -> i32 {
if !response.is_null() {
(*response).ptr = ptr::null_mut();
(*response).len = 0;
}
if method.is_null() {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
return 1;
}
let method = match CStr::from_ptr(method).to_str() {
Ok(value) => value,
Err(_) => {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is not utf-8"}}"#);
return 1;
}
};
let _ = request;
let _ = request_len;
match method {
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}"); 0 },"command_line.register" => { write_response(response, "{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"example-cli-rust-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}}"); 0 },"command_line.execute" => { write_response(response, "{\"ok\":true,\"result\":{\"Stdout\":\"ImV4YW1wbGUtY2xpLXJ1c3QgY29tbWFuZCBleGVjdXRlZFxcbiI=\",\"ExitCode\":0}}"); 0 },
_ => {
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
0
}
}
}
unsafe extern "C" fn plugin_free(ptr: *mut std::ffi::c_void, len: usize) {
if !ptr.is_null() {
let _ = Vec::from_raw_parts(ptr as *mut u8, len, len);
}
}
unsafe extern "C" fn plugin_shutdown() {}
fn write_response(response: *mut CliproxyBuffer, text: &str) {
if response.is_null() {
return;
}
let mut bytes = text.as_bytes().to_vec();
let len = bytes.len();
let ptr = bytes.as_mut_ptr();
std::mem::forget(bytes);
unsafe {
(*response).ptr = ptr;
(*response).len = len;
}
}
#[allow(dead_code)]
fn call_host(method: &str, payload: &str) {
unsafe {
if STORED_HOST.is_null() {
return;
}
let host = &*STORED_HOST;
let Some(call) = host.call else {
return;
};
let mut method_bytes = method.as_bytes().to_vec();
method_bytes.push(0);
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
let rc = call(
host.host_ctx,
method_bytes.as_ptr() as *const c_char,
payload.as_ptr(),
payload.len(),
&mut response,
);
if rc == 0 && !response.ptr.is_null() {
if let Some(free_buffer) = host.free_buffer {
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
}
}
}
}
@@ -0,0 +1,25 @@
# Codex Service Tier Plugin
This plugin is a request normalizer for Codex outbound requests.
When the plugin is enabled and `fast` is set to `true`, it sets the top-level `service_tier` field to `priority` for requests where:
- `req.ToFormat` is `codex`
- `req.Model` is `gpt-5.5`
Requests that do not match these conditions are returned unchanged.
## Configuration
Add the plugin under `plugins.configs`:
```yaml
plugins:
configs:
codex-service-tier:
enabled: true
priority: 1
fast: false
```
`fast` is a boolean field. Set it to `true` to enable priority service tier shaping for matching Codex `gpt-5.5` requests.
@@ -0,0 +1,17 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/codex-service-tier/go
go 1.26.0
require (
github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
github.com/tidwall/sjson v1.2.5
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
@@ -0,0 +1,13 @@
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,246 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef struct {
uint32_t abi_version;
void* host_ctx;
void* call;
void* free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
*/
import "C"
import (
"encoding/json"
"strings"
"sync/atomic"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"github.com/tidwall/sjson"
"gopkg.in/yaml.v3"
)
var fastEnabled atomic.Bool
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type lifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
}
type pluginConfig struct {
Fast bool `yaml:"fast"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities registrationCapability `json:"capabilities"`
}
type registrationCapability struct {
RequestNormalizer bool `json:"request_normalizer"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(_ *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
if errConfigure := configure(request); errConfigure != nil {
return nil, errConfigure
}
return okEnvelope(pluginRegistration())
case pluginabi.MethodRequestNormalize:
return normalizeRequest(request)
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func configure(raw []byte) error {
var req lifecycleRequest
if len(raw) > 0 {
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return errUnmarshal
}
}
cfg := pluginConfig{}
if len(req.ConfigYAML) > 0 {
fast, errDecodeFast := decodeFastConfig(req.ConfigYAML)
if errDecodeFast != nil {
return errDecodeFast
}
cfg.Fast = fast
}
fastEnabled.Store(cfg.Fast)
return nil
}
func pluginRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: "codex-service-tier",
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png",
ConfigFields: []pluginapi.ConfigField{{
Name: "fast",
Type: pluginapi.ConfigFieldTypeBoolean,
Description: "Sets Codex gpt-5.5 Responses requests to the priority service tier.",
}},
},
Capabilities: registrationCapability{
RequestNormalizer: true,
},
}
}
func normalizeRequest(raw []byte) ([]byte, error) {
var req pluginapi.RequestTransformRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
body := req.Body
if !shouldSetPriorityServiceTier(req) {
return okEnvelope(pluginapi.PayloadResponse{Body: body})
}
updated, okSet := setPriorityServiceTier(body)
if !okSet {
return okEnvelope(pluginapi.PayloadResponse{Body: body})
}
return okEnvelope(pluginapi.PayloadResponse{Body: updated})
}
func shouldSetPriorityServiceTier(req pluginapi.RequestTransformRequest) bool {
if !fastEnabled.Load() {
return false
}
if !strings.EqualFold(req.ToFormat, "codex") {
return false
}
return req.Model == "gpt-5.5"
}
func decodeFastConfig(configYAML []byte) (bool, error) {
var cfg pluginConfig
if errUnmarshal := yaml.Unmarshal(configYAML, &cfg); errUnmarshal != nil {
return false, errUnmarshal
}
return cfg.Fast, nil
}
func setPriorityServiceTier(body []byte) ([]byte, bool) {
updated, errSet := sjson.SetBytes(body, "service_tier", "priority")
if errSet != nil {
return nil, false
}
return updated, true
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
project(cliproxy_executor_c C)
add_library(cliproxy_executor_c SHARED src/plugin.c)
set_target_properties(cliproxy_executor_c PROPERTIES
OUTPUT_NAME "executor-c"
PREFIX ""
)
+129
View File
@@ -0,0 +1,129 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#define CLIPROXY_EXPORT __declspec(dllexport)
#else
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
#endif
#define ABI_VERSION 1
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
static const cliproxy_host_api* stored_host = NULL;
static void write_response(cliproxy_buffer* response, const char* text) {
if (response == NULL || text == NULL) {
return;
}
size_t len = strlen(text);
void* ptr = malloc(len);
if (ptr == NULL) {
response->ptr = NULL;
response->len = 0;
return;
}
memcpy(ptr, text, len);
response->ptr = ptr;
response->len = len;
}
static void call_host(const char* method, const char* payload) {
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
return;
}
cliproxy_buffer response = {0};
const uint8_t* request = (const uint8_t*)payload;
size_t request_len = payload == NULL ? 0 : strlen(payload);
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
stored_host->free_buffer(response.ptr, response.len);
}
}
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (response != NULL) {
response->ptr = NULL;
response->len = 0;
}
if (method == NULL) {
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
return 1;
}
if (strcmp(method, "plugin.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}");
return 0;
}
if (strcmp(method, "plugin.reconfigure") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}");
return 0;
}
if (strcmp(method, "executor.identifier") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-executor-c\"}}");
return 0;
}
if (strcmp(method, "executor.execute") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItYyIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiJ9\",\"Headers\":{\"content-type\":[\"application/json\"]}}}");
return 0;
}
if (strcmp(method, "executor.execute_stream") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItY1xuXG4i\"}]}}");
return 0;
}
if (strcmp(method, "executor.count_tokens") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}}");
return 0;
}
if (strcmp(method, "executor.http_request") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLWMifQ==\"}}");
return 0;
}
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
(void)request;
(void)request_len;
return 0;
}
static void plugin_free(void* ptr, size_t len) {
(void)len;
free(ptr);
}
static void plugin_shutdown(void) {}
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
if (plugin == NULL) {
return 1;
}
stored_host = host;
plugin->abi_version = ABI_VERSION;
plugin->call = plugin_call;
plugin->free_buffer = plugin_free;
plugin->shutdown = plugin_shutdown;
return 0;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/executor/go
go 1.26
+181
View File
@@ -0,0 +1,181 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"encoding/json"
"net/http"
"time"
"unsafe"
)
const abiVersion uint32 = 1
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
C.store_host_api(host)
plugin.abi_version = C.uint32_t(abiVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
raw, errHandle := handleMethod(C.GoString(method))
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
_ = request
_ = requestLen
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
_ = len
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string) ([]byte, error) {
_ = http.StatusOK
_ = time.Second
switch method {
case "plugin.register":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}")
case "plugin.reconfigure":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}")
case "executor.identifier":
return okEnvelopeJSON("{\"identifier\":\"example-executor-go\"}")
case "executor.execute":
return okEnvelopeJSON("{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItZ28iLCJvYmplY3QiOiJjaGF0LmNvbXBsZXRpb24ifQ==\",\"Headers\":{\"content-type\":[\"application/json\"]}}")
case "executor.execute_stream":
return okEnvelopeJSON("{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItZ29cblxuIg==\"}]}")
case "executor.count_tokens":
return okEnvelopeJSON("{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}")
case "executor.http_request":
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLWdvIn0=\"}")
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func okEnvelopeJSON(result string) ([]byte, error) {
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
func callHost(method string, payload []byte) {
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var req *C.uint8_t
if len(payload) > 0 {
req = (*C.uint8_t)(C.CBytes(payload))
defer C.free(unsafe.Pointer(req))
}
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
C.free_host_buffer(response.ptr, response.len)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-executor-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-executor-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -0,0 +1,127 @@
use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;
const ABI_VERSION: u32 = 1;
#[repr(C)]
pub struct CliproxyBuffer {
ptr: *mut u8,
len: usize,
}
type HostCall = unsafe extern "C" fn(*mut std::ffi::c_void, *const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type HostFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginCall = unsafe extern "C" fn(*const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type PluginFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginShutdown = unsafe extern "C" fn();
#[repr(C)]
pub struct CliproxyHostApi {
abi_version: u32,
host_ctx: *mut std::ffi::c_void,
call: Option<HostCall>,
free_buffer: Option<HostFree>,
}
#[repr(C)]
pub struct CliproxyPluginApi {
abi_version: u32,
call: Option<PluginCall>,
free_buffer: Option<PluginFree>,
shutdown: Option<PluginShutdown>,
}
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
#[no_mangle]
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
if plugin.is_null() {
return 1;
}
unsafe {
STORED_HOST = host;
(*plugin).abi_version = ABI_VERSION;
(*plugin).call = Some(plugin_call);
(*plugin).free_buffer = Some(plugin_free);
(*plugin).shutdown = Some(plugin_shutdown);
}
0
}
unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, request_len: usize, response: *mut CliproxyBuffer) -> i32 {
if !response.is_null() {
(*response).ptr = ptr::null_mut();
(*response).len = 0;
}
if method.is_null() {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
return 1;
}
let method = match CStr::from_ptr(method).to_str() {
Ok(value) => value,
Err(_) => {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is not utf-8"}}"#);
return 1;
}
};
let _ = request;
let _ = request_len;
match method {
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}"); 0 },"executor.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-executor-rust\"}}"); 0 },"executor.execute" => { write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItcnVzdCIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiJ9\",\"Headers\":{\"content-type\":[\"application/json\"]}}}"); 0 },"executor.execute_stream" => { write_response(response, "{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItcnVzdFxuXG4i\"}]}}"); 0 },"executor.count_tokens" => { write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}}"); 0 },"executor.http_request" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLXJ1c3QifQ==\"}}"); 0 },
_ => {
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
0
}
}
}
unsafe extern "C" fn plugin_free(ptr: *mut std::ffi::c_void, len: usize) {
if !ptr.is_null() {
let _ = Vec::from_raw_parts(ptr as *mut u8, len, len);
}
}
unsafe extern "C" fn plugin_shutdown() {}
fn write_response(response: *mut CliproxyBuffer, text: &str) {
if response.is_null() {
return;
}
let mut bytes = text.as_bytes().to_vec();
let len = bytes.len();
let ptr = bytes.as_mut_ptr();
std::mem::forget(bytes);
unsafe {
(*response).ptr = ptr;
(*response).len = len;
}
}
#[allow(dead_code)]
fn call_host(method: &str, payload: &str) {
unsafe {
if STORED_HOST.is_null() {
return;
}
let host = &*STORED_HOST;
let Some(call) = host.call else {
return;
};
let mut method_bytes = method.as_bytes().to_vec();
method_bytes.push(0);
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
let rc = call(
host.host_ctx,
method_bytes.as_ptr() as *const c_char,
payload.as_ptr(),
payload.len(),
&mut response,
);
if rc == 0 && !response.ptr.is_null() {
if let Some(free_buffer) = host.free_buffer {
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
}
}
}
}
@@ -0,0 +1,19 @@
# Frontend Auth Exclusive Plugin Example
This example registers a frontend auth provider with `frontend_auth_provider_exclusive: true`.
When enabled and selected, this provider becomes the only request authentication provider. Built-in config API keys and other frontend auth providers do not authenticate requests while this provider is active.
The example accepts requests that include:
```http
X-Example-Frontend-Auth: exclusive
```
Build:
```bash
cd examples/plugin/frontend-auth-exclusive/go
go build -buildmode=c-shared -o /tmp/cliproxy-frontend-auth-exclusive.dylib .
```
@@ -0,0 +1,7 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth-exclusive/go
go 1.26.0
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
@@ -0,0 +1,194 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
*/
import "C"
import (
"encoding/json"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities capabilities `json:"capabilities"`
}
type capabilities struct {
FrontendAuthProvider bool `json:"frontend_auth_provider"`
FrontendAuthProviderExclusive bool `json:"frontend_auth_provider_exclusive"`
}
type identifierResponse struct {
Identifier string `json:"identifier"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
_ = host
if plugin == nil {
return 1
}
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
_ = len
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
return okEnvelope(exampleRegistration())
case pluginabi.MethodFrontendAuthIdentifier:
return okEnvelope(identifierResponse{Identifier: "example-frontend-auth-exclusive-go"})
case pluginabi.MethodFrontendAuthAuthenticate:
return authenticate(request)
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func exampleRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: "example-frontend-auth-exclusive-go",
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
Logo: "https://example.invalid/example-frontend-auth-exclusive-go.png",
ConfigFields: []pluginapi.ConfigField{},
},
Capabilities: capabilities{
FrontendAuthProvider: true,
FrontendAuthProviderExclusive: true,
},
}
}
func authenticate(request []byte) ([]byte, error) {
var req pluginapi.FrontendAuthRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: false})
}
if req.Headers.Get("X-Example-Frontend-Auth") != "exclusive" {
return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: false})
}
return okEnvelope(pluginapi.FrontendAuthResponse{
Authenticated: true,
Principal: "example-frontend-auth-exclusive-go",
Metadata: map[string]string{
"mode": "exclusive",
"provider": "example-frontend-auth-exclusive-go",
},
})
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
project(cliproxy_frontend_auth_c C)
add_library(cliproxy_frontend_auth_c SHARED src/plugin.c)
set_target_properties(cliproxy_frontend_auth_c PROPERTIES
OUTPUT_NAME "frontend-auth-c"
PREFIX ""
)

Some files were not shown because too many files have changed in this diff Show More