Big update
This commit is contained in:
parent
8b607dd700
commit
2e6afc460b
474 changed files with 934 additions and 86159 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,3 @@ auth-dir: ".dev/auths"
|
|||
|
||||
api-keys:
|
||||
- "dev-api-key"
|
||||
|
||||
plugins:
|
||||
enabled: true
|
||||
dir: ".dev/plugins"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue