Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.git
node_modules
frontend/node_modules
frontend/dist
backend/internal/managementasset/dist
backend/auths
backend/logs
backend/plugins
backend/config.yaml
backend/.dev
result
result-*

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
frontend/dist/
backend/internal/managementasset/dist/
backend/.dev/
result
result-*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Methanium
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.

View file

@ -1,2 +1 @@
# vibe-proxy
# Vibe Proxy

41
backend/.dockerignore Normal file
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

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
backend/.env.example Normal file
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

61
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,61 @@
# 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/*
/internal/managementasset/dist/
# Authentication data
auths/*
!auths/.gitkeep
# Tooling metadata
.vscode/*
.worktrees/
.codex/*
.claude/*
.claude
.gemini/*
.serena/*
.agent/*
.agents
.pi
.agents/*
.opencode/*
.idea/*
.beads/*
.bmad/*
_bmad/*
_bmad-output/*
.gocache/
.gitnexus/
# macOS
.DS_Store
._*
# docker
docker-compose.override.yml
# Local LLM Wiki vault
.llm-wiki

58
backend/AGENTS.md Normal file
View file

@ -0,0 +1,58 @@
# AGENTS.md
Go 1.26+ proxy server providing OpenAI/Gemini/Claude/Codex compatible APIs with OAuth and round-robin load balancing.
## Repository
- GitHub: https://github.com/router-for-me/CLIProxyAPI
## Commands
```bash
gofmt -w . # Format (required after Go changes)
go build -o cli-proxy-api ./cmd/server # Build
go run ./cmd/server # Run dev server
go test ./... # Run all tests
go test -v -run TestName ./path/to/pkg # Run single test
go build -o test-output ./cmd/server && rm test-output # Verify compile (REQUIRED after changes)
```
- Common flags: `--config <path>`, `--tui`, `--standalone`, `--local-model`, `--no-browser`, `--oauth-callback-port <port>`
## Config
- Default config: `config.yaml` (template: `config.example.yaml`)
- `.env` is auto-loaded from the working directory
- Auth material defaults under `auths/`
- Storage backends: file-based default; optional Postgres/git/object store (`PGSTORE_*`, `GITSTORE_*`, `OBJECTSTORE_*`)
## Architecture
- `cmd/server/` — Server entrypoint
- `internal/api/` — Gin HTTP API (routes, middleware, modules)
- `internal/api/modules/amp/` — Amp integration (Amp-style routes + reverse proxy)
- `internal/thinking/` — Main thinking/reasoning pipeline. `ApplyThinking()` (apply.go) parses suffixes (`suffix.go`, suffix overrides body), normalizes config to canonical `ThinkingConfig` (`types.go`), normalizes and validates centrally (`validate.go`/`convert.go`), then applies provider-specific output via `ProviderApplier`. Do not break this "canonical representation → per-provider translation" architecture.
- `internal/runtime/executor/` — Per-provider runtime executors (incl. Codex WebSocket)
- `internal/translator/` — Provider protocol translators (and shared `common`)
- `internal/registry/` — Model registry + remote updater (`StartModelsUpdater`); `--local-model` disables remote updates
- `internal/store/` — Storage implementations and secret resolution
- `internal/managementasset/` — Config snapshots and management assets
- `internal/cache/` — Request signature caching
- `internal/watcher/` — Config hot-reload and watchers
- `internal/wsrelay/` — WebSocket relay sessions
- `internal/usage/` — Usage and token accounting
- `internal/tui/` — Bubbletea terminal UI (`--tui`, `--standalone`)
- `sdk/cliproxy/` — Embeddable SDK entry (service/builder/watchers/pipeline)
- `test/` — Cross-module integration tests
## Code Conventions
- Keep changes small and simple (KISS)
- Comments in English only
- If editing code that already contains non-English comments, translate them to English (dont add new non-English comments)
- For user-visible strings, keep the existing language used in that file/area
- New Markdown docs should be in English unless the file is explicitly language-specific (e.g. `README_CN.md`)
- As a rule, do not make standalone changes to `internal/translator/`. You may modify it only as part of broader changes elsewhere.
- If a task requires changing only `internal/translator/`, run `gh repo view --json viewerPermission -q .viewerPermission` to confirm you have `WRITE`, `MAINTAIN`, or `ADMIN`. If you do, you may proceed; otherwise, file a GitHub issue including the goal, rationale, and the intended implementation code, then stop further work.
- `internal/runtime/executor/` should contain executors and their unit tests only. Place any helper/supporting files under `internal/runtime/executor/helps/`.
- Follow `gofmt`; keep imports goimports-style; wrap errors with context where helpful
- Do not use `log.Fatal`/`log.Fatalf` (terminates the process); prefer returning errors and logging via logrus
- Shadowed variables: use method suffix (`errStart := server.Start()`)
- Wrap defer errors: `defer func() { if err := f.Close(); err != nil { log.Errorf(...) } }()`
- Use logrus structured logging; avoid leaking secrets/tokens in logs
- Avoid panics in HTTP handlers; prefer logged errors and meaningful HTTP status codes
- Timeouts are allowed only during credential acquisition; after an upstream connection is established, do not set timeouts for any subsequent network behavior. Intentional exceptions that must remain allowed are the Codex websocket liveness deadlines in `internal/runtime/executor/codex_websockets_executor.go`, the wsrelay session deadlines in `internal/wsrelay/session.go`, the management APICall timeout in `internal/api/handlers/management/api_tools.go`, and the `cmd/fetch_antigravity_models` utility timeouts

1
backend/CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

55
backend/Dockerfile Normal file
View file

@ -0,0 +1,55 @@
FROM node:24-bookworm-slim AS frontend
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.21.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/package.json frontend/package.json
RUN pnpm install --frozen-lockfile
COPY frontend frontend
ARG VERSION=dev
RUN VERSION="${VERSION}" pnpm --dir frontend build
FROM golang:1.26-bookworm AS builder
WORKDIR /app/backend
RUN apt-get update && apt-get install -y --no-install-recommends build-essential git && rm -rf /var/lib/apt/lists/*
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend .
COPY --from=frontend /app/frontend/dist ./internal/managementasset/dist
ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=1 GOOS=linux go build -tags frontend -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/backend/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
COPY backend/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
backend/LICENSE Normal file
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.

0
backend/auths/.gitkeep Normal file
View file

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 ""
}
}

View file

@ -0,0 +1,336 @@
// 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_client_models.json")
// --client-version <ver> Codex client_version query value (default: "0.144.1")
// --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_client_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 []json.RawMessage `json:"models"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return 0, fmt.Errorf("failed to parse response JSON: %w", err)
}
// Keep this check intentionally loose: fetch_codex_models dumps the upstream
// Codex API payload. Strict CPA catalog validation belongs in
// cmd/validate_codex_models and registry.ValidateCodexClientModelsJSON.
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 ""
}
}

View file

@ -0,0 +1,48 @@
package main
import "testing"
func TestCodexModelsURL(t *testing.T) {
got, err := codexModelsURL(" 0.144.1 ")
if err != nil {
t.Fatalf("codexModelsURL: %v", err)
}
want := "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"
if got != want {
t.Fatalf("codexModelsURL = %q, want %q", got, want)
}
}
func TestCountModels(t *testing.T) {
count, err := countModels([]byte(`{"models":[{"slug":"a"},{"slug":"b"}]}`))
if err != nil {
t.Fatalf("countModels(valid): %v", err)
}
if count != 2 {
t.Fatalf("countModels(valid) = %d, want 2", count)
}
// Upstream dumps may omit CPA catalog-required fields; counting must still work.
count, err = countModels([]byte(`{"models":[{"slug":"gpt-5.6-sol"}]}`))
if err != nil {
t.Fatalf("countModels(incomplete upstream model): %v", err)
}
if count != 1 {
t.Fatalf("countModels(incomplete upstream model) = %d, want 1", count)
}
count, err = countModels([]byte(`{"models":[]}`))
if err != nil {
t.Fatalf("countModels(empty): %v", err)
}
if count != 0 {
t.Fatalf("countModels(empty) = %d, want 0", count)
}
if _, err := countModels([]byte(`{"models":`)); err == nil {
t.Fatal("countModels(malformed) error = nil, want error")
}
if _, err := countModels([]byte(`{}`)); err == nil {
t.Fatal("countModels(missing models) error = nil, want error")
}
}

832
backend/cmd/server/main.go Normal file
View file

@ -0,0 +1,832 @@
// 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/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"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
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 models.json and codex_client_models.json only, skip remote model catalog 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 homePluginStatusReady bool
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 func() {
if homeClient != nil {
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
pluginSyncCfg := *parsed
parsed.Plugins.StoreAuth = nil
var errHomePlugins error
platform := homeplugins.CurrentPlatform()
if pluginSyncCfg.Plugins.Enabled {
ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
installedVersions, errInstalledPlugins := homeplugins.InstalledVersions(&pluginSyncCfg)
if errInstalledPlugins != nil {
homePluginStatusReady = true
errHomePlugins = errInstalledPlugins
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errInstalledPlugins)
} else {
pluginSyncRequest := sdkpluginstore.PluginSyncRequest{
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
GOOS: platform.GOOS,
GOARCH: platform.GOARCH,
InstalledVersions: installedVersions,
}
pluginSyncResponse, errFetchPlugins := homeClient.GetPluginSync(ctxHomePlugins, pluginSyncRequest)
errHomePlugins = errFetchPlugins
switch {
case errHomePlugins == nil:
homePluginStatusReady = true
homePluginSyncReport, errHomePlugins = homeplugins.SyncResolvedWithReport(ctxHomePlugins, &pluginSyncCfg, pluginSyncResponse.Items, pluginSyncResponse.ExpiresAt, pluginSyncRequest.InstalledVersions, pluginHost)
case errors.Is(errHomePlugins, home.ErrPluginSyncUnsupported):
homePluginStatusReady = true
homePluginSyncReport, errHomePlugins = homeplugins.SyncWithReport(ctxHomePlugins, &pluginSyncCfg, pluginHost)
default:
homePluginStatusReady = true
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errHomePlugins)
}
pluginSyncRequest.Clear()
pluginSyncResponse.Clear()
}
cancelHomePlugins()
} else {
homePluginStatusReady = true
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, nil)
}
if errHomePlugins != nil {
log.Errorf("failed to sync plugins from home: %v", errHomePlugins)
}
if homePluginStatusReady {
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, homeCfg.NodeID, homePluginSyncReport)
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
}
// 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 && homePluginStatusReady {
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 homeClient != nil {
// The bootstrap client is not owned by the runtime service. Close it after
// the final startup report so it cannot retain an idle RESP connection.
homeClient.Close()
homeClient = nil
}
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 catalogs, remote model updates disabled")
}
if tuiMode {
if standalone {
// Standalone mode: start an embedded local server and connect TUI client to it.
misc.StartAntigravityVersionUpdater(context.Background())
startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
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
misc.StartAntigravityVersionUpdater(context.Background())
startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
}
}
}
// modelCatalogUpdaterPlan decides which remote model catalogs should refresh.
// Codex client templates still refresh under Home mode because the model list
// comes from Home IDs while template metadata stays edge-local.
func modelCatalogUpdaterPlan(localModel, homeEnabled bool) (startModels, startCodexClient bool) {
if localModel {
return false, false
}
return !homeEnabled, true
}
func startModelCatalogUpdaters(localModel, homeEnabled bool) {
startModels, startCodexClient := modelCatalogUpdaterPlan(localModel, homeEnabled)
if startCodexClient {
registry.StartCodexClientModelsUpdater(context.Background())
}
if startModels {
registry.StartModelsUpdater(context.Background())
} else if homeEnabled {
log.Info("Home mode: remote models.json updates disabled; Codex client model list follows Home model IDs")
}
}
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
}

View file

@ -0,0 +1,137 @@
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)
}
})
}
}
func TestModelCatalogUpdaterPlan(t *testing.T) {
tests := []struct {
name string
localModel bool
homeEnabled bool
wantModels bool
wantCodexClient bool
}{
{
name: "normal CPA refreshes both catalogs",
localModel: false,
homeEnabled: false,
wantModels: true,
wantCodexClient: true,
},
{
name: "home mode keeps models.json local and refreshes codex templates",
localModel: false,
homeEnabled: true,
wantModels: false,
wantCodexClient: true,
},
{
name: "local-model disables both remote catalogs",
localModel: true,
homeEnabled: false,
wantModels: false,
wantCodexClient: false,
},
{
name: "local-model disables both remote catalogs even under home",
localModel: true,
homeEnabled: true,
wantModels: false,
wantCodexClient: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotModels, gotCodex := modelCatalogUpdaterPlan(tt.localModel, tt.homeEnabled)
if gotModels != tt.wantModels || gotCodex != tt.wantCodexClient {
t.Fatalf("modelCatalogUpdaterPlan(%v, %v) = (%v, %v), want (%v, %v)",
tt.localModel, tt.homeEnabled, gotModels, gotCodex, tt.wantModels, tt.wantCodexClient)
}
})
}
}

View file

@ -0,0 +1,32 @@
// Command validate_codex_models validates a Codex client model catalog file.
package main
import (
"flag"
"fmt"
"os"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
func main() {
var inputPath string
flag.StringVar(&inputPath, "file", "", "Codex client model catalog JSON file")
flag.Parse()
if strings.TrimSpace(inputPath) == "" {
fmt.Fprintln(os.Stderr, "error: --file is required")
os.Exit(2)
}
data, err := os.ReadFile(inputPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: read %s: %v\n", inputPath, err)
os.Exit(1)
}
if err = registry.ValidateCodexClientModelsJSON(data); err != nil {
fmt.Fprintf(os.Stderr, "error: invalid Codex client model catalog %s: %v\n", inputPath, err)
os.Exit(1)
}
fmt.Printf("Validated Codex client model catalog: %s\n", inputPath)
}

16
backend/config.dev.yaml Normal file
View file

@ -0,0 +1,16 @@
host: "127.0.0.1"
port: 8317
remote-management:
allow-remote: false
secret-key: ""
disable-control-panel: false
auth-dir: ".dev/auths"
api-keys:
- "dev-api-key"
plugins:
enabled: true
dir: ".dev/plugins"

844
backend/config.example.yaml Normal file
View file

@ -0,0 +1,844 @@
# 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 HTTP routes when true.
disable-control-panel: false
# 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"
# Credential concurrency is configured by Home in Home mode. The synthesized Home config is
# authoritative and local values, including the values below, are ignored. Do not use local
# configuration to override a Home concurrency policy.
# credential-concurrency:
# lifecycle-config-revision: 1
# observation-barrier-revision: 0
# cpa-heartbeat-timeout: "3s"
# cpa-cancel-bound: "5s"
# reclaim-grace: "5s"
# cleanup-interval: "5s"
# release-flush-interval: 250ms
# release-max-backoff: 2s
# busy-retry-min: 250ms
# busy-retry-max: 1s
# max-limit: 1000000
# Credential in-flight observation snapshot contract.
# credential-in-flight:
# snapshot-interval: 2s
# stale-after: 10s
# max-part-bytes: 262144
# max-part-count: 64
# max-revision-bytes: 16777216
# max-aggregate-groups: 100000
# max-details: 10000
# max-string-bytes: 256
# staging-retention: 1m
# 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 additional credential retry rounds after the first round exhausts
# its eligible credentials. Round 0 is the initial round; round r only admits
# credentials whose effective request-retry is at least r. Explicit non-negative
# credential/provider overrides take precedence; omitted or negative overrides
# inherit this global value, and explicit 0 only admits round 0. New CPA nodes
# send retry_round=0 for the initial round and increment it for additional rounds;
# legacy dispatch methods omit the field and keep old semantics.
# Additional rounds apply to HTTP 403, 408, 429, 500, 502, 503, and 504 failures.
# Individual credential/provider overrides take precedence; 0 disables additional
# rounds, while an omitted or negative override inherits this global setting.
request-retry: 3
# Maximum number of different credentials to try in each credential retry round
# after per-credential round filtering. Set to 0 to try all available
# credentials. Credentials skipped by this cap still age with the global round,
# so the cap does not guarantee a fixed number of actual retries per credential.
max-retry-credentials: 0
# Maximum cooldown wait in seconds between retry rounds.
# Set to 0 or below to never wait for credential cooldown.
# Retry rounds that need no wait remain controlled by request-retry.
max-retry-interval: 30
# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
# A credential/provider disable-cooling value, when present, overrides this global value.
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
# Claude Code compatibility settings.
claude-code:
# When true, return original model IDs in Anthropic model list responses instead of cloaked IDs.
disable-cloaking-model-list: 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), weighted-round-robin, fill-first
# weighted-round-robin uses each credential's integer weight (default 1, maximum 1,000,000).
# Non-positive weights exclude the credential while this strategy is active.
# For OAuth/file credentials, add a top-level numeric "weight" field to the auth JSON.
# Enable universal session-sticky routing for all clients.
# Explicit Claude Code, Codex, OpenCode, and pi session headers are preferred,
# followed by prompt_cache_key, Responses conversation IDs, legacy body IDs,
# execution or derived session identity, and the existing first-message hash fallback.
# Automatic failover is always enabled when bound auth becomes unavailable.
# An established binding outranks credential priority: once a session is bound, that
# credential is kept even if a higher-priority credential recovers. Credential priority
# still decides cold bindings, requests without a session, and post-failover rebinding.
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
# Disable forcing the official Codex User-Agent and Originator headers on HTTP/SSE and WebSocket requests.
disable-codex-cloaking: false
# Hold back the initial handshake events (response.created, response.in_progress and the
# websocket metadata frames) until the upstream emits its first generated event.
# Why: the upstream smuggles `server_is_overloaded` rejections *inside* an HTTP 200 stream,
# right after those handshake events, instead of returning 503 on the wire. Buffering them
# keeps the downstream response headers uncommitted long enough to transparently retry on
# another credential. Only overload/rate-limit rejections trigger failover; every other
# terminal failure is still delivered in-stream exactly as before.
# Trade-off: response headers are delayed until generation starts, which can trip client or
# reverse-proxy read timeouts (e.g. nginx proxy_read_timeout) on long reasoning requests.
# Default: false
stream-bootstrap-buffering: false
# When true, optimize Codex Desktop, codex-tui, and codex_cli_rs requests for multi-agent v2.
# This refreshes Codex spawn_agent model details, removes message parameter encryption,
# normalizes encrypted agent_message content for Codex, and converts agent_message input
# into standard user messages for non-Codex upstream protocols.
optimize-multi-agent-v2: false
# Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process.
# This requires inbound UDP reachability. Keep disabled to preserve direct media behavior.
live-media-relay:
enabled: false
# Maximum concurrent media sessions. Zero uses the default of 32.
max-sessions: 32
# Reject downstream SDP candidates that target private, loopback, link-local, or unspecified IPs.
# Keep false for local or trusted-network Codex Desktop connections.
disable-private-remote-ips: false
# Public IPv4 or IPv6 address advertised when CPA is behind 1:1 NAT.
public-ip: ""
# Optional UDP allocation range. Both values must be set together and provide at least two ports per session.
udp-port-min: 0
udp-port-max: 0
# Optional STUN/TURN servers. TURN credentials are never returned by the JSON config API.
# Without a concrete global/per-auth proxy-url, WebRTC uses normal direct ICE/STUN/TURN connectivity.
# With http, https, socks5, or socks5h proxy-url, the OpenAI-facing leg is forced through
# authenticated ICE-TCP over that proxy and never falls back to UDP or a direct connection.
# The Codex Desktop-facing leg remains direct, and configured ICE servers still apply to it.
# ice-servers:
# - urls:
# - "stun:stun.example.com:3478"
# - urls:
# - "turn:turn.example.com:3478?transport=udp"
# username: "user"
# credential: "secret"
# Antigravity provider behavior.
# antigravity:
# sensitive-words: # optional: words to obfuscate with zero-width characters in system instructions
# - "API"
# - "proxy"
# xAI provider behavior.
xai:
# When true, inject the native x_search tool when the request does not declare it.
# The injected tool is also added to tool_choice.allowed_tools when applicable.
inject-x-search: 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"
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
# - status: 400 # HTTP status code to match
# match: # optional: string contains matching
# - "maximum_context_length"
# - "context_length_exceeded"
# match-regexr: # optional: regular expression matching
# - "maximum_context_length$"
# - "^context_length_exceeded"
# action: "stop" # "stop" (return error, no cooling), "stop-and-cooldown" (return error and cool down),
# # "continue" (try next credential, no cooling), "continue-and-cooldown" (try next credential and cool down)
# base-url: "https://generativelanguage.googleapis.com"
# headers:
# X-Custom-Header: "custom-value"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# 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
# max-context-length: 1048576 # optional: override Codex client context window metadata
# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
# thinking: # optional: exact thinking capability for this configured model
# levels: ["high", "medium", "low", "none", "auto"]
# 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"
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# prefix: "native" # optional: require calls like "native/gemini-3-pro-preview" to target this credential
# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
# - status: 400
# match:
# - "invalid_argument"
# action: "continue"
# base-url: "https://generativelanguage.googleapis.com"
# headers:
# X-Custom-Header: "custom-value"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# 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
# max-context-length: 1048576 # optional: override Codex client context window metadata
# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
# thinking: # optional: exact thinking capability for this configured model
# levels: ["high", "medium", "low", "none", "auto"]
# excluded-models:
# - "gemini-2.5-pro"
# Codex API keys
# codex-api-key:
# - api-key: "sk-atSM..."
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
# - status: 400
# match:
# - "context_window_exceeded"
# action: "stop-and-cooldown"
# base-url: "https://www.example.com" # use the custom codex API endpoint
# alpha-search: false # optional: allow this key to serve /v1/alpha/search via base-url + /alpha/search
# headers:
# X-Custom-Header: "custom-value"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# 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
# max-context-length: 1048576 # optional: override Codex client context window metadata
# force-mapping: true # optional: rewrite response model fields back to the alias
# # When true and codex.optimize-multi-agent-v2 is also true, convert Codex
# # MultiAgentV2 agent_message items into portable Responses message/user input
# # for third-party Responses-compatible endpoints that reject agent_message.
# # Default false keeps agent_message unchanged for native OpenAI/Codex endpoints.
# # It also preserves thinking blocks with empty signatures for compatible upstreams.
# is-compat: false
# thinking: # optional: exact thinking capability for this configured model
# levels: ["xhigh", "high", "medium", "low"]
# 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)
# xAI API keys
# Uses the native xAI executor, including its Responses namespace-tool handling.
# xai-api-key:
# - api-key: "xai-..."
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# prefix: "xai" # optional: require calls like "xai/grok-4.5" to target this credential
# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
# - status: 400
# match:
# - "rate_limit_exceeded"
# action: "continue-and-cooldown"
# base-url: "https://api.x.ai/v1" # xAI-compatible Responses API endpoint
# websockets: true # optional: use the xAI upstream websocket transport for downstream websocket requests
# headers:
# X-Custom-Header: "custom-value"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# models:
# - name: "grok-4.5" # upstream model name
# alias: "grok-latest" # client alias mapped to the upstream model
# display-name: "Grok Latest" # optional catalog display name
# max-context-length: 1048576 # optional: override Codex client context window metadata
# force-mapping: true # optional: rewrite response model fields back to the alias
# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
# thinking: # optional: exact thinking capability for this configured model
# levels: ["xhigh", "high", "medium", "low"]
# excluded-models:
# - "grok-4.1" # exclude specific models (exact match)
# - "grok-3-*" # wildcard matching prefix
# 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..."
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
# - status: 400
# match:
# - "prompt is too long"
# action: "stop"
# base-url: "https://www.example.com" # use the custom claude API endpoint
# headers:
# X-Custom-Header: "custom-value"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# 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
# max-context-length: 1048576 # optional: override Codex client context window metadata
# force-mapping: true # optional: rewrite response model fields back to the alias
# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
# thinking: # optional: exact thinking capability for this configured model
# levels: ["max", "xhigh", "high", "medium", "low", "minimal", "none", "auto"]
# 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: explicitly enable request cloaking for non-Claude-Code clients
# mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code
# # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through
# # "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): legacy-model whitelist uses a user system-reminder;
# # all other and future models use messages[].role=system
# # true: strip caller prompts and keep only Claude Code billing and identity blocks
# 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
# # Every custom tool on a cloaked OAuth request automatically uses a caller-stable opaque mcp__<server>__<tool> alias.
#
# # fingerprint-profile (optional, top-level on this claude-api-key entry; not a cloak sub-field):
# # OAuth and API-key fingerprints are different contracts.
# # - Real Claude OAuth stays on the strict Claude Code CLI wire fingerprint.
# # - API keys (official Anthropic, custom gateways, Kimi) stay loose and
# # caller-owned unless this field is set.
# #
# # Default (omit / empty): keep the caller request fingerprint and headers.
# # Official api.anthropic.com API keys do not add extra CLI betas/identity unless
# # this field is set. Custom gateways and delegated providers are the same.
# #
# # Controls request fingerprint only on /v1/messages (and related Claude executor paths).
# # Auth scheme stays API key (x-api-key on api.anthropic.com; Bearer on custom base-url).
# # Does NOT enable OAuth refresh, profile fetch, or OAuth-cancellation semantics.
# #
# # Values:
# # omit / empty = caller-owned API-key fingerprint (respects caller)
# # "claude-code-cli" = same Messages fingerprint as Claude Code OAuth CLI,
# # including official Anthropic API keys: OAuth Anthropic-Beta
# # set, CCH signing on api.anthropic.com, stable CLI
# # metadata.user_id / session_id / device identity.
# # API keys seed identity from the key;
# # delegated OAuth providers use stable auth ID instead of
# # rotating access tokens. "oauth-cli" is a legacy alias.
# #
# # count_tokens keeps the native model/messages/tools shape for every origin, including
# # Kimi opt-in. It does not send billing/CCH, currentDate, metadata, or diagnostics.
# #
# # CCH: the billing block may carry a per-request cch hash. CPA emits it exactly where
# # Claude Code does, which is api.anthropic.com (first-party) and Vertex only. An opt-in
# # on any other gateway (including Kimi) still sends the billing block, but without cch,
# # so a per-request hash cannot bust that gateway's prompt cache. api.anthropic.com
# # strips the block itself (0 tokens, no cache impact). Kimi drops the whole block by
# # default and keeps it, unsigned, after an explicit fingerprint opt-in.
# # A real Claude OAuth credential always signs, on every upstream: a downstream Claude
# # Code pointed at CPA cannot produce that value itself.
# #
# # Example (official Anthropic or a custom Messages gateway):
# # - api-key: "your-key"
# # # base-url: "https://gateway.example" # omit for api.anthropic.com
# # fingerprint-profile: "claude-code-cli"
# # cloak:
# # mode: "always" # recommended when upstream rejects non-CLI clients
# #
# # Delegated Anthropic Messages OAuth files (Kimi, etc.) use "fingerprint_profile"
# # in the auth JSON. Refresh keeps it. Example:
# # {
# # "type": "kimi",
# # "access_token": "...",
# # "refresh_token": "...",
# # "fingerprint_profile": "claude-code-cli"
# # }
# # Legacy "fingerprint-profile" credentials remain supported and are normalized at load time.
# # fingerprint-profile: "claude-code-cli" # optional claude-api-key provider field; default is empty (caller-owned); uncomment to opt in
# experimental-cch-signing: false # deprecated compatibility field; CCH is generated automatically
# # for real Claude OAuth on any upstream, and for claude-code-cli profiles
# # only on api.anthropic.com; Vertex keeps provider-native signing
# Anthropic-Beta is assembled per request rather than sent as a fixed list, matching
# Claude Code 2.1.220: context-1m sits right after claude-code, mid-conversation-system
# is added only for models that accept a role=system turn, advanced-tool-use only when
# the request declares tools, and server-side-fallback / fallback-credit /
# structured-outputs trail effort. On direct api.anthropic.com a caller may only ask for
# betas real Claude Code also sends, and they are placed at their observed positions;
# anything else is dropped so the outgoing set stays one a real client could produce.
# Other Anthropic-compatible upstreams still forward caller betas verbatim.
#
# Default headers for Claude API requests. Update only after measuring a new Claude Code release.
# Unconfirmed clients use this CLI baseline. Verified native Claude Code CLI, sdk-cli,
# and VSCode requests preserve their measured entrypoint and software shape only when the
# Claude Code version, package version, and runtime version exactly match this configured
# baseline; unmeasured versions fall back to it. In legacy mode, timeout is a fallback and
# verified native OS/arch values remain client-supplied. When stabilize-device-profile is
# enabled, OS/arch are pinned to the values below and cached profiles remain constrained to
# the same exact software baseline rather than learning newer client versions.
# claude-header-defaults:
# user-agent: "claude-cli/2.1.220 (external, cli)"
# package-version: "0.94.0"
# runtime-version: "v26.3.0"
# os: "MacOS"
# arch: "arm64"
# timeout: "600"
# timezone: "Asia/Singapore" # fallback IANA timezone for cloaked currentDate; a credential JSON "timezone" takes priority
# 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.
# support-prompt-cache-key: false # optional: derive prompt_cache_key for requests from all input protocols
# disable-cooling: false # optional provider override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-provider override; 0 disables additional rounds; omit or set < 0 to inherit global
# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
# - status: 400
# match:
# - "maximum_context_length"
# - "context_length_exceeded"
# match-regexr:
# - "maximum_context_length$"
# - "^context_length_exceeded"
# action: "stop" # "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
# headers:
# X-Custom-Header: "custom-value"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# api-key-entries:
# - api-key: "sk-or-v1-...b780"
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# 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
# max-context-length: 1048576 # optional: override Codex client context window metadata
# 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. Use [text] for upstreams that reject multimodal tool result content.
# output-modalities: [text] # optional: declare output modalities when known
# is-compat: false # optional: preserve Claude thinking blocks for compatible upstreams
# 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
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
# 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"
# # Values starting with "$" dynamically copy the header value from downstream client requests.
# # If the client did not send the specified header, the header is omitted.
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# 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
# thinking: # optional: exact thinking capability for this configured model
# levels: ["high", "medium", "low", "none", "auto"]
# - 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, xai-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 fields:
# fork: true # keep the upstream model and also expose the alias as a separate client-visible model
# display-name: "Model Name" # override the human-readable name shown in model catalogs
# force-mapping: true # 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".
# Legacy "model-aliases" credentials remain supported and are normalized at load time.
# 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)
# display-name: "Antigravity Gemini 3.1 Pro" # optional catalog display name
# 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"
# OAuth provider request-scoped error rules (custom error classification for OAuth credentials)
# oauth-request-scoped-errors:
# vertex:
# - status: 400
# match:
# - "maximum_context_length"
# - "context_length_exceeded"
# match-regexr:
# - "maximum_context_length$"
# - "^context_length_exceeded"
# action: "stop" # options: "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
# aistudio:
# - status: 400
# match:
# - "invalid_argument"
# action: "stop"
# antigravity:
# - status: 500
# match:
# - "internal_server_error"
# action: "stop-and-cooldown"
# claude:
# - status: 400
# match:
# - "prompt is too long"
# action: "stop"
# codex:
# - status: 400
# match:
# - "context_window_exceeded"
# action: "stop"
# kimi:
# - status: 400
# match:
# - "length_limit"
# action: "stop"
# xai:
# - status: 400
# match:
# - "max_tokens_exceeded"
# action: "stop"
# 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"

54
backend/docker-build.ps1 Normal file
View file

@ -0,0 +1,54 @@
# 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"
$compose = @("compose", "--project-directory", $PSScriptRoot, "-f", (Join-Path $PSScriptRoot "docker-compose.yml"))
# --- 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
}
}

66
backend/docker-build.sh Normal file
View file

@ -0,0 +1,66 @@
#!/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
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE=(docker compose --project-directory "${SCRIPT_DIR}" -f "${SCRIPT_DIR}/docker-compose.yml")
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 ---"
"${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..."
"${COMPOSE[@]}" build \
--build-arg VERSION="${VERSION}" \
--build-arg COMMIT="${COMMIT}" \
--build-arg BUILD_DATE="${BUILD_DATE}"
echo "Starting the services..."
"${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

View file

@ -0,0 +1,30 @@
services:
cli-proxy-api:
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
pull_policy: always
build:
context: ..
dockerfile: backend/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:
- ${CLI_PROXY_HOME_PATH:-./home}:/root/.cli-proxy-api
- ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
- ${CLI_PROXY_PLUGIN_PATH:-./plugins}:/CLIProxyAPI/plugins
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

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: backend/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
- ${CLI_PROXY_PLUGIN_PATH:-./plugins}:/CLIProxyAPI/plugins
restart: unless-stopped

154
backend/docs/sdk-access.md Normal file
View file

@ -0,0 +1,154 @@
# @sdk/access SDK Reference
The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime.
## Importing
```go
import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
)
```
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
## Provider Registry
Providers are registered globally and then attached to a `Manager` as a snapshot:
- `RegisterProvider(type, provider)` installs a pre-initialized provider instance.
- Registration order is preserved the first time each `type` is seen.
- `RegisteredProviders()` returns the providers in that order.
## Manager Lifecycle
```go
manager := sdkaccess.NewManager()
manager.SetProviders(sdkaccess.RegisteredProviders())
```
* `NewManager` constructs an empty manager.
* `SetProviders` replaces the provider slice using a defensive copy.
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
If the manager itself is `nil` or no providers are configured, the call returns `nil, nil`, allowing callers to treat access control as disabled.
## Authenticating Requests
```go
result, authErr := manager.Authenticate(ctx, req)
switch {
case authErr == nil:
// Authentication succeeded; result describes the provider and principal.
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
// No recognizable credentials were supplied.
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
// Supplied credentials were present but rejected.
default:
// Internal/transport failure was returned by a provider.
}
```
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that return `AuthErrorCodeNotHandled`, and aggregates `AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` for a final result.
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
## Built-in `config-api-key` Provider
The proxy includes one built-in access provider:
- `config-api-key`: Validates API keys declared under top-level `api-keys`.
- Credential sources: `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, `?key=`, `?auth_token=`
- Metadata: `Result.Metadata["source"]` is set to the matched source label.
In the CLI server and `sdk/cliproxy`, this provider is registered automatically based on the loaded configuration.
```yaml
api-keys:
- sk-test-123
- sk-prod-456
```
## Loading Providers from External Go Modules
To consume a provider shipped in another Go module, import it for its registration side effect:
```go
import (
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
)
```
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before you call `RegisteredProviders()` (or before `cliproxy.NewBuilder().Build()`).
### Metadata and auditing
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, `query-key`, `query-auth-token`). Populate this map in custom providers to enrich logs and downstream auditing.
## Writing Custom Providers
```go
type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
token := r.Header.Get("X-Custom")
if token == "" {
return nil, sdkaccess.NewNotHandledError()
}
if token != "expected" {
return nil, sdkaccess.NewInvalidCredentialError()
}
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: "service-user",
Metadata: map[string]string{"source": "x-custom"},
}, nil
}
func init() {
sdkaccess.RegisterProvider("custom", &customProvider{})
}
```
A provider must implement `Identifier()` and `Authenticate()`. To make it available to the access manager, call `RegisterProvider` inside `init` with an initialized provider instance.
## Error Semantics
- `NewNoCredentialsError()` (`AuthErrorCodeNoCredentials`): no credentials were present or recognized. (HTTP 401)
- `NewInvalidCredentialError()` (`AuthErrorCodeInvalidCredential`): credentials were present but rejected. (HTTP 401)
- `NewNotHandledError()` (`AuthErrorCodeNotHandled`): fall through to the next provider.
- `NewInternalAuthError(message, cause)` (`AuthErrorCodeInternal`): transport/system failure. (HTTP 500)
Errors propagate immediately to the caller unless they are classified as `not_handled` / `no_credentials` / `invalid_credential` and can be aggregated by the manager.
## Integration with cliproxy Service
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a manager lets you reuse the same instance in your host process:
```go
coreCfg, _ := config.LoadConfig("config.yaml")
accessManager := sdkaccess.NewManager()
svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg).
WithConfigPath("config.yaml").
WithRequestAccessManager(accessManager).
Build()
```
Register any custom providers (typically via blank imports) before calling `Build()` so they are present in the global registry snapshot.
### Hot reloading
When configuration changes, refresh any config-backed providers and then reset the manager's provider chain:
```go
// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
configaccess.Register(&newCfg.SDKConfig)
accessManager.SetProviders(sdkaccess.RegisteredProviders())
```
This mirrors the behaviour in `internal/access.ApplyAccessProviders`, enabling runtime updates without restarting the process.

View file

@ -0,0 +1,154 @@
# @sdk/access 开发指引
`github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。
## 引用方式
```go
import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
)
```
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
## Provider Registry
访问提供者是全局注册,然后以快照形式挂到 `Manager` 上:
- `RegisterProvider(type, provider)` 注册一个已经初始化好的 provider 实例。
- 每个 `type` 第一次出现时会记录其注册顺序。
- `RegisteredProviders()` 会按该顺序返回 provider 列表。
## 管理器生命周期
```go
manager := sdkaccess.NewManager()
manager.SetProviders(sdkaccess.RegisteredProviders())
```
- `NewManager` 创建空管理器。
- `SetProviders` 替换提供者切片并做防御性拷贝。
- `Providers` 返回适合并发读取的快照。
如果管理器本身为 `nil` 或未配置任何 provider调用会返回 `nil, nil`,可视为关闭访问控制。
## 认证请求
```go
result, authErr := manager.Authenticate(ctx, req)
switch {
case authErr == nil:
// Authentication succeeded; result carries provider and principal.
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
// No recognizable credentials were supplied.
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
// Credentials were present but rejected.
default:
// Provider surfaced a transport-level failure.
}
```
`Manager.Authenticate` 会按顺序遍历 provider遇到成功立即返回`AuthErrorCodeNotHandled` 会继续尝试下一个;`AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` 会在遍历结束后汇总给调用方。
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
## 内建 `config-api-key` Provider
代理内置一个访问提供者:
- `config-api-key`:校验 `config.yaml` 顶层的 `api-keys`
- 凭证来源:`Authorization: Bearer``X-Goog-Api-Key``X-Api-Key``?key=``?auth_token=`
- 元数据:`Result.Metadata["source"]` 会写入匹配到的来源标识
在 CLI 服务端与 `sdk/cliproxy` 中,该 provider 会根据加载到的配置自动注册。
```yaml
api-keys:
- sk-test-123
- sk-prod-456
```
## 引入外部 Go 模块提供者
若要消费其它 Go 模块输出的访问提供者,直接用空白标识符导入以触发其 `init` 注册即可:
```go
import (
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
)
```
空白导入可确保 `init` 先执行,从而在你调用 `RegisteredProviders()`(或 `cliproxy.NewBuilder().Build()`)之前完成 `sdkaccess.RegisterProvider`
### 元数据与审计
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization``x-goog-api-key``x-api-key``query-key``query-auth-token`)。自定义提供者同样可以填充该 Map以便丰富日志与审计场景。
## 编写自定义提供者
```go
type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
token := r.Header.Get("X-Custom")
if token == "" {
return nil, sdkaccess.NewNotHandledError()
}
if token != "expected" {
return nil, sdkaccess.NewInvalidCredentialError()
}
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: "service-user",
Metadata: map[string]string{"source": "x-custom"},
}, nil
}
func init() {
sdkaccess.RegisterProvider("custom", &customProvider{})
}
```
自定义提供者需要实现 `Identifier()``Authenticate()`。在 `init` 中用已初始化实例调用 `RegisterProvider` 注册到全局 registry。
## 错误语义
- `NewNoCredentialsError()``AuthErrorCodeNoCredentials`未提供或未识别到凭证。HTTP 401
- `NewInvalidCredentialError()``AuthErrorCodeInvalidCredential`凭证存在但校验失败。HTTP 401
- `NewNotHandledError()``AuthErrorCodeNotHandled`):告诉管理器跳到下一个 provider。
- `NewInternalAuthError(message, cause)``AuthErrorCodeInternal`):网络/系统错误。HTTP 500
除可汇总的 `not_handled` / `no_credentials` / `invalid_credential` 外,其它错误会立即冒泡返回。
## 与 cliproxy 集成
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果希望在宿主进程里复用同一个 `Manager` 实例,可传入自定义管理器:
```go
coreCfg, _ := config.LoadConfig("config.yaml")
accessManager := sdkaccess.NewManager()
svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg).
WithConfigPath("config.yaml").
WithRequestAccessManager(accessManager).
Build()
```
请在调用 `Build()` 之前完成自定义 provider 的注册(通常通过空白导入触发 `init`),以确保它们被包含在全局 registry 的快照中。
### 动态热更新提供者
当配置发生变化时,刷新依赖配置的 provider然后重置 manager 的 provider 链:
```go
// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
configaccess.Register(&newCfg.SDKConfig)
accessManager.SetProviders(sdkaccess.RegisteredProviders())
```
这一流程与 `internal/access.ApplyAccessProviders` 保持一致,避免为更新访问策略而重启进程。

View file

@ -0,0 +1,138 @@
# SDK Advanced: Executors & Translators
This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
- Implement a provider executor that talks to your upstream API
- Register request/response translators for schema conversion
- Register models so they appear in `/v1/models`
The examples use Go 1.24+ and the v6 module path.
## Concepts
- Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests.
- Translator registry: schema conversion functions routed by `sdk/translator`. The builtin handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones.
- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
## 1) Implement a Provider Executor
Create a type that satisfies `auth.ProviderExecutor`.
```go
package myprov
import (
"context"
"net/http"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
type Executor struct{}
func (Executor) Identifier() string { return "myprov" }
// Optional: mutate outbound HTTP requests with credentials
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
// Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
return nil
}
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
// Build HTTP request based on req.Payload (already translated into provider format)
// Use perauth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
// Perform call and return provider JSON payload
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
}
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
ch := make(chan clipexec.StreamChunk, 1)
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
return ch, nil
}
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
// Optionally refresh tokens and return updated auth
return a, nil
}
```
Register the executor with the core manager before starting the service:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.RegisterExecutor(myprov.Executor{})
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
```
If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
## 2) Register Translators
The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`s default registry.
Direction matters:
- Request: register from inbound schema to provider schema
- Response: register from provider schema back to inbound schema
Example: Convert OpenAI Chat → MyProv Chat and back.
```go
package myprov
import (
"context"
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
const (
FOpenAI = sdktr.Format("openai.chat")
FMyProv = sdktr.Format("myprov.chat")
)
func init() {
sdktr.Register(FOpenAI, FMyProv,
// Request transform (model, rawJSON, stream)
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
// Response transform (stream & nonstream)
sdktr.ResponseTransform{
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
},
)
}
```
When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
## 3) Register Models
Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
```go
models := []*cliproxy.ModelInfo{
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
}
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
```
The embedded server calls this automatically for builtin providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks.
## Credentials & Transports
- Use `Manager.SetRoundTripperProvider` to inject perauth `*http.Transport` (e.g., proxy):
```go
core.SetRoundTripperProvider(myProvider) // returns transport per auth
```
- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
## Testing Tips
- Enable request logging: Management API GET/PUT `/v0/management/request-log`
- Toggle debug logs: Management API GET/PUT `/v0/management/debug`
- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher

View file

@ -0,0 +1,131 @@
# SDK 高级指南:执行器与翻译器
本文介绍如何使用 SDK 扩展内嵌代理:
- 实现自定义 Provider 执行器以调用你的上游 API
- 注册请求/响应翻译器进行协议转换
- 注册模型以出现在 `/v1/models`
示例基于 Go 1.24+ 与 v6 模块路径。
## 概念
- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key`gemini``claude``codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
## 1) 实现 Provider 执行器
创建类型满足 `auth.ProviderExecutor` 接口。
```go
package myprov
import (
"context"
"net/http"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
type Executor struct{}
func (Executor) Identifier() string { return "myprov" }
// 可选:在原始 HTTP 请求上注入凭据
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
// 例如req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
return nil
}
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
// 基于 req.Payload 构造上游请求,返回上游 JSON 负载
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
}
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
ch := make(chan clipexec.StreamChunk, 1)
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
return ch, nil
}
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
```
在启动服务前将执行器注册到核心管理器:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.RegisterExecutor(myprov.Executor{})
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
```
当凭据的 `Provider``"myprov"` 时,管理器会将请求路由到你的执行器。
## 2) 注册翻译器
内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
方向很重要:
- 请求从“入站格式”转换为“provider 格式”
- 响应从“provider 格式”转换回“入站格式”
示例OpenAI Chat → MyProv Chat 及其反向。
```go
package myprov
import (
"context"
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
const (
FOpenAI = sdktr.Format("openai.chat")
FMyProv = sdktr.Format("myprov.chat")
)
func init() {
sdktr.Register(FOpenAI, FMyProv,
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
sdktr.ResponseTransform{
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
},
)
}
```
当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
## 3) 注册模型
通过全局模型注册表将模型暴露到 `/v1/models`
```go
models := []*cliproxy.ModelInfo{
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
}
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
```
内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
## 凭据与传输
- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
```go
core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
```
- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
## 测试建议
- 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
- 切换调试日志:管理 API GET/PUT `/v0/management/debug`
- 热更新:`config.yaml``auths/` 变化会自动被侦测并应用

163
backend/docs/sdk-usage.md Normal file
View file

@ -0,0 +1,163 @@
# CLI Proxy SDK Guide
The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hotreload, and translation layers without depending on the CLI binary.
## Install & Import
```bash
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
```
```go
import (
"context"
"errors"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
)
```
Note the `/v6` module path.
## Minimal Embed
```go
cfg, err := config.LoadConfig("config.yaml")
if err != nil { panic(err) }
svc, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml"). // absolute or working-dir relative
Build()
if err != nil { panic(err) }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
panic(err)
}
```
The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
## Server Options (middleware, routes, logs)
The server accepts options via `WithServerOptions`:
```go
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithServerOptions(
// Add global middleware
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
// Tweak gin engine early (CORS, trusted proxies, etc.)
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
// Add your own routes after defaults
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
}),
// Override request log writer/dir
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
}),
).
Build()
```
These options mirror the internals used by the CLI server.
## Management API (when embedded)
- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
- Remote access additionally requires `remote-management.allow-remote: true`.
- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
## Using the Core Auth Manager
The service uses a core `auth.Manager` for selection, execution, and autorefresh. When embedding, you can provide your own manager to customize transports or hooks:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.SetRoundTripperProvider(myRTProvider) // perauth *http.Transport
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithCoreAuthManager(core).
Build()
```
Implement a custom perauth transport:
```go
type myRTProvider struct{}
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
if a == nil || a.ProxyURL == "" { return nil }
u, _ := url.Parse(a.ProxyURL)
return &http.Transport{ Proxy: http.ProxyURL(u) }
}
```
Programmatic execution is available on the manager:
```go
// Nonstreaming
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
// Streaming
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
for ch := range chunks { /* ... */ }
```
Note: Builtin provider executors are wired automatically when you run the `Service`. If you want to use `Manager` standalone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`.
## Custom Client Sources
Replace the default loaders if your creds live outside the local filesystem:
```go
type memoryTokenProvider struct{}
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
// Populate from memory/remote store and return counts
return &cliproxy.TokenClientResult{}, nil
}
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithTokenClientProvider(&memoryTokenProvider{}).
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
Build()
```
## Hooks
Observe lifecycle without patching internals:
```go
hooks := cliproxy.Hooks{
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
}
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
```
## Shutdown
`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Shutdown(ctx)
```
## Notes
- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
- Request logging can be toggled at runtime via the Management API.
- Gemini Web features (`gemini-web.*`) are honored in the embedded server.

View file

@ -0,0 +1,164 @@
# CLI Proxy SDK 使用指南
`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
## 安装与导入
```bash
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
```
```go
import (
"context"
"errors"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
)
```
注意模块路径包含 `/v6`
## 最小可用示例
```go
cfg, err := config.LoadConfig("config.yaml")
if err != nil { panic(err) }
svc, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
Build()
if err != nil { panic(err) }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
panic(err)
}
```
服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
## 服务器可选项(中间件、路由、日志)
通过 `WithServerOptions` 自定义:
```go
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithServerOptions(
// 追加全局中间件
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
// 提前调整 gin 引擎(如 CORS、trusted proxies
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
// 在默认路由之后追加自定义路由
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
}),
// 覆盖请求日志的创建(启用/目录)
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
}),
).
Build()
```
这些选项与 CLI 服务器内部用法保持一致。
## 管理 API内嵌时
- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
- 远程访问还需要 `remote-management.allow-remote: true`
- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`
## 使用核心鉴权管理器
服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithCoreAuthManager(core).
Build()
```
实现每个账户的自定义传输:
```go
type myRTProvider struct{}
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
if a == nil || a.ProxyURL == "" { return nil }
u, _ := url.Parse(a.ProxyURL)
return &http.Transport{ Proxy: http.ProxyURL(u) }
}
```
管理器提供编程式执行接口:
```go
// 非流式
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
// 流式
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
for ch := range chunks { /* ... */ }
```
说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
## 自定义凭据来源
当凭据不在本地文件系统时,替换默认加载器:
```go
type memoryTokenProvider struct{}
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
// 从内存/远端加载并返回数量统计
return &cliproxy.TokenClientResult{}, nil
}
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithTokenClientProvider(&memoryTokenProvider{}).
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
Build()
```
## 启动钩子
无需修改内部代码即可观察生命周期:
```go
hooks := cliproxy.Hooks{
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
}
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
```
## 关闭
`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Shutdown(ctx)
```
## 说明
- 热更新:`config.yaml``auths/` 变化会被自动侦测并应用。
- 请求日志可通过管理 API 在运行时开关。
- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。

View file

@ -0,0 +1,32 @@
# SDK Watcher Integration
The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled.
## Update Queue Contract
- `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`.
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts.
- The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop.
## Watcher Behaviour
- `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals.
- Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream.
- The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly.
## High-Frequency Change Handling
- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
- Back-pressure is absorbed in two places:
- The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
- The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
- If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states.
## Usage Checklist
1. Instantiate the SDK service (builder or manual construction).
2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`.
Following this flow keeps auth changes responsive while avoiding full reloads for every edit.

View file

@ -0,0 +1,32 @@
# SDK Watcher集成说明
本文档介绍SDK服务与文件监控器之间的增量更新队列包括接口契约、高频变更下的处理策略以及接入步骤。
## 更新队列契约
- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add``modify``delete``ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher必须在watcher启动前完成。
- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道并在`consumeAuthUpdates`中使用专职goroutine消费消费侧会主动“抽干”积压事件降低切换开销。
## Watcher行为
- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
- 以凭据ID为维度对更新进行合并同一凭据在短时间内的多次变更只会保留最新状态例如先写后删只会下发`delete`)。
- watcher内部运行异步分发循环生产者只向内存缓冲追加事件并唤醒分发协程即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环确保协程正常退出。
## 高频变更处理
- 分发循环与服务消费协程相互独立因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
- 背压通过两级缓冲吸收:
- 分发缓冲map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
- 服务端通道的256容量加上消费侧的“抽干”逻辑可平稳处理多个突发批次。
- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
## 接入步骤
1. 实例化SDK Service构建器或手工创建
2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道然后再启动watcher。
4. Reload回调专注于配置更新认证增量会通过队列送达并由`handleAuthUpdate`自动应用。
遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。

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
}

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)
}

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)" "$@"

View file

@ -0,0 +1,123 @@
# Standard Dynamic Library Plugin Examples
This directory contains standard dynamic library plugin examples for the CLIProxyAPI C ABI.
## Layout
- `simple/`: 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.
- `request-lifecycle/`: Go-only request admission example with concurrency control, active HTTP termination, and terminal callbacks.
- `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
```
## Request Lifecycle
`request-lifecycle` combines `request_interceptor` with `request_lifecycle_plugin`. It acquires a concurrency slot before auth selection, can return a custom `403` or `429` response without contacting an upstream model, and releases admitted slots from `request.complete` on success, failure, rejection, or cancellation.
```yaml
plugins:
configs:
request-lifecycle:
enabled: true
priority: 100
max_concurrency: 2
reject_keyword: "blocked"
```
See `request-lifecycle/README.md` for build instructions and lifecycle semantics.
## 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/...`.

