diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 8f6ce17..f5b8d95 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ backend/internal/managementasset/dist/ backend/.dev/ result result-* +.direnv diff --git a/README.md b/README.md index 9a76602..7bd6bd1 100644 --- a/README.md +++ b/README.md @@ -1 +1,54 @@ # Vibe Proxy + +Vibe Proxy connects multiple OpenAI Codex OAuth accounts, displays their quotas, and exposes them through an OpenAI-compatible API. + +The management UI is available at `/management.html`. It supports adding and removing Codex accounts and refreshing their quota. Proxy API keys and server settings are deployment configuration, not UI settings. + +## Endpoints + +- `GET /v1/models` +- `POST /v1/chat/completions` +- `POST /v1/responses` +- `GET /v1/responses` for WebSocket transport +- `GET /healthz` + +## NixOS + +Add the module and package from the flake: + +```nix +{ + inputs.vibe-proxy.url = "github:methanium/vibe-proxy"; + + outputs = { nixpkgs, vibe-proxy, ... }: { + nixosConfigurations.host = nixpkgs.lib.nixosSystem { + modules = [ + vibe-proxy.nixosModules.default + ({ ... }: { + services.vibe-proxy = { + enable = true; + host = "127.0.0.1"; + port = 8317; + settings.api-keys = [ "replace-with-a-random-api-key" ]; + environmentFiles = [ "/run/secrets/vibe-proxy" ]; + }; + }) + ]; + }; + }; +} +``` + +The environment file should contain: + +```sh +MANAGEMENT_PASSWORD=replace-with-a-random-management-key +``` + +The module stores OAuth credentials under `/var/lib/vibe-proxy/auths` by default. Set `openFirewall = true` only when the service should be reachable directly from other machines. + +Build or run the combined package with `nix build` or `nix run`. + +## Other deployments + +Copy `backend/config.example.yaml` to `config.yaml`, replace the example API key, and set `MANAGEMENT_PASSWORD`. Docker Compose and the standalone binary both use local JSON credential storage. diff --git a/backend/Dockerfile b/backend/Dockerfile index df0650b..de8df1a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 871e69c..ffce324 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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, "") } diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go deleted file mode 100644 index fce4be9..0000000 --- a/backend/cmd/server/main_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/backend/config.dev.yaml b/backend/config.dev.yaml index dbc371c..ffa5164 100644 --- a/backend/config.dev.yaml +++ b/backend/config.dev.yaml @@ -10,7 +10,3 @@ auth-dir: ".dev/auths" api-keys: - "dev-api-key" - -plugins: - enabled: true - dir: ".dev/plugins" diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 997cf11..e02e865 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -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..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____ 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 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b80d9ce..9ce6a55 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -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 diff --git a/backend/internal/api/handlers/management/api_tools.go b/backend/internal/api/handlers/management/api_tools.go index a619afd..d98aeac 100644 --- a/backend/internal/api/handlers/management/api_tools.go +++ b/backend/internal/api/handlers/management/api_tools.go @@ -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. // diff --git a/backend/internal/api/handlers/management/auth_files.go b/backend/internal/api/handlers/management/auth_files.go index f42b681..330fe0f 100644 --- a/backend/internal/api/handlers/management/auth_files.go +++ b/backend/internal/api/handlers/management/auth_files.go @@ -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 } diff --git a/backend/internal/api/handlers/management/auth_files_crud.go b/backend/internal/api/handlers/management/auth_files_crud.go index 2c193b3..7049c71 100644 --- a/backend/internal/api/handlers/management/auth_files_crud.go +++ b/backend/internal/api/handlers/management/auth_files_crud.go @@ -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 } diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 4747aad..d63c080 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -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) } diff --git a/backend/internal/api/server_management.go b/backend/internal/api/server_management.go index 550ea6c..56fe2a2 100644 --- a/backend/internal/api/server_management.go +++ b/backend/internal/api/server_management.go @@ -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 } diff --git a/backend/internal/api/server_reload.go b/backend/internal/api/server_reload.go index 386dd2b..a1a9daf 100644 --- a/backend/internal/api/server_reload.go +++ b/backend/internal/api/server_reload.go @@ -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 diff --git a/backend/internal/api/server_routes.go b/backend/internal/api/server_routes.go index 1152811..e38e5b2 100644 --- a/backend/internal/api/server_routes.go +++ b/backend/internal/api/server_routes.go @@ -1,51 +1,21 @@ package api import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" "net/http" - "sort" - "strconv" - "strings" - "time" "github.com/gin-gonic/gin" managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" - claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models" - codexlive "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/live" - codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models" - "github.com/router-for-me/CLIProxyAPI/v7/internal/client/grokbuild" - "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" - "github.com/router-for-me/CLIProxyAPI/v7/internal/home" - "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini" "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" - coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" - log "github.com/sirupsen/logrus" ) const oauthCallbackSuccessHTML = `Authentication successful

Authentication successful!

You can close this window.

This window will close automatically in 5 seconds.

