Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
backend/internal/managementasset/dist/
|
||||||
|
backend/.dev/
|
||||||
|
result
|
||||||
|
result-*
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
# vibe-proxy
|
# Vibe Proxy
|
||||||
|
|
||||||
|
|
|
||||||
41
backend/.dockerignore
Normal file
41
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Git and GitHub folders
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Docker and CI/CD related files
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
.goreleaser.yml
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
# Documentation and license
|
||||||
|
docs
|
||||||
|
README.md
|
||||||
|
README_CN.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Runtime data folders (should be mounted as volumes)
|
||||||
|
auths
|
||||||
|
logs
|
||||||
|
conv
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Development/editor
|
||||||
|
bin
|
||||||
|
.vscode
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
.codex-worktrees
|
||||||
|
.gemini
|
||||||
|
.serena
|
||||||
|
.agent
|
||||||
|
.agents
|
||||||
|
.antigravitycli
|
||||||
|
.opencode
|
||||||
|
.idea
|
||||||
|
.junie
|
||||||
|
.worktrees
|
||||||
|
.bmad
|
||||||
|
_bmad
|
||||||
|
_bmad-output
|
||||||
5
backend/.env.cluster.example
Normal file
5
backend/.env.cluster.example
Normal 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
34
backend/.env.example
Normal 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
61
backend/.gitignore
vendored
Normal 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
58
backend/AGENTS.md
Normal 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 (don’t 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
1
backend/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@AGENTS.md
|
||||||
55
backend/Dockerfile
Normal file
55
backend/Dockerfile
Normal 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
22
backend/LICENSE
Normal 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
0
backend/auths/.gitkeep
Normal file
305
backend/cmd/fetch_antigravity_models/main.go
Normal file
305
backend/cmd/fetch_antigravity_models/main.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
336
backend/cmd/fetch_codex_models/main.go
Normal file
336
backend/cmd/fetch_codex_models/main.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/cmd/fetch_codex_models/main_test.go
Normal file
48
backend/cmd/fetch_codex_models/main_test.go
Normal 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
832
backend/cmd/server/main.go
Normal 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
|
||||||
|
}
|
||||||
137
backend/cmd/server/main_test.go
Normal file
137
backend/cmd/server/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/cmd/validate_codex_models/main.go
Normal file
32
backend/cmd/validate_codex_models/main.go
Normal 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
16
backend/config.dev.yaml
Normal 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
844
backend/config.example.yaml
Normal 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
54
backend/docker-build.ps1
Normal 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
66
backend/docker-build.sh
Normal 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
|
||||||
30
backend/docker-compose.cluster.yml
Normal file
30
backend/docker-compose.cluster.yml
Normal 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
|
||||||
29
backend/docker-compose.yml
Normal file
29
backend/docker-compose.yml
Normal 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
154
backend/docs/sdk-access.md
Normal 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.
|
||||||
154
backend/docs/sdk-access_CN.md
Normal file
154
backend/docs/sdk-access_CN.md
Normal 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` 保持一致,避免为更新访问策略而重启进程。
|
||||||
138
backend/docs/sdk-advanced.md
Normal file
138
backend/docs/sdk-advanced.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# SDK Advanced: Executors & Translators
|
||||||
|
|
||||||
|
This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
|
||||||
|
- Implement a provider executor that talks to your upstream API
|
||||||
|
- Register request/response translators for schema conversion
|
||||||
|
- Register models so they appear in `/v1/models`
|
||||||
|
|
||||||
|
The examples use Go 1.24+ and the v6 module path.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests.
|
||||||
|
- Translator registry: schema conversion functions routed by `sdk/translator`. The built‑in handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones.
|
||||||
|
- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
|
||||||
|
|
||||||
|
## 1) Implement a Provider Executor
|
||||||
|
|
||||||
|
Create a type that satisfies `auth.ProviderExecutor`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct{}
|
||||||
|
|
||||||
|
func (Executor) Identifier() string { return "myprov" }
|
||||||
|
|
||||||
|
// Optional: mutate outbound HTTP requests with credentials
|
||||||
|
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||||
|
// Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||||
|
// Build HTTP request based on req.Payload (already translated into provider format)
|
||||||
|
// Use per‑auth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
|
||||||
|
// Perform call and return provider JSON payload
|
||||||
|
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||||
|
ch := make(chan clipexec.StreamChunk, 1)
|
||||||
|
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
||||||
|
// Optionally refresh tokens and return updated auth
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register the executor with the core manager before starting the service:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.RegisterExecutor(myprov.Executor{})
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
|
||||||
|
|
||||||
|
## 2) Register Translators
|
||||||
|
|
||||||
|
The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`’s default registry.
|
||||||
|
|
||||||
|
Direction matters:
|
||||||
|
- Request: register from inbound schema to provider schema
|
||||||
|
- Response: register from provider schema back to inbound schema
|
||||||
|
|
||||||
|
Example: Convert OpenAI Chat → MyProv Chat and back.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FOpenAI = sdktr.Format("openai.chat")
|
||||||
|
FMyProv = sdktr.Format("myprov.chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sdktr.Register(FOpenAI, FMyProv,
|
||||||
|
// Request transform (model, rawJSON, stream)
|
||||||
|
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
||||||
|
// Response transform (stream & non‑stream)
|
||||||
|
sdktr.ResponseTransform{
|
||||||
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||||
|
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||||
|
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
|
||||||
|
|
||||||
|
## 3) Register Models
|
||||||
|
|
||||||
|
Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
|
||||||
|
|
||||||
|
```go
|
||||||
|
models := []*cliproxy.ModelInfo{
|
||||||
|
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
||||||
|
}
|
||||||
|
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
||||||
|
```
|
||||||
|
|
||||||
|
The embedded server calls this automatically for built‑in providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks.
|
||||||
|
|
||||||
|
## Credentials & Transports
|
||||||
|
|
||||||
|
- Use `Manager.SetRoundTripperProvider` to inject per‑auth `*http.Transport` (e.g., proxy):
|
||||||
|
```go
|
||||||
|
core.SetRoundTripperProvider(myProvider) // returns transport per auth
|
||||||
|
```
|
||||||
|
- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
|
||||||
|
|
||||||
|
## Testing Tips
|
||||||
|
|
||||||
|
- Enable request logging: Management API GET/PUT `/v0/management/request-log`
|
||||||
|
- Toggle debug logs: Management API GET/PUT `/v0/management/debug`
|
||||||
|
- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher
|
||||||
|
|
||||||
131
backend/docs/sdk-advanced_CN.md
Normal file
131
backend/docs/sdk-advanced_CN.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# SDK 高级指南:执行器与翻译器
|
||||||
|
|
||||||
|
本文介绍如何使用 SDK 扩展内嵌代理:
|
||||||
|
- 实现自定义 Provider 执行器以调用你的上游 API
|
||||||
|
- 注册请求/响应翻译器进行协议转换
|
||||||
|
- 注册模型以出现在 `/v1/models`
|
||||||
|
|
||||||
|
示例基于 Go 1.24+ 与 v6 模块路径。
|
||||||
|
|
||||||
|
## 概念
|
||||||
|
|
||||||
|
- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key(如 `gemini`、`claude`、`codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
|
||||||
|
- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
|
||||||
|
- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
|
||||||
|
|
||||||
|
## 1) 实现 Provider 执行器
|
||||||
|
|
||||||
|
创建类型满足 `auth.ProviderExecutor` 接口。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct{}
|
||||||
|
|
||||||
|
func (Executor) Identifier() string { return "myprov" }
|
||||||
|
|
||||||
|
// 可选:在原始 HTTP 请求上注入凭据
|
||||||
|
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||||
|
// 例如:req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||||
|
// 基于 req.Payload 构造上游请求,返回上游 JSON 负载
|
||||||
|
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||||
|
ch := make(chan clipexec.StreamChunk, 1)
|
||||||
|
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
|
||||||
|
```
|
||||||
|
|
||||||
|
在启动服务前将执行器注册到核心管理器:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.RegisterExecutor(myprov.Executor{})
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
当凭据的 `Provider` 为 `"myprov"` 时,管理器会将请求路由到你的执行器。
|
||||||
|
|
||||||
|
## 2) 注册翻译器
|
||||||
|
|
||||||
|
内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
|
||||||
|
|
||||||
|
方向很重要:
|
||||||
|
- 请求:从“入站格式”转换为“provider 格式”
|
||||||
|
- 响应:从“provider 格式”转换回“入站格式”
|
||||||
|
|
||||||
|
示例:OpenAI Chat → MyProv Chat 及其反向。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package myprov
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FOpenAI = sdktr.Format("openai.chat")
|
||||||
|
FMyProv = sdktr.Format("myprov.chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sdktr.Register(FOpenAI, FMyProv,
|
||||||
|
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
||||||
|
sdktr.ResponseTransform{
|
||||||
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||||
|
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||||
|
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
|
||||||
|
|
||||||
|
## 3) 注册模型
|
||||||
|
|
||||||
|
通过全局模型注册表将模型暴露到 `/v1/models`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
models := []*cliproxy.ModelInfo{
|
||||||
|
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
||||||
|
}
|
||||||
|
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
||||||
|
```
|
||||||
|
|
||||||
|
内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
|
||||||
|
|
||||||
|
## 凭据与传输
|
||||||
|
|
||||||
|
- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
|
||||||
|
```go
|
||||||
|
core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
|
||||||
|
```
|
||||||
|
- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
- 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
|
||||||
|
- 切换调试日志:管理 API GET/PUT `/v0/management/debug`
|
||||||
|
- 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用
|
||||||
|
|
||||||
163
backend/docs/sdk-usage.md
Normal file
163
backend/docs/sdk-usage.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# CLI Proxy SDK Guide
|
||||||
|
|
||||||
|
The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hot‑reload, and translation layers without depending on the CLI binary.
|
||||||
|
|
||||||
|
## Install & Import
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `/v6` module path.
|
||||||
|
|
||||||
|
## Minimal Embed
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
svc, err := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml"). // absolute or working-dir relative
|
||||||
|
Build()
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
|
||||||
|
|
||||||
|
## Server Options (middleware, routes, logs)
|
||||||
|
|
||||||
|
The server accepts options via `WithServerOptions`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithServerOptions(
|
||||||
|
// Add global middleware
|
||||||
|
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
||||||
|
// Tweak gin engine early (CORS, trusted proxies, etc.)
|
||||||
|
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
||||||
|
// Add your own routes after defaults
|
||||||
|
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
||||||
|
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
||||||
|
}),
|
||||||
|
// Override request log writer/dir
|
||||||
|
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||||
|
}),
|
||||||
|
).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
These options mirror the internals used by the CLI server.
|
||||||
|
|
||||||
|
## Management API (when embedded)
|
||||||
|
|
||||||
|
- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
|
||||||
|
- Remote access additionally requires `remote-management.allow-remote: true`.
|
||||||
|
- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
|
||||||
|
|
||||||
|
## Using the Core Auth Manager
|
||||||
|
|
||||||
|
The service uses a core `auth.Manager` for selection, execution, and auto‑refresh. When embedding, you can provide your own manager to customize transports or hooks:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.SetRoundTripperProvider(myRTProvider) // per‑auth *http.Transport
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithCoreAuthManager(core).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement a custom per‑auth transport:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type myRTProvider struct{}
|
||||||
|
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
||||||
|
if a == nil || a.ProxyURL == "" { return nil }
|
||||||
|
u, _ := url.Parse(a.ProxyURL)
|
||||||
|
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Programmatic execution is available on the manager:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Non‑streaming
|
||||||
|
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
||||||
|
|
||||||
|
// Streaming
|
||||||
|
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
||||||
|
for ch := range chunks { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Built‑in provider executors are wired automatically when you run the `Service`. If you want to use `Manager` stand‑alone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`.
|
||||||
|
|
||||||
|
## Custom Client Sources
|
||||||
|
|
||||||
|
Replace the default loaders if your creds live outside the local filesystem:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type memoryTokenProvider struct{}
|
||||||
|
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
||||||
|
// Populate from memory/remote store and return counts
|
||||||
|
return &cliproxy.TokenClientResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithTokenClientProvider(&memoryTokenProvider{}).
|
||||||
|
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
Observe lifecycle without patching internals:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hooks := cliproxy.Hooks{
|
||||||
|
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
||||||
|
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
||||||
|
}
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shutdown
|
||||||
|
|
||||||
|
`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = svc.Shutdown(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
|
||||||
|
- Request logging can be toggled at runtime via the Management API.
|
||||||
|
- Gemini Web features (`gemini-web.*`) are honored in the embedded server.
|
||||||
164
backend/docs/sdk-usage_CN.md
Normal file
164
backend/docs/sdk-usage_CN.md
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
# CLI Proxy SDK 使用指南
|
||||||
|
|
||||||
|
`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
|
||||||
|
|
||||||
|
## 安装与导入
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意模块路径包含 `/v6`。
|
||||||
|
|
||||||
|
## 最小可用示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
svc, err := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
|
||||||
|
Build()
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
|
||||||
|
|
||||||
|
## 服务器可选项(中间件、路由、日志)
|
||||||
|
|
||||||
|
通过 `WithServerOptions` 自定义:
|
||||||
|
|
||||||
|
```go
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithServerOptions(
|
||||||
|
// 追加全局中间件
|
||||||
|
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
||||||
|
// 提前调整 gin 引擎(如 CORS、trusted proxies)
|
||||||
|
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
||||||
|
// 在默认路由之后追加自定义路由
|
||||||
|
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
||||||
|
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
||||||
|
}),
|
||||||
|
// 覆盖请求日志的创建(启用/目录)
|
||||||
|
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||||
|
}),
|
||||||
|
).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
这些选项与 CLI 服务器内部用法保持一致。
|
||||||
|
|
||||||
|
## 管理 API(内嵌时)
|
||||||
|
|
||||||
|
- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
|
||||||
|
- 远程访问还需要 `remote-management.allow-remote: true`。
|
||||||
|
- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`。
|
||||||
|
|
||||||
|
## 使用核心鉴权管理器
|
||||||
|
|
||||||
|
服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
|
||||||
|
|
||||||
|
```go
|
||||||
|
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||||
|
core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithCoreAuthManager(core).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
实现每个账户的自定义传输:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type myRTProvider struct{}
|
||||||
|
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
||||||
|
if a == nil || a.ProxyURL == "" { return nil }
|
||||||
|
u, _ := url.Parse(a.ProxyURL)
|
||||||
|
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
管理器提供编程式执行接口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 非流式
|
||||||
|
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
||||||
|
|
||||||
|
// 流式
|
||||||
|
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
||||||
|
for ch := range chunks { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
|
||||||
|
|
||||||
|
## 自定义凭据来源
|
||||||
|
|
||||||
|
当凭据不在本地文件系统时,替换默认加载器:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type memoryTokenProvider struct{}
|
||||||
|
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
||||||
|
// 从内存/远端加载并返回数量统计
|
||||||
|
return &cliproxy.TokenClientResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, _ := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithTokenClientProvider(&memoryTokenProvider{}).
|
||||||
|
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动钩子
|
||||||
|
|
||||||
|
无需修改内部代码即可观察生命周期:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hooks := cliproxy.Hooks{
|
||||||
|
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
||||||
|
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
||||||
|
}
|
||||||
|
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关闭
|
||||||
|
|
||||||
|
`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = svc.Shutdown(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 热更新:`config.yaml` 与 `auths/` 变化会被自动侦测并应用。
|
||||||
|
- 请求日志可通过管理 API 在运行时开关。
|
||||||
|
- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。
|
||||||
|
|
||||||
32
backend/docs/sdk-watcher.md
Normal file
32
backend/docs/sdk-watcher.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# SDK Watcher Integration
|
||||||
|
|
||||||
|
The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled.
|
||||||
|
|
||||||
|
## Update Queue Contract
|
||||||
|
|
||||||
|
- `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`.
|
||||||
|
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts.
|
||||||
|
- The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop.
|
||||||
|
|
||||||
|
## Watcher Behaviour
|
||||||
|
|
||||||
|
- `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals.
|
||||||
|
- Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream.
|
||||||
|
- The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly.
|
||||||
|
|
||||||
|
## High-Frequency Change Handling
|
||||||
|
|
||||||
|
- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
|
||||||
|
- Back-pressure is absorbed in two places:
|
||||||
|
- The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
|
||||||
|
- The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
|
||||||
|
- If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states.
|
||||||
|
|
||||||
|
## Usage Checklist
|
||||||
|
|
||||||
|
1. Instantiate the SDK service (builder or manual construction).
|
||||||
|
2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
|
||||||
|
3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
|
||||||
|
4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`.
|
||||||
|
|
||||||
|
Following this flow keeps auth changes responsive while avoiding full reloads for every edit.
|
||||||
32
backend/docs/sdk-watcher_CN.md
Normal file
32
backend/docs/sdk-watcher_CN.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# SDK Watcher集成说明
|
||||||
|
|
||||||
|
本文档介绍SDK服务与文件监控器之间的增量更新队列,包括接口契约、高频变更下的处理策略以及接入步骤。
|
||||||
|
|
||||||
|
## 更新队列契约
|
||||||
|
|
||||||
|
- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add`、`modify`或`delete`,`ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`。
|
||||||
|
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher,必须在watcher启动前完成。
|
||||||
|
- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道,并在`consumeAuthUpdates`中使用专职goroutine消费;消费侧会主动“抽干”积压事件,降低切换开销。
|
||||||
|
|
||||||
|
## Watcher行为
|
||||||
|
|
||||||
|
- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
|
||||||
|
- 以凭据ID为维度对更新进行合并,同一凭据在短时间内的多次变更只会保留最新状态(例如先写后删只会下发`delete`)。
|
||||||
|
- watcher内部运行异步分发循环:生产者只向内存缓冲追加事件并唤醒分发协程,即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环,确保协程正常退出。
|
||||||
|
|
||||||
|
## 高频变更处理
|
||||||
|
|
||||||
|
- 分发循环与服务消费协程相互独立,因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
|
||||||
|
- 背压通过两级缓冲吸收:
|
||||||
|
- 分发缓冲(map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
|
||||||
|
- 服务端通道的256容量加上消费侧的“抽干”逻辑,可平稳处理多个突发批次。
|
||||||
|
- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
|
||||||
|
|
||||||
|
## 接入步骤
|
||||||
|
|
||||||
|
1. 实例化SDK Service(构建器或手工创建)。
|
||||||
|
2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
|
||||||
|
3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道,然后再启动watcher。
|
||||||
|
4. Reload回调专注于配置更新;认证增量会通过队列送达,并由`handleAuthUpdate`自动应用。
|
||||||
|
|
||||||
|
遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。
|
||||||
225
backend/examples/custom-provider/main.go
Normal file
225
backend/examples/custom-provider/main.go
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// Package main demonstrates how to create a custom AI provider executor
|
||||||
|
// and integrate it with the CLI Proxy API server. This example shows how to:
|
||||||
|
// - Create a custom executor that implements the Executor interface
|
||||||
|
// - Register custom translators for request/response transformation
|
||||||
|
// - Integrate the custom provider with the SDK server
|
||||||
|
// - Register custom models in the model registry
|
||||||
|
//
|
||||||
|
// This example uses a simple echo service (httpbin.org) as the upstream API
|
||||||
|
// for demonstration purposes. In a real implementation, you would replace
|
||||||
|
// this with your actual AI service provider.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/logging"
|
||||||
|
sdktr "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// providerKey is the identifier for our custom provider.
|
||||||
|
providerKey = "myprov"
|
||||||
|
|
||||||
|
// fOpenAI represents the OpenAI chat format.
|
||||||
|
fOpenAI = sdktr.Format("openai.chat")
|
||||||
|
|
||||||
|
// fMyProv represents our custom provider's chat format.
|
||||||
|
fMyProv = sdktr.Format("myprov.chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
// init registers trivial translators for demonstration purposes.
|
||||||
|
// In a real implementation, you would implement proper request/response
|
||||||
|
// transformation logic between OpenAI format and your provider's format.
|
||||||
|
func init() {
|
||||||
|
sdktr.Register(fOpenAI, fMyProv,
|
||||||
|
func(model string, raw []byte, stream bool) []byte { return raw },
|
||||||
|
sdktr.ResponseTransform{
|
||||||
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) [][]byte {
|
||||||
|
return [][]byte{raw}
|
||||||
|
},
|
||||||
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []byte {
|
||||||
|
return raw
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyExecutor is a minimal provider implementation for demonstration purposes.
|
||||||
|
// It implements the Executor interface to handle requests to a custom AI provider.
|
||||||
|
type MyExecutor struct{}
|
||||||
|
|
||||||
|
// Identifier returns the unique identifier for this executor.
|
||||||
|
func (MyExecutor) Identifier() string { return providerKey }
|
||||||
|
|
||||||
|
// PrepareRequest optionally injects credentials to raw HTTP requests.
|
||||||
|
// This method is called before each request to allow the executor to modify
|
||||||
|
// the HTTP request with authentication headers or other necessary modifications.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - req: The HTTP request to prepare
|
||||||
|
// - a: The authentication information
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if request preparation fails
|
||||||
|
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||||
|
if req == nil || a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if a.Attributes != nil {
|
||||||
|
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHTTPClient(a *coreauth.Auth) *http.Client {
|
||||||
|
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
u, err := url.Parse(a.ProxyURL)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upstreamEndpoint(a *coreauth.Auth) string {
|
||||||
|
if a != nil && a.Attributes != nil {
|
||||||
|
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Demo echo endpoint; replace with your upstream.
|
||||||
|
return "https://httpbin.org/post"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||||
|
client := buildHTTPClient(a)
|
||||||
|
endpoint := upstreamEndpoint(a)
|
||||||
|
|
||||||
|
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
|
||||||
|
if errNew != nil {
|
||||||
|
return clipexec.Response{}, errNew
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Inject credentials via PrepareRequest hook.
|
||||||
|
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
||||||
|
return clipexec.Response{}, errPrep
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, errDo := client.Do(httpReq)
|
||||||
|
if errDo != nil {
|
||||||
|
return clipexec.Response{}, errDo
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return clipexec.Response{Payload: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("myprov executor: request is nil")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = req.Context()
|
||||||
|
}
|
||||||
|
httpReq := req.WithContext(ctx)
|
||||||
|
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
||||||
|
return nil, errPrep
|
||||||
|
}
|
||||||
|
client := buildHTTPClient(a)
|
||||||
|
return client.Do(httpReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
||||||
|
return clipexec.Response{}, errors.New("count tokens not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (*clipexec.StreamResult, error) {
|
||||||
|
ch := make(chan clipexec.StreamChunk, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
|
||||||
|
}()
|
||||||
|
return &clipexec.StreamResult{Chunks: ch}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStore := sdkAuth.GetTokenStore()
|
||||||
|
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
||||||
|
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||||
|
}
|
||||||
|
core := coreauth.NewManager(tokenStore, nil, nil)
|
||||||
|
core.RegisterExecutor(MyExecutor{})
|
||||||
|
|
||||||
|
hooks := cliproxy.Hooks{
|
||||||
|
OnAfterStart: func(s *cliproxy.Service) {
|
||||||
|
// Register demo models for the custom provider so they appear in /v1/models.
|
||||||
|
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
|
||||||
|
for _, a := range core.List() {
|
||||||
|
if strings.EqualFold(a.Provider, providerKey) {
|
||||||
|
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := cliproxy.NewBuilder().
|
||||||
|
WithConfig(cfg).
|
||||||
|
WithConfigPath("config.yaml").
|
||||||
|
WithCoreAuthManager(core).
|
||||||
|
WithServerOptions(
|
||||||
|
// Optional: add a simple middleware + custom request logger
|
||||||
|
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
|
||||||
|
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||||
|
return logging.NewFileRequestLoggerWithOptions(true, "logs", filepath.Dir(cfgPath), cfg.ErrorLogsMaxFiles)
|
||||||
|
}),
|
||||||
|
).
|
||||||
|
WithHooks(hooks).
|
||||||
|
Build()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {
|
||||||
|
panic(errRun)
|
||||||
|
}
|
||||||
|
_ = os.Stderr // keep os import used (demo only)
|
||||||
|
_ = time.Second
|
||||||
|
}
|
||||||
140
backend/examples/http-request/main.go
Normal file
140
backend/examples/http-request/main.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest
|
||||||
|
// to execute arbitrary HTTP requests with provider credentials injected.
|
||||||
|
//
|
||||||
|
// This example registers a minimal custom executor that injects an Authorization
|
||||||
|
// header from auth.Attributes["api_key"], then performs two requests against
|
||||||
|
// httpbin.org to show the injected headers.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||||
|
clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const providerKey = "echo"
|
||||||
|
|
||||||
|
// EchoExecutor is a minimal provider implementation for demonstration purposes.
|
||||||
|
type EchoExecutor struct{}
|
||||||
|
|
||||||
|
func (EchoExecutor) Identifier() string { return providerKey }
|
||||||
|
|
||||||
|
func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
|
||||||
|
if req == nil || auth == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("echo executor: request is nil")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = req.Context()
|
||||||
|
}
|
||||||
|
httpReq := req.WithContext(ctx)
|
||||||
|
if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {
|
||||||
|
return nil, errPrep
|
||||||
|
}
|
||||||
|
return http.DefaultClient.Do(httpReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
||||||
|
return clipexec.Response{}, errors.New("echo executor: Execute not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (*clipexec.StreamResult, error) {
|
||||||
|
return nil, errors.New("echo executor: ExecuteStream not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
|
||||||
|
return nil, errors.New("echo executor: Refresh not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
||||||
|
return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetLevel(log.InfoLevel)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
core := coreauth.NewManager(nil, nil, nil)
|
||||||
|
core.RegisterExecutor(EchoExecutor{})
|
||||||
|
|
||||||
|
auth := &coreauth.Auth{
|
||||||
|
ID: "demo-echo",
|
||||||
|
Provider: providerKey,
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"api_key": "demo-api-key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 1: Build a prepared request and execute it using your own http.Client.
|
||||||
|
reqPrepared, errReqPrepared := core.NewHttpRequest(
|
||||||
|
ctx,
|
||||||
|
auth,
|
||||||
|
http.MethodGet,
|
||||||
|
"https://httpbin.org/anything",
|
||||||
|
nil,
|
||||||
|
http.Header{"X-Example": []string{"prepared"}},
|
||||||
|
)
|
||||||
|
if errReqPrepared != nil {
|
||||||
|
panic(errReqPrepared)
|
||||||
|
}
|
||||||
|
respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)
|
||||||
|
if errDoPrepared != nil {
|
||||||
|
panic(errDoPrepared)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := respPrepared.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("close response body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)
|
||||||
|
if errReadPrepared != nil {
|
||||||
|
panic(errReadPrepared)
|
||||||
|
}
|
||||||
|
fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared)
|
||||||
|
|
||||||
|
// Example 2: Execute a raw request via core.HttpRequest (auto inject + do).
|
||||||
|
rawBody := []byte(`{"hello":"world"}`)
|
||||||
|
rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody))
|
||||||
|
if errRawReq != nil {
|
||||||
|
panic(errRawReq)
|
||||||
|
}
|
||||||
|
rawReq.Header.Set("Content-Type", "application/json")
|
||||||
|
rawReq.Header.Set("X-Example", "executed")
|
||||||
|
|
||||||
|
respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)
|
||||||
|
if errDoExec != nil {
|
||||||
|
panic(errDoExec)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := respExec.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("close response body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
bodyExec, errReadExec := io.ReadAll(respExec.Body)
|
||||||
|
if errReadExec != nil {
|
||||||
|
panic(errReadExec)
|
||||||
|
}
|
||||||
|
fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec)
|
||||||
|
}
|
||||||
48
backend/examples/plugin/Makefile
Normal file
48
backend/examples/plugin/Makefile
Normal 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)" "$@"
|
||||||
123
backend/examples/plugin/README.md
Normal file
123
backend/examples/plugin/README.md
Normal 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/...`.
|
||||||
122
backend/examples/plugin/README_CN.md
Normal file
122
backend/examples/plugin/README_CN.md
Normal 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/...` 下。
|
||||||
8
backend/examples/plugin/auth/c/CMakeLists.txt
Normal file
8
backend/examples/plugin/auth/c/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(cliproxy_auth_c C)
|
||||||
|
|
||||||
|
add_library(cliproxy_auth_c SHARED src/plugin.c)
|
||||||
|
set_target_properties(cliproxy_auth_c PROPERTIES
|
||||||
|
OUTPUT_NAME "auth-c"
|
||||||
|
PREFIX ""
|
||||||
|
)
|
||||||
129
backend/examples/plugin/auth/c/src/plugin.c
Normal file
129
backend/examples/plugin/auth/c/src/plugin.c
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define CLIPROXY_EXPORT __declspec(dllexport)
|
||||||
|
#else
|
||||||
|
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define ABI_VERSION 1
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void* ptr;
|
||||||
|
size_t len;
|
||||||
|
} cliproxy_buffer;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_host_free_fn)(void*, size_t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
void* host_ctx;
|
||||||
|
cliproxy_host_call_fn call;
|
||||||
|
cliproxy_host_free_fn free_buffer;
|
||||||
|
} cliproxy_host_api;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
|
||||||
|
typedef void (*cliproxy_plugin_shutdown_fn)(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
cliproxy_plugin_call_fn call;
|
||||||
|
cliproxy_plugin_free_fn free_buffer;
|
||||||
|
cliproxy_plugin_shutdown_fn shutdown;
|
||||||
|
} cliproxy_plugin_api;
|
||||||
|
|
||||||
|
static const cliproxy_host_api* stored_host = NULL;
|
||||||
|
|
||||||
|
static void write_response(cliproxy_buffer* response, const char* text) {
|
||||||
|
if (response == NULL || text == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t len = strlen(text);
|
||||||
|
void* ptr = malloc(len);
|
||||||
|
if (ptr == NULL) {
|
||||||
|
response->ptr = NULL;
|
||||||
|
response->len = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(ptr, text, len);
|
||||||
|
response->ptr = ptr;
|
||||||
|
response->len = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void call_host(const char* method, const char* payload) {
|
||||||
|
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cliproxy_buffer response = {0};
|
||||||
|
const uint8_t* request = (const uint8_t*)payload;
|
||||||
|
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||||
|
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||||
|
stored_host->free_buffer(response.ptr, response.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||||
|
if (response != NULL) {
|
||||||
|
response->ptr = NULL;
|
||||||
|
response->len = 0;
|
||||||
|
}
|
||||||
|
if (method == NULL) {
|
||||||
|
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "plugin.register") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "auth.identifier") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-auth-c\"}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "auth.parse") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "auth.login.start") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-auth-c\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "auth.login.poll") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "auth.refresh") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||||
|
(void)request;
|
||||||
|
(void)request_len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void plugin_free(void* ptr, size_t len) {
|
||||||
|
(void)len;
|
||||||
|
free(ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void plugin_shutdown(void) {}
|
||||||
|
|
||||||
|
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
|
||||||
|
if (plugin == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
stored_host = host;
|
||||||
|
plugin->abi_version = ABI_VERSION;
|
||||||
|
plugin->call = plugin_call;
|
||||||
|
plugin->free_buffer = plugin_free;
|
||||||
|
plugin->shutdown = plugin_shutdown;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
3
backend/examples/plugin/auth/go/go.mod
Normal file
3
backend/examples/plugin/auth/go/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/auth/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
181
backend/examples/plugin/auth/go/main.go
Normal file
181
backend/examples/plugin/auth/go/main.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void* ptr;
|
||||||
|
size_t len;
|
||||||
|
} cliproxy_buffer;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_host_free_fn)(void*, size_t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
void* host_ctx;
|
||||||
|
cliproxy_host_call_fn call;
|
||||||
|
cliproxy_host_free_fn free_buffer;
|
||||||
|
} cliproxy_host_api;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
|
||||||
|
typedef void (*cliproxy_plugin_shutdown_fn)(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
cliproxy_plugin_call_fn call;
|
||||||
|
cliproxy_plugin_free_fn free_buffer;
|
||||||
|
cliproxy_plugin_shutdown_fn shutdown;
|
||||||
|
} cliproxy_plugin_api;
|
||||||
|
|
||||||
|
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
extern void cliproxyPluginFree(void*, size_t);
|
||||||
|
extern void cliproxyPluginShutdown(void);
|
||||||
|
|
||||||
|
static const cliproxy_host_api* stored_host;
|
||||||
|
|
||||||
|
static void store_host_api(const cliproxy_host_api* host) {
|
||||||
|
stored_host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||||
|
if (stored_host == NULL || stored_host->call == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void free_host_buffer(void* ptr, size_t len) {
|
||||||
|
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||||
|
stored_host->free_buffer(ptr, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const abiVersion uint32 = 1
|
||||||
|
|
||||||
|
type envelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *envelopeError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type envelopeError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
|
||||||
|
//export cliproxy_plugin_init
|
||||||
|
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
|
||||||
|
if plugin == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
C.store_host_api(host)
|
||||||
|
plugin.abi_version = C.uint32_t(abiVersion)
|
||||||
|
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
|
||||||
|
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
|
||||||
|
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginCall
|
||||||
|
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
|
||||||
|
if response != nil {
|
||||||
|
response.ptr = nil
|
||||||
|
response.len = 0
|
||||||
|
}
|
||||||
|
if method == nil {
|
||||||
|
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
raw, errHandle := handleMethod(C.GoString(method))
|
||||||
|
if errHandle != nil {
|
||||||
|
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
writeResponse(response, raw)
|
||||||
|
_ = request
|
||||||
|
_ = requestLen
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginFree
|
||||||
|
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||||
|
if ptr != nil {
|
||||||
|
C.free(ptr)
|
||||||
|
}
|
||||||
|
_ = len
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginShutdown
|
||||||
|
func cliproxyPluginShutdown() {}
|
||||||
|
|
||||||
|
func handleMethod(method string) ([]byte, error) {
|
||||||
|
_ = http.StatusOK
|
||||||
|
_ = time.Second
|
||||||
|
switch method {
|
||||||
|
case "plugin.register":
|
||||||
|
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}")
|
||||||
|
case "plugin.reconfigure":
|
||||||
|
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}")
|
||||||
|
case "auth.identifier":
|
||||||
|
return okEnvelopeJSON("{\"identifier\":\"example-auth-go\"}")
|
||||||
|
case "auth.parse":
|
||||||
|
return okEnvelopeJSON("{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}}}")
|
||||||
|
case "auth.login.start":
|
||||||
|
return okEnvelopeJSON("{\"Provider\":\"example-auth-go\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}")
|
||||||
|
case "auth.login.poll":
|
||||||
|
return okEnvelopeJSON("{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}}}")
|
||||||
|
case "auth.refresh":
|
||||||
|
return okEnvelopeJSON("{\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}")
|
||||||
|
default:
|
||||||
|
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||||
|
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorEnvelope(code, message string) []byte {
|
||||||
|
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
|
||||||
|
if response == nil || len(raw) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ptr := C.CBytes(raw)
|
||||||
|
if ptr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ptr = ptr
|
||||||
|
response.len = C.size_t(len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func callHost(method string, payload []byte) {
|
||||||
|
cMethod := C.CString(method)
|
||||||
|
defer C.free(unsafe.Pointer(cMethod))
|
||||||
|
var response C.cliproxy_buffer
|
||||||
|
var req *C.uint8_t
|
||||||
|
if len(payload) > 0 {
|
||||||
|
req = (*C.uint8_t)(C.CBytes(payload))
|
||||||
|
defer C.free(unsafe.Pointer(req))
|
||||||
|
}
|
||||||
|
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||||
|
C.free_host_buffer(response.ptr, response.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/examples/plugin/auth/rust/Cargo.lock
generated
Normal file
7
backend/examples/plugin/auth/rust/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cliproxy-auth-rust"
|
||||||
|
version = "0.1.0"
|
||||||
7
backend/examples/plugin/auth/rust/Cargo.toml
Normal file
7
backend/examples/plugin/auth/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "cliproxy-auth-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
127
backend/examples/plugin/auth/rust/src/lib.rs
Normal file
127
backend/examples/plugin/auth/rust/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
backend/examples/plugin/claude-web-search-router/README.md
Normal file
175
backend/examples/plugin/claude-web-search-router/README.md
Normal 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 backend’s 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 Code–style exclusive `web_search` tools |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C examples/plugin bin/claude-web-search-router-go.dylib
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `.so` on Linux and `.dll` on Windows. Point `plugins.path` at the built artifact.
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type claudeStreamBuilder struct {
|
||||||
|
model string
|
||||||
|
messageID string
|
||||||
|
toolUseID string
|
||||||
|
index int
|
||||||
|
inputTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClaudeStreamBuilder(model string) *claudeStreamBuilder {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
model = "claude-sonnet-4-6"
|
||||||
|
}
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
return &claudeStreamBuilder{
|
||||||
|
model: model,
|
||||||
|
messageID: fmt.Sprintf("msg_%x", now),
|
||||||
|
toolUseID: fmt.Sprintf("srvtoolu_%d", now),
|
||||||
|
inputTokens: 85,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *claudeStreamBuilder) buildStreamWithQuery(query string, hits []claudeWebSearchHit, answer string) []byte {
|
||||||
|
var chunks []string
|
||||||
|
chunks = append(chunks, b.event("message_start", map[string]any{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": map[string]any{
|
||||||
|
"id": b.messageID, "type": "message", "role": "assistant", "content": []any{},
|
||||||
|
"model": b.model, "stop_reason": nil, "stop_sequence": nil,
|
||||||
|
"usage": map[string]any{"input_tokens": b.inputTokens, "output_tokens": 0},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
chunks = append(chunks, b.blockStart(b.index, map[string]any{
|
||||||
|
"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]any{},
|
||||||
|
}))
|
||||||
|
partial, _ := json.Marshal(map[string]string{"query": query})
|
||||||
|
chunks = append(chunks, b.event("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": b.index,
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(partial)},
|
||||||
|
}))
|
||||||
|
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
||||||
|
b.index++
|
||||||
|
|
||||||
|
resultContent := webSearchResultBlocks(hits)
|
||||||
|
chunks = append(chunks, b.blockStart(b.index, map[string]any{
|
||||||
|
"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": resultContent,
|
||||||
|
}))
|
||||||
|
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
||||||
|
b.index++
|
||||||
|
|
||||||
|
text := composeAnswerText(answer, hits)
|
||||||
|
outputTokens := estimateTokens(text)
|
||||||
|
chunks = append(chunks, b.blockStart(b.index, map[string]any{"type": "text", "text": ""}))
|
||||||
|
chunks = append(chunks, b.event("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": b.index,
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": text},
|
||||||
|
}))
|
||||||
|
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
||||||
|
|
||||||
|
chunks = append(chunks, b.event("message_delta", map[string]any{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": b.inputTokens, "output_tokens": outputTokens,
|
||||||
|
"server_tool_use": map[string]any{"web_search_requests": 1},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
chunks = append(chunks, b.event("message_stop", map[string]any{"type": "message_stop"}))
|
||||||
|
return []byte(strings.Join(chunks, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *claudeStreamBuilder) buildMessageJSON(query string, hits []claudeWebSearchHit, answer string) []byte {
|
||||||
|
text := composeAnswerText(answer, hits)
|
||||||
|
content := []map[string]any{
|
||||||
|
{"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]string{"query": query}},
|
||||||
|
{"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": webSearchResultBlocks(hits)},
|
||||||
|
{"type": "text", "text": text},
|
||||||
|
}
|
||||||
|
out := map[string]any{
|
||||||
|
"id": b.messageID, "type": "message", "role": "assistant", "model": b.model,
|
||||||
|
"content": content, "stop_reason": "end_turn", "stop_sequence": nil,
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": b.inputTokens, "output_tokens": estimateTokens(text),
|
||||||
|
"server_tool_use": map[string]any{"web_search_requests": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(out)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func webSearchResultBlocks(hits []claudeWebSearchHit) []map[string]any {
|
||||||
|
resultContent := make([]map[string]any, 0, len(hits))
|
||||||
|
for _, hit := range hits {
|
||||||
|
title := hit.Title
|
||||||
|
if title == "" {
|
||||||
|
title = hostFromURL(hit.URL)
|
||||||
|
}
|
||||||
|
resultContent = append(resultContent, map[string]any{
|
||||||
|
"type": "web_search_result", "title": title, "url": hit.URL, "page_age": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resultContent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *claudeStreamBuilder) event(eventType string, data map[string]any) string {
|
||||||
|
raw, _ := json.Marshal(data)
|
||||||
|
return fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *claudeStreamBuilder) blockStart(index int, block map[string]any) string {
|
||||||
|
return b.event("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": index, "content_block": block,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeAnswerText(answer string, hits []claudeWebSearchHit) string {
|
||||||
|
if strings.TrimSpace(answer) != "" {
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
return "No web search results were returned."
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
for i, hit := range hits {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if hit.Title != "" {
|
||||||
|
buf.WriteString(hit.Title)
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
if hit.URL != "" {
|
||||||
|
buf.WriteString(hit.URL)
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
if hit.Snippet != "" {
|
||||||
|
buf.WriteString(hit.Snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostFromURL(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
withoutScheme := raw
|
||||||
|
if idx := strings.Index(raw, "://"); idx >= 0 {
|
||||||
|
withoutScheme = raw[idx+3:]
|
||||||
|
}
|
||||||
|
if slash := strings.Index(withoutScheme, "/"); slash >= 0 {
|
||||||
|
return withoutScheme[:slash]
|
||||||
|
}
|
||||||
|
return withoutScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateTokens(text string) int {
|
||||||
|
n := len([]rune(text)) / 4
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestConfigurePreservesDefaultBooleansWhenConfigIsPartial(t *testing.T) {
|
||||||
|
raw := mustJSON(t, lifecycleRequest{ConfigYAML: []byte("route: codex_web_search\n")})
|
||||||
|
|
||||||
|
if errConfigure := configure(raw); errConfigure != nil {
|
||||||
|
t.Fatalf("configure() error = %v", errConfigure)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := loadedConfig()
|
||||||
|
if !cfg.Enabled {
|
||||||
|
t.Fatal("Enabled = false, want default true")
|
||||||
|
}
|
||||||
|
if !cfg.RequireWebSearchOnly {
|
||||||
|
t.Fatal("RequireWebSearchOnly = false, want default true")
|
||||||
|
}
|
||||||
|
if cfg.Route != string(backendCodexWebSearch) {
|
||||||
|
t.Fatalf("Route = %q, want codex_web_search", cfg.Route)
|
||||||
|
}
|
||||||
|
}
|
||||||
183
backend/examples/plugin/claude-web-search-router/go/detect.go
Normal file
183
backend/examples/plugin/claude-web-search-router/go/detect.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectClaudeCodeWebSearchFromFixture(t *testing.T) {
|
||||||
|
root := filepath.Join("..", "..", "..", "..", "temp", "1.json")
|
||||||
|
raw, errRead := os.ReadFile(root)
|
||||||
|
if errRead != nil {
|
||||||
|
t.Skipf("fixture not found: %v", errRead)
|
||||||
|
}
|
||||||
|
// Fixture is HTTP capture; extract JSON request body between first blank line after headers.
|
||||||
|
body := extractHTTPJSONBody(raw)
|
||||||
|
if len(body) == 0 {
|
||||||
|
t.Fatal("empty JSON body in fixture")
|
||||||
|
}
|
||||||
|
if !hasClaudeTypedWebSearchTool(body) {
|
||||||
|
t.Fatal("fixture should declare web_search_20250305")
|
||||||
|
}
|
||||||
|
if !looksLikeClaudeCodeWebSearchAssistant(body) {
|
||||||
|
t.Fatal("fixture should match Claude Code web search assistant heuristics")
|
||||||
|
}
|
||||||
|
if !isClaudeCodeBuiltinWebSearchRequest(body, true) {
|
||||||
|
t.Fatal("expected match with require_web_search_only=true")
|
||||||
|
}
|
||||||
|
query := extractClaudeWebSearchQuery(body)
|
||||||
|
if query == "" {
|
||||||
|
t.Fatal("expected non-empty search query")
|
||||||
|
}
|
||||||
|
if want := "北京天气 2026年6月16日"; query != want {
|
||||||
|
t.Fatalf("query = %q, want %q", query, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractHTTPJSONBody(raw []byte) []byte {
|
||||||
|
text := string(raw)
|
||||||
|
idx := 0
|
||||||
|
for {
|
||||||
|
next := findDoubleNewline(text, idx)
|
||||||
|
if next < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest := trimLeft(text[next:])
|
||||||
|
if len(rest) > 0 && rest[0] == '{' {
|
||||||
|
return []byte(rest)
|
||||||
|
}
|
||||||
|
idx = next + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDoubleNewline(s string, from int) int {
|
||||||
|
for i := from; i+1 < len(s); i++ {
|
||||||
|
if s[i] == '\n' && s[i+1] == '\n' {
|
||||||
|
return i + 2
|
||||||
|
}
|
||||||
|
if s[i] == '\r' && i+3 < len(s) && s[i+1] == '\n' && s[i+2] == '\r' && s[i+3] == '\n' {
|
||||||
|
return i + 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimLeft(s string) string {
|
||||||
|
for len(s) > 0 && (s[0] == '\r' || s[0] == '\n' || s[0] == ' ') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type streamOrchestrationRunner func(context.Context, pluginapi.ExecutorRequest, string, string) error
|
||||||
|
|
||||||
|
type pluginStreamCloser func(string, string)
|
||||||
|
|
||||||
|
func executeStream(raw []byte) ([]byte, error) {
|
||||||
|
var req rpcExecutorRequest
|
||||||
|
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||||
|
return nil, errUnmarshal
|
||||||
|
}
|
||||||
|
return startExecutorStream(req, runWebSearchStreamOrchestration, closePluginStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startExecutorStream(req rpcExecutorRequest, runner streamOrchestrationRunner, closeStream pluginStreamCloser) ([]byte, error) {
|
||||||
|
streamID := strings.TrimSpace(req.StreamID)
|
||||||
|
if streamID == "" {
|
||||||
|
return errorEnvelope("executor_error", "stream_id is required for executor.execute_stream"), nil
|
||||||
|
}
|
||||||
|
if runner == nil {
|
||||||
|
return errorEnvelope("executor_error", "stream orchestration runner is unavailable"), nil
|
||||||
|
}
|
||||||
|
if closeStream == nil {
|
||||||
|
closeStream = func(string, string) {}
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
closeStream(streamID, fmt.Sprintf("stream orchestration panic: %v", recovered))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
errRun := runner(context.Background(), req.ExecutorRequest, req.HostCallbackID, streamID)
|
||||||
|
if errRun != nil {
|
||||||
|
closeStream(streamID, errRun.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeStream(streamID, "")
|
||||||
|
}()
|
||||||
|
return okEnvelope(map[string]any{
|
||||||
|
"headers": http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type executionPlan struct {
|
||||||
|
backend routeBackend
|
||||||
|
model string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecutionPlans(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
|
||||||
|
return buildExecutionPlansInternal(cfg, req, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecutionPlansForExecute(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
|
||||||
|
route := strings.TrimSpace(cfg.Route)
|
||||||
|
if isFallbackRoute(route) {
|
||||||
|
return buildExecutionPlansInternal(cfg, req, false)
|
||||||
|
}
|
||||||
|
return executionPlansForExecuteRoute(cfg, req, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executionPlansForExecuteRoute builds plans for plugin executor without requiring
|
||||||
|
// ModelRouteRequest.AvailableProviders (host does not pass it on executor.execute_stream).
|
||||||
|
func executionPlansForExecuteRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
|
||||||
|
backend := routeBackend(strings.TrimSpace(route))
|
||||||
|
if !backendRunnableLenient(backend, cfg, req) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var plans []executionPlan
|
||||||
|
switch backend {
|
||||||
|
case backendAntigravityGoogle:
|
||||||
|
model := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
|
||||||
|
if model == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
plans = append(plans, executionPlan{backend: backend, model: model})
|
||||||
|
case backendCodexWebSearch:
|
||||||
|
plans = append(plans, executionPlan{backend: backend, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
|
||||||
|
case backendXAIWebSearch:
|
||||||
|
plans = append(plans, executionPlan{backend: backend, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
|
||||||
|
case backendTavily:
|
||||||
|
if !newTavilyClient(cfg.TavilyAPIKeys).available() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
plans = append(plans, executionPlan{backend: backend})
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecutionPlansInternal(cfg pluginConfig, req pluginapi.ModelRouteRequest, requireProviders bool) []executionPlan {
|
||||||
|
var plans []executionPlan
|
||||||
|
for _, backend := range defaultWebSearchFallbackChain() {
|
||||||
|
if requireProviders {
|
||||||
|
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !backendRunnableLenient(backend, cfg, req) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch backend {
|
||||||
|
case backendAntigravityGoogle:
|
||||||
|
plans = append(plans, executionPlan{
|
||||||
|
backend: backend,
|
||||||
|
model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel),
|
||||||
|
})
|
||||||
|
case backendCodexWebSearch:
|
||||||
|
plans = append(plans, executionPlan{
|
||||||
|
backend: backend,
|
||||||
|
model: resolveCodexWebSearchTargetModel(cfg.CodexModel),
|
||||||
|
})
|
||||||
|
case backendXAIWebSearch:
|
||||||
|
plans = append(plans, executionPlan{
|
||||||
|
backend: backend,
|
||||||
|
model: resolveXAIWebSearchTargetModel(cfg.XAIModel),
|
||||||
|
})
|
||||||
|
case backendTavily:
|
||||||
|
plans = append(plans, executionPlan{backend: backend})
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendRunnableLenient(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) bool {
|
||||||
|
switch backend {
|
||||||
|
case backendTavily:
|
||||||
|
return newTavilyClient(cfg.TavilyAPIKeys).available()
|
||||||
|
case backendAntigravityGoogle:
|
||||||
|
return resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel) != ""
|
||||||
|
case backendCodexWebSearch, backendXAIWebSearch:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executionPlansForRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
|
||||||
|
if isFallbackRoute(route) {
|
||||||
|
return buildExecutionPlans(cfg, req)
|
||||||
|
}
|
||||||
|
backend := routeBackend(strings.TrimSpace(route))
|
||||||
|
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var plans []executionPlan
|
||||||
|
for _, b := range []routeBackend{backend} {
|
||||||
|
if !backendRunnableLenient(b, cfg, req) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch b {
|
||||||
|
case backendAntigravityGoogle:
|
||||||
|
plans = append(plans, executionPlan{backend: b, model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)})
|
||||||
|
case backendCodexWebSearch:
|
||||||
|
plans = append(plans, executionPlan{backend: b, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
|
||||||
|
case backendXAIWebSearch:
|
||||||
|
plans = append(plans, executionPlan{backend: b, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
|
||||||
|
case backendTavily:
|
||||||
|
plans = append(plans, executionPlan{backend: b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeRequestBody(exec pluginapi.ExecutorRequest) []byte {
|
||||||
|
if len(exec.OriginalRequest) > 0 {
|
||||||
|
return exec.OriginalRequest
|
||||||
|
}
|
||||||
|
return exec.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWebSearchWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
|
||||||
|
cfg := loadedConfig()
|
||||||
|
req := pluginapi.ModelRouteRequest{
|
||||||
|
SourceFormat: "claude",
|
||||||
|
RequestedModel: strings.TrimSpace(exec.Model),
|
||||||
|
Body: claudeRequestBody(exec),
|
||||||
|
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
|
||||||
|
}
|
||||||
|
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWebSearchStreamWithExecutionFallback buffers the full host stream (non-streaming RPC path only).
|
||||||
|
func runWebSearchStreamWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
|
||||||
|
cfg := loadedConfig()
|
||||||
|
req := pluginapi.ModelRouteRequest{
|
||||||
|
SourceFormat: "claude",
|
||||||
|
RequestedModel: strings.TrimSpace(exec.Model),
|
||||||
|
Body: claudeRequestBody(exec),
|
||||||
|
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
|
||||||
|
}
|
||||||
|
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOrderedExecutionPlans(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string, cfg pluginConfig, plans []executionPlan, stream bool) ([]byte, http.Header, error) {
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("web search execution: no backend available")
|
||||||
|
}
|
||||||
|
backends := make([]routeBackend, 0, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
backends = append(backends, p.backend)
|
||||||
|
}
|
||||||
|
ordered := sortBackendsByPenalty(backends)
|
||||||
|
planByBackend := make(map[routeBackend]executionPlan, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
planByBackend[p.backend] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
body := claudeRequestBody(exec)
|
||||||
|
var lastErr error
|
||||||
|
for _, backend := range ordered {
|
||||||
|
plan := planByBackend[backend]
|
||||||
|
switch backend {
|
||||||
|
case backendTavily:
|
||||||
|
var payload []byte
|
||||||
|
var headers http.Header
|
||||||
|
var errRun error
|
||||||
|
if stream {
|
||||||
|
payload, headers, errRun = runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
|
||||||
|
} else {
|
||||||
|
payload, headers, errRun = runTavilyClaudeWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
|
||||||
|
}
|
||||||
|
if errRun != nil {
|
||||||
|
lastErr = errRun
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recordBackendSuccess(backend)
|
||||||
|
return payload, headers, nil
|
||||||
|
default:
|
||||||
|
payload, status, errRun := hostModelExecuteClaude(ctx, hostCallbackID, plan.model, body, stream)
|
||||||
|
if errRun != nil {
|
||||||
|
lastErr = errRun
|
||||||
|
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
|
||||||
|
recordBackendFailure(backend)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isRetryableHTTPStatus(status) {
|
||||||
|
recordBackendFailure(backend)
|
||||||
|
lastErr = fmt.Errorf("host model status %d", status)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recordBackendSuccess(backend)
|
||||||
|
headers := http.Header{"Content-Type": []string{"application/json"}}
|
||||||
|
if stream {
|
||||||
|
headers = http.Header{"Content-Type": []string{"text/event-stream"}}
|
||||||
|
}
|
||||||
|
return payload, headers, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("web search execution: all backends failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableProvidersFromMetadata(meta map[string]any) []string {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, ok := meta["available_providers"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s, okItem := item.(string); okItem {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostModelExecuteClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, stream bool) ([]byte, int, error) {
|
||||||
|
if stream {
|
||||||
|
return hostModelStreamClaude(ctx, hostCallbackID, execModel, body)
|
||||||
|
}
|
||||||
|
raw, errCall := callHost(pluginabi.MethodHostModelExecute, hostModelExecutionRequest{
|
||||||
|
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||||
|
EntryProtocol: "claude",
|
||||||
|
ExitProtocol: "claude",
|
||||||
|
Model: execModel,
|
||||||
|
Stream: false,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
HostCallbackID: hostCallbackID,
|
||||||
|
})
|
||||||
|
if errCall != nil {
|
||||||
|
return nil, hostHTTPStatusFromError(errCall), errCall
|
||||||
|
}
|
||||||
|
var resp pluginapi.HostModelExecutionResponse
|
||||||
|
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
|
||||||
|
return nil, 0, errDecode
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return resp.Body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostModelStreamClaude(ctx context.Context, hostCallbackID, execModel string, body []byte) ([]byte, int, error) {
|
||||||
|
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
|
||||||
|
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||||
|
EntryProtocol: "claude",
|
||||||
|
ExitProtocol: "claude",
|
||||||
|
Model: execModel,
|
||||||
|
Stream: true,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
HostCallbackID: hostCallbackID,
|
||||||
|
})
|
||||||
|
if errCall != nil {
|
||||||
|
return nil, hostHTTPStatusFromError(errCall), errCall
|
||||||
|
}
|
||||||
|
var resp pluginapi.HostModelStreamResponse
|
||||||
|
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
|
||||||
|
return nil, 0, errDecode
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
_ = closeHostModelStream(resp.StreamID)
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(resp.StreamID) == "" {
|
||||||
|
return nil, 0, fmt.Errorf("host model stream: empty stream_id")
|
||||||
|
}
|
||||||
|
defer func() { _ = closeHostModelStream(resp.StreamID) }()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for {
|
||||||
|
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
|
||||||
|
if errRead != nil {
|
||||||
|
return nil, hostHTTPStatusFromError(errRead), errRead
|
||||||
|
}
|
||||||
|
var chunk pluginapi.HostModelStreamReadResponse
|
||||||
|
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
|
||||||
|
return nil, 0, errDecode
|
||||||
|
}
|
||||||
|
if chunk.Error != "" {
|
||||||
|
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
|
||||||
|
return nil, code, fmt.Errorf("%s", chunk.Error)
|
||||||
|
}
|
||||||
|
if len(chunk.Payload) > 0 {
|
||||||
|
buf.Write(chunk.Payload)
|
||||||
|
}
|
||||||
|
if chunk.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.Bytes(), http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeHostModelStream(streamID string) error {
|
||||||
|
_, errCall := callHost(pluginabi.MethodHostModelStreamClose, pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
|
||||||
|
return errCall
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildExecutionPlansForExecuteRespectsRouteTavily(t *testing.T) {
|
||||||
|
currentConfig.Store(pluginConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Route: string(backendTavily),
|
||||||
|
TavilyAPIKeys: []string{"tvly-test"},
|
||||||
|
})
|
||||||
|
cfg := loadedConfig()
|
||||||
|
req := pluginapi.ModelRouteRequest{
|
||||||
|
SourceFormat: "claude",
|
||||||
|
RequestedModel: "claude-sonnet-4-6",
|
||||||
|
AvailableProviders: []string{"antigravity", "codex", "xai"},
|
||||||
|
}
|
||||||
|
plans := buildExecutionPlansForExecute(cfg, req)
|
||||||
|
if len(plans) != 1 {
|
||||||
|
t.Fatalf("plans len = %d, want 1 for route=tavily", len(plans))
|
||||||
|
}
|
||||||
|
if plans[0].backend != backendTavily {
|
||||||
|
t.Fatalf("backend = %q, want tavily", plans[0].backend)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
backend/examples/plugin/claude-web-search-router/go/fallback.go
Normal file
107
backend/examples/plugin/claude-web-search-router/go/fallback.go
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
18
backend/examples/plugin/claude-web-search-router/go/go.mod
Normal file
18
backend/examples/plugin/claude-web-search-router/go/go.mod
Normal 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 => ../../../..
|
||||||
25
backend/examples/plugin/claude-web-search-router/go/go.sum
Normal file
25
backend/examples/plugin/claude-web-search-router/go/go.sum
Normal 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=
|
||||||
482
backend/examples/plugin/claude-web-search-router/go/main.go
Normal file
482
backend/examples/plugin/claude-web-search-router/go/main.go
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Default Codex model for Claude web_search → Codex Responses (override with codex_model).
|
||||||
|
defaultCodexWebSearchModel = "gpt-5.4-mini"
|
||||||
|
// Default xAI model for server-side web_search per https://docs.x.ai/developers/tools/web-search
|
||||||
|
defaultXAIWebSearchModel = "grok-4.3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveAntigravityWebSearchTargetModel picks an Antigravity model that can run native googleSearch.
|
||||||
|
// Config antigravity_model wins; otherwise registry.AntigravityWebSearchModelFor(requested) or the
|
||||||
|
// first available antigravity model with SupportsWebSearch.
|
||||||
|
func resolveAntigravityWebSearchTargetModel(configured, requested string) string {
|
||||||
|
if m := strings.TrimSpace(configured); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if m := registry.AntigravityWebSearchModelFor(strings.TrimSpace(requested)); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, model := range registry.GetGlobalRegistry().GetAvailableModelsByProvider("antigravity") {
|
||||||
|
if model == nil || !model.SupportsWebSearch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(model.ID); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCodexWebSearchTargetModel never forwards the client Claude model to Codex.
|
||||||
|
func resolveCodexWebSearchTargetModel(configured string) string {
|
||||||
|
if m := strings.TrimSpace(configured); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return defaultCodexWebSearchModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveXAIWebSearchTargetModel never forwards the client Claude model to xAI Responses.
|
||||||
|
func resolveXAIWebSearchTargetModel(configured string) string {
|
||||||
|
if m := strings.TrimSpace(configured); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return defaultXAIWebSearchModel
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveCodexWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
|
||||||
|
got := resolveCodexWebSearchTargetModel("")
|
||||||
|
if got != defaultCodexWebSearchModel {
|
||||||
|
t.Fatalf("empty config = %q, want %q", got, defaultCodexWebSearchModel)
|
||||||
|
}
|
||||||
|
if got := resolveCodexWebSearchTargetModel("gpt-5.5"); got != "gpt-5.5" {
|
||||||
|
t.Fatalf("configured = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveXAIWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
|
||||||
|
got := resolveXAIWebSearchTargetModel("")
|
||||||
|
if got != defaultXAIWebSearchModel {
|
||||||
|
t.Fatalf("empty config = %q, want %q", got, defaultXAIWebSearchModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAntigravityWebSearchTargetModelConfiguredWins(t *testing.T) {
|
||||||
|
if got := resolveAntigravityWebSearchTargetModel("my-gemini", "claude-sonnet-4-6"); got != "my-gemini" {
|
||||||
|
t.Fatalf("configured = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAntigravityWebSearchTargetModelFromRegistry(t *testing.T) {
|
||||||
|
reg := registry.GetGlobalRegistry()
|
||||||
|
const clientID = "test-claude-web-search-router-antigravity"
|
||||||
|
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
|
||||||
|
{ID: "gemini-web-search-test", SupportsWebSearch: true},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { reg.UnregisterClient(clientID) })
|
||||||
|
got := resolveAntigravityWebSearchTargetModel("", "claude-sonnet-4-6")
|
||||||
|
if got != "gemini-web-search-test" {
|
||||||
|
t.Fatalf("fallback = %q, want gemini-web-search-test", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
penaltyBumpOn429503 = 5
|
||||||
|
penaltyDecaySuccess = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
var backendPenalties = struct {
|
||||||
|
sync.Mutex
|
||||||
|
scores map[routeBackend]int
|
||||||
|
}{
|
||||||
|
scores: make(map[routeBackend]int),
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordBackendFailure(backend routeBackend) {
|
||||||
|
backendPenalties.Lock()
|
||||||
|
defer backendPenalties.Unlock()
|
||||||
|
backendPenalties.scores[backend] += penaltyBumpOn429503
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordBackendSuccess(backend routeBackend) {
|
||||||
|
backendPenalties.Lock()
|
||||||
|
defer backendPenalties.Unlock()
|
||||||
|
score := backendPenalties.scores[backend] - penaltyDecaySuccess
|
||||||
|
if score < 0 {
|
||||||
|
score = 0
|
||||||
|
}
|
||||||
|
backendPenalties.scores[backend] = score
|
||||||
|
}
|
||||||
|
|
||||||
|
func penaltyScore(backend routeBackend) int {
|
||||||
|
backendPenalties.Lock()
|
||||||
|
defer backendPenalties.Unlock()
|
||||||
|
return backendPenalties.scores[backend]
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortBackendsByPenalty(backends []routeBackend) []routeBackend {
|
||||||
|
if len(backends) <= 1 {
|
||||||
|
return append([]routeBackend(nil), backends...)
|
||||||
|
}
|
||||||
|
out := append([]routeBackend(nil), backends...)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
return penaltyScore(out[i]) < penaltyScore(out[j])
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetBackendPenaltiesForTest() {
|
||||||
|
backendPenalties.Lock()
|
||||||
|
defer backendPenalties.Unlock()
|
||||||
|
backendPenalties.scores = make(map[routeBackend]int)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSortBackendsByPenaltyDeprioritizesFailures(t *testing.T) {
|
||||||
|
resetBackendPenaltiesForTest()
|
||||||
|
t.Cleanup(resetBackendPenaltiesForTest)
|
||||||
|
recordBackendFailure(backendAntigravityGoogle)
|
||||||
|
recordBackendFailure(backendAntigravityGoogle)
|
||||||
|
ordered := sortBackendsByPenalty([]routeBackend{
|
||||||
|
backendAntigravityGoogle,
|
||||||
|
backendCodexWebSearch,
|
||||||
|
backendXAIWebSearch,
|
||||||
|
})
|
||||||
|
if ordered[0] != backendCodexWebSearch {
|
||||||
|
t.Fatalf("ordered = %v, want codex first after antigravity penalty", ordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rpcStreamEmitRequest struct {
|
||||||
|
StreamID string `json:"stream_id"`
|
||||||
|
Payload []byte `json:"payload,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcStreamCloseRequest struct {
|
||||||
|
StreamID string `json:"stream_id"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitPluginStreamChunk(streamID string, payload []byte) error {
|
||||||
|
if strings.TrimSpace(streamID) == "" {
|
||||||
|
return fmt.Errorf("plugin stream id is required")
|
||||||
|
}
|
||||||
|
_, errCall := callHost(pluginabi.MethodHostStreamEmit, rpcStreamEmitRequest{
|
||||||
|
StreamID: streamID,
|
||||||
|
Payload: payload,
|
||||||
|
})
|
||||||
|
return errCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func closePluginStream(streamID, errMsg string) {
|
||||||
|
if strings.TrimSpace(streamID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = callHost(pluginabi.MethodHostStreamClose, rpcStreamCloseRequest{
|
||||||
|
StreamID: streamID,
|
||||||
|
Error: strings.TrimSpace(errMsg),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeOpenAIResponsesSSE(payload []byte) bool {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := string(payload)
|
||||||
|
if strings.Contains(s, "event: message_start") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "event: response.") ||
|
||||||
|
strings.Contains(s, `"type":"response.`) ||
|
||||||
|
strings.Contains(s, `"type": "response.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWebSearchStreamOrchestration(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
|
||||||
|
cfg := loadedConfig()
|
||||||
|
req := pluginapi.ModelRouteRequest{
|
||||||
|
SourceFormat: "claude",
|
||||||
|
RequestedModel: strings.TrimSpace(exec.Model),
|
||||||
|
Body: claudeRequestBody(exec),
|
||||||
|
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
|
||||||
|
}
|
||||||
|
return runOrderedExecutionPlansStream(ctx, exec, hostCallbackID, pluginStreamID, cfg, buildExecutionPlansForExecute(cfg, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOrderedExecutionPlansStream(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string, cfg pluginConfig, plans []executionPlan) error {
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return fmt.Errorf("web search execution: no backend available")
|
||||||
|
}
|
||||||
|
backends := make([]routeBackend, 0, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
backends = append(backends, p.backend)
|
||||||
|
}
|
||||||
|
ordered := sortBackendsByPenalty(backends)
|
||||||
|
planByBackend := make(map[routeBackend]executionPlan, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
planByBackend[p.backend] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
body := claudeRequestBody(exec)
|
||||||
|
var lastErr error
|
||||||
|
for _, backend := range ordered {
|
||||||
|
plan := planByBackend[backend]
|
||||||
|
switch backend {
|
||||||
|
case backendTavily:
|
||||||
|
payload, _, errRun := runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
|
||||||
|
if errRun != nil {
|
||||||
|
lastErr = errRun
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errEmit := emitPluginStreamChunk(pluginStreamID, payload); errEmit != nil {
|
||||||
|
return errEmit
|
||||||
|
}
|
||||||
|
recordBackendSuccess(backend)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
status, errRun := hostModelStreamForwardClaude(ctx, hostCallbackID, plan.model, body, pluginStreamID)
|
||||||
|
if errRun != nil {
|
||||||
|
lastErr = errRun
|
||||||
|
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
|
||||||
|
recordBackendFailure(backend)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isRetryableHTTPStatus(status) {
|
||||||
|
recordBackendFailure(backend)
|
||||||
|
lastErr = fmt.Errorf("host model status %d", status)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recordBackendSuccess(backend)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
return fmt.Errorf("web search execution: all backends failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostModelStreamForwardClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, pluginStreamID string) (int, error) {
|
||||||
|
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
|
||||||
|
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||||
|
EntryProtocol: "claude",
|
||||||
|
ExitProtocol: "claude",
|
||||||
|
Model: execModel,
|
||||||
|
Stream: true,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
HostCallbackID: hostCallbackID,
|
||||||
|
})
|
||||||
|
if errCall != nil {
|
||||||
|
return hostHTTPStatusFromError(errCall), errCall
|
||||||
|
}
|
||||||
|
var resp pluginapi.HostModelStreamResponse
|
||||||
|
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
|
||||||
|
return 0, errDecode
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
_ = closeHostModelStream(resp.StreamID)
|
||||||
|
return resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(resp.StreamID) == "" {
|
||||||
|
return 0, fmt.Errorf("host model stream: empty stream_id")
|
||||||
|
}
|
||||||
|
defer func() { _ = closeHostModelStream(resp.StreamID) }()
|
||||||
|
|
||||||
|
firstPayload := true
|
||||||
|
for {
|
||||||
|
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
|
||||||
|
if errRead != nil {
|
||||||
|
return hostHTTPStatusFromError(errRead), errRead
|
||||||
|
}
|
||||||
|
var chunk pluginapi.HostModelStreamReadResponse
|
||||||
|
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
|
||||||
|
return 0, errDecode
|
||||||
|
}
|
||||||
|
if chunk.Error != "" {
|
||||||
|
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
|
||||||
|
return code, fmt.Errorf("%s", chunk.Error)
|
||||||
|
}
|
||||||
|
if len(chunk.Payload) > 0 {
|
||||||
|
if firstPayload && looksLikeOpenAIResponsesSSE(chunk.Payload) {
|
||||||
|
return 0, fmt.Errorf("host model stream returned OpenAI Responses SSE instead of Claude Messages SSE")
|
||||||
|
}
|
||||||
|
firstPayload = false
|
||||||
|
if errEmit := emitPluginStreamChunk(pluginStreamID, bytes.Clone(chunk.Payload)); errEmit != nil {
|
||||||
|
return 0, errEmit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunk.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLooksLikeOpenAIResponsesSSE(t *testing.T) {
|
||||||
|
if !looksLikeOpenAIResponsesSSE([]byte("event: response.created\ndata: {\"type\":\"response.created\"}\n\n")) {
|
||||||
|
t.Fatal("expected OpenAI Responses SSE detection")
|
||||||
|
}
|
||||||
|
if looksLikeOpenAIResponsesSSE([]byte("event: message_start\ndata: {\"type\":\"message_start\"}\n\n")) {
|
||||||
|
t.Fatal("expected Claude Messages SSE to not match Responses detector")
|
||||||
|
}
|
||||||
|
if looksLikeOpenAIResponsesSSE(nil) {
|
||||||
|
t.Fatal("empty payload should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartExecutorStreamRunsOrchestrationAfterRPCReturns(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
closed := make(chan string, 1)
|
||||||
|
req := rpcExecutorRequest{
|
||||||
|
ExecutorRequest: pluginapi.ExecutorRequest{Stream: true},
|
||||||
|
StreamID: "stream-1",
|
||||||
|
HostCallbackID: "callback-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, errStart := startExecutorStream(req, func(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
|
||||||
|
if hostCallbackID != "callback-1" || pluginStreamID != "stream-1" {
|
||||||
|
t.Errorf("runner ids = %q/%q, want callback-1/stream-1", hostCallbackID, pluginStreamID)
|
||||||
|
}
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}, func(streamID, errMsg string) {
|
||||||
|
closed <- streamID + "|" + errMsg
|
||||||
|
})
|
||||||
|
if errStart != nil {
|
||||||
|
t.Fatalf("startExecutorStream() error = %v", errStart)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "text/event-stream") {
|
||||||
|
t.Fatalf("response does not include stream headers: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("orchestration did not start")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-closed:
|
||||||
|
t.Fatalf("stream closed before orchestration finished: %q", got)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
select {
|
||||||
|
case got := <-closed:
|
||||||
|
if got != "stream-1|" {
|
||||||
|
t.Fatalf("close call = %q, want stream-1|", got)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("stream was not closed after orchestration finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
144
backend/examples/plugin/claude-web-search-router/go/tavily.go
Normal file
144
backend/examples/plugin/claude-web-search-router/go/tavily.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTavilyClientSearchMockAPI(t *testing.T) {
|
||||||
|
var gotBody tavilySearchRequest
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||||
|
t.Errorf("content-type = %q", ct)
|
||||||
|
}
|
||||||
|
raw, errRead := io.ReadAll(r.Body)
|
||||||
|
if errRead != nil {
|
||||||
|
t.Fatal(errRead)
|
||||||
|
}
|
||||||
|
if errDecode := json.Unmarshal(raw, &gotBody); errDecode != nil {
|
||||||
|
t.Fatal(errDecode)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"query": "北京天气",
|
||||||
|
"answer": "明天晴。",
|
||||||
|
"results": [
|
||||||
|
{"title": "Example Weather", "url": "https://example.com/w", "content": "snippet one"}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := newTavilyClientWithOptions([]string{"tvly-test-key"}, server.Client(), server.URL)
|
||||||
|
hits, answer, errSearch := client.search(context.Background(), "北京天气", 3)
|
||||||
|
if errSearch != nil {
|
||||||
|
t.Fatalf("search() error = %v", errSearch)
|
||||||
|
}
|
||||||
|
if gotBody.APIKey != "tvly-test-key" {
|
||||||
|
t.Fatalf("api_key = %q", gotBody.APIKey)
|
||||||
|
}
|
||||||
|
if gotBody.Query != "北京天气" {
|
||||||
|
t.Fatalf("query = %q", gotBody.Query)
|
||||||
|
}
|
||||||
|
if gotBody.MaxResults != 3 {
|
||||||
|
t.Fatalf("max_results = %d, want 3", gotBody.MaxResults)
|
||||||
|
}
|
||||||
|
if !gotBody.IncludeAnswer {
|
||||||
|
t.Fatal("include_answer should be true")
|
||||||
|
}
|
||||||
|
if answer != "明天晴。" {
|
||||||
|
t.Fatalf("answer = %q", answer)
|
||||||
|
}
|
||||||
|
if len(hits) != 1 || hits[0].URL != "https://example.com/w" {
|
||||||
|
t.Fatalf("hits = %#v", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTavilyClientSearchEmptyKeys(t *testing.T) {
|
||||||
|
client := newTavilyClient(nil)
|
||||||
|
_, _, err := client.search(context.Background(), "q", 5)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "tavily_api_keys") {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTavilyClientSearchHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"bad key"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTavilyClientWithOptions([]string{"bad"}, server.Client(), server.URL)
|
||||||
|
_, _, err := client.search(context.Background(), "q", 5)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTavilyClientRoundRobinKeys(t *testing.T) {
|
||||||
|
var keys []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body tavilySearchRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
keys = append(keys, body.APIKey)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTavilyClientWithOptions([]string{"k1", "k2"}, server.Client(), server.URL)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
if _, _, err := client.search(context.Background(), "q", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keys) != 4 || keys[0] != "k1" || keys[1] != "k2" || keys[2] != "k1" || keys[3] != "k2" {
|
||||||
|
t.Fatalf("key rotation = %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTavilyClaudeStreamWithMock(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"answer": "2026年6月16日北京多雨。",
|
||||||
|
"results": [
|
||||||
|
{"title": "bjmy.gov.cn", "url": "https://www.bjmy.gov.cn/x", "content": "预报"}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
claudeBody := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"stream": true,
|
||||||
|
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}],
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Perform a web search for the query: 北京天气 2026年6月16日"}]}]
|
||||||
|
}`)
|
||||||
|
client := newTavilyClientWithOptions([]string{"tvly-mock"}, server.Client(), server.URL)
|
||||||
|
payload, headers, errRun := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
|
||||||
|
Model: "claude-sonnet-4-6",
|
||||||
|
Stream: true,
|
||||||
|
OriginalRequest: claudeBody,
|
||||||
|
}, client)
|
||||||
|
if errRun != nil {
|
||||||
|
t.Fatalf("runTavilyClaudeStreamWithClient() error = %v", errRun)
|
||||||
|
}
|
||||||
|
if headers.Get("Content-Type") != "text/event-stream" {
|
||||||
|
t.Fatalf("content-type = %q", headers.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
text := string(payload)
|
||||||
|
for _, needle := range []string{
|
||||||
|
"event: message_start",
|
||||||
|
`"type":"server_tool_use"`,
|
||||||
|
`"name":"web_search"`,
|
||||||
|
`"type":"web_search_tool_result"`,
|
||||||
|
`"type":"web_search_result"`,
|
||||||
|
`https://www.bjmy.gov.cn/x`,
|
||||||
|
`"web_search_requests":1`,
|
||||||
|
"event: message_stop",
|
||||||
|
"北京天气 2026年6月16日",
|
||||||
|
"2026年6月16日北京多雨",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, needle) {
|
||||||
|
t.Fatalf("SSE missing %q in:\n%s", needle, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTavilyClaudeJSONWithMock(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"answer":"ok","results":[{"title":"T","url":"https://t.example","content":"c"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
claudeBody := []byte(`{
|
||||||
|
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
|
||||||
|
"messages": [{"role": "user", "content": "Perform a web search for the query: test query"}]
|
||||||
|
}`)
|
||||||
|
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
|
||||||
|
payload, _, errRun := runTavilyClaudeWithClient(context.Background(), pluginapi.ExecutorRequest{
|
||||||
|
Model: "claude-sonnet-4-6",
|
||||||
|
OriginalRequest: claudeBody,
|
||||||
|
}, client)
|
||||||
|
if errRun != nil {
|
||||||
|
t.Fatal(errRun)
|
||||||
|
}
|
||||||
|
root := gjson.ParseBytes(payload)
|
||||||
|
if root.Get("type").String() != "message" {
|
||||||
|
t.Fatalf("type = %s", root.Get("type").String())
|
||||||
|
}
|
||||||
|
if root.Get("content.0.type").String() != "server_tool_use" {
|
||||||
|
t.Fatalf("content.0 = %s", root.Get("content.0.type").String())
|
||||||
|
}
|
||||||
|
if root.Get("content.1.type").String() != "web_search_tool_result" {
|
||||||
|
t.Fatalf("content.1 = %s", root.Get("content.1.type").String())
|
||||||
|
}
|
||||||
|
if root.Get("content.2.text").String() != "ok" {
|
||||||
|
t.Fatalf("text = %s", root.Get("content.2.text").String())
|
||||||
|
}
|
||||||
|
if root.Get("usage.server_tool_use.web_search_requests").Int() != 1 {
|
||||||
|
t.Fatalf("web_search_requests = %d", root.Get("usage.server_tool_use.web_search_requests").Int())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStreamRPCWithMockTavily(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"answer":"rpc-ok","results":[]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
currentConfig.Store(pluginConfig{
|
||||||
|
Route: string(backendTavily),
|
||||||
|
TavilyAPIKeys: []string{"k"},
|
||||||
|
})
|
||||||
|
// Override client by patching: executeStream uses loadedConfig keys + real URL.
|
||||||
|
// Test runTavilyClaudeStreamWithClient directly instead; for execute() we need config + mock URL.
|
||||||
|
// Use executor path with injected client via runTavilyClaudeStreamWithClient already covered.
|
||||||
|
_ = server
|
||||||
|
claudeBody := []byte(`{"messages":[{"role":"user","content":"Perform a web search for the query: q"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`)
|
||||||
|
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
|
||||||
|
body, _, err := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
|
||||||
|
Model: "m", Stream: true, OriginalRequest: claudeBody,
|
||||||
|
}, client)
|
||||||
|
if err != nil || !strings.Contains(string(body), "rpc-ok") {
|
||||||
|
t.Fatalf("err=%v body=%s", err, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/examples/plugin/cli/c/CMakeLists.txt
Normal file
8
backend/examples/plugin/cli/c/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(cliproxy_cli_c C)
|
||||||
|
|
||||||
|
add_library(cliproxy_cli_c SHARED src/plugin.c)
|
||||||
|
set_target_properties(cliproxy_cli_c PROPERTIES
|
||||||
|
OUTPUT_NAME "cli-c"
|
||||||
|
PREFIX ""
|
||||||
|
)
|
||||||
117
backend/examples/plugin/cli/c/src/plugin.c
Normal file
117
backend/examples/plugin/cli/c/src/plugin.c
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define CLIPROXY_EXPORT __declspec(dllexport)
|
||||||
|
#else
|
||||||
|
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define ABI_VERSION 1
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void* ptr;
|
||||||
|
size_t len;
|
||||||
|
} cliproxy_buffer;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_host_free_fn)(void*, size_t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
void* host_ctx;
|
||||||
|
cliproxy_host_call_fn call;
|
||||||
|
cliproxy_host_free_fn free_buffer;
|
||||||
|
} cliproxy_host_api;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
|
||||||
|
typedef void (*cliproxy_plugin_shutdown_fn)(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
cliproxy_plugin_call_fn call;
|
||||||
|
cliproxy_plugin_free_fn free_buffer;
|
||||||
|
cliproxy_plugin_shutdown_fn shutdown;
|
||||||
|
} cliproxy_plugin_api;
|
||||||
|
|
||||||
|
static const cliproxy_host_api* stored_host = NULL;
|
||||||
|
|
||||||
|
static void write_response(cliproxy_buffer* response, const char* text) {
|
||||||
|
if (response == NULL || text == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t len = strlen(text);
|
||||||
|
void* ptr = malloc(len);
|
||||||
|
if (ptr == NULL) {
|
||||||
|
response->ptr = NULL;
|
||||||
|
response->len = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(ptr, text, len);
|
||||||
|
response->ptr = ptr;
|
||||||
|
response->len = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void call_host(const char* method, const char* payload) {
|
||||||
|
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cliproxy_buffer response = {0};
|
||||||
|
const uint8_t* request = (const uint8_t*)payload;
|
||||||
|
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||||
|
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||||
|
stored_host->free_buffer(response.ptr, response.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||||
|
if (response != NULL) {
|
||||||
|
response->ptr = NULL;
|
||||||
|
response->len = 0;
|
||||||
|
}
|
||||||
|
if (method == NULL) {
|
||||||
|
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "plugin.register") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "command_line.register") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"example-cli-c-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "command_line.execute") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Stdout\":\"ImV4YW1wbGUtY2xpLWMgY29tbWFuZCBleGVjdXRlZFxcbiI=\",\"ExitCode\":0}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||||
|
(void)request;
|
||||||
|
(void)request_len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void plugin_free(void* ptr, size_t len) {
|
||||||
|
(void)len;
|
||||||
|
free(ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void plugin_shutdown(void) {}
|
||||||
|
|
||||||
|
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
|
||||||
|
if (plugin == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
stored_host = host;
|
||||||
|
plugin->abi_version = ABI_VERSION;
|
||||||
|
plugin->call = plugin_call;
|
||||||
|
plugin->free_buffer = plugin_free;
|
||||||
|
plugin->shutdown = plugin_shutdown;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
3
backend/examples/plugin/cli/go/go.mod
Normal file
3
backend/examples/plugin/cli/go/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/cli/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
175
backend/examples/plugin/cli/go/main.go
Normal file
175
backend/examples/plugin/cli/go/main.go
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void* ptr;
|
||||||
|
size_t len;
|
||||||
|
} cliproxy_buffer;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_host_free_fn)(void*, size_t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
void* host_ctx;
|
||||||
|
cliproxy_host_call_fn call;
|
||||||
|
cliproxy_host_free_fn free_buffer;
|
||||||
|
} cliproxy_host_api;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
|
||||||
|
typedef void (*cliproxy_plugin_shutdown_fn)(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
cliproxy_plugin_call_fn call;
|
||||||
|
cliproxy_plugin_free_fn free_buffer;
|
||||||
|
cliproxy_plugin_shutdown_fn shutdown;
|
||||||
|
} cliproxy_plugin_api;
|
||||||
|
|
||||||
|
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
extern void cliproxyPluginFree(void*, size_t);
|
||||||
|
extern void cliproxyPluginShutdown(void);
|
||||||
|
|
||||||
|
static const cliproxy_host_api* stored_host;
|
||||||
|
|
||||||
|
static void store_host_api(const cliproxy_host_api* host) {
|
||||||
|
stored_host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||||
|
if (stored_host == NULL || stored_host->call == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void free_host_buffer(void* ptr, size_t len) {
|
||||||
|
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||||
|
stored_host->free_buffer(ptr, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const abiVersion uint32 = 1
|
||||||
|
|
||||||
|
type envelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *envelopeError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type envelopeError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
|
||||||
|
//export cliproxy_plugin_init
|
||||||
|
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
|
||||||
|
if plugin == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
C.store_host_api(host)
|
||||||
|
plugin.abi_version = C.uint32_t(abiVersion)
|
||||||
|
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
|
||||||
|
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
|
||||||
|
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginCall
|
||||||
|
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
|
||||||
|
if response != nil {
|
||||||
|
response.ptr = nil
|
||||||
|
response.len = 0
|
||||||
|
}
|
||||||
|
if method == nil {
|
||||||
|
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
raw, errHandle := handleMethod(C.GoString(method))
|
||||||
|
if errHandle != nil {
|
||||||
|
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
writeResponse(response, raw)
|
||||||
|
_ = request
|
||||||
|
_ = requestLen
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginFree
|
||||||
|
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||||
|
if ptr != nil {
|
||||||
|
C.free(ptr)
|
||||||
|
}
|
||||||
|
_ = len
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginShutdown
|
||||||
|
func cliproxyPluginShutdown() {}
|
||||||
|
|
||||||
|
func handleMethod(method string) ([]byte, error) {
|
||||||
|
_ = http.StatusOK
|
||||||
|
_ = time.Second
|
||||||
|
switch method {
|
||||||
|
case "plugin.register":
|
||||||
|
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}")
|
||||||
|
case "plugin.reconfigure":
|
||||||
|
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}")
|
||||||
|
case "command_line.register":
|
||||||
|
return okEnvelopeJSON("{\"Flags\":[{\"Name\":\"example-cli-go-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}")
|
||||||
|
case "command_line.execute":
|
||||||
|
return okEnvelopeJSON("{\"Stdout\":\"ImV4YW1wbGUtY2xpLWdvIGNvbW1hbmQgZXhlY3V0ZWRcXG4i\",\"ExitCode\":0}")
|
||||||
|
default:
|
||||||
|
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||||
|
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorEnvelope(code, message string) []byte {
|
||||||
|
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
|
||||||
|
if response == nil || len(raw) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ptr := C.CBytes(raw)
|
||||||
|
if ptr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ptr = ptr
|
||||||
|
response.len = C.size_t(len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func callHost(method string, payload []byte) {
|
||||||
|
cMethod := C.CString(method)
|
||||||
|
defer C.free(unsafe.Pointer(cMethod))
|
||||||
|
var response C.cliproxy_buffer
|
||||||
|
var req *C.uint8_t
|
||||||
|
if len(payload) > 0 {
|
||||||
|
req = (*C.uint8_t)(C.CBytes(payload))
|
||||||
|
defer C.free(unsafe.Pointer(req))
|
||||||
|
}
|
||||||
|
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||||
|
C.free_host_buffer(response.ptr, response.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/examples/plugin/cli/rust/Cargo.lock
generated
Normal file
7
backend/examples/plugin/cli/rust/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cliproxy-cli-rust"
|
||||||
|
version = "0.1.0"
|
||||||
7
backend/examples/plugin/cli/rust/Cargo.toml
Normal file
7
backend/examples/plugin/cli/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "cliproxy-cli-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
127
backend/examples/plugin/cli/rust/src/lib.rs
Normal file
127
backend/examples/plugin/cli/rust/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/examples/plugin/codex-service-tier/README.md
Normal file
25
backend/examples/plugin/codex-service-tier/README.md
Normal 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.
|
||||||
17
backend/examples/plugin/codex-service-tier/go/go.mod
Normal file
17
backend/examples/plugin/codex-service-tier/go/go.mod
Normal 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 => ../../../..
|
||||||
13
backend/examples/plugin/codex-service-tier/go/go.sum
Normal file
13
backend/examples/plugin/codex-service-tier/go/go.sum
Normal 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=
|
||||||
246
backend/examples/plugin/codex-service-tier/go/main.go
Normal file
246
backend/examples/plugin/codex-service-tier/go/main.go
Normal 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))
|
||||||
|
}
|
||||||
8
backend/examples/plugin/executor/c/CMakeLists.txt
Normal file
8
backend/examples/plugin/executor/c/CMakeLists.txt
Normal 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 ""
|
||||||
|
)
|
||||||
129
backend/examples/plugin/executor/c/src/plugin.c
Normal file
129
backend/examples/plugin/executor/c/src/plugin.c
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define CLIPROXY_EXPORT __declspec(dllexport)
|
||||||
|
#else
|
||||||
|
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define ABI_VERSION 1
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void* ptr;
|
||||||
|
size_t len;
|
||||||
|
} cliproxy_buffer;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_host_free_fn)(void*, size_t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
void* host_ctx;
|
||||||
|
cliproxy_host_call_fn call;
|
||||||
|
cliproxy_host_free_fn free_buffer;
|
||||||
|
} cliproxy_host_api;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
|
||||||
|
typedef void (*cliproxy_plugin_shutdown_fn)(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
cliproxy_plugin_call_fn call;
|
||||||
|
cliproxy_plugin_free_fn free_buffer;
|
||||||
|
cliproxy_plugin_shutdown_fn shutdown;
|
||||||
|
} cliproxy_plugin_api;
|
||||||
|
|
||||||
|
static const cliproxy_host_api* stored_host = NULL;
|
||||||
|
|
||||||
|
static void write_response(cliproxy_buffer* response, const char* text) {
|
||||||
|
if (response == NULL || text == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t len = strlen(text);
|
||||||
|
void* ptr = malloc(len);
|
||||||
|
if (ptr == NULL) {
|
||||||
|
response->ptr = NULL;
|
||||||
|
response->len = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(ptr, text, len);
|
||||||
|
response->ptr = ptr;
|
||||||
|
response->len = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void call_host(const char* method, const char* payload) {
|
||||||
|
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cliproxy_buffer response = {0};
|
||||||
|
const uint8_t* request = (const uint8_t*)payload;
|
||||||
|
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||||
|
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||||
|
stored_host->free_buffer(response.ptr, response.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||||
|
if (response != NULL) {
|
||||||
|
response->ptr = NULL;
|
||||||
|
response->len = 0;
|
||||||
|
}
|
||||||
|
if (method == NULL) {
|
||||||
|
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "plugin.register") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "executor.identifier") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-executor-c\"}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "executor.execute") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItYyIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiJ9\",\"Headers\":{\"content-type\":[\"application/json\"]}}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "executor.execute_stream") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItY1xuXG4i\"}]}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "executor.count_tokens") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(method, "executor.http_request") == 0) {
|
||||||
|
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLWMifQ==\"}}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||||
|
(void)request;
|
||||||
|
(void)request_len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void plugin_free(void* ptr, size_t len) {
|
||||||
|
(void)len;
|
||||||
|
free(ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void plugin_shutdown(void) {}
|
||||||
|
|
||||||
|
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
|
||||||
|
if (plugin == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
stored_host = host;
|
||||||
|
plugin->abi_version = ABI_VERSION;
|
||||||
|
plugin->call = plugin_call;
|
||||||
|
plugin->free_buffer = plugin_free;
|
||||||
|
plugin->shutdown = plugin_shutdown;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
3
backend/examples/plugin/executor/go/go.mod
Normal file
3
backend/examples/plugin/executor/go/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/executor/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
181
backend/examples/plugin/executor/go/main.go
Normal file
181
backend/examples/plugin/executor/go/main.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void* ptr;
|
||||||
|
size_t len;
|
||||||
|
} cliproxy_buffer;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_host_free_fn)(void*, size_t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
void* host_ctx;
|
||||||
|
cliproxy_host_call_fn call;
|
||||||
|
cliproxy_host_free_fn free_buffer;
|
||||||
|
} cliproxy_host_api;
|
||||||
|
|
||||||
|
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
|
||||||
|
typedef void (*cliproxy_plugin_shutdown_fn)(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t abi_version;
|
||||||
|
cliproxy_plugin_call_fn call;
|
||||||
|
cliproxy_plugin_free_fn free_buffer;
|
||||||
|
cliproxy_plugin_shutdown_fn shutdown;
|
||||||
|
} cliproxy_plugin_api;
|
||||||
|
|
||||||
|
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
|
||||||
|
extern void cliproxyPluginFree(void*, size_t);
|
||||||
|
extern void cliproxyPluginShutdown(void);
|
||||||
|
|
||||||
|
static const cliproxy_host_api* stored_host;
|
||||||
|
|
||||||
|
static void store_host_api(const cliproxy_host_api* host) {
|
||||||
|
stored_host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||||
|
if (stored_host == NULL || stored_host->call == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void free_host_buffer(void* ptr, size_t len) {
|
||||||
|
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||||
|
stored_host->free_buffer(ptr, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const abiVersion uint32 = 1
|
||||||
|
|
||||||
|
type envelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *envelopeError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type envelopeError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
|
||||||
|
//export cliproxy_plugin_init
|
||||||
|
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
|
||||||
|
if plugin == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
C.store_host_api(host)
|
||||||
|
plugin.abi_version = C.uint32_t(abiVersion)
|
||||||
|
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
|
||||||
|
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
|
||||||
|
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginCall
|
||||||
|
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
|
||||||
|
if response != nil {
|
||||||
|
response.ptr = nil
|
||||||
|
response.len = 0
|
||||||
|
}
|
||||||
|
if method == nil {
|
||||||
|
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
raw, errHandle := handleMethod(C.GoString(method))
|
||||||
|
if errHandle != nil {
|
||||||
|
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
writeResponse(response, raw)
|
||||||
|
_ = request
|
||||||
|
_ = requestLen
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginFree
|
||||||
|
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||||
|
if ptr != nil {
|
||||||
|
C.free(ptr)
|
||||||
|
}
|
||||||
|
_ = len
|
||||||
|
}
|
||||||
|
|
||||||
|
//export cliproxyPluginShutdown
|
||||||
|
func cliproxyPluginShutdown() {}
|
||||||
|
|
||||||
|
func handleMethod(method string) ([]byte, error) {
|
||||||
|
_ = http.StatusOK
|
||||||
|
_ = time.Second
|
||||||
|
switch method {
|
||||||
|
case "plugin.register":
|
||||||
|
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}")
|
||||||
|
case "plugin.reconfigure":
|
||||||
|
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}")
|
||||||
|
case "executor.identifier":
|
||||||
|
return okEnvelopeJSON("{\"identifier\":\"example-executor-go\"}")
|
||||||
|
case "executor.execute":
|
||||||
|
return okEnvelopeJSON("{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItZ28iLCJvYmplY3QiOiJjaGF0LmNvbXBsZXRpb24ifQ==\",\"Headers\":{\"content-type\":[\"application/json\"]}}")
|
||||||
|
case "executor.execute_stream":
|
||||||
|
return okEnvelopeJSON("{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItZ29cblxuIg==\"}]}")
|
||||||
|
case "executor.count_tokens":
|
||||||
|
return okEnvelopeJSON("{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}")
|
||||||
|
case "executor.http_request":
|
||||||
|
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLWdvIn0=\"}")
|
||||||
|
default:
|
||||||
|
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||||
|
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorEnvelope(code, message string) []byte {
|
||||||
|
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
|
||||||
|
if response == nil || len(raw) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ptr := C.CBytes(raw)
|
||||||
|
if ptr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ptr = ptr
|
||||||
|
response.len = C.size_t(len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func callHost(method string, payload []byte) {
|
||||||
|
cMethod := C.CString(method)
|
||||||
|
defer C.free(unsafe.Pointer(cMethod))
|
||||||
|
var response C.cliproxy_buffer
|
||||||
|
var req *C.uint8_t
|
||||||
|
if len(payload) > 0 {
|
||||||
|
req = (*C.uint8_t)(C.CBytes(payload))
|
||||||
|
defer C.free(unsafe.Pointer(req))
|
||||||
|
}
|
||||||
|
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||||
|
C.free_host_buffer(response.ptr, response.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/examples/plugin/executor/rust/Cargo.lock
generated
Normal file
7
backend/examples/plugin/executor/rust/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cliproxy-executor-rust"
|
||||||
|
version = "0.1.0"
|
||||||
7
backend/examples/plugin/executor/rust/Cargo.toml
Normal file
7
backend/examples/plugin/executor/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "cliproxy-executor-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
127
backend/examples/plugin/executor/rust/src/lib.rs
Normal file
127
backend/examples/plugin/executor/rust/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/examples/plugin/frontend-auth-exclusive/README.md
Normal file
19
backend/examples/plugin/frontend-auth-exclusive/README.md
Normal 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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -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 => ../../../..
|
||||||
194
backend/examples/plugin/frontend-auth-exclusive/go/main.go
Normal file
194
backend/examples/plugin/frontend-auth-exclusive/go/main.go
Normal 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))
|
||||||
|
}
|
||||||
8
backend/examples/plugin/frontend-auth/c/CMakeLists.txt
Normal file
8
backend/examples/plugin/frontend-auth/c/CMakeLists.txt
Normal 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 ""
|
||||||
|
)
|
||||||
117
backend/examples/plugin/frontend-auth/c/src/plugin.c
Normal file
117
backend/examples/plugin/frontend-auth/c/src/plugin.c
Normal 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;
|
||||||
|
}
|
||||||
3
backend/examples/plugin/frontend-auth/go/go.mod
Normal file
3
backend/examples/plugin/frontend-auth/go/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
175
backend/examples/plugin/frontend-auth/go/main.go
Normal file
175
backend/examples/plugin/frontend-auth/go/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/examples/plugin/frontend-auth/rust/Cargo.lock
generated
Normal file
7
backend/examples/plugin/frontend-auth/rust/Cargo.lock
generated
Normal 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"
|
||||||
7
backend/examples/plugin/frontend-auth/rust/Cargo.toml
Normal file
7
backend/examples/plugin/frontend-auth/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "cliproxy-frontend-auth-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
127
backend/examples/plugin/frontend-auth/rust/src/lib.rs
Normal file
127
backend/examples/plugin/frontend-auth/rust/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/examples/plugin/host-callback-auth-files/README.md
Normal file
89
backend/examples/plugin/host-callback-auth-files/README.md
Normal 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.
|
||||||
|
|
@ -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 => ../../../..
|
||||||
531
backend/examples/plugin/host-callback-auth-files/go/main.go
Normal file
531
backend/examples/plugin/host-callback-auth-files/go/main.go
Normal 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&auth_index=<AUTH_INDEX></code></li>")
|
||||||
|
out.WriteString("<li><code>?op=runtime&auth_index=<AUTH_INDEX></code></li>")
|
||||||
|
out.WriteString("<li><code>?op=save&name=example.json&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
|
||||||
|
}
|
||||||
8
backend/examples/plugin/host-callback/c/CMakeLists.txt
Normal file
8
backend/examples/plugin/host-callback/c/CMakeLists.txt
Normal 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 ""
|
||||||
|
)
|
||||||
120
backend/examples/plugin/host-callback/c/src/plugin.c
Normal file
120
backend/examples/plugin/host-callback/c/src/plugin.c
Normal 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;
|
||||||
|
}
|
||||||
3
backend/examples/plugin/host-callback/go/go.mod
Normal file
3
backend/examples/plugin/host-callback/go/go.mod
Normal 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
Loading…
Reference in a new issue