View file

@ -0,0 +1,122 @@
# 标准动态库插件示例
本目录包含 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。
- `request-lifecycle/`:仅 Go 实现的请求生命周期插件,演示并发控制、主动终止 HTTP 请求和终态回调。
- `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
```
## 请求生命周期
`request-lifecycle` 同时声明 `request_interceptor``request_lifecycle_plugin`。它会在认证选择前占用并发槽位,可以直接返回自定义 `403``429` 响应而不请求上游模型,并在成功、失败、拒绝或取消时通过 `request.complete` 释放已接入请求的槽位。
```yaml
plugins:
configs:
request-lifecycle:
enabled: true
priority: 100
max_concurrency: 2
reject_keyword: "blocked"
```
构建方式和生命周期语义详见 `request-lifecycle/README.md`
## 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/...` 下。

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 ""
)

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;
}

View file

@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/auth/go
go 1.26

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)
}
}

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"

View file

@ -0,0 +1,7 @@
[package]
name = "cliproxy-auth-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]

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);
}
}
}
}

View file

@ -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.

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"}},
})
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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",
}
}

View file

@ -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
}

View file

@ -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.47.0 // indirect
)
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..

View file

@ -0,0 +1,25 @@
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=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=

View file

@ -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))
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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] + "..."
}

View file