` -const codexAlphaSearchSourceFormat = "codex-alpha-search" - -// setupRoutes configures the API routes for the server. -// It defines the endpoints and associates them with their respective handlers. func (s *Server) setupRoutes() { healthzHandler := func(c *gin.Context) { if c.Request.Method == http.MethodHead { c.Status(http.StatusOK) return } - c.JSON(http.StatusOK, gin.H{"status": "ok"}) } s.engine.GET("/healthz", healthzHandler) @@ -55,110 +25,30 @@ func (s *Server) setupRoutes() { s.engine.HEAD("/management.html", s.serveManagementControlPanel) s.engine.GET("/management-assets/*filepath", s.serveManagementAsset) s.engine.HEAD("/management-assets/*filepath", s.serveManagementAsset) - openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers) - geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers) - claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers) - openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers) - s.codexLiveHandler = codexlive.NewHandler(s.handlers.AuthManager, s.cfg) - // OpenAI compatible API routes + openAIHandlers := openai.NewOpenAIAPIHandler(s.handlers) + responsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers) v1 := s.engine.Group("/v1") v1.Use(AuthMiddleware(s.accessManager)) { - v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers)) - v1.POST("/chat/completions", openaiHandlers.ChatCompletions) - v1.POST("/completions", openaiHandlers.Completions) - v1.POST("/images/generations", openaiHandlers.ImagesGenerations) - v1.POST("/images/edits", openaiHandlers.ImagesEdits) - v1.POST("/videos", openaiHandlers.XAIVideosGenerations) - v1.POST("/videos/generations", openaiHandlers.XAIVideosGenerations) - v1.POST("/videos/edits", openaiHandlers.XAIVideosEdits) - v1.POST("/videos/extensions", openaiHandlers.XAIVideosExtensions) - v1.GET("/videos/:request_id", openaiHandlers.XAIVideosRetrieve) - v1.POST("/messages", claudeCodeHandlers.ClaudeMessages) - v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens) - v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) - v1.POST("/responses", openaiResponsesHandlers.Responses) - v1.POST("/responses/compact", openaiResponsesHandlers.Compact) - v1.POST("/alpha/search", s.codexAlphaSearch) - v1.POST("/live", s.codexLiveHandler.Handle) - v1.GET("/live/:call_id", s.codexLiveHandler.HandleSideband) + v1.GET("/models", openAIHandlers.OpenAIModels) + v1.POST("/chat/completions", openAIHandlers.ChatCompletions) + v1.GET("/responses", responsesHandlers.ResponsesWebsocket) + v1.POST("/responses", responsesHandlers.Responses) } - realtimeAuth := realtimeAuthMiddleware(s.accessManager, s.codexLiveHandler) - standardAuth := realtimeStandardAuthMiddleware(s.accessManager) - s.engine.GET("/v1/realtime", realtimeAuth, s.codexLiveHandler.HandleRealtimeWebsocket) - s.engine.POST("/v1/realtime", realtimeAuth, s.codexLiveHandler.Handle) - s.engine.POST("/v1/realtime/calls", realtimeAuth, s.codexLiveHandler.Handle) - s.engine.GET("/v1/realtime/calls/:call_id", realtimeAuth, s.codexLiveHandler.HandleSideband) - s.engine.POST("/v1/realtime/client_secrets", standardAuth, s.codexLiveHandler.CreateClientSecret) - s.engine.POST("/v1/realtime/sessions", standardAuth, s.codexLiveHandler.CreateLegacySession) - s.engine.POST("/v1/realtime/transcription_sessions", standardAuth, s.codexLiveHandler.HandleTranscriptionSession) - s.engine.GET("/v1/realtime/translations", realtimeAuth, s.codexLiveHandler.HandleTranslation) - s.engine.POST("/v1/realtime/translations", realtimeAuth, s.codexLiveHandler.HandleTranslation) - s.engine.POST("/v1/realtime/translations/client_secrets", standardAuth, s.codexLiveHandler.HandleTranslation) - s.engine.POST("/v1/realtime/calls/:call_id/hangup", standardAuth, s.codexLiveHandler.HandleHangup) - s.engine.POST("/v1/realtime/calls/:call_id/accept", standardAuth, s.codexLiveHandler.HandleSIPControl) - s.engine.POST("/v1/realtime/calls/:call_id/reject", standardAuth, s.codexLiveHandler.HandleSIPControl) - s.engine.POST("/v1/realtime/calls/:call_id/refer", standardAuth, s.codexLiveHandler.HandleSIPControl) - - openaiV1 := s.engine.Group("/openai/v1") - openaiV1.Use(AuthMiddleware(s.accessManager)) - { - openaiV1.POST("/videos", openaiHandlers.VideosCreate) - openaiV1.GET("/videos/:video_id/content", openaiHandlers.VideosContent) - openaiV1.GET("/videos/:video_id", openaiHandlers.VideosRetrieve) - } - - // Codex CLI direct route aliases (chatgpt_base_url compatible) - codexDirect := s.engine.Group("/backend-api/codex") - codexDirect.Use(AuthMiddleware(s.accessManager)) - { - codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) - codexDirect.POST("/responses", openaiResponsesHandlers.Responses) - codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact) - codexDirect.POST("/alpha/search", s.codexAlphaSearch) - } - - // Gemini compatible API routes - v1beta := s.engine.Group("/v1beta") - v1beta.Use(AuthMiddleware(s.accessManager)) - { - v1beta.GET("/models", s.geminiModelsHandler(geminiHandlers)) - v1beta.POST("/interactions", geminiHandlers.Interactions) - v1beta.POST("/models/*action", geminiHandlers.GeminiHandler) - v1beta.GET("/models/*action", s.geminiGetHandler(geminiHandlers)) - } - - // Root endpoint s.engine.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "message": "CLI Proxy API Server", + "message": "Vibe Proxy", "endpoints": []string{ - "POST /v1/chat/completions", - "POST /v1/completions", "GET /v1/models", + "POST /v1/chat/completions", + "GET /v1/responses", + "POST /v1/responses", }, }) }) - // OAuth callback endpoints (reuse main server port) - // These endpoints receive provider redirects and persist - // the short-lived code/state for the waiting goroutine. - s.engine.GET("/anthropic/callback", func(c *gin.Context) { - code := c.Query("code") - state := c.Query("state") - errStr := c.Query("error") - if errStr == "" { - errStr = c.Query("error_description") - } - if state != "" { - _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "anthropic", state, code, errStr) - } - c.Header("Content-Type", "text/html; charset=utf-8") - c.String(http.StatusOK, oauthCallbackSuccessHTML) - }) - s.engine.GET("/codex/callback", func(c *gin.Context) { code := c.Query("code") state := c.Query("state") @@ -172,882 +62,4 @@ func (s *Server) setupRoutes() { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, oauthCallbackSuccessHTML) }) - - s.engine.GET("/antigravity/callback", func(c *gin.Context) { - code := c.Query("code") - state := c.Query("state") - errStr := c.Query("error") - if errStr == "" { - errStr = c.Query("error_description") - } - if state != "" { - _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "antigravity", state, code, errStr) - } - c.Header("Content-Type", "text/html; charset=utf-8") - c.String(http.StatusOK, oauthCallbackSuccessHTML) - }) - - // Management routes are registered lazily by registerManagementRoutes when a secret is configured. -} - -func (s *Server) codexAlphaSearchModelRouterHost() handlers.PluginModelRouterHost { - if s == nil { - return nil - } - if s.pluginHost != nil { - return s.pluginHost - } - if s.handlers != nil && s.handlers.ModelRouterHost != nil { - return s.handlers.ModelRouterHost - } - return nil -} - -func (s *Server) codexAlphaSearchSelectionModel(ctx context.Context, c *gin.Context, body []byte, model string) (string, error) { - host := s.codexAlphaSearchModelRouterHost() - if host == nil { - return model, nil - } - - var headers http.Header - queryValues := make(map[string][]string) - requestPath := "" - if c != nil && c.Request != nil { - headers = c.Request.Header.Clone() - if c.Request.URL != nil { - queryValues = c.Request.URL.Query() - requestPath = c.Request.URL.Path - } - } - metadata := map[string]any{ - coreexecutor.RequestedModelMetadataKey: model, - } - if requestPath != "" { - metadata[coreexecutor.RequestPathMetadataKey] = requestPath - } - resp, handled := host.RouteModel(ctx, pluginapi.ModelRouteRequest{ - SourceFormat: codexAlphaSearchSourceFormat, - RequestedModel: model, - Headers: headers, - Query: queryValues, - Body: body, - Metadata: metadata, - }) - if !handled || !resp.Handled { - return model, nil - } - if resp.TargetKind != pluginapi.ModelRouteTargetProvider || !strings.EqualFold(strings.TrimSpace(resp.Target), "codex") { - return "", fmt.Errorf("unsupported Codex Alpha Search model route target %q (%q)", resp.TargetKind, resp.Target) - } - if targetModel := strings.TrimSpace(resp.TargetModel); targetModel != "" { - return targetModel, nil - } - return model, nil -} - -func sanitizeCodexAlphaSearchBody(body []byte) []byte { - var payload map[string]json.RawMessage - if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil || payload == nil { - return body - } - - removed := false - for _, field := range []string{"prompt_cache_key", "prompt_cache_retention"} { - if _, exists := payload[field]; exists { - delete(payload, field) - removed = true - } - } - if !removed { - return body - } - - sanitizedBody, errMarshal := json.Marshal(payload) - if errMarshal != nil { - return body - } - return sanitizedBody -} - -// rewriteCodexAlphaSearchModel replaces the top-level model field with the -// credential-resolved upstream model before the request is forwarded. -func rewriteCodexAlphaSearchModel(body []byte, upstreamModel string) []byte { - upstreamModel = strings.TrimSpace(upstreamModel) - if upstreamModel == "" { - return body - } - - var payload map[string]json.RawMessage - if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil || payload == nil { - return body - } - if _, exists := payload["model"]; !exists { - return body - } - - modelJSON, errMarshalModel := json.Marshal(upstreamModel) - if errMarshalModel != nil { - return body - } - if string(payload["model"]) == string(modelJSON) { - return body - } - - payload["model"] = modelJSON - rewrittenBody, errMarshal := json.Marshal(payload) - if errMarshal != nil { - return body - } - return rewrittenBody -} - -func homeSelectionAttemptContext(ctx context.Context, selection *auth.HomeDispatchSelection) (context.Context, func(), error) { - if selection == nil { - return nil, func() {}, errors.New("Home dispatch selection is nil") - } - return selection.AttemptContext(ctx) -} - -// codexAlphaSearch forwards the standalone search endpoint used by current -// Codex clients. Unlike /responses, this payload is already in Codex search -// format and must not pass through a protocol translator. -func (s *Server) codexAlphaSearch(c *gin.Context) { - if s == nil || s.handlers == nil || s.handlers.AuthManager == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth manager unavailable"}) - return - } - - body, err := io.ReadAll(io.LimitReader(c.Request.Body, 16<<20)) - if err != nil { - c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "Failed to read search request"}) - return - } - - var routing struct { - ID string `json:"id"` - Model string `json:"model"` - } - _ = json.Unmarshal(body, &routing) - upstreamRequestBody := sanitizeCodexAlphaSearchBody(body) - - selectionHeaders := c.Request.Header.Clone() - if sessionID := strings.TrimSpace(routing.ID); sessionID != "" { - selectionHeaders.Set("X-Session-ID", sessionID) - } - ctx := context.WithValue(c.Request.Context(), "gin", c) - selectionModel, errRoute := s.codexAlphaSearchSelectionModel(ctx, c, body, strings.TrimSpace(routing.Model)) - if errRoute != nil { - log.WithError(errRoute).Warn("codex alpha search: model router returned an unsupported target") - c.JSON(clienterror.HTTPStatusFromErrorOr(errRoute, http.StatusServiceUnavailable), gin.H{"error": errRoute.Error()}) - return - } - selectionOpts := coreexecutor.Options{Headers: selectionHeaders, OriginalRequest: body} - var selection *auth.HomeDispatchSelection - var selected *auth.Auth - if s.handlers.AuthManager.HomeEnabled() { - selection, err = s.handlers.AuthManager.SelectHomeAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts) - if selection != nil { - selected = selection.CloneAuth() - } - } else { - selected, err = s.handlers.AuthManager.SelectAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts) - } - if err != nil { - status := clienterror.HTTPStatusFromErrorOr(err, http.StatusServiceUnavailable) - for _, value := range auth.SafeResponseHeaders(err).Values("Retry-After") { - c.Writer.Header().Add("Retry-After", value) - } - c.JSON(status, gin.H{"error": err.Error()}) - return - } - if selected == nil { - if selection != nil { - selection.End("missing_auth") - } - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth unavailable"}) - return - } - var releaseAttempt func() - if selection != nil { - attemptCtx, release, errBind := homeSelectionAttemptContext(ctx, selection) - if errBind != nil { - selection.End("attempt_bind_failed") - c.JSON(http.StatusServiceUnavailable, gin.H{"error": errBind.Error()}) - return - } - ctx = attemptCtx - releaseAttempt = release - defer releaseAttempt() - } - logging.SetGinCPATraceID(c, selected.EnsureIndex()) - - baseHeaders := make(http.Header) - baseHeaders.Set("Content-Type", "application/json") - baseHeaders.Set("Accept", "application/json") - baseHeaders.Set("Originator", "codex_cli_rs") - for _, name := range []string{"Version", "User-Agent", "Session_id", "X-Client-Request-Id"} { - if value := strings.TrimSpace(c.GetHeader(name)); value != "" { - baseHeaders.Set(name, value) - } - } - - errMissingBaseURL := errors.New("Codex Alpha Search API key base URL unavailable") - routeModel := strings.TrimSpace(selectionModel) - if routeModel == "" { - routeModel = strings.TrimSpace(routing.Model) - } - performRequest := func(current *auth.Auth) (*http.Response, error) { - headers := baseHeaders.Clone() - if accountID, ok := current.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" { - headers.Set("Chatgpt-Account-Id", accountID) - } - upstreamURL := "https://chatgpt.com/backend-api/codex/alpha/search" - requestBody := upstreamRequestBody - // API-key Alpha Search reuses normal credential-aware model resolution so - // CPA routing prefixes and model aliases are not forwarded upstream. - if current.AuthKind() == auth.AuthKindAPIKey { - baseURL := "" - if current.Attributes != nil { - baseURL = strings.TrimSpace(current.Attributes["base_url"]) - } - if baseURL == "" { - return nil, errMissingBaseURL - } - upstreamURL = strings.TrimRight(baseURL, "/") + "/alpha/search" - if upstreamModel := s.handlers.AuthManager.ResolveExecutionModel(current, routeModel); upstreamModel != "" { - requestBody = rewriteCodexAlphaSearchModel(upstreamRequestBody, upstreamModel) - } - } - req, errRequest := s.handlers.AuthManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamURL, requestBody, headers) - if errRequest != nil { - return nil, errRequest - } - authType, authValue := current.AccountInfo() - helps.RecordAPIRequest(ctx, s.cfg, helps.UpstreamRequestLog{ - URL: upstreamURL, - Method: http.MethodPost, - Headers: req.Header.Clone(), - Body: requestBody, - Provider: "codex", - AuthID: current.ID, - AuthLabel: current.Label, - AuthType: authType, - AuthValue: authValue, - }) - return s.handlers.AuthManager.HttpRequest(ctx, current, req) - } - - if errCtx := ctx.Err(); errCtx != nil { - if selection != nil { - selection.End("attempt_canceled") - } - c.JSON(clienterror.HTTPStatusFromErrorOr(errCtx, http.StatusRequestTimeout), gin.H{"error": errCtx.Error()}) - return - } - resp, err := performRequest(selected) - if err != nil { - if errors.Is(err, errMissingBaseURL) { - if selection != nil { - selection.End("missing_base_url") - } - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return - } - if selection != nil { - selection.End("request_failed") - } - helps.RecordAPIResponseError(ctx, s.cfg, err) - c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()}) - return - } - if selection != nil && resp.StatusCode == http.StatusUnauthorized { - s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel) - helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone()) - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) - if errClose := resp.Body.Close(); errClose != nil { - log.Errorf("codex alpha search: close unauthorized response body error: %v", errClose) - } - refreshed, didRefresh, errRefresh := s.handlers.AuthManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected) - if errRefresh != nil { - selection.End("refresh_failed") - c.JSON(clienterror.HTTPStatusFromErrorOr(errRefresh, http.StatusServiceUnavailable), gin.H{"error": errRefresh.Error()}) - return - } - if !didRefresh || refreshed == nil { - selection.End("refresh_unavailable") - c.JSON(http.StatusUnauthorized, gin.H{"error": "Codex credential unauthorized"}) - return - } - selected = refreshed - logging.SetGinCPATraceID(c, selected.EnsureIndex()) - resp, err = performRequest(selected) - if err != nil { - if errors.Is(err, errMissingBaseURL) { - selection.End("missing_base_url") - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return - } - selection.End("retry_failed") - helps.RecordAPIResponseError(ctx, s.cfg, err) - c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()}) - return - } - if resp.StatusCode == http.StatusUnauthorized { - s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel) - } - } - closeResponseBody := func() error { - errClose := resp.Body.Close() - if errClose != nil { - log.Errorf("codex alpha search: close response body error: %v", errClose) - } - return errClose - } - if selection != nil { - if errBind := selection.Bind(closeResponseBody); errBind != nil { - selection.End("response_bind_failed") - c.JSON(http.StatusServiceUnavailable, gin.H{"error": errBind.Error()}) - return - } - defer selection.End("response_closed") - } else { - defer func() { _ = closeResponseBody() }() - } - helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone()) - upstreamBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) - if err != nil { - helps.RecordAPIResponseError(ctx, s.cfg, err) - c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": "Failed to read Codex search response"}) - return - } - helps.AppendAPIResponseChunk(ctx, s.cfg, upstreamBody) - if contentType := resp.Header.Get("Content-Type"); contentType != "" { - c.Header("Content-Type", contentType) - } - c.Status(resp.StatusCode) - _, _ = c.Writer.Write(upstreamBody) -} - -// AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine. -// The handler is served as-is without additional middleware beyond the standard stack already configured. -func (s *Server) AttachWebsocketRoute(path string, handler http.Handler) { - if s == nil || s.engine == nil || handler == nil { - return - } - trimmed := strings.TrimSpace(path) - if trimmed == "" { - trimmed = "/v1/ws" - } - if !strings.HasPrefix(trimmed, "/") { - trimmed = "/" + trimmed - } - s.wsRouteMu.Lock() - if _, exists := s.wsRoutes[trimmed]; exists { - s.wsRouteMu.Unlock() - return - } - s.wsRoutes[trimmed] = struct{}{} - s.wsRouteMu.Unlock() - - authMiddleware := AuthMiddleware(s.accessManager) - conditionalAuth := func(c *gin.Context) { - if !s.wsAuthEnabled.Load() { - c.Next() - return - } - authMiddleware(c) - } - finalHandler := func(c *gin.Context) { - handler.ServeHTTP(c.Writer, c.Request) - c.Abort() - } - - s.engine.GET(trimmed, conditionalAuth, finalHandler) -} - -// isAnthropicModelsRequest reports whether a /v1/models request should be served in -// Anthropic format. Anthropic API clients send the Anthropic-Version header; Claude -// Code additionally uses a claude-cli User-Agent. -func isAnthropicModelsRequest(c *gin.Context) bool { - if c.GetHeader("Anthropic-Version") != "" { - return true - } - return strings.HasPrefix(c.GetHeader("User-Agent"), "claude-cli") -} - -// unifiedModelsHandler creates a unified handler for the /v1/models endpoint -// that routes to different handlers based on the request. -// Anthropic API requests (Anthropic-Version header, or a claude-cli User-Agent) -// route to the Claude handler, otherwise they route to the OpenAI handler. -func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc { - return func(c *gin.Context) { - if grokbuild.IsGrokShellUserAgent(c.GetHeader("User-Agent")) { - s.handleGrokModels(c) - return - } - - if _, ok := c.Request.URL.Query()["client_version"]; ok { - if s != nil && s.cfg != nil && s.cfg.Home.Enabled { - s.handleHomeCodexClientModels(c) - return - } - openaiHandler.OpenAIModels(c) - return - } - - if s != nil && s.cfg != nil && s.cfg.Home.Enabled { - s.handleHomeModels(c) - return - } - - // Route to Claude handler for Anthropic API requests. - if isAnthropicModelsRequest(c) { - claudeHandler.ClaudeModels(c) - } else { - openaiHandler.OpenAIModels(c) - } - } -} - -func grokModelsFromHomeEntries(entries []homeModelEntry) []grokbuild.ModelInfo { - models := make([]grokbuild.ModelInfo, 0, len(entries)) - for _, entry := range entries { - models = append(models, grokbuild.ModelInfo{ - ID: entry.id, - DisplayName: entry.displayName, - ContextLength: entry.contextLength, - }) - } - return models -} - -func grokModelsFromRegistryInfos(infos []*registry.ModelInfo) []grokbuild.ModelInfo { - models := make([]grokbuild.ModelInfo, 0, len(infos)) - for _, info := range infos { - if info == nil { - continue - } - model := grokbuild.ModelInfo{ - ID: info.ID, - DisplayName: info.DisplayName, - ContextLength: info.ContextLength, - } - if info.Thinking != nil { - model.ReasoningLevels = append([]string(nil), info.Thinking.Levels...) - } - models = append(models, model) - } - return models -} - -func (s *Server) handleGrokModels(c *gin.Context) { - var models []grokbuild.ModelInfo - if s != nil && s.cfg != nil && s.cfg.Home.Enabled { - entries, ok := s.loadHomeModelEntries(c) - if !ok { - return - } - models = grokModelsFromHomeEntries(entries) - } else { - models = grokModelsFromRegistryInfos(registry.GetGlobalRegistry().GetAvailableModelInfos()) - } - c.JSON(http.StatusOK, grokbuild.BuildResponse(models)) -} - -// handleHomeCodexClientModels builds the Codex client catalog from Home model IDs. -// Template metadata still comes from the local/remote codex_client_models catalog. -func (s *Server) handleHomeCodexClientModels(c *gin.Context) { - entries, ok := s.loadHomeModelEntries(c) - if !ok { - return - } - - models := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - model := map[string]any{ - "id": entry.id, - "object": "model", - } - if entry.created > 0 { - model["created"] = entry.created - } - if entry.ownedBy != "" { - model["owned_by"] = entry.ownedBy - } - if entry.displayName != "" { - model["display_name"] = entry.displayName - model["description"] = entry.displayName - } - if entry.maxCompletionTokens > 0 { - model["max_completion_tokens"] = entry.maxCompletionTokens - } - models = append(models, model) - } - - c.JSON(http.StatusOK, codexmodels.BuildResponse(models, nil, s.cfg.Codex.OptimizeMultiAgentV2)) -} - -func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { - return func(c *gin.Context) { - if s != nil && s.cfg != nil && s.cfg.Home.Enabled { - s.handleHomeGeminiModels(c) - return - } - - geminiHandler.GeminiModels(c) - } -} - -func (s *Server) geminiGetHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { - return func(c *gin.Context) { - if s != nil && s.cfg != nil && s.cfg.Home.Enabled { - s.handleHomeGeminiModel(c) - return - } - - geminiHandler.GeminiGetHandler(c) - } -} - -type homeModelEntry struct { - id string - created int64 - ownedBy string - displayName string - contextLength int - maxCompletionTokens int -} - -func (s *Server) handleHomeModels(c *gin.Context) { - entries, ok := s.loadHomeModelEntries(c) - if !ok { - return - } - - isClaude := isAnthropicModelsRequest(c) - - if isClaude { - disableCloaking := s.cfg != nil && s.cfg.ClaudeCode.DisableCloakingModelList - c.JSON(http.StatusOK, claudemodels.BuildResponse(formatHomeClaudeModels(entries), disableCloaking)) - return - } - - filtered := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - model := map[string]any{ - "id": entry.id, - "object": "model", - } - if entry.created > 0 { - model["created"] = entry.created - } - if entry.ownedBy != "" { - model["owned_by"] = entry.ownedBy - } - filtered = append(filtered, model) - } - c.JSON(http.StatusOK, gin.H{ - "object": "list", - "data": filtered, - }) -} - -func formatHomeClaudeModels(entries []homeModelEntry) []map[string]any { - out := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - out = append(out, formatHomeClaudeModel(entry)) - } - return out -} - -func formatHomeClaudeModel(entry homeModelEntry) map[string]any { - displayName := entry.displayName - if displayName == "" { - displayName = entry.id - } - maxInput := entry.contextLength - if maxInput <= 0 { - maxInput = registry.DefaultClaudeMaxInputTokens - } - maxOutput := entry.maxCompletionTokens - if maxOutput <= 0 { - maxOutput = registry.DefaultClaudeMaxOutputTokens - } - model := map[string]any{ - "id": entry.id, - "object": "model", - "owned_by": entry.ownedBy, - "type": "model", - "display_name": displayName, - "max_input_tokens": maxInput, - "max_tokens": maxOutput, - } - if entry.created > 0 { - model["created_at"] = time.Unix(entry.created, 0).UTC().Format(time.RFC3339) - } - return model -} - -func (s *Server) handleHomeGeminiModels(c *gin.Context) { - entries, ok := s.loadHomeModelEntries(c) - if !ok { - return - } - - c.JSON(http.StatusOK, gin.H{ - "models": formatHomeGeminiModels(entries), - }) -} - -func (s *Server) handleHomeGeminiModel(c *gin.Context) { - entries, ok := s.loadHomeModelEntries(c) - if !ok { - return - } - - action := strings.TrimPrefix(c.Param("action"), "/") - action = strings.TrimSpace(action) - for _, entry := range entries { - if homeGeminiModelMatches(entry, action) { - c.JSON(http.StatusOK, formatHomeGeminiModel(entry)) - return - } - } - - c.JSON(http.StatusNotFound, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: "Not Found", - Type: "not_found", - }, - }) -} - -func (s *Server) loadHomeModelEntries(c *gin.Context) ([]homeModelEntry, bool) { - if s == nil || c == nil || c.Request == nil { - return nil, false - } - client := home.Current() - if client == nil { - c.JSON(http.StatusServiceUnavailable, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: "home control center unavailable", - Type: "server_error", - }, - }) - return nil, false - } - - raw, errGet := client.GetModels(c.Request.Context(), c.Request.Header, c.Request.URL.Query()) - if errGet != nil { - c.JSON(http.StatusBadGateway, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: errGet.Error(), - Type: "server_error", - }, - }) - return nil, false - } - - if statusCode, ok := homeModelsAuthStatus(raw); ok { - c.JSON(statusCode, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: homeModelsErrorMessage(raw), - Type: "authentication_error", - }, - }) - return nil, false - } - - entries, errDecode := decodeHomeModels(raw) - if errDecode != nil { - c.JSON(http.StatusBadGateway, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: errDecode.Error(), - Type: "server_error", - }, - }) - return nil, false - } - - return entries, true -} - -func formatHomeGeminiModels(entries []homeModelEntry) []map[string]any { - out := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - out = append(out, formatHomeGeminiModel(entry)) - } - return out -} - -func formatHomeGeminiModel(entry homeModelEntry) map[string]any { - name := entry.id - if !strings.HasPrefix(name, "models/") { - name = "models/" + name - } - displayName := entry.displayName - if displayName == "" { - displayName = entry.id - } - return map[string]any{ - "name": name, - "displayName": displayName, - "description": displayName, - "supportedGenerationMethods": []string{"generateContent"}, - } -} - -func homeGeminiModelMatches(entry homeModelEntry, action string) bool { - id := strings.TrimSpace(entry.id) - if id == "" || action == "" { - return false - } - normalizedAction := strings.TrimPrefix(action, "models/") - normalizedID := strings.TrimPrefix(id, "models/") - return action == id || action == "models/"+id || normalizedAction == normalizedID -} - -// homeModelsAuthStatus inspects a home models response for an authentication/error envelope. -// It returns the HTTP status code to surface (401 for credential issues, 502 otherwise) -// and true when the payload is an error response rather than model data. -func homeModelsAuthStatus(raw []byte) (int, bool) { - errType := homeModelsErrorType(raw) - if errType == "" { - return 0, false - } - if errType == "no_credentials" || errType == "invalid_credential" { - return http.StatusUnauthorized, true - } - return http.StatusBadGateway, true -} - -func homeModelsErrorType(raw []byte) string { - top, ok := unmarshalHomeModelsTopLevel(raw) - if !ok { - return "" - } - rawErr, exists := top["error"] - if !exists { - return "" - } - var errObj struct { - Type string `json:"type"` - } - if errUnmarshal := json.Unmarshal(rawErr, &errObj); errUnmarshal != nil { - return "" - } - return strings.TrimSpace(errObj.Type) -} - -func homeModelsErrorMessage(raw []byte) string { - top, ok := unmarshalHomeModelsTopLevel(raw) - if !ok { - return "home models request failed" - } - rawErr, exists := top["error"] - if !exists { - return "home models request failed" - } - var errObj struct { - Message string `json:"message"` - } - if errUnmarshal := json.Unmarshal(rawErr, &errObj); errUnmarshal != nil { - return "home models request failed" - } - if msg := strings.TrimSpace(errObj.Message); msg != "" { - return msg - } - return "home models request failed" -} - -func unmarshalHomeModelsTopLevel(raw []byte) (map[string]json.RawMessage, bool) { - if len(raw) == 0 { - return nil, false - } - var top map[string]json.RawMessage - if errUnmarshal := json.Unmarshal(raw, &top); errUnmarshal != nil { - return nil, false - } - return top, true -} - -func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { - if len(raw) == 0 { - return nil, fmt.Errorf("home models payload is empty") - } - - var bySection map[string][]map[string]any - if err := json.Unmarshal(raw, &bySection); err != nil { - return nil, fmt.Errorf("parse home models payload: %w", err) - } - if len(bySection) == 0 { - return nil, fmt.Errorf("home models payload has no sections") - } - - seen := make(map[string]struct{}) - out := make([]homeModelEntry, 0, 256) - for _, models := range bySection { - for _, model := range models { - id, _ := model["id"].(string) - id = strings.TrimSpace(id) - if id == "" { - name, _ := model["name"].(string) - name = strings.TrimSpace(name) - id = strings.TrimPrefix(name, "models/") - } - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - - ownedBy, _ := model["owned_by"].(string) - ownedBy = strings.TrimSpace(ownedBy) - displayName, _ := model["display_name"].(string) - displayName = strings.TrimSpace(displayName) - if displayName == "" { - displayName, _ = model["displayName"].(string) - displayName = strings.TrimSpace(displayName) - } - - out = append(out, homeModelEntry{ - id: id, - created: homeModelInt64Value(model, "created"), - ownedBy: ownedBy, - displayName: displayName, - contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")), - maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")), - }) - } - } - - sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) - if len(out) == 0 { - return nil, fmt.Errorf("home models payload contains no models") - } - return out, nil -} - -func homeModelInt64Value(model map[string]any, keys ...string) int64 { - for _, key := range keys { - switch value := model[key].(type) { - case float64: - return int64(value) - case int64: - return value - case int: - return int64(value) - case json.Number: - if n, errInt := value.Int64(); errInt == nil { - return n - } - case string: - if n, errParse := strconv.ParseInt(strings.TrimSpace(value), 10, 64); errParse == nil { - return n - } - } - } - return 0 } diff --git a/backend/internal/translator/init.go b/backend/internal/translator/init.go index 65428dd..10754e8 100644 --- a/backend/internal/translator/init.go +++ b/backend/internal/translator/init.go @@ -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" ) diff --git a/backend/sdk/cliproxy/service_auth.go b/backend/sdk/cliproxy/service_auth.go index 11b1e1d..8a8d682 100644 --- a/backend/sdk/cliproxy/service_auth.go +++ b/backend/sdk/cliproxy/service_auth.go @@ -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 diff --git a/backend/sdk/cliproxy/service_executors.go b/backend/sdk/cliproxy/service_executors.go index 0213ee4..5129806 100644 --- a/backend/sdk/cliproxy/service_executors.go +++ b/backend/sdk/cliproxy/service_executors.go @@ -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{ diff --git a/backend/sdk/cliproxy/service_lifecycle.go b/backend/sdk/cliproxy/service_lifecycle.go index e16b143..2717483 100644 --- a/backend/sdk/cliproxy/service_lifecycle.go +++ b/backend/sdk/cliproxy/service_lifecycle.go @@ -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) } diff --git a/flake.nix b/flake.nix index 92c0cc2..f01302b 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "CLI Proxy API with its Vite management frontend"; + description = "Lightweight Codex OAuth proxy with an OpenAI-compatible API"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -49,7 +49,6 @@ { default = pkgs.mkShell { packages = with pkgs; [ - gcc git go_1_26 golangci-lint @@ -57,11 +56,9 @@ gotools nodejs_24 nixfmt - pkg-config pnpm yaml-language-server ]; - CGO_ENABLED = "1"; }; } ); diff --git a/frontend/.github/workflows/ci.yml b/frontend/.github/workflows/ci.yml deleted file mode 100644 index 9d6133c..0000000 --- a/frontend/.github/workflows/ci.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - - dev - -jobs: - verify: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: '11.21.0' - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Verify - run: pnpm verify diff --git a/frontend/.github/workflows/release.yml b/frontend/.github/workflows/release.yml deleted file mode 100644 index 5aa9641..0000000 --- a/frontend/.github/workflows/release.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Build and Release - -on: - push: - tags: - - 'v*' - -jobs: - build-and-release: - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: '11.21.0' - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Build frontend - run: pnpm build - env: - VERSION: ${{ github.ref_name }} - - - name: Prepare release assets - run: | - mv dist/index.html dist/management.html - tar -czf management-webui.tar.gz -C dist . - - - name: Generate release notes - run: | - set -euo pipefail - current_tag="${GITHUB_REF_NAME}" - previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)" - if [ -n "${previous_tag}" ]; then - range="${previous_tag}..${current_tag}" - else - range="${current_tag}" - fi - - : > release-notes.md - git log --pretty=format:"- %h %s" "${range}" >> release-notes.md - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - files: management-webui.tar.gz - body_path: release-notes.md - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/frontend/index.html b/frontend/index.html index d31b83f..1876161 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,11 +1,10 @@ - + - - - CLI Proxy API Management Center + + Vibe Proxy Accounts
diff --git a/frontend/package.json b/frontend/package.json index b9b060f..bbbd3d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,50 +8,29 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "vitest run", + "test": "vitest run src --passWithNoTests", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives", "verify": "pnpm test && pnpm lint && pnpm build", - "format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "type-check": "tsc --noEmit" }, "dependencies": { - "@codemirror/lang-yaml": "^6.1.3", - "@codemirror/merge": "^6.12.2", - "@codemirror/search": "^6.7.1", - "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.9", - "@uiw/react-codemirror": "^4.25.11", - "axios": "1.18.1", - "i18next": "^26.3.6", - "motion": "^12.42.2", - "motion-dom": "^12.43.0", "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-i18next": "^17.0.9", - "react-router": "^7.18.2", - "react-router-dom": "^7.18.1", - "yaml": "^2.9.0", - "zustand": "^5.0.14" + "react-dom": "^19.2.7" }, "devDependencies": { "@eslint/js": "10.0.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.63.0", - "@typescript-eslint/parser": "^8.63.0", "@vitejs/plugin-react": "^6.0.3", "eslint": "10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "prettier": "^3.9.5", - "sass": "^1.101.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", "vite": "^8.1.4", "vitest": "^4.1.11" - }, - "overrides": { - "form-data": "4.0.6" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2938e3..e2acd72 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,59 +1,321 @@ -import { useEffect } from 'react'; -import { Outlet, RouterProvider, createHashRouter } from 'react-router-dom'; -import { LoginPage } from '@/pages/LoginPage'; -import { NotificationContainer } from '@/components/common/NotificationContainer'; -import { ConfirmationModal } from '@/components/common/ConfirmationModal'; -import { MainLayout } from '@/components/layout/MainLayout'; -import { ProtectedRoute } from '@/router/ProtectedRoute'; -import { useLanguageStore, useThemeStore } from '@/stores'; +import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { api, type CodexAccount } from './api'; +import { fetchCodexQuota, type CodexQuota } from './codexQuota'; + +const SESSION_KEY = 'vibe-proxy-management-key'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Something went wrong'; +} + +function statusFor(account: CodexAccount): { label: string; tone: string } { + if (account.disabled) return { label: 'Disabled', tone: 'muted' }; + if (account.unavailable || account.status === 'error') return { label: 'Unavailable', tone: 'bad' }; + return { label: account.status || 'Ready', tone: 'good' }; +} + +function Login({ onLogin }: { onLogin: (key: string) => void }) { + const [key, setKey] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const managementKey = key.trim(); + if (!managementKey) return; + + setLoading(true); + setError(''); + try { + await api.listAccounts(managementKey); + sessionStorage.setItem(SESSION_KEY, managementKey); + onLogin(managementKey); + } catch (loginError) { + setError(errorMessage(loginError)); + } finally { + setLoading(false); + } + } -function RootShell() { return ( - <> - - - - +
+
+ +

Vibe Proxy

+

Account management

+

Sign in with the management key for this server.

+
+ + setKey(event.target.value)} + placeholder="Enter management key" + /> + {error &&

{error}

} + +
+
+
); } -const router = createHashRouter([ - { - element: , - children: [ - { path: '/login', element: }, - { - path: '/*', - element: ( - - - - ), - }, - ], - }, -]); - -function App() { - const initializeTheme = useThemeStore((state) => state.initializeTheme); - const language = useLanguageStore((state) => state.language); - const setLanguage = useLanguageStore((state) => state.setLanguage); - - useEffect(() => { - const cleanupTheme = initializeTheme(); - return cleanupTheme; - }, [initializeTheme]); - - useEffect(() => { - setLanguage(language); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // 仅用于首屏同步 i18n 语言 - - useEffect(() => { - document.documentElement.lang = language; - }, [language]); - - return ; +function QuotaView({ quota }: { quota: CodexQuota }) { + return ( +
+ {quota.planType && {quota.planType}} + {quota.windows.length === 0 ? ( +

No quota windows returned.

+ ) : ( + quota.windows.map((window) => ( +
+
+ {window.label} + {window.remaining === null ? '--' : `${Math.round(window.remaining)}%`} +
+
+ +
+ {window.resetAt && ( + Resets {new Date(window.resetAt).toLocaleString()} + )} +
+ )) + )} +
+ ); } -export default App; +function AccountCard({ + account, + managementKey, + onDelete, +}: { + account: CodexAccount; + managementKey: string; + onDelete: () => void; +}) { + const [quota, setQuota] = useState(); + const [quotaError, setQuotaError] = useState(''); + const [loadingQuota, setLoadingQuota] = useState(false); + const [deleting, setDeleting] = useState(false); + const status = statusFor(account); + + async function refreshQuota() { + setLoadingQuota(true); + setQuotaError(''); + try { + setQuota(await fetchCodexQuota(account, managementKey)); + } catch (error) { + setQuotaError(errorMessage(error)); + } finally { + setLoadingQuota(false); + } + } + + async function remove() { + if (!window.confirm(`Delete ${account.email || 'this Codex account'}?`)) return; + setDeleting(true); + try { + await api.deleteAccount(account.name, managementKey); + onDelete(); + } catch (error) { + setQuotaError(errorMessage(error)); + setDeleting(false); + } + } + + return ( +
+
+
+ +
+

{account.email || 'Email unavailable'}

+ {status.label} +
+
+ +
+ + {account.statusMessage &&

{account.statusMessage}

} + {quota && } + {quotaError &&

{quotaError}

} + +
+ ); +} + +function Management({ managementKey, onLogout }: { managementKey: string; onLogout: () => void }) { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [oauth, setOauth] = useState<{ url: string; state: string }>(); + const [callbackUrl, setCallbackUrl] = useState(''); + const [oauthStatus, setOauthStatus] = useState(''); + const [adding, setAdding] = useState(false); + const pollRef = useRef(undefined); + + const loadAccounts = useCallback(async () => { + setError(''); + try { + setAccounts(await api.listAccounts(managementKey)); + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setLoading(false); + } + }, [managementKey]); + + const stopPolling = useCallback(() => { + if (pollRef.current !== undefined) window.clearInterval(pollRef.current); + pollRef.current = undefined; + }, []); + + useEffect(() => { + void loadAccounts(); + return stopPolling; + }, [loadAccounts, stopPolling]); + + function pollAuth(state: string) { + stopPolling(); + pollRef.current = window.setInterval(async () => { + try { + const result = await api.authStatus(state, managementKey); + if (result.status === 'ok') { + stopPolling(); + setOauth(undefined); + setCallbackUrl(''); + setOauthStatus('Account added.'); + setAdding(false); + await loadAccounts(); + } else if (result.status === 'error') { + stopPolling(); + setOauthStatus(result.error || 'Authorization failed.'); + setAdding(false); + } + } catch (pollError) { + stopPolling(); + setOauthStatus(errorMessage(pollError)); + setAdding(false); + } + }, 2500); + } + + async function addAccount() { + setAdding(true); + setOauthStatus(''); + try { + const result = await api.startCodexAuth(managementKey); + if (!result.state) throw new Error('The server did not return an OAuth state.'); + setOauth({ url: result.url, state: result.state }); + window.open(result.url, '_blank', 'noopener,noreferrer'); + pollAuth(result.state); + } catch (oauthError) { + setOauthStatus(errorMessage(oauthError)); + setAdding(false); + } + } + + async function submitCallback(event: FormEvent) { + event.preventDefault(); + if (!callbackUrl.trim()) return; + try { + await api.submitCallback(callbackUrl.trim(), managementKey); + setOauthStatus('Callback submitted. Waiting for the account...'); + } catch (callbackError) { + setOauthStatus(errorMessage(callbackError)); + } + } + + return ( +
+
+ + + Vibe Proxy + + +
+ +
+
+

OpenAI Codex

+

Accounts

+

Connect accounts and check their current usage limits.

+
+ +
+ + {oauth && ( +
+
+ Finish authorization in the OpenAI window. +

If it did not open, open the authorization link.

+
+
+ +
+ setCallbackUrl(event.target.value)} + placeholder="http://localhost:1455/auth/callback?code=..." + /> + +
+
+
+ )} + + {oauthStatus &&

{oauthStatus}

} + {error &&

{error}

} + +
+ {loading ? ( +
Loading accounts...
+ ) : accounts.length === 0 ? ( +
+

No Codex accounts yet

+

Add an OpenAI account to start routing Codex requests.

+
+ ) : ( + accounts.map((account) => ( + + )) + )} +
+
+ ); +} + +export default function App() { + const [managementKey, setManagementKey] = useState(() => sessionStorage.getItem(SESSION_KEY) || ''); + + function logout() { + sessionStorage.removeItem(SESSION_KEY); + setManagementKey(''); + } + + return managementKey ? ( + + ) : ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..1c8e7ac --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,95 @@ +const API_ROOT = '/v0/management'; + +export interface CodexAccount { + name: string; + type?: string; + provider?: string; + email?: string; + disabled?: boolean; + unavailable?: boolean; + status?: string; + statusMessage?: string; + status_message?: string; + authIndex?: string | number | null; + auth_index?: string | number | null; + [key: string]: unknown; +} + +async function request(path: string, managementKey: string, init?: RequestInit): Promise { + const response = await fetch(`${API_ROOT}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${managementKey}`, + 'Content-Type': 'application/json', + ...init?.headers, + }, + }); + + const text = await response.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = text; + } + + if (!response.ok) { + const detail = + body && typeof body === 'object' && 'error' in body + ? String((body as { error: unknown }).error) + : typeof body === 'string' + ? body + : response.statusText; + throw new Error(detail || `Request failed with HTTP ${response.status}`); + } + + return body as T; +} + +function isCodexAccount(account: CodexAccount): boolean { + return [account.type, account.provider].some((value) => String(value || '').toLowerCase() === 'codex'); +} + +export const api = { + async listAccounts(managementKey: string): Promise { + const result = await request<{ files?: CodexAccount[] }>('/auth-files', managementKey); + return (result.files || []) + .filter(isCodexAccount) + .map((account) => ({ + ...account, + email: typeof account.email === 'string' ? account.email.trim() : undefined, + statusMessage: account.statusMessage || account.status_message, + })); + }, + + deleteAccount(name: string, managementKey: string) { + return request(`/auth-files?name=${encodeURIComponent(name)}`, managementKey, { + method: 'DELETE', + }); + }, + + startCodexAuth(managementKey: string) { + return request<{ url: string; state?: string }>('/codex-auth-url?is_webui=true', managementKey); + }, + + authStatus(state: string, managementKey: string) { + return request<{ status: 'ok' | 'wait' | 'error'; error?: string }>( + `/get-auth-status?state=${encodeURIComponent(state)}`, + managementKey, + ); + }, + + submitCallback(redirectUrl: string, managementKey: string) { + return request('/oauth-callback', managementKey, { + method: 'POST', + body: JSON.stringify({ provider: 'codex', redirect_url: redirectUrl }), + }); + }, + + getCodexQuota(authIndex: string, managementKey: string) { + return request>('/codex-quota', managementKey, { + method: 'POST', + body: JSON.stringify({ auth_index: authIndex }), + }); + }, +}; diff --git a/frontend/src/assets/icons/antigravity.svg b/frontend/src/assets/icons/antigravity.svg deleted file mode 100644 index 734c297..0000000 --- a/frontend/src/assets/icons/antigravity.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/icons/apikey-fun.png b/frontend/src/assets/icons/apikey-fun.png deleted file mode 100644 index 0364ec6..0000000 Binary files a/frontend/src/assets/icons/apikey-fun.png and /dev/null differ diff --git a/frontend/src/assets/icons/bestproxy.png b/frontend/src/assets/icons/bestproxy.png deleted file mode 100644 index f77ac86..0000000 Binary files a/frontend/src/assets/icons/bestproxy.png and /dev/null differ diff --git a/frontend/src/assets/icons/claude.svg b/frontend/src/assets/icons/claude.svg deleted file mode 100644 index 62dc0db..0000000 --- a/frontend/src/assets/icons/claude.svg +++ /dev/null @@ -1 +0,0 @@ -Claude \ No newline at end of file diff --git a/frontend/src/assets/icons/claudeapi.png b/frontend/src/assets/icons/claudeapi.png deleted file mode 100644 index 776ced8..0000000 Binary files a/frontend/src/assets/icons/claudeapi.png and /dev/null differ diff --git a/frontend/src/assets/icons/code0.png b/frontend/src/assets/icons/code0.png deleted file mode 100644 index a440e8a..0000000 Binary files a/frontend/src/assets/icons/code0.png and /dev/null differ diff --git a/frontend/src/assets/icons/codex.svg b/frontend/src/assets/icons/codex.svg deleted file mode 100644 index d5cb0ac..0000000 --- a/frontend/src/assets/icons/codex.svg +++ /dev/null @@ -1 +0,0 @@ -Codex \ No newline at end of file diff --git a/frontend/src/assets/icons/deepseek.svg b/frontend/src/assets/icons/deepseek.svg deleted file mode 100644 index 3fc2302..0000000 --- a/frontend/src/assets/icons/deepseek.svg +++ /dev/null @@ -1 +0,0 @@ -DeepSeek \ No newline at end of file diff --git a/frontend/src/assets/icons/fenno-ai.png b/frontend/src/assets/icons/fenno-ai.png deleted file mode 100644 index 173b654..0000000 Binary files a/frontend/src/assets/icons/fenno-ai.png and /dev/null differ diff --git a/frontend/src/assets/icons/gemini.svg b/frontend/src/assets/icons/gemini.svg deleted file mode 100644 index f1cf357..0000000 --- a/frontend/src/assets/icons/gemini.svg +++ /dev/null @@ -1 +0,0 @@ -Gemini \ No newline at end of file diff --git a/frontend/src/assets/icons/glm.svg b/frontend/src/assets/icons/glm.svg deleted file mode 100644 index 0c6e61c..0000000 --- a/frontend/src/assets/icons/glm.svg +++ /dev/null @@ -1 +0,0 @@ -Zhipu \ No newline at end of file diff --git a/frontend/src/assets/icons/grok-dark.svg b/frontend/src/assets/icons/grok-dark.svg deleted file mode 100644 index 9d4ebdb..0000000 --- a/frontend/src/assets/icons/grok-dark.svg +++ /dev/null @@ -1 +0,0 @@ -Grok diff --git a/frontend/src/assets/icons/grok.svg b/frontend/src/assets/icons/grok.svg deleted file mode 100644 index efb1a61..0000000 --- a/frontend/src/assets/icons/grok.svg +++ /dev/null @@ -1 +0,0 @@ -Grok \ No newline at end of file diff --git a/frontend/src/assets/icons/iflow.svg b/frontend/src/assets/icons/iflow.svg deleted file mode 100644 index ec7a6f4..0000000 --- a/frontend/src/assets/icons/iflow.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/icons/infistar.png b/frontend/src/assets/icons/infistar.png deleted file mode 100644 index 650b3ba..0000000 Binary files a/frontend/src/assets/icons/infistar.png and /dev/null differ diff --git a/frontend/src/assets/icons/kimi-dark.svg b/frontend/src/assets/icons/kimi-dark.svg deleted file mode 100644 index 3e84c92..0000000 --- a/frontend/src/assets/icons/kimi-dark.svg +++ /dev/null @@ -1 +0,0 @@ -Kimi diff --git a/frontend/src/assets/icons/kimi-light.svg b/frontend/src/assets/icons/kimi-light.svg deleted file mode 100644 index 29878cd..0000000 --- a/frontend/src/assets/icons/kimi-light.svg +++ /dev/null @@ -1 +0,0 @@ -Kimi diff --git a/frontend/src/assets/icons/lmu-ai.png b/frontend/src/assets/icons/lmu-ai.png deleted file mode 100644 index 9936686..0000000 Binary files a/frontend/src/assets/icons/lmu-ai.png and /dev/null differ diff --git a/frontend/src/assets/icons/minimax.svg b/frontend/src/assets/icons/minimax.svg deleted file mode 100644 index 2a60bd4..0000000 --- a/frontend/src/assets/icons/minimax.svg +++ /dev/null @@ -1 +0,0 @@ -Minimax \ No newline at end of file diff --git a/frontend/src/assets/icons/openai-dark.svg b/frontend/src/assets/icons/openai-dark.svg deleted file mode 100644 index bdb605a..0000000 --- a/frontend/src/assets/icons/openai-dark.svg +++ /dev/null @@ -1 +0,0 @@ -OpenAI \ No newline at end of file diff --git a/frontend/src/assets/icons/openai-light.svg b/frontend/src/assets/icons/openai-light.svg deleted file mode 100644 index 238e9be..0000000 --- a/frontend/src/assets/icons/openai-light.svg +++ /dev/null @@ -1 +0,0 @@ -OpenAI \ No newline at end of file diff --git a/frontend/src/assets/icons/qiniu-cloud.png b/frontend/src/assets/icons/qiniu-cloud.png deleted file mode 100644 index 3485d1f..0000000 Binary files a/frontend/src/assets/icons/qiniu-cloud.png and /dev/null differ diff --git a/frontend/src/assets/icons/qwen.svg b/frontend/src/assets/icons/qwen.svg deleted file mode 100644 index 33b3f64..0000000 --- a/frontend/src/assets/icons/qwen.svg +++ /dev/null @@ -1 +0,0 @@ -Qwen \ No newline at end of file diff --git a/frontend/src/assets/icons/vertex.svg b/frontend/src/assets/icons/vertex.svg deleted file mode 100644 index efc3589..0000000 --- a/frontend/src/assets/icons/vertex.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/logoInline.ts b/frontend/src/assets/logoInline.ts deleted file mode 100644 index 28d5731..0000000 --- a/frontend/src/assets/logoInline.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const INLINE_LOGO_JPEG = - 'data:image/jpeg;base64,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'; diff --git a/frontend/src/codexQuota.ts b/frontend/src/codexQuota.ts new file mode 100644 index 0000000..cabfd01 --- /dev/null +++ b/frontend/src/codexQuota.ts @@ -0,0 +1,122 @@ +import { api, type CodexAccount } from './api'; + +interface UsageWindow { + used_percent?: unknown; + usedPercent?: unknown; + reset_at?: unknown; + resetAt?: unknown; + reset_after_seconds?: unknown; + resetAfterSeconds?: unknown; +} + +interface RateLimit { + primary_window?: UsageWindow | null; + primaryWindow?: UsageWindow | null; + secondary_window?: UsageWindow | null; + secondaryWindow?: UsageWindow | null; +} + +function normalizeAuthIndex(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) return value.toString(); + if (typeof value === 'string') return value.trim() || null; + return null; +} + +function normalizeNumberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function parseCodexUsagePayload(payload: unknown): Record | null { + if (typeof payload === 'string' && payload.trim()) { + try { + return JSON.parse(payload) as Record; + } catch { + return null; + } + } + return payload !== null && typeof payload === 'object' + ? (payload as Record) + : null; +} + +export interface CodexQuota { + planType: string | null; + windows: Array<{ id: string; label: string; remaining: number | null; resetAt: number | null }>; +} + +function resetAt(window: UsageWindow): number | null { + const absolute = normalizeNumberValue(window.reset_at ?? window.resetAt); + if (absolute !== null) return absolute < 1e12 ? absolute * 1000 : absolute; + const offset = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds); + return offset === null ? null : Date.now() + offset * 1000; +} + +function addRateLimit( + result: CodexQuota['windows'], + limit: RateLimit | null | undefined, + prefix: string, + labels: [string, string], +) { + const windows = [limit?.primary_window ?? limit?.primaryWindow, limit?.secondary_window ?? limit?.secondaryWindow]; + windows.forEach((window, index) => { + if (!window) return; + const used = normalizeNumberValue(window.used_percent ?? window.usedPercent); + result.push({ + id: `${prefix}-${index}`, + label: labels[index], + remaining: used === null ? null : Math.max(0, Math.min(100, 100 - used)), + resetAt: resetAt(window), + }); + }); +} + +export async function fetchCodexQuota( + account: CodexAccount, + managementKey: string, +): Promise { + const authIndex = normalizeAuthIndex(account.auth_index ?? account.authIndex); + if (!authIndex) throw new Error('This account has no auth index, so quota cannot be queried.'); + + const response = await api.getCodexQuota(authIndex, managementKey); + const statusCode = Number(response.status_code || 0); + if (statusCode < 200 || statusCode >= 300) { + throw new Error(`Quota request failed with HTTP ${statusCode || 'unknown'}`); + } + + const payload = parseCodexUsagePayload(response.body); + if (!payload) throw new Error('The quota response was empty or invalid.'); + + const source = payload; + const windows: CodexQuota['windows'] = []; + addRateLimit( + windows, + (source.rate_limit ?? source.rateLimit) as RateLimit | undefined, + 'codex', + ['5-hour limit', 'Weekly limit'], + ); + addRateLimit( + windows, + (source.code_review_rate_limit ?? source.codeReviewRateLimit) as RateLimit | undefined, + 'review', + ['Code review 5-hour limit', 'Code review weekly limit'], + ); + + return { + planType: + typeof source.plan_type === 'string' + ? source.plan_type + : typeof source.planType === 'string' + ? source.planType + : typeof account.plan_type === 'string' + ? account.plan_type + : typeof account.planType === 'string' + ? account.planType + : null, + windows, + }; +} diff --git a/frontend/src/components/common/ConfirmationModal.tsx b/frontend/src/components/common/ConfirmationModal.tsx deleted file mode 100644 index 416bfca..0000000 --- a/frontend/src/components/common/ConfirmationModal.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Modal } from '@/components/ui/Modal'; -import { Button } from '@/components/ui/Button'; -import { useNotificationStore } from '@/stores'; - -export function ConfirmationModal() { - const { t } = useTranslation(); - const confirmation = useNotificationStore((state) => state.confirmation); - const hideConfirmation = useNotificationStore((state) => state.hideConfirmation); - const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading); - - const { isOpen, isLoading, options } = confirmation; - - if (!isOpen || !options) { - return null; - } - - const { - title, - message, - onConfirm, - onCancel, - confirmText, - cancelText, - variant = 'primary', - } = options; - - const handleConfirm = async () => { - try { - setConfirmationLoading(true); - await onConfirm(); - hideConfirmation(); - } catch (error) { - console.error('Confirmation action failed:', error); - // Optional: show error notification here if needed, - // but usually the calling component handles specific errors. - } finally { - setConfirmationLoading(false); - } - }; - - const handleCancel = () => { - if (isLoading) { - return; - } - if (onCancel) { - onCancel(); - } - hideConfirmation(); - }; - - return ( - - {typeof message === 'string' ? ( -

{message}

- ) : ( -
{message}
- )} -
- - -
-
- ); -} diff --git a/frontend/src/components/common/NotificationContainer.tsx b/frontend/src/components/common/NotificationContainer.tsx deleted file mode 100644 index 9f1903d..0000000 --- a/frontend/src/components/common/NotificationContainer.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNotificationStore } from '@/stores'; -import { IconX } from '@/components/ui/icons'; -import type { Notification } from '@/types'; - -interface AnimatedNotification extends Notification { - isExiting?: boolean; -} - -const ANIMATION_DURATION = 300; // ms - -export function NotificationContainer() { - const { t } = useTranslation(); - const { notifications, removeNotification } = useNotificationStore(); - const [animatedNotifications, setAnimatedNotifications] = useState([]); - const prevNotificationsRef = useRef([]); - - useEffect(() => { - const prevNotifications = prevNotificationsRef.current; - const prevIds = new Set(prevNotifications.map((n) => n.id)); - const currentIds = new Set(notifications.map((n) => n.id)); - - const newNotifications = notifications.filter((n) => !prevIds.has(n.id)); - - const removedIds = new Set( - prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id) - ); - - setAnimatedNotifications((prev) => { - let updated = prev.map((n) => (removedIds.has(n.id) ? { ...n, isExiting: true } : n)); - - newNotifications.forEach((n) => { - if (!updated.find((animatedNotification) => animatedNotification.id === n.id)) { - updated.push({ ...n, isExiting: false }); - } - }); - - updated = updated.filter((n) => currentIds.has(n.id) || n.isExiting); - - return updated; - }); - - if (removedIds.size > 0) { - setTimeout(() => { - setAnimatedNotifications((prev) => prev.filter((n) => !removedIds.has(n.id))); - }, ANIMATION_DURATION); - } - - prevNotificationsRef.current = notifications; - }, [notifications]); - - const handleClose = (id: string) => { - setAnimatedNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n)) - ); - - setTimeout(() => { - removeNotification(id); - }, ANIMATION_DURATION); - }; - - if (!animatedNotifications.length) return null; - - return ( -
- {animatedNotifications.map((notification) => ( -
-
{notification.message}
- -
- ))} -
- ); -} diff --git a/frontend/src/components/common/PageTransition.scss b/frontend/src/components/common/PageTransition.scss deleted file mode 100644 index 6ff5600..0000000 --- a/frontend/src/components/common/PageTransition.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use '@/styles/variables.scss' as *; - -.page-transition { - position: relative; - flex: 1 1 auto; - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; - - &__layer { - display: flex; - flex-direction: column; - gap: $spacing-lg; - min-height: 0; - flex: 1; - background: var(--bg-secondary); - backface-visibility: hidden; - transform: translateZ(0); - - // During animation, exit layer uses absolute positioning - &--exit { - position: absolute; - inset: 0; - overflow: hidden; - pointer-events: none; - will-change: transform, opacity; - } - - &--stacked { - display: none; - - // Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back. - // Older stacked layers remain `display: none` for performance. - &.page-transition__layer--stacked-keep { - display: flex; - position: absolute; - inset: 0; - overflow: hidden; - pointer-events: none; - opacity: 0; - will-change: transform, opacity; - } - } - } - - &--animating &__layer { - will-change: transform, opacity; - } - - &--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) { - position: relative; - } -} diff --git a/frontend/src/components/common/PageTransition.tsx b/frontend/src/components/common/PageTransition.tsx deleted file mode 100644 index 1573ace..0000000 --- a/frontend/src/components/common/PageTransition.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { useLocation, type Location } from 'react-router-dom'; -import { animate } from 'motion/mini'; -import type { AnimationPlaybackControlsWithThen } from 'motion-dom'; -import { - PAGE_TRANSITION_LAYER_CONTEXT_VALUES, - PageTransitionLayerContext, - type LayerStatus, -} from './PageTransitionLayer'; -import './PageTransition.scss'; - -interface PageTransitionProps { - render: (location: Location) => ReactNode; - getRouteOrder?: (pathname: string) => number | null; - getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant; - scrollContainerRef?: React.RefObject; -} - -// Premium personality: enter > exit, decelerate-in / accelerate-out. -const VERTICAL_ENTER_DURATION = 0.36; -const VERTICAL_EXIT_DURATION = 0.22; -const VERTICAL_ENTER_DISTANCE = 28; -const VERTICAL_EXIT_DISTANCE = 12; -const REDUCED_MOTION_DURATION = 0.15; - -const IOS_TRANSITION_DURATION = 0.44; -const IOS_ENTER_FROM_X_PERCENT = 100; -const IOS_EXIT_TO_X_PERCENT_FORWARD = -22; -const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100; -const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -22; -const IOS_BACKGROUND_SCALE = 0.96; -const IOS_BACKGROUND_OPACITY = 0.5; -const IOS_SHADOW_VALUE = '-20px 0 36px rgba(0, 0, 0, 0.20)'; - -// easeOutQuart: powerful but elegant deceleration for hero entrances. -const easeOutQuart = (progress: number) => 1 - (1 - progress) ** 4; -// easeInQuad: gentle start, accelerates away — exits should not linger. -const easeInQuad = (progress: number) => progress * progress; -// easeOutCubic: smooth Apple-style settle for iOS push/pop. -const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3; - -const prefersReducedMotion = () => - typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - -const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`; -const buildIosTransform = (xPercent: number, y: number, scale = 1) => - scale === 1 - ? `translate3d(${xPercent}%, ${y}px, 0px)` - : `translate3d(${xPercent}%, ${y}px, 0px) scale(${scale})`; - -const clearLayerStyles = (element: HTMLElement | null) => { - if (!element) return; - element.style.removeProperty('transform'); - element.style.removeProperty('opacity'); - element.style.removeProperty('box-shadow'); -}; - -type Layer = { - key: string; - location: Location; - status: LayerStatus; -}; - -type TransitionDirection = 'forward' | 'backward'; - -type TransitionVariant = 'vertical' | 'ios'; - -export function PageTransition({ - render, - getRouteOrder, - getTransitionVariant, - scrollContainerRef, -}: PageTransitionProps) { - const location = useLocation(); - const currentLayerRef = useRef(null); - const exitingLayerRef = useRef(null); - const transitionDirectionRef = useRef('forward'); - const transitionVariantRef = useRef('vertical'); - const exitScrollOffsetRef = useRef(0); - const enterScrollOffsetRef = useRef(0); - const scrollPositionsRef = useRef(new Map()); - const nextLayersRef = useRef(null); - - const [isAnimating, setIsAnimating] = useState(false); - const [layers, setLayers] = useState(() => [ - { - key: location.key, - location, - status: 'current', - }, - ]); - const currentLayer = - layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1]; - const currentLayerKey = currentLayer?.key ?? location.key; - const currentLayerPathname = currentLayer?.location.pathname; - - const resolveScrollContainer = useCallback(() => { - if (scrollContainerRef?.current) return scrollContainerRef.current; - if (typeof document === 'undefined') return null; - return document.scrollingElement as HTMLElement | null; - }, [scrollContainerRef]); - - useLayoutEffect(() => { - if (isAnimating) return; - if (location.key === currentLayerKey) return; - if (currentLayerPathname === location.pathname) return; - const scrollContainer = resolveScrollContainer(); - const exitScrollOffset = scrollContainer?.scrollTop ?? 0; - exitScrollOffsetRef.current = exitScrollOffset; - scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset); - - enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0; - const resolveOrderIndex = (pathname?: string) => { - if (!getRouteOrder || !pathname) return null; - const index = getRouteOrder(pathname); - return typeof index === 'number' && index >= 0 ? index : null; - }; - const fromIndex = resolveOrderIndex(currentLayerPathname); - const toIndex = resolveOrderIndex(location.pathname); - const nextVariant: TransitionVariant = getTransitionVariant - ? getTransitionVariant(currentLayerPathname ?? '', location.pathname) - : 'vertical'; - - let nextDirection: TransitionDirection = - fromIndex === null || toIndex === null || fromIndex === toIndex - ? 'forward' - : toIndex > fromIndex - ? 'forward' - : 'backward'; - - // When using iOS-style stacking, history POP within the same "section" can have equal route order. - // In that case, prefer treating navigation to an existing layer as a backward (pop) transition. - if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) { - nextDirection = 'backward'; - } - - transitionDirectionRef.current = nextDirection; - transitionVariantRef.current = nextVariant; - - const shouldSkipExitLayer = (() => { - if (nextVariant !== 'ios' || nextDirection !== 'backward') return false; - const normalizeSegments = (pathname: string) => - pathname - .split('/') - .filter(Boolean) - .filter((segment) => segment.length > 0); - const fromSegments = normalizeSegments(currentLayerPathname ?? ''); - const toSegments = normalizeSegments(location.pathname); - if (!fromSegments.length || !toSegments.length) return false; - return fromSegments[0] === toSegments[0] && toSegments.length === 1; - })(); - - setLayers((prev) => { - const variant = transitionVariantRef.current; - const direction = transitionDirectionRef.current; - const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current'); - const resolvedCurrentIndex = - previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1; - const previousCurrent = prev[resolvedCurrentIndex]; - const previousStack: Layer[] = prev - .filter((_, idx) => idx !== resolvedCurrentIndex) - .map((layer): Layer => ({ ...layer, status: 'stacked' })); - - const nextCurrent: Layer = { key: location.key, location, status: 'current' }; - - if (!previousCurrent) { - nextLayersRef.current = [nextCurrent]; - return [nextCurrent]; - } - - if (variant === 'ios') { - if (direction === 'forward') { - const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' }; - const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' }; - - nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent]; - return [...previousStack, exitingLayer, nextCurrent]; - } - - const targetIndex = prev.findIndex((layer) => layer.key === location.key); - if (targetIndex !== -1) { - const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => { - const isTarget = idx === targetIndex; - return { - ...layer, - location: isTarget ? location : layer.location, - status: isTarget ? 'current' : 'stacked', - }; - }); - - if (shouldSkipExitLayer) { - nextLayersRef.current = targetStack; - return targetStack; - } - - const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' }; - nextLayersRef.current = targetStack; - return [...targetStack, exitingLayer]; - } - } - - if (shouldSkipExitLayer) { - nextLayersRef.current = [nextCurrent]; - return [nextCurrent]; - } - - const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' }; - - nextLayersRef.current = [nextCurrent]; - return [exitingLayer, nextCurrent]; - }); - setIsAnimating(true); - }, [ - isAnimating, - location, - currentLayerKey, - currentLayerPathname, - getRouteOrder, - getTransitionVariant, - resolveScrollContainer, - layers, - ]); - - // Run Motion animation when animating starts - useLayoutEffect(() => { - if (!isAnimating) return; - - if (!currentLayerRef.current) return; - - const currentLayerEl = currentLayerRef.current; - const exitingLayerEl = exitingLayerRef.current; - const transitionVariant = transitionVariantRef.current; - - clearLayerStyles(currentLayerEl); - clearLayerStyles(exitingLayerEl); - - const scrollContainer = resolveScrollContainer(); - const exitScrollOffset = exitScrollOffsetRef.current; - const enterScrollOffset = enterScrollOffsetRef.current; - if (scrollContainer && exitScrollOffset !== enterScrollOffset) { - scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' }); - } - - const transitionDirection = transitionDirectionRef.current; - const isForward = transitionDirection === 'forward'; - const enterFromY = isForward ? VERTICAL_ENTER_DISTANCE : -VERTICAL_ENTER_DISTANCE; - const exitToY = isForward ? -VERTICAL_EXIT_DISTANCE : VERTICAL_EXIT_DISTANCE; - const exitBaseY = enterScrollOffset - exitScrollOffset; - const reduceMotion = prefersReducedMotion(); - const activeAnimations: AnimationPlaybackControlsWithThen[] = []; - let cancelled = false; - let completed = false; - const completeTransition = () => { - if (completed) return; - completed = true; - - const nextLayers = nextLayersRef.current; - nextLayersRef.current = null; - setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting')); - setIsAnimating(false); - - clearLayerStyles(currentLayerEl); - clearLayerStyles(exitingLayerEl); - }; - - if (reduceMotion) { - // Accessibility: skip spatial motion entirely, fall back to a quick crossfade. - if (exitingLayerEl) { - exitingLayerEl.style.transform = - transitionVariant === 'ios' - ? buildIosTransform(0, exitBaseY) - : buildVerticalTransform(exitBaseY); - activeAnimations.push( - animate( - exitingLayerEl, - { opacity: [1, 0] }, - { duration: REDUCED_MOTION_DURATION, ease: easeOutCubic } - ) - ); - } - currentLayerEl.style.opacity = '0'; - activeAnimations.push( - animate( - currentLayerEl, - { opacity: [0, 1] }, - { duration: REDUCED_MOTION_DURATION, ease: easeOutCubic } - ) - ); - } else if (transitionVariant === 'ios') { - const exitToXPercent = isForward - ? IOS_EXIT_TO_X_PERCENT_FORWARD - : IOS_EXIT_TO_X_PERCENT_BACKWARD; - const enterFromXPercent = isForward - ? IOS_ENTER_FROM_X_PERCENT - : IOS_ENTER_FROM_X_PERCENT_BACKWARD; - - // Background layer (the one being pushed back / coming forward from behind) gets - // scale + opacity dim to read as "behind". Top layer is the one sliding fully on/off. - const exitScaleTo = isForward ? IOS_BACKGROUND_SCALE : 1; - const exitOpacityTo = isForward ? IOS_BACKGROUND_OPACITY : 1; - const enterScaleFrom = isForward ? 1 : IOS_BACKGROUND_SCALE; - const enterOpacityFrom = isForward ? 1 : IOS_BACKGROUND_OPACITY; - - if (exitingLayerEl) { - exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY, 1); - exitingLayerEl.style.opacity = '1'; - } - - currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0, enterScaleFrom); - currentLayerEl.style.opacity = String(enterOpacityFrom); - - // Shadow sits on whichever layer is visually in front of the other during the slide. - const topLayerEl = isForward ? currentLayerEl : exitingLayerEl; - if (topLayerEl) { - topLayerEl.style.boxShadow = IOS_SHADOW_VALUE; - } - - if (exitingLayerEl) { - activeAnimations.push( - animate( - exitingLayerEl, - { - transform: [ - buildIosTransform(0, exitBaseY, 1), - buildIosTransform(exitToXPercent, exitBaseY, exitScaleTo), - ], - opacity: [1, exitOpacityTo], - }, - { - duration: IOS_TRANSITION_DURATION, - ease: easeOutCubic, - } - ) - ); - } - - activeAnimations.push( - animate( - currentLayerEl, - { - transform: [ - buildIosTransform(enterFromXPercent, 0, enterScaleFrom), - buildIosTransform(0, 0, 1), - ], - opacity: [enterOpacityFrom, 1], - }, - { - duration: IOS_TRANSITION_DURATION, - ease: easeOutCubic, - } - ) - ); - } else { - // Vertical: split timing — exit leaves quickly (accelerate), enter settles slowly (decelerate). - if (exitingLayerEl) { - exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY); - activeAnimations.push( - animate( - exitingLayerEl, - { - transform: [ - buildVerticalTransform(exitBaseY), - buildVerticalTransform(exitBaseY + exitToY), - ], - opacity: [1, 0], - }, - { - duration: VERTICAL_EXIT_DURATION, - ease: easeInQuad, - } - ) - ); - } - - currentLayerEl.style.transform = buildVerticalTransform(enterFromY); - currentLayerEl.style.opacity = '0'; - activeAnimations.push( - animate( - currentLayerEl, - { - transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)], - opacity: [0, 1], - }, - { - duration: VERTICAL_ENTER_DURATION, - ease: easeOutQuart, - } - ) - ); - } - - if (!activeAnimations.length) { - completeTransition(); - } else { - void Promise.all( - activeAnimations.map((animation) => animation.finished.catch(() => undefined)) - ).then(() => { - if (cancelled) return; - completeTransition(); - }); - } - - return () => { - cancelled = true; - activeAnimations.forEach((animation) => animation.stop()); - }; - }, [isAnimating, resolveScrollContainer]); - - return ( -
- {(() => { - const currentIndex = layers.findIndex((layer) => layer.status === 'current'); - const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex; - const keepStackedIndex = layers - .slice(0, resolvedCurrentIndex) - .map((layer, index) => ({ layer, index })) - .reverse() - .find(({ layer }) => layer.status === 'stacked')?.index; - - return layers.map((layer, index) => { - const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex; - return ( -
- - {render(layer.location)} - -
- ); - }); - })()} -
- ); -} diff --git a/frontend/src/components/common/PageTransitionLayer.ts b/frontend/src/components/common/PageTransitionLayer.ts deleted file mode 100644 index 036b11f..0000000 --- a/frontend/src/components/common/PageTransitionLayer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createContext, useContext } from 'react'; - -export type LayerStatus = 'current' | 'exiting' | 'stacked'; - -export type PageTransitionLayerContextValue = { - status: LayerStatus; - isCurrentLayer: boolean; - isAnimating: boolean; -}; - -export const PageTransitionLayerContext = createContext( - null -); - -export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record< - LayerStatus, - PageTransitionLayerContextValue -> = { - current: { status: 'current', isCurrentLayer: true, isAnimating: false }, - stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false }, - exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false }, -}; - -export function usePageTransitionLayer() { - return useContext(PageTransitionLayerContext); -} diff --git a/frontend/src/components/common/SecondaryScreenShell.module.scss b/frontend/src/components/common/SecondaryScreenShell.module.scss deleted file mode 100644 index 1561beb..0000000 --- a/frontend/src/components/common/SecondaryScreenShell.module.scss +++ /dev/null @@ -1,83 +0,0 @@ -@use '../../styles/variables' as *; - -.container { - display: flex; - flex-direction: column; - gap: $spacing-lg; - min-height: 0; -} - -.topBar { - position: sticky; - top: 0; - z-index: 5; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: $spacing-md; - padding: $spacing-sm $spacing-md; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - min-height: 44px; -} - -.topBarTitle { - min-width: 0; - text-align: center; - font-size: 16px; - font-weight: 650; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - justify-self: center; -} - -.backButton { - padding-left: 6px; - padding-right: 10px; - justify-self: start; - gap: 0; -} - -.backButton > span:last-child { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.backIcon { - display: inline-flex; - align-items: center; - justify-content: center; - - svg { - display: block; - } -} - -.backText { - font-weight: 600; - line-height: 18px; -} - -.rightSlot { - justify-self: end; - display: flex; - justify-content: flex-end; -} - -.loadingState { - display: flex; - align-items: center; - justify-content: center; - gap: $spacing-sm; - padding: $spacing-2xl 0; - color: var(--text-secondary); -} - -.content { - display: flex; - flex-direction: column; - gap: $spacing-lg; -} diff --git a/frontend/src/components/common/SecondaryScreenShell.tsx b/frontend/src/components/common/SecondaryScreenShell.tsx deleted file mode 100644 index 2dc3513..0000000 --- a/frontend/src/components/common/SecondaryScreenShell.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { forwardRef, type ReactNode } from 'react'; -import { Button } from '@/components/ui/Button'; -import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; -import { IconChevronLeft } from '@/components/ui/icons'; -import styles from './SecondaryScreenShell.module.scss'; - -export type SecondaryScreenShellProps = { - title: ReactNode; - onBack?: () => void; - backLabel?: string; - backAriaLabel?: string; - rightAction?: ReactNode; - isLoading?: boolean; - loadingLabel?: ReactNode; - className?: string; - contentClassName?: string; - children?: ReactNode; -}; - -export const SecondaryScreenShell = forwardRef( - function SecondaryScreenShell( - { - title, - onBack, - backLabel = 'Back', - backAriaLabel, - rightAction, - isLoading = false, - loadingLabel = 'Loading...', - className = '', - contentClassName = '', - children, - }, - ref - ) { - const containerClassName = [styles.container, className].filter(Boolean).join(' '); - const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' '); - const titleTooltip = typeof title === 'string' ? title : undefined; - const resolvedBackAriaLabel = backAriaLabel ?? backLabel; - - return ( -
-
- {onBack ? ( - - ) : ( -
- )} -
- {title} -
-
{rightAction}
-
- - {isLoading ? ( -
- - {loadingLabel} -
- ) : ( -
{children}
- )} -
- ); - } -); diff --git a/frontend/src/components/excludedModels/ExcludedModelRuleChip.module.scss b/frontend/src/components/excludedModels/ExcludedModelRuleChip.module.scss deleted file mode 100644 index eee4d86..0000000 --- a/frontend/src/components/excludedModels/ExcludedModelRuleChip.module.scss +++ /dev/null @@ -1,102 +0,0 @@ -.chipRow { - display: flex; - flex-wrap: wrap; - gap: 6px; - min-width: 0; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - min-width: 0; - max-width: 100%; - padding: 4px 5px 4px 9px; - border-radius: $radius-full; - color: var(--text-primary); - font-family: $font-mono; - font-size: 11px; - line-height: 1.5; -} - -.label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.detail { - flex-shrink: 0; - color: var(--text-tertiary); - font-size: 10px; -} - -/* 显式勾选:实线 + primary 染色,读起来是「我选的」。 */ -.exact { - border: 1px solid color-mix(in srgb, var(--primary-color) 45%, var(--border-color)); - background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary)); -} - -/* 规则派生:虚线 = 「不是逐个挑的,是某条规则算出来的」。 */ -.wildcard { - border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color)); - background: transparent; - color: var(--text-secondary); -} - -/* 目录外的精确规则:同样虚线,但更弱——它指向一个我们无法确认存在的模型。 */ -.unknown { - border: 1px dashed var(--border-color); - background: transparent; - color: var(--text-tertiary); -} - -.remove { - display: inline-flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - padding: 0; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - transition: - background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out), - color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out), - transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out); - - &:active:not(:disabled) { - transform: scale(0.9); - } - - &:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 1px; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.5; - } - - @media (hover: hover) and (pointer: fine) { - &:hover:not(:disabled) { - background: var(--bg-tertiary); - color: var(--text-primary); - } - } -} - -@media (prefers-reduced-motion: reduce) { - .remove { - transition: none; - } - - .remove:active:not(:disabled) { - transform: none; - } -} diff --git a/frontend/src/components/excludedModels/ExcludedModelRuleChip.tsx b/frontend/src/components/excludedModels/ExcludedModelRuleChip.tsx deleted file mode 100644 index a7d5b70..0000000 --- a/frontend/src/components/excludedModels/ExcludedModelRuleChip.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactNode } from 'react'; -import { IconX } from '@/components/ui/icons'; -import styles from './ExcludedModelRuleChip.module.scss'; - -/** - * 排除项 chip —— 按**来源**区分三种形态,取代两处近乎重复的手写标记 - * (`AuthFileDetailsSheet.module.scss` 的 `.excludedModelChip` 与 - * `AuthFilesOAuthExcludedEditPage.module.scss` 的 `.customRuleChip`)。 - * - * - `exact` 实线 primary 染色:用户显式勾选的模型,可直接移除。 - * - `wildcard` 虚线:由通配符规则派生出的模型。没有 ✕——要移除得去改那条规则, - * 直接给个 ✕ 会承诺一件它做不到的事。 - * - `unknown` 虚线弱化:精确规则但目录里没有(如已下线的模型 id),可移除。 - */ -export type ExcludedModelChipVariant = 'exact' | 'wildcard' | 'unknown'; - -export interface ExcludedModelRuleChipProps { - label: string; - variant?: ExcludedModelChipVariant; - /** 次要说明,例如派生该 chip 的规则。 */ - detail?: string; - /** 省略即不渲染 ✕。 */ - onRemove?: () => void; - removeAriaLabel?: string; - disabled?: boolean; - title?: string; -} - -/** chip 的换行容器。单独导出,免得每个消费方各写一遍 flex-wrap。 */ -export function ExcludedModelChipRow({ children }: { children: ReactNode }) { - return
{children}
; -} - -export function ExcludedModelRuleChip({ - label, - variant = 'exact', - detail, - onRemove, - removeAriaLabel, - disabled = false, - title, -}: ExcludedModelRuleChipProps) { - return ( - - {label} - {detail ? {detail} : null} - {onRemove ? ( - - ) : null} - - ); -} diff --git a/frontend/src/components/excludedModels/ExcludedModelsPanel.tsx b/frontend/src/components/excludedModels/ExcludedModelsPanel.tsx deleted file mode 100644 index b9bd0bb..0000000 --- a/frontend/src/components/excludedModels/ExcludedModelsPanel.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { IconCheck, IconSearch } from '@/components/ui/icons'; -import { - getModelExclusionState, - type ExclusionStats, - type ModelExclusionState, -} from './excludedModelRules'; -import styles from './ExcludedModelsPicker.module.scss'; - -export interface ExcludedModelCandidate { - id: string; - displayName?: string; -} - -interface ExcludedModelsPanelProps { - rules: readonly string[]; - candidates: readonly ExcludedModelCandidate[]; - /** 由 Picker 算好传下来,避免在 footer 里把整个目录再扫一遍。 */ - stats: ExclusionStats; - onToggle: (modelId: string, excluded: boolean) => void; - onSelectAll: () => void; - onClear: () => void; - disabled: boolean; - listboxId: string; - /** 展开后是否把焦点送进搜索框(键盘展开时为 true,鼠标点开时也为 true)。 */ - autoFocus: boolean; - /** 收起面板并把焦点还给 trigger。 */ - onDismiss: () => void; -} - -const matchesQuery = (candidate: ExcludedModelCandidate, query: string): boolean => - candidate.id.toLowerCase().includes(query) || - (candidate.displayName ?? '').toLowerCase().includes(query); - -export function ExcludedModelsPanel({ - rules, - candidates, - stats, - onToggle, - onSelectAll, - onClear, - disabled, - listboxId, - autoFocus, - onDismiss, -}: ExcludedModelsPanelProps) { - const { t } = useTranslation(); - const [query, setQuery] = useState(''); - const [highlight, setHighlight] = useState(0); - const inputRef = useRef(null); - - const visible = useMemo(() => { - const normalized = query.trim().toLowerCase(); - if (!normalized) return candidates; - return candidates.filter((candidate) => matchesQuery(candidate, normalized)); - }, [candidates, query]); - - // 高亮永远钳在可见范围内:过滤后列表变短,旧索引会指向不存在的行。 - const activeIndex = visible.length === 0 ? -1 : Math.min(highlight, visible.length - 1); - const activeId = activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined; - - useLayoutEffect(() => { - if (!autoFocus) return; - // preventScroll:裸 focus() 会把外层 Sheet 的滚动猛拽过来,动画中途还会把面板顶出视野。 - inputRef.current?.focus({ preventScroll: true }); - }, [autoFocus]); - - useEffect(() => { - if (!autoFocus || activeIndex < 0) return; - document - .getElementById(`${listboxId}-opt-${activeIndex}`) - ?.scrollIntoView({ block: 'nearest' }); - }, [activeIndex, autoFocus, listboxId]); - - const toggleAt = (index: number) => { - const candidate = visible[index]; - if (!candidate || disabled) return; - const current = getModelExclusionState(rules, candidate.id); - // 纯通配符命中的行不可直接切换——它的排除权属于那条规则。行内副文本常驻解释原因。 - if (current.state === 'excluded' && current.by === 'wildcard') return; - onToggle(candidate.id, current.state !== 'excluded'); - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - setHighlight((prev) => Math.min(prev + 1, visible.length - 1)); - return; - case 'ArrowUp': - event.preventDefault(); - setHighlight((prev) => Math.max(prev - 1, 0)); - return; - case 'Home': - if (visible.length === 0) return; - event.preventDefault(); - setHighlight(0); - return; - case 'End': - if (visible.length === 0) return; - event.preventDefault(); - setHighlight(visible.length - 1); - return; - case 'Enter': - event.preventDefault(); - if (activeIndex >= 0) toggleAt(activeIndex); - return; - case 'Escape': - // 外层 Sheet 在 document 上、OAuth 页在 window 上都听 Escape。 - // 不拦住就会「关面板 = 关 Sheet / 离开页面 + 触发未保存弹窗」。 - event.preventDefault(); - event.stopPropagation(); - if (query) { - setQuery(''); - setHighlight(0); - return; - } - onDismiss(); - return; - default: - } - }; - - return ( -
-
-
- -
- {visible.length === 0 ? ( -

- {query.trim() - ? t('excluded_models.no_results', { query: query.trim() }) - : t('excluded_models.catalog_empty')} -

- ) : ( - visible.map((candidate, index) => ( - setHighlight(index)} - onToggle={() => toggleAt(index)} - /> - )) - )} -
- -
- - {t('excluded_models.footer_count', { excluded: stats.excluded, total: stats.total })} - - - - - -
-
- ); -} - -interface ExcludedModelRowProps { - id: string; - candidate: ExcludedModelCandidate; - state: ModelExclusionState; - highlighted: boolean; - onHover: () => void; - onToggle: () => void; -} - -function ExcludedModelRow({ - id, - candidate, - state, - highlighted, - onHover, - onToggle, -}: ExcludedModelRowProps) { - const { t } = useTranslation(); - const excluded = state.state === 'excluded'; - const lockedByRule = state.state === 'excluded' && state.by === 'wildcard'; - // 把「哪条规则、用哪句话解释」在一处收敛好,下面的 JSX 就不必再做类型收窄。 - const wildcardReason = - state.state === 'excluded' && (state.by === 'wildcard' || state.by === 'both') - ? { - rule: state.rule, - text: - state.by === 'wildcard' - ? t('excluded_models.wildcard_locked', { rule: state.rule }) - : t('excluded_models.also_wildcard', { rule: state.rule }), - muted: state.by === 'both', - } - : null; - - const rowClass = [ - styles.row, - excluded ? styles.rowExcluded : '', - lockedByRule ? styles.rowLocked : '', - highlighted ? styles.rowHighlighted : '', - ] - .filter(Boolean) - .join(' '); - - return ( -
- - - {candidate.id} - {candidate.displayName && candidate.displayName !== candidate.id ? ( - {candidate.displayName} - ) : null} - {wildcardReason ? {wildcardReason.text} : null} - - {wildcardReason ? ( - - {t('excluded_models.badge_wildcard')} - - ) : null} -
- ); -} diff --git a/frontend/src/components/excludedModels/ExcludedModelsPicker.module.scss b/frontend/src/components/excludedModels/ExcludedModelsPicker.module.scss deleted file mode 100644 index b9d2728..0000000 --- a/frontend/src/components/excludedModels/ExcludedModelsPicker.module.scss +++ /dev/null @@ -1,477 +0,0 @@ -.root { - display: flex; - flex-direction: column; - gap: $spacing-sm; - min-width: 0; -} - -/* -------------------------------------------------------------------------- */ -/* Trigger —— 摘要 + 计量条,取代「把计数塞进 placeholder」 */ -/* -------------------------------------------------------------------------- */ - -.trigger { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-sm; - width: 100%; - min-height: 40px; - padding: 0 12px; - overflow: hidden; - border: 1px solid var(--border-color); - border-radius: $radius-md; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 13px; - text-align: left; - cursor: pointer; - transition: - border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out), - background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out), - transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out); - - /* 整条 40px 宽元素上 0.97 太橡皮;0.99 足够被感知又不显廉价。 */ - &:active:not(:disabled) { - transform: scale(0.99); - } - - &:focus-visible { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba($primary-color, 0.18); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.6; - } - - @media (hover: hover) and (pointer: fine) { - &:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color)); - } - } -} - -.triggerText { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.triggerSpinner { - flex-shrink: 0; - color: var(--text-tertiary); - animation: excluded-spin 900ms linear infinite; -} - -.chevron { - flex-shrink: 0; - color: var(--text-tertiary); - transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out); -} - -.triggerOpen .chevron { - transform: rotate(180deg); -} - -/* 底边发丝计量条:零成本地长期回答「我到底排除了多少」。 */ -.meter { - position: absolute; - right: 0; - bottom: 0; - left: 0; - height: 2px; - background: var(--bg-tertiary); -} - -.meterFill { - display: block; - height: 100%; - background: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary)); - transition: width 360ms var(--ease-out-strong, ease); -} - -/* -------------------------------------------------------------------------- */ -/* 内联展开:grid 0fr→1fr。搜索框会在展开状态下过滤列表,每次击键都改高度, */ -/* grid 轨道自动重解,无需测量,也没有 ResizeObserver 要去跟击键搏斗。 */ -/* -------------------------------------------------------------------------- */ - -.disclosure { - display: grid; - grid-template-rows: 0fr; - /* 退场更快 + 加速。themes.scss 无 --ease-in* token,这里用关键字而非发明全局 token。 */ - transition: grid-template-rows 120ms ease-in; -} - -.disclosureOpen { - grid-template-rows: 1fr; - transition: grid-template-rows var(--dur-hover, 200ms) var(--ease-out-strong, ease-out); -} - -.disclosureInner { - /* 必需:grid item 默认 min-height:auto,漏了它面板收不回去。 */ - min-height: 0; - overflow: hidden; -} - -.panel { - display: flex; - flex-direction: column; - margin-top: 6px; - overflow: hidden; - border: 1px solid var(--border-color); - border-radius: $radius-md; - background: var(--bg-secondary); - transform-origin: top; - animation: excluded-panel-in var(--dur-hover, 200ms) var(--ease-out-strong, ease-out) both; -} - -/* 只写 from,让元素的静止样式定义终点(与 toolbar-popover-in 同一写法)。 */ -@keyframes excluded-panel-in { - from { - opacity: 0; - /* 0.98 而非 0.95——面板是宽内联块,600px 下 0.95 是 30px 的横向蠕动。 */ - transform: scale(0.98); - } -} - -@keyframes excluded-spin { - to { - transform: rotate(360deg); - } -} - -/* -------------------------------------------------------------------------- */ -/* 搜索 */ -/* -------------------------------------------------------------------------- */ - -.searchRow { - position: relative; - display: flex; - align-items: center; - padding: 8px; - border-bottom: 1px solid var(--border-color); -} - -.searchIcon { - position: absolute; - left: 18px; - color: var(--text-tertiary); - pointer-events: none; -} - -.search { - width: 100%; - padding: 6px 10px 6px 32px; - border: 1px solid var(--border-color); - border-radius: $radius-sm; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 12px; - - &::placeholder { - color: var(--text-tertiary); - } - - &:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba($primary-color, 0.18); - } -} - -/* -------------------------------------------------------------------------- */ -/* 列表 */ -/* -------------------------------------------------------------------------- */ - -.list { - display: flex; - flex-direction: column; - max-height: 260px; - padding: 6px; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; -} - -.row { - display: flex; - align-items: center; - gap: $spacing-sm; - padding: 7px 8px; - border-radius: $radius-sm; - cursor: pointer; - transition: background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out); - - &:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: -2px; - } -} - -.rowHighlighted { - background: var(--bg-tertiary); -} - -.rowExcluded .checkbox { - border-color: var(--primary-color); - background: var(--primary-color); - color: var(--bg-primary); -} - -/* 纯规则命中:压暗且不可切换,但仍可聚焦、仍会朗读原因。 */ -.rowLocked { - cursor: default; - opacity: 0.62; -} - -.checkbox { - display: inline-flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border: 1px solid var(--border-color); - border-radius: $radius-sm; - background: var(--bg-primary); - transition: - background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out), - border-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out); -} - -.rowText { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - flex: 1; -} - -.rowId { - overflow: hidden; - color: var(--text-primary); - font-family: $font-mono; - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rowDisplayName, -.rowReason { - overflow: hidden; - color: var(--text-tertiary); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.badge { - flex-shrink: 0; - padding: 2px 6px; - border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color)); - border-radius: $radius-full; - color: var(--text-secondary); - font-size: 10px; -} - -.badgeMuted { - border-style: dotted; - color: var(--text-tertiary); -} - -.noResults { - margin: 0; - padding: $spacing-lg $spacing-sm; - color: var(--text-tertiary); - font-size: 12px; - text-align: center; -} - -/* -------------------------------------------------------------------------- */ -/* 吸底摘要 */ -/* -------------------------------------------------------------------------- */ - -.footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-sm; - padding: 8px 10px; - border-top: 1px solid var(--border-color); - background: var(--bg-primary); -} - -.footerCount { - color: var(--text-secondary); - font-size: 11px; - font-variant-numeric: tabular-nums; -} - -.footerActions { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.footerButton { - padding: 4px 8px; - border: 0; - border-radius: $radius-sm; - background: transparent; - color: var(--text-secondary); - font-size: 11px; - cursor: pointer; - transition: - background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out), - color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out), - transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out); - - &:active:not(:disabled) { - transform: scale(0.96); - } - - &:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 1px; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.5; - } - - @media (hover: hover) and (pointer: fine) { - &:hover:not(:disabled) { - background: var(--bg-tertiary); - color: var(--text-primary); - } - } -} - -/* -------------------------------------------------------------------------- */ -/* 无目录降级 / 规则编辑器 */ -/* -------------------------------------------------------------------------- */ - -.catalogNotice { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-sm; - margin-top: 6px; - padding: 12px; - border: 1px solid var(--border-color); - border-radius: $radius-md; - background: var(--bg-secondary); - color: var(--text-secondary); - font-size: 12px; -} - -.retryButton { - flex-shrink: 0; - padding: 4px 10px; - border: 1px solid var(--border-color); - border-radius: $radius-sm; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 11px; - cursor: pointer; - transition: transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out); - - &:active { - transform: scale(0.96); - } -} - -.chipsMore { - align-self: center; - color: var(--text-tertiary); - font-size: 11px; -} - -.ruleEditor { - display: flex; - flex-direction: column; - gap: 6px; -} - -.ruleLabel { - color: var(--text-secondary); - font-size: 12px; - font-weight: 500; -} - -.ruleMatches { - display: flex; - flex-direction: column; - gap: 2px; - margin: 0; - padding: 0; - list-style: none; - - li { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-tertiary); - font-size: 11px; - } - - code { - color: var(--text-secondary); - font-family: $font-mono; - } -} - -/* 零命中是 warning 不是 error:规则可以合法地指向目录不认识的模型。 */ -.ruleMatchNone { - color: var(--warning-color, #{$warning-color}); -} - -.ruleWarning { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - color: var(--warning-color, #{$warning-color}); - font-size: 11px; -} - -/* -------------------------------------------------------------------------- */ - -@media (prefers-reduced-motion: reduce) { - .disclosure, - .disclosureOpen { - transition: none; - } - - .panel { - animation: none; - } - - .trigger, - .chevron, - .meterFill, - .row, - .checkbox, - .footerButton, - .retryButton { - transition: none; - } - - .triggerSpinner { - animation: none; - } - - .trigger:active:not(:disabled), - .footerButton:active:not(:disabled), - .retryButton:active { - transform: none; - } -} diff --git a/frontend/src/components/excludedModels/ExcludedModelsPicker.tsx b/frontend/src/components/excludedModels/ExcludedModelsPicker.tsx deleted file mode 100644 index a866c2b..0000000 --- a/frontend/src/components/excludedModels/ExcludedModelsPicker.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { useCallback, useId, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { IconAlertTriangle, IconChevronDown, IconLoader2 } from '@/components/ui/icons'; -import { ExcludedModelChipRow, ExcludedModelRuleChip } from './ExcludedModelRuleChip'; -import { ExcludedModelsPanel, type ExcludedModelCandidate } from './ExcludedModelsPanel'; -import { - formatExcludedRulesText, - getModelExclusionState, - matchedModelsByRule, - normalizeExcludedRules, - replaceCustomExcludedRules, - splitExcludedRules, - summarizeExclusion, - toggleExcludedRule, -} from './excludedModelRules'; -import styles from './ExcludedModelsPicker.module.scss'; - -export type { ExcludedModelCandidate }; - -export type ExcludedModelsCatalogState = 'ready' | 'loading' | 'unavailable' | 'error'; - -/** 派生 chip 的上限——超过这个数就只报总数,否则 chip 行会淹没整个字段。 */ -const DERIVED_CHIP_LIMIT = 8; - -export interface ExcludedModelsPickerProps { - /** 规范的规则列表。调用方内部存文本/Set 都行,在边界上适配一次即可。 */ - value: readonly string[]; - onChange: (next: string[]) => void; - - candidates: readonly ExcludedModelCandidate[]; - catalogState?: ExcludedModelsCatalogState; - onRetryCatalog?: () => void; - - /** 真实禁用(未连接 / 保存中)。**绝不要**因为目录为空就传 true。 */ - disabled?: boolean; - - /** picker 不得读写、也不许用户输入的规则。provider 表单传 `['*']`。 */ - reservedRules?: readonly string[]; - reservedRuleMessage?: string; - - /** 关掉通配符规则编辑器。 */ - showRuleEditor?: boolean; - - labelledBy?: string; - className?: string; -} - -export function ExcludedModelsPicker({ - value, - onChange, - candidates, - catalogState = 'ready', - onRetryCatalog, - disabled = false, - reservedRules, - reservedRuleMessage, - showRuleEditor = true, - labelledBy, - className, -}: ExcludedModelsPickerProps) { - const { t } = useTranslation(); - const baseId = useId(); - const panelId = `${baseId}-panel`; - const listboxId = `${baseId}-listbox`; - const [open, setOpen] = useState(false); - const [reservedHit, setReservedHit] = useState(false); - const triggerRef = useRef(null); - - const reservedKeys = useMemo( - () => new Set((reservedRules ?? []).map((rule) => rule.trim().toLowerCase())), - [reservedRules] - ); - - /** - * 保留规则在**入口**就被剥掉,因此 picker 内部从不见到它,也就不可能把它写回去。 - * provider 表单的 `'*'`(= 已停用)由 disabled 开关独占,排除面无权触碰。 - */ - const rules = useMemo( - () => - normalizeExcludedRules(value).filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())), - [reservedKeys, value] - ); - - const candidateIds = useMemo(() => candidates.map((c) => c.id), [candidates]); - const stats = useMemo(() => summarizeExclusion(rules, candidateIds), [candidateIds, rules]); - const { exactRules, unknownRules, customRules } = useMemo( - () => splitExcludedRules(rules, candidateIds), - [candidateIds, rules] - ); - - const commit = useCallback( - (next: readonly string[]) => { - // 出口再滤一次保留规则:纵深防御,规则编辑器里手打的 `*` 到不了调用方。 - onChange(next.filter((rule) => !reservedKeys.has(rule.trim().toLowerCase()))); - }, - [onChange, reservedKeys] - ); - - const hasCatalog = catalogState === 'ready' && candidates.length > 0; - - /** 通配符派生出的模型(排除掉已显式勾选的,那些走实线 chip)。 */ - const derivedModels = useMemo(() => { - if (!hasCatalog) return []; - const out: Array<{ id: string; rule: string }> = []; - candidateIds.forEach((id) => { - const state = getModelExclusionState(rules, id); - if (state.state === 'excluded' && state.by === 'wildcard') out.push({ id, rule: state.rule }); - }); - return out; - }, [candidateIds, hasCatalog, rules]); - - const ruleSummaries = useMemo( - () => (hasCatalog ? matchedModelsByRule(customRules, candidateIds) : []), - [candidateIds, customRules, hasCatalog] - ); - - const handleToggle = (modelId: string, excluded: boolean) => - commit(toggleExcludedRule(rules, modelId, excluded)); - - const handleSelectAll = () => commit(normalizeExcludedRules([...rules, ...candidateIds])); - - /** 只清精确勾选,通配符规则留给它自己的编辑器——否则一次点击会抹掉用户手写的规则。 */ - const handleClear = () => commit(customRules); - - const handleRuleEditorChange = (text: string) => { - const typedReserved = text - .split(/\r?\n/) - .some((line) => reservedKeys.has(line.trim().toLowerCase())); - setReservedHit(typedReserved); - commit(replaceCustomExcludedRules(rules, candidateIds, text)); - }; - - const dismissPanel = useCallback(() => { - setOpen(false); - triggerRef.current?.focus({ preventScroll: true }); - }, []); - - const summaryText = () => { - if (catalogState === 'loading') return t('excluded_models.catalog_loading'); - if (hasCatalog) { - if (stats.excluded === 0 && rules.length === 0) return t('excluded_models.trigger_empty'); - return t('excluded_models.trigger_summary', { - excluded: stats.excluded, - available: stats.available, - }); - } - // 无目录:只能诚实地报规则条数,不能假装知道「还剩几个可用」。 - if (rules.length === 0) return t('excluded_models.trigger_empty'); - return t('excluded_models.trigger_summary_rules', { n: rules.length }); - }; - - return ( -
- - -
-
- {catalogState === 'ready' || candidates.length > 0 ? ( - - ) : ( -
- - {catalogState === 'loading' - ? t('excluded_models.catalog_loading') - : catalogState === 'error' - ? t('excluded_models.catalog_error') - : t('excluded_models.catalog_unavailable')} - - {onRetryCatalog && catalogState !== 'loading' ? ( - - ) : null} -
- )} -
-
- - {exactRules.length > 0 || derivedModels.length > 0 || unknownRules.length > 0 ? ( - - {exactRules.map((rule) => ( - commit(toggleExcludedRule(rules, rule, false))} - removeAriaLabel={t('excluded_models.chip_remove', { rule })} - disabled={disabled} - /> - ))} - {derivedModels.slice(0, DERIVED_CHIP_LIMIT).map((item) => ( - - ))} - {derivedModels.length > DERIVED_CHIP_LIMIT ? ( - - {t('excluded_models.chips_more', { n: derivedModels.length - DERIVED_CHIP_LIMIT })} - - ) : null} - {unknownRules.map((rule) => ( - commit(toggleExcludedRule(rules, rule, false))} - removeAriaLabel={t('excluded_models.chip_remove', { rule })} - disabled={disabled} - /> - ))} - - ) : null} - - {showRuleEditor ? ( -
- -