Big update

This commit is contained in:
Alois 2026-08-27 15:02:32 +02:00
commit 2e6afc460b
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
474 changed files with 934 additions and 86159 deletions

View file

@ -19,8 +19,6 @@ 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
@ -32,24 +30,20 @@ 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/
RUN CGO_ENABLED=0 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 ./vibe-proxy ./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
RUN mkdir /app
COPY --from=builder /app/backend/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
COPY --from=builder /app/backend/vibe-proxy /app/vibe-proxy
COPY backend/config.example.yaml /CLIProxyAPI/config.example.yaml
COPY backend/config.example.yaml /app/config.example.yaml
WORKDIR /CLIProxyAPI
WORKDIR /app
EXPOSE 8317
ENV TZ=Asia/Shanghai
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
CMD ["./CLIProxyAPI"]
CMD ["./vibe-proxy", "--config", "/app/config.yaml"]

View file

@ -1,6 +1,3 @@
// 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 (
@ -8,38 +5,54 @@ import (
"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"
)
type codexStore struct {
fileStore *sdkAuth.FileTokenStore
}
func (s *codexStore) SetBaseDir(dir string) {
s.fileStore.SetBaseDir(dir)
}
func (s *codexStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
auths, errList := s.fileStore.List(ctx)
if errList != nil {
return nil, errList
}
codexAuths := make([]*coreauth.Auth, 0, len(auths))
for _, auth := range auths {
if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") && auth.AuthKind() == "oauth" {
codexAuths = append(codexAuths, auth)
}
}
return codexAuths, nil
}
func (s *codexStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") || auth.AuthKind() != "oauth" {
return "", errors.New("only Codex OAuth credentials are supported")
}
return s.fileStore.Save(ctx, auth)
}
func (s *codexStore) Delete(ctx context.Context, id string) error {
return s.fileStore.Delete(ctx, id)
}
var (
Version = "dev"
Commit = "none"
@ -47,7 +60,6 @@ var (
DefaultConfigPath = ""
)
// init initializes the shared logger setup.
func init() {
logging.SetupBaseLogger()
buildinfo.Version = Version
@ -55,778 +67,54 @@ func init() {
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.StringVar(&configPath, "config", DefaultConfigPath, "configuration file path")
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
)
if strings.TrimSpace(configPath) == "" {
workingDirectory, errWorkingDirectory := os.Getwd()
if errWorkingDirectory != nil {
log.WithError(errWorkingDirectory).Error("failed to get working directory")
return
}
configPath = filepath.Join(workingDirectory, "config.yaml")
}
wd, err := os.Getwd()
if err != nil {
log.Errorf("failed to get working directory: %v", err)
cfg, errLoadConfig := config.LoadConfig(configPath)
if errLoadConfig != nil {
log.WithError(errLoadConfig).Error("failed to load configuration")
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)
resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir)
if errResolveAuthDir != nil {
log.WithError(errResolveAuthDir).Error("failed to resolve auth directory")
return
}
if cfg == nil {
cfg = &config.Config{}
}
cfg.AuthDir = resolvedAuthDir
cfg.GeminiKey = nil
cfg.InteractionsKey = nil
cfg.CodexKey = nil
cfg.XAIKey = nil
cfg.ClaudeKey = nil
cfg.OpenAICompatibility = nil
cfg.VertexCompatAPIKey = nil
cfg.OAuthExcludedModels = nil
cfg.OAuthModelAlias = nil
cfg.Plugins.Enabled = false
cfg.Home.Enabled = false
cfg.Routing.Strategy = "round-robin"
cfg.Routing.SessionAffinity = false
// 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
}
}
if errConfigureLogging := logging.ConfigureLogOutput(cfg); errConfigureLogging != nil {
log.WithError(errConfigureLogging).Error("failed to configure log output")
return
}
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
util.SetLogLevel(cfg)
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
sdkAuth.RegisterTokenStore(&codexStore{fileStore: sdkAuth.NewFileTokenStore()})
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
fmt.Printf("Vibe Proxy %s (%s), built %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
cmd.StartService(cfg, configPath, "")
}

View file

@ -1,137 +0,0 @@
package main
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestShouldEnableExampleAPIKeySafeMode(t *testing.T) {
cfgWithExampleKey := &config.Config{
SDKConfig: config.SDKConfig{
APIKeys: []string{"real-key", " your-api-key-1 "},
},
}
cfgWithRealKey := &config.Config{
SDKConfig: config.SDKConfig{
APIKeys: []string{"real-key"},
},
}
tests := []struct {
name string
cfg *config.Config
commandMode bool
tuiMode bool
standalone bool
cloudConfigMissing bool
homeMode bool
want bool
}{
{
name: "normal server with example key",
cfg: cfgWithExampleKey,
want: true,
},
{
name: "standalone tui with example key",
cfg: cfgWithExampleKey,
tuiMode: true,
standalone: true,
want: true,
},
{
name: "pure tui client is not blocked",
cfg: cfgWithExampleKey,
tuiMode: true,
standalone: false,
commandMode: false,
want: false,
},
{
name: "one-shot command is not blocked",
cfg: cfgWithExampleKey,
commandMode: true,
want: false,
},
{
name: "home mode is not blocked",
cfg: cfgWithExampleKey,
homeMode: true,
want: false,
},
{
name: "cloud standby without config is not blocked",
cfg: cfgWithExampleKey,
cloudConfigMissing: true,
want: false,
},
{
name: "normal server with real key",
cfg: cfgWithRealKey,
want: false,
},
{
name: "nil config",
cfg: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldEnableExampleAPIKeySafeMode(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
if got != tt.want {
t.Fatalf("shouldEnableExampleAPIKeySafeMode() = %t, want %t", got, tt.want)
}
})
}
}
func TestModelCatalogUpdaterPlan(t *testing.T) {
tests := []struct {
name string
localModel bool
homeEnabled bool
wantModels bool
wantCodexClient bool
}{
{
name: "normal CPA refreshes both catalogs",
localModel: false,
homeEnabled: false,
wantModels: true,
wantCodexClient: true,
},
{
name: "home mode keeps models.json local and refreshes codex templates",
localModel: false,
homeEnabled: true,
wantModels: false,
wantCodexClient: true,
},
{
name: "local-model disables both remote catalogs",
localModel: true,
homeEnabled: false,
wantModels: false,
wantCodexClient: false,
},
{
name: "local-model disables both remote catalogs even under home",
localModel: true,
homeEnabled: true,
wantModels: false,
wantCodexClient: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotModels, gotCodex := modelCatalogUpdaterPlan(tt.localModel, tt.homeEnabled)
if gotModels != tt.wantModels || gotCodex != tt.wantCodexClient {
t.Fatalf("modelCatalogUpdaterPlan(%v, %v) = (%v, %v), want (%v, %v)",
tt.localModel, tt.homeEnabled, gotModels, gotCodex, tt.wantModels, tt.wantCodexClient)
}
})
}
}

View file

@ -10,7 +10,3 @@ auth-dir: ".dev/auths"
api-keys:
- "dev-api-key"
plugins:
enabled: true
dir: ".dev/plugins"

View file

@ -1,844 +1,32 @@
# 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
# Address and port for the OpenAI-compatible API and management UI.
host: "127.0.0.1"
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
# MANAGEMENT_PASSWORD can provide the management key without storing it here.
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"
# Codex OAuth credentials are stored as JSON files in this directory.
auth-dir: "~/.vibe-proxy/auths"
# API keys for authentication
# Clients use one of these keys with the OpenAI-compatible endpoints.
api-keys:
- "your-api-key-1"
- "your-api-key-2"
- "your-api-key-3"
- "replace-with-a-random-api-key"
# 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.
# Optional HTTP, HTTPS, SOCKS5, or SOCKS5H proxy for OAuth and upstream requests.
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.
# Multiple Codex accounts are selected in round-robin order with automatic failover.
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"
strategy: "round-robin"
# 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"
debug: false
logging-to-file: false
usage-statistics-enabled: false

View file