@ -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)
}
}

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 ""
)

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;
}

View file

@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/cli/go
go 1.26

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)
}
}

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"

View file

@ -0,0 +1,7 @@
[package]
name = "cliproxy-cli-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]

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);
}
}
}
}

View file

@ -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.

View file

@ -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 => ../../../..

View file

@ -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=

View file

@ -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))
}

View file

@ -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 ""
)

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;
}

View file

@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/executor/go
go 1.26

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)
}
}

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"

View file

@ -0,0 +1,7 @@
[package]
name = "cliproxy-executor-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]

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);
}
}
}
}

View file

@ -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 .
```

View file

@ -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 => ../../../..

View file

@ -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))
}

View file

@ -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 ""
)

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-frontend-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}");
return 0;
}
if (strcmp(method, "plugin.reconfigure") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}");
return 0;
}
if (strcmp(method, "frontend_auth.identifier") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-frontend-auth-c\"}}");
return 0;
}
if (strcmp(method, "frontend_auth.authenticate") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"Authenticated\":true,\"Principal\":\"example-frontend-auth-c\",\"Metadata\":{\"provider\":\"example-frontend-auth-c\"}}}");
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;
}

View file

@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth/go
go 1.26

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-frontend-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}")
case "plugin.reconfigure":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}")
case "frontend_auth.identifier":
return okEnvelopeJSON("{\"identifier\":\"example-frontend-auth-go\"}")
case "frontend_auth.authenticate":
return okEnvelopeJSON("{\"Authenticated\":true,\"Principal\":\"example-frontend-auth-go\",\"Metadata\":{\"provider\":\"example-frontend-auth-go\"}}")
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)
}
}

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-frontend-auth-rust"
version = "0.1.0"

View file

@ -0,0 +1,7 @@
[package]
name = "cliproxy-frontend-auth-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]

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-frontend-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}"); 0 },"frontend_auth.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-frontend-auth-rust\"}}"); 0 },"frontend_auth.authenticate" => { write_response(response, "{\"ok\":true,\"result\":{\"Authenticated\":true,\"Principal\":\"example-frontend-auth-rust\",\"Metadata\":{\"provider\":\"example-frontend-auth-rust\"}}}"); 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);
}
}
}
}

View file

@ -0,0 +1,89 @@
# Host Callback Auth Files Plugin
This Go-only plugin demonstrates how a plugin-owned browser resource can call the host auth file callbacks:
- `host.auth.list`
- `host.auth.get`
- `host.auth.get_runtime`
- `host.auth.save`
## Purpose and Scope
The plugin registers a Management API resource named `Host Auth Files` at `/status`. CPA exposes it under:
```text
/v0/resource/plugins/host-callback-auth-files/status
```
The resource reads URL query parameters, calls the host auth callbacks, and renders the result in HTML. It does not implement executor, translator, auth provider, or scheduler capabilities.
## Build
From this directory:
```bash
cd go
go build -buildmode=c-shared -o host-callback-auth-files.dylib .
rm -f host-callback-auth-files.dylib host-callback-auth-files.h
```
Use the platform extension expected by your target system:
- `.dylib` on macOS
- `.so` on Linux
- `.dll` on Windows
## Configuration
Build the dynamic library and place it under the configured plugin directory with a basename that matches the plugin ID. For example, `plugins/host-callback-auth-files.dylib` maps to `plugins.configs.host-callback-auth-files`.
```yaml
plugins:
enabled: true
dir: "plugins"
configs:
host-callback-auth-files:
enabled: true
priority: 1
```
This plugin does not define plugin-specific configuration fields.
## Resource URL Examples
List all auth files:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=list
```
Read physical JSON by auth index:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=get&auth_index=<AUTH_INDEX>
```
Read runtime info by auth index:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=runtime&auth_index=<AUTH_INDEX>
```
Save physical JSON:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=save&name=example-auth.json&json=%7B%22type%22%3A%22gemini%22%2C%22email%22%3A%22demo%40example.com%22%2C%22api_key%22%3A%22demo-key%22%7D
```
## Parameters
- `op`: one of `list`, `get`, `runtime`, `save`. Default is `list`.
- `auth_index`: required for `get` and `runtime`.
- `name`: required for `save`. Must end with `.json`.
- `json`: required for `save`. Must be valid JSON.
## Notes
- `host.auth.get` returns the physical auth file JSON.
- `host.auth.get_runtime` returns runtime credential metadata.
- `host.auth.save` writes the JSON to the auth directory and upserts the runtime auth record.

View file

@ -0,0 +1,7 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback-auth-files/go
go 1.26.0
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..

View file

@ -0,0 +1,531 @@
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 (
"bytes"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
const (
pluginName = "host-callback-auth-files"
resourcePath = "/status"
resourceContentType = "text/html; charset=utf-8"
)
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 registrationCapabilities `json:"capabilities"`
}
type registrationCapabilities struct {
ManagementAPI bool `json:"management_api"`
}
type managementRegistration struct {
Resources []managementResource `json:"resources,omitempty"`
}
type managementResource struct {
Path string `json:"Path"`
Menu string `json:"Menu"`
Description string `json:"Description"`
}
type managementRequest struct {
Method string
Path string
Headers http.Header
Query url.Values
Body []byte
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type managementResponse struct {
StatusCode int `json:"StatusCode"`
Headers http.Header `json:"Headers"`
Body []byte `json:"Body"`
}
type authListResponse struct {
Files []pluginapi.HostAuthFileEntry `json:"files"`
}
type authOpOptions struct {
Op string
AuthIndex string
Name string
JSON json.RawMessage
}
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, 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(pluginRegistration())
case pluginabi.MethodManagementRegister:
return okEnvelope(managementRegistration{
Resources: []managementResource{{
Path: resourcePath,
Menu: "Host Auth Files",
Description: "Lists auth files and demonstrates host.auth list/get/runtime/save callbacks.",
}},
})
case pluginabi.MethodManagementHandle:
return handleManagement(request)
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func pluginRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: pluginName,
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{},
},
Capabilities: registrationCapabilities{
ManagementAPI: true,
},
}
}
func handleManagement(raw []byte) ([]byte, error) {
var req managementRequest
if len(raw) > 0 {
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode management request: %w", errUnmarshal)
}
}
opts, errOptions := optionsFromManagementRequest(req)
if errOptions != nil {
page := renderPage(opts, nil, errOptions.Error())
return okEnvelope(htmlResponse(http.StatusBadRequest, page))
}
result, errRun := runAuthOp(opts)
if errRun != nil {
page := renderPage(opts, nil, errRun.Error())
return okEnvelope(htmlResponse(http.StatusOK, page))
}
page := renderPage(opts, result, "")
return okEnvelope(htmlResponse(http.StatusOK, page))
}
func optionsFromManagementRequest(req managementRequest) (authOpOptions, error) {
opts := authOpOptions{Op: "list"}
if len(req.Body) > 0 {
var bodyOpts authOpOptions
if errUnmarshal := json.Unmarshal(req.Body, &bodyOpts); errUnmarshal != nil {
return opts, fmt.Errorf("decode JSON request body: %w", errUnmarshal)
}
applyAuthOpOptions(&opts, bodyOpts)
}
if errApply := applyQueryAuthOptions(&opts, req.Query); errApply != nil {
return opts, errApply
}
return opts, nil
}
func applyAuthOpOptions(dst *authOpOptions, src authOpOptions) {
if strings.TrimSpace(src.Op) != "" {
dst.Op = strings.ToLower(strings.TrimSpace(src.Op))
}
if strings.TrimSpace(src.AuthIndex) != "" {
dst.AuthIndex = strings.TrimSpace(src.AuthIndex)
}
if strings.TrimSpace(src.Name) != "" {
dst.Name = strings.TrimSpace(src.Name)
}
if len(src.JSON) > 0 && string(src.JSON) != "null" {
dst.JSON = append(json.RawMessage(nil), src.JSON...)
}
}
func applyQueryAuthOptions(opts *authOpOptions, query url.Values) error {
if query == nil {
return nil
}
if raw := strings.TrimSpace(query.Get("op")); raw != "" {
opts.Op = strings.ToLower(raw)
}
if raw := strings.TrimSpace(query.Get("auth_index")); raw != "" {
opts.AuthIndex = raw
}
if raw := strings.TrimSpace(query.Get("name")); raw != "" {
opts.Name = raw
}
if raw := strings.TrimSpace(query.Get("json")); raw != "" {
if !json.Valid([]byte(raw)) {
return fmt.Errorf("query json must be valid JSON")
}
opts.JSON = json.RawMessage(raw)
}
return nil
}
func runAuthOp(opts authOpOptions) (any, error) {
switch opts.Op {
case "list", "":
return callHostAuthList()
case "get":
if opts.AuthIndex == "" {
return nil, fmt.Errorf("auth_index is required for op=get")
}
return callHostAuthGet(opts.AuthIndex)
case "runtime", "get_runtime":
if opts.AuthIndex == "" {
return nil, fmt.Errorf("auth_index is required for op=runtime")
}
return callHostAuthGetRuntime(opts.AuthIndex)
case "save":
if opts.Name == "" {
return nil, fmt.Errorf("name is required for op=save")
}
if len(opts.JSON) == 0 {
return nil, fmt.Errorf("json is required for op=save")
}
return callHostAuthSave(opts.Name, opts.JSON)
default:
return nil, fmt.Errorf("unknown op %q: use list, get, runtime, or save", opts.Op)
}
}
func callHostAuthList() (authListResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthList, map[string]any{})
if errCall != nil {
return authListResponse{}, errCall
}
var resp authListResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return authListResponse{}, fmt.Errorf("decode host.auth.list result: %w", errUnmarshal)
}
return resp, nil
}
func callHostAuthGet(authIndex string) (pluginapi.HostAuthGetResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthGet, pluginapi.HostAuthGetRequest{AuthIndex: authIndex})
if errCall != nil {
return pluginapi.HostAuthGetResponse{}, errCall
}
var resp pluginapi.HostAuthGetResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return pluginapi.HostAuthGetResponse{}, fmt.Errorf("decode host.auth.get result: %w", errUnmarshal)
}
return resp, nil
}
func callHostAuthGetRuntime(authIndex string) (pluginapi.HostAuthGetRuntimeResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthGetRuntime, pluginapi.HostAuthGetRequest{AuthIndex: authIndex})
if errCall != nil {
return pluginapi.HostAuthGetRuntimeResponse{}, errCall
}
var resp pluginapi.HostAuthGetRuntimeResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return pluginapi.HostAuthGetRuntimeResponse{}, fmt.Errorf("decode host.auth.get_runtime result: %w", errUnmarshal)
}
return resp, nil
}
func callHostAuthSave(name string, rawJSON json.RawMessage) (pluginapi.HostAuthSaveResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthSave, pluginapi.HostAuthSaveRequest{
Name: name,
JSON: rawJSON,
})
if errCall != nil {
return pluginapi.HostAuthSaveResponse{}, errCall
}
var resp pluginapi.HostAuthSaveResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return pluginapi.HostAuthSaveResponse{}, fmt.Errorf("decode host.auth.save result: %w", errUnmarshal)
}
return resp, nil
}
func callHost(method string, payload any) (json.RawMessage, error) {
rawPayload, errMarshal := json.Marshal(payload)
if errMarshal != nil {
return nil, fmt.Errorf("marshal host callback payload %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 payload %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 callback 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 htmlResponse(statusCode int, body []byte) managementResponse {
return managementResponse{
StatusCode: statusCode,
Headers: http.Header{
"content-type": []string{resourceContentType},
},
Body: body,
}
}
func renderPage(opts authOpOptions, result any, errText string) []byte {
var out bytes.Buffer
out.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>Host Auth Files</title>")
out.WriteString("<style>body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;margin:2rem;line-height:1.45;color:#1f2933}code,pre{background:#f3f4f6;border-radius:6px}code{padding:.1rem .3rem}pre{padding:1rem;overflow:auto;white-space:pre-wrap}dl{display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}dt{font-weight:600}dd{margin:0}.error{color:#b42318}</style>")
out.WriteString("</head><body><main>")
out.WriteString("<h1>Host Auth Files</h1>")
out.WriteString("<dl>")
writeDefinition(&out, "op", opts.Op)
if opts.AuthIndex != "" {
writeDefinition(&out, "auth_index", opts.AuthIndex)
}
if opts.Name != "" {
writeDefinition(&out, "name", opts.Name)
}
out.WriteString("</dl>")
if errText != "" {
out.WriteString("<h2>Error</h2><pre class=\"error\">")
out.WriteString(html.EscapeString(errText))
out.WriteString("</pre>")
}
if result != nil {
out.WriteString("<h2>Result</h2><pre>")
out.WriteString(html.EscapeString(prettyJSON(result)))
out.WriteString("</pre>")
}
out.WriteString("<h2>Usage</h2><ul>")
out.WriteString("<li><code>?op=list</code></li>")
out.WriteString("<li><code>?op=get&amp;auth_index=&lt;AUTH_INDEX&gt;</code></li>")
out.WriteString("<li><code>?op=runtime&amp;auth_index=&lt;AUTH_INDEX&gt;</code></li>")
out.WriteString("<li><code>?op=save&amp;name=example.json&amp;json=...</code></li>")
out.WriteString("</ul>")
out.WriteString("</main></body></html>")
return out.Bytes()
}
func writeDefinition(out *bytes.Buffer, key string, value string) {
out.WriteString("<dt>")
out.WriteString(html.EscapeString(key))
out.WriteString("</dt><dd><code>")
out.WriteString(html.EscapeString(value))
out.WriteString("</code></dd>")
}
func prettyBody(raw []byte) string {
var buf bytes.Buffer
if errIndent := json.Indent(&buf, raw, "", " "); errIndent == nil {
return buf.String()
}
return string(raw)
}
func prettyJSON(v any) string {
raw, errMarshal := json.MarshalIndent(v, "", " ")
if errMarshal != nil {
return fmt.Sprintf("%v", v)
}
return string(raw)
}
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))
}
func cloneHeader(headers http.Header) http.Header {
if headers == nil {
return nil
}
cloned := make(http.Header, len(headers))
for key, values := range headers {
cloned[key] = append([]string(nil), values...)
}
return cloned
}
func cloneValues(values url.Values) url.Values {
if values == nil {
return nil
}
cloned := make(url.Values, len(values))
for key, items := range values {
cloned[key] = append([]string(nil), items...)
}
return cloned
}

View file

@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
project(cliproxy_host_callback_c C)
add_library(cliproxy_host_callback_c SHARED src/plugin.c)
set_target_properties(cliproxy_host_callback_c PROPERTIES
OUTPUT_NAME "host-callback-c"
PREFIX ""
)

View file

@ -0,0 +1,120 @@
#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-host-callback-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}");
return 0;
}
if (strcmp(method, "plugin.reconfigure") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}");
return 0;
}
if (strcmp(method, "management.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-c/status.\"}]}}");
return 0;
}
if (strcmp(method, "management.handle") == 0) {
call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}");
call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}");
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}");
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;
}

View file

@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback/go
go 1.26

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