@ -1,7 +1,6 @@
services:
cli-proxy-api:
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
pull_policy: always
vibe-proxy:
image: ${VIBE_PROXY_IMAGE:-vibe-proxy:latest}
build:
context: ..
dockerfile: backend/Dockerfile
@ -9,21 +8,12 @@ services:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
container_name: cli-proxy-api
# env_file:
# - .env
container_name: vibe-proxy
environment:
DEPLOY: ${DEPLOY:-}
MANAGEMENT_PASSWORD: ${MANAGEMENT_PASSWORD:-}
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
- ${VIBE_PROXY_CONFIG_PATH:-./config.yaml}:/app/config.yaml:ro
- ${VIBE_PROXY_AUTH_PATH:-./auths}:/root/.vibe-proxy/auths
restart: unless-stopped

View file

@ -26,6 +26,8 @@ const (
var antigravityOAuthTokenURL = "https://oauth2.googleapis.com/token"
var codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
type apiCallRequest struct {
AuthIndexSnake *string `json:"auth_index"`
AuthIndexCamel *string `json:"authIndex"`
@ -43,6 +45,83 @@ type apiCallResponse struct {
Body string `json:"body"`
}
// CodexQuota fetches the usage payload for one exact Codex credential.
func (h *Handler) CodexQuota(c *gin.Context) {
var body struct {
AuthIndexSnake *string `json:"auth_index"`
AuthIndexCamel *string `json:"authIndex"`
Method string `json:"method"`
URL string `json:"url"`
}
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
authIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel)
if authIndex == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "auth_index is required"})
return
}
if method := strings.ToUpper(strings.TrimSpace(body.Method)); method != "" && method != http.MethodGet {
c.JSON(http.StatusBadRequest, gin.H{"error": "only GET is allowed"})
return
}
if requestedURL := strings.TrimSpace(body.URL); requestedURL != "" && requestedURL != codexUsageURL {
c.JSON(http.StatusBadRequest, gin.H{"error": "url is not allowed"})
return
}
auth := h.authByIndex(authIndex)
if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
c.JSON(http.StatusNotFound, gin.H{"error": "Codex auth not found"})
return
}
token := tokenValueForAuth(auth)
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Codex auth token not found"})
return
}
req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, codexUsageURL, nil)
if errNewRequest != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build request"})
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal")
if accountID, ok := auth.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
req.Header.Set("Chatgpt-Account-Id", strings.TrimSpace(accountID))
}
resp, errDo := (&http.Client{
Transport: h.apiCallTransport(auth, ""),
}).Do(req)
if errDo != nil {
log.WithError(errDo).Debug("management Codex quota request failed")
c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
return
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("Codex quota response body close error: %v", errClose)
}
}()
respBody, errReadAll := io.ReadAll(resp.Body)
if errReadAll != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
return
}
c.JSON(http.StatusOK, apiCallResponse{
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: string(respBody),
})
}
// APICall makes a generic HTTP request on behalf of the management API caller.
// It is protected by the management middleware.
//

View file

@ -101,6 +101,9 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
auths := h.authManager.List()
files := make([]gin.H, 0, len(auths))
for _, auth := range auths {
if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") || auth.AuthKind() != "oauth" {
continue
}
if !matchesAuthFileLookup(auth, nameFilter, authIndexFilter) {
continue
}

View file

@ -135,40 +135,6 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
return
}
ctx := c.Request.Context()
if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
entries, err := os.ReadDir(h.cfg.AuthDir)
if err != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
return
}
deleted := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
full := filepath.Join(h.cfg.AuthDir, name)
if !filepath.IsAbs(full) {
if abs, errAbs := filepath.Abs(full); errAbs == nil {
full = abs
}
}
if err = os.Remove(full); err == nil {
if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
c.JSON(500, gin.H{"error": errDel.Error()})
return
}
deleted++
h.removeAuth(ctx, full)
}
}
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
return
}
names, errNames := requestedAuthFileNamesForDelete(c)
if errNames != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()})
@ -178,35 +144,15 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid name"})
return
}
if len(names) == 1 {
if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
c.JSON(status, gin.H{"error": errDelete.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
if len(names) != 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "exactly one account is required"})
return
}
deletedFiles := make([]string, 0, len(names))
failed := make([]gin.H, 0)
for _, name := range names {
deletedName, _, errDelete := h.deleteAuthFileByName(ctx, name)
if errDelete != nil {
failed = append(failed, gin.H{"name": name, "error": errDelete.Error()})
continue
}
deletedFiles = append(deletedFiles, deletedName)
}
if len(failed) > 0 {
c.JSON(http.StatusMultiStatus, gin.H{
"status": "partial",
"deleted": len(deletedFiles),
"files": deletedFiles,
"failed": failed,
})
if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
c.JSON(status, gin.H{"error": errDelete.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted": len(deletedFiles), "files": deletedFiles})
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (h *Handler) multipartAuthFileHeaders(c *gin.Context) ([]*multipart.FileHeader, error) {
@ -347,7 +293,11 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
targetID := ""
if targetAuth := h.findAuthForDelete(name); targetAuth != nil {
targetAuth := h.findAuthForDelete(name)
if targetAuth == nil || !strings.EqualFold(strings.TrimSpace(targetAuth.Provider), "codex") || targetAuth.AuthKind() != "oauth" {
return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
}
if targetAuth != nil {
if !isPluginVirtualSourceDelete(name, targetAuth) {
return filepath.Base(name), http.StatusConflict, errPluginVirtualAuth
}

View file

@ -236,9 +236,6 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
if hasManagementSecret {
s.registerManagementRoutes()
}
s.refreshPluginManagementRoutes()
engine.NoRoute(s.pluginManagementNoRoute)
if optionState.keepAliveEnabled {
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
}

View file

@ -1,7 +1,6 @@
package api
import (
"context"
"errors"
"io/fs"
"net/http"
@ -26,165 +25,16 @@ func (s *Server) registerManagementRoutes() {
log.Info("management routes registered after secret key configuration")
s.engine.POST("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.PostOAuthCallback)
s.engine.GET("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.GetOAuthCallback)
mgmt := s.engine.Group("/v0/management")
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
{
mgmt.GET("/config", s.mgmt.GetConfig)
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
mgmt.GET("/latest-version", s.mgmt.GetLatestVersion)
mgmt.GET("/plugins", s.mgmt.ListPlugins)
mgmt.GET("/plugin-store", s.mgmt.ListPluginStore)
mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore)
mgmt.DELETE("/plugins/:id", s.mgmt.DeletePlugin)
mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig)
mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)
mgmt.PATCH("/plugins/:id/config", s.mgmt.PatchPluginConfig)
mgmt.GET("/debug", s.mgmt.GetDebug)
mgmt.PUT("/debug", s.mgmt.PutDebug)
mgmt.PATCH("/debug", s.mgmt.PutDebug)
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
mgmt.GET("/logs-max-total-size-mb", s.mgmt.GetLogsMaxTotalSizeMB)
mgmt.PUT("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB)
mgmt.PATCH("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB)
mgmt.GET("/error-logs-max-files", s.mgmt.GetErrorLogsMaxFiles)
mgmt.PUT("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles)
mgmt.PATCH("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles)
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
mgmt.POST("/api-call", s.mgmt.APICall)
mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
mgmt.POST("/reset-quota", s.mgmt.ResetQuota)
mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage)
mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue)
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey)
mgmt.DELETE("/gemini-api-key", s.mgmt.DeleteGeminiKey)
mgmt.GET("/interactions-api-key", s.mgmt.GetInteractionsKeys)
mgmt.PUT("/interactions-api-key", s.mgmt.PutInteractionsKeys)
mgmt.PATCH("/interactions-api-key", s.mgmt.PatchInteractionsKey)
mgmt.DELETE("/interactions-api-key", s.mgmt.DeleteInteractionsKey)
mgmt.GET("/logs", s.mgmt.GetLogs)
mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
mgmt.GET("/request-error-logs", s.mgmt.GetRequestErrorLogs)
mgmt.GET("/request-error-logs/:name", s.mgmt.DownloadRequestErrorLog)
mgmt.GET("/request-log-by-id/:id", s.mgmt.GetRequestLogByID)
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
mgmt.GET("/ws-auth", s.mgmt.GetWebsocketAuth)
mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth)
mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth)
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
mgmt.GET("/max-retry-credentials", s.mgmt.GetMaxRetryCredentials)
mgmt.PUT("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials)
mgmt.PATCH("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials)
mgmt.GET("/max-retry-interval", s.mgmt.GetMaxRetryInterval)
mgmt.PUT("/max-retry-interval", s.mgmt.PutMaxRetryInterval)
mgmt.PATCH("/max-retry-interval", s.mgmt.PutMaxRetryInterval)
mgmt.GET("/force-model-prefix", s.mgmt.GetForceModelPrefix)
mgmt.PUT("/force-model-prefix", s.mgmt.PutForceModelPrefix)
mgmt.PATCH("/force-model-prefix", s.mgmt.PutForceModelPrefix)
mgmt.GET("/routing/strategy", s.mgmt.GetRoutingStrategy)
mgmt.PUT("/routing/strategy", s.mgmt.PutRoutingStrategy)
mgmt.PATCH("/routing/strategy", s.mgmt.PutRoutingStrategy)
mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
mgmt.GET("/xai-api-key", s.mgmt.GetXAIKeys)
mgmt.PUT("/xai-api-key", s.mgmt.PutXAIKeys)
mgmt.PATCH("/xai-api-key", s.mgmt.PatchXAIKey)
mgmt.DELETE("/xai-api-key", s.mgmt.DeleteXAIKey)
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
mgmt.GET("/vertex-api-key", s.mgmt.GetVertexCompatKeys)
mgmt.PUT("/vertex-api-key", s.mgmt.PutVertexCompatKeys)
mgmt.PATCH("/vertex-api-key", s.mgmt.PatchVertexCompatKey)
mgmt.DELETE("/vertex-api-key", s.mgmt.DeleteVertexCompatKey)
mgmt.GET("/oauth-excluded-models", s.mgmt.GetOAuthExcludedModels)
mgmt.PUT("/oauth-excluded-models", s.mgmt.PutOAuthExcludedModels)
mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels)
mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels)
mgmt.GET("/oauth-model-alias", s.mgmt.GetOAuthModelAlias)
mgmt.PUT("/oauth-model-alias", s.mgmt.PutOAuthModelAlias)
mgmt.PATCH("/oauth-model-alias", s.mgmt.PatchOAuthModelAlias)
mgmt.DELETE("/oauth-model-alias", s.mgmt.DeleteOAuthModelAlias)
mgmt.GET("/oauth-request-scoped-errors", s.mgmt.GetOAuthRequestScopedErrors)
mgmt.PUT("/oauth-request-scoped-errors", s.mgmt.PutOAuthRequestScopedErrors)
mgmt.PATCH("/oauth-request-scoped-errors", s.mgmt.PatchOAuthRequestScopedErrors)
mgmt.DELETE("/oauth-request-scoped-errors", s.mgmt.DeleteOAuthRequestScopedErrors)
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
mgmt.GET("/model-definitions/:channel", s.mgmt.GetStaticModelDefinitions)
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus)
mgmt.PATCH("/auth-files/fields", s.mgmt.PatchAuthFileFields)
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
mgmt.POST("/codex-quota", s.mgmt.CodexQuota)
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
mgmt.GET("/xai-auth-url", s.mgmt.RequestXAIToken)
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
mgmt.DELETE("/oauth-session", s.mgmt.CancelAuthSession)
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
}
}
@ -202,10 +52,6 @@ func (s *Server) managementAvailable(c *gin.Context) bool {
c.AbortWithStatus(http.StatusNotFound)
return false
}
if s.cfg.Home.Enabled {
c.AbortWithStatus(http.StatusNotFound)
return false
}
if !s.managementRoutesEnabled.Load() {
c.AbortWithStatus(http.StatusNotFound)
return false
@ -213,90 +59,15 @@ func (s *Server) managementAvailable(c *gin.Context) bool {
return true
}
func (s *Server) refreshPluginManagementRoutes() {
if s == nil || s.pluginHost == nil || s.engine == nil {
return
}
s.pluginHost.RegisterManagementRoutes(context.Background(), s.registeredManagementRouteKeys())
}
// RefreshPluginManagementRoutes rebuilds plugin-owned Management API routes.
func (s *Server) RefreshPluginManagementRoutes() {
s.refreshPluginManagementRoutes()
}
func (s *Server) registeredManagementRouteKeys() map[string]struct{} {
out := make(map[string]struct{})
if s == nil || s.engine == nil {
return out
}
for _, route := range s.engine.Routes() {
if strings.HasPrefix(route.Path, "/v0/management/") || route.Path == "/v0/management" {
out[strings.ToUpper(strings.TrimSpace(route.Method))+" "+route.Path] = struct{}{}
}
}
return out
}
func (s *Server) RefreshPluginManagementRoutes() {}
func (s *Server) pluginManagementNoRoute(c *gin.Context) {
if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
if c != nil {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
path := c.Request.URL.Path
if strings.HasPrefix(path, "/v0/resource/plugins/") {
s.pluginResourceNoRoute(c)
return
}
if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {
c.AbortWithStatus(http.StatusNotFound)
return
}
if s.pluginHost == nil || s.mgmt == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if !s.managementAvailable(c) {
return
}
s.mgmt.Middleware()(c)
if c.IsAborted() {
return
}
if s.mgmt.ServePluginAuthURL(c) {
c.Abort()
return
}
if s.pluginHost.ServeManagementHTTP(c.Writer, c.Request) {
c.Abort()
return
}
c.AbortWithStatus(http.StatusNotFound)
}
func (s *Server) pluginResourceNoRoute(c *gin.Context) {
if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
if c != nil {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) {
c.Abort()
return
}
c.AbortWithStatus(http.StatusNotFound)
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}
@ -314,7 +85,7 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
func (s *Server) serveManagementAsset(c *gin.Context) {
cfg := s.cfg
if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}

View file

@ -188,7 +188,6 @@ func (s *Server) UpdateClientsContext(ctx context.Context, cfg *config.Config) b
s.mgmt.SetAuthManager(s.handlers.AuthManager)
s.mgmt.SetPluginHost(s.pluginHost)
}
s.refreshPluginManagementRoutes()
// Count client sources from configuration and auth store.
authEntries := 0

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,6 @@
package translator
import (
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/interactions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/claude"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/interactions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/responses"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/claude"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/interactions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/interactions/claude"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/responses"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/interactions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/responses"
)

View file

@ -20,8 +20,6 @@ func newDefaultAuthManager() *sdkAuth.Manager {
return sdkAuth.NewManager(
sdkAuth.GetTokenStore(),
sdkAuth.NewCodexAuthenticator(),
sdkAuth.NewClaudeAuthenticator(),
sdkAuth.NewXAIAuthenticator(),
)
}
@ -112,6 +110,9 @@ func (s *Service) handleAuthUpdates(ctx context.Context, updates []watcher.AuthU
if update.Auth == nil || update.Auth.ID == "" {
continue
}
if !strings.EqualFold(strings.TrimSpace(update.Auth.Provider), "codex") || update.Auth.AuthKind() != "oauth" {
continue
}
auth := s.prepareCoreAuthForModelRegistration(registrationCtx, update.Auth)
if auth == nil {
continue

View file

@ -199,18 +199,7 @@ func (s *Service) registerAvailableExecutors(ctx context.Context, opts executorR
}
func baselineExecutorAuths() []*coreauth.Auth {
providers := []string{
"codex",
"claude",
constant.Gemini,
constant.GeminiInteractions,
"vertex",
"aistudio",
"antigravity",
"kimi",
"xai",
"openai-compatibility",
}
providers := []string{"codex"}
auths := make([]*coreauth.Auth, 0, len(providers))
for _, provider := range providers {
auth := &coreauth.Auth{

View file

@ -130,26 +130,6 @@ func (s *Service) Run(ctx context.Context) error {
s.startHomeSubscriber(ctx)
}
if s.server != nil && s.wsGateway != nil {
s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler())
s.server.SetWebsocketAuthChangeHandler(func(oldEnabled, newEnabled bool) {
if oldEnabled == newEnabled {
return
}
if !oldEnabled && newEnabled {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if errStop := s.wsGateway.Stop(ctx); errStop != nil {
log.Warnf("failed to reset websocket connections after ws-auth change %t -> %t: %v", oldEnabled, newEnabled, errStop)
return
}
log.Debugf("ws-auth enabled; existing websocket sessions terminated to enforce authentication")
return
}
log.Debugf("ws-auth disabled; existing websocket sessions remain connected")
})
}
if s.hooks.OnBeforeStart != nil {
s.hooks.OnBeforeStart(s.cfg)
}