Compare commits

...

27 Commits

Author SHA1 Message Date
Luis Pater
b641d90287 Fixed #91
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
refactor(translator): streamline Codex response handling and remove redundant code

- Updated `ConvertCodexResponseToOpenAIResponses` logic for clarity and consistency.
- Simplified `ConvertCodexResponseToOpenAIResponsesNonStream` by removing unnecessary buffer setup and scanner logic.
- Switched to using `sjson.SetRaw` for improved processing of raw input strings.
2025-10-15 12:58:18 +08:00
Luis Pater
32d01a6a7c Merge pull request #125 from router-for-me/object
add S3-compatible object store
2025-10-15 11:52:54 +08:00
hkfires
9ef76dcc61 Add Object Storage 2025-10-15 11:47:35 +08:00
Luis Pater
4576f9915b Fixed: #121
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
feat(translator): map Claude web search tool type to Codex web_search

- Added special handling to replace `web_search_20250305` tool type with `{"type":"web_search"}` in Claude request processing.
2025-10-15 09:32:12 +08:00
Luis Pater
c945e35983 feat(translator): improve Claude request handling with enhanced content processing
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Introduced helper functions (`appendTextContent`, `appendImageContent`, etc.) for structured content construction.
- Refactored message generation logic for better clarity, supporting mixed content scenarios (text, images, and function calls).
- Added `flushMessage` to ensure proper grouping of message contents.
2025-10-14 23:58:37 +08:00
hkfires
1cd275f4c1 Merge branch 'dev' 2025-10-14 15:47:39 +08:00
hkfires
4bc1ed6031 feat(config): use block style for YAML maps/lists; keep [] for empty 2025-10-14 15:43:58 +08:00
hkfires
78989d6c0d feat(store)!: Lock AuthDir when use gitstore/pgstore 2025-10-14 15:43:58 +08:00
hkfires
d6aa1e5ba0 fix(postgresstore): normalize config line endings for DB/disk writes 2025-10-14 15:43:58 +08:00
hkfires
50c1c50dbd docs: document PostgreSQL-backed config/token store 2025-10-14 15:43:58 +08:00
hkfires
5123cfd47e feat(store): add PostgreSQL-backed config store with env selection 2025-10-14 15:43:58 +08:00
Chén Mù
9072accc43 Merge pull request #118 from router-for-me/config
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
feat(config): use block style for YAML maps/lists
2025-10-14 13:44:00 +08:00
hkfires
0d8134aabe feat(config): use block style for YAML maps/lists; keep [] for empty 2025-10-14 13:17:04 +08:00
Chén Mù
4fdbdf7925 Merge pull request #117 from router-for-me/pg
feat(store): add PostgreSQL-backed config store with env selection
2025-10-14 11:28:19 +08:00
hkfires
50c84485c3 feat(store)!: Lock AuthDir when use gitstore/pgstore 2025-10-14 10:46:45 +08:00
hkfires
f335aeeedb fix(postgresstore): normalize config line endings for DB/disk writes 2025-10-14 08:38:15 +08:00
Luis Pater
32a8102d71 feat(usage): add support for tracking request source in usage records
- Introduced `Source` field to usage-related structs for better origin tracking.
- Updated `newUsageReporter` to resolve and populate the `Source` attribute.
- Implemented `resolveUsageSource` to determine source from auth metadata or API key.
2025-10-14 02:11:43 +08:00
hkfires
61f6a612e3 docs: document PostgreSQL-backed config/token store 2025-10-13 22:31:01 +08:00
hkfires
42087d5387 feat(store): add PostgreSQL-backed config store with env selection 2025-10-13 21:05:43 +08:00
Luis Pater
f2710c03ab Merge pull request #116 from router-for-me/log
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
fix(management,config,watcher): treat empty base-url as removal; improve config change logs
2025-10-13 20:48:33 +08:00
hkfires
39abde2413 refactor(watcher): remove redundant quota-exceeded change logs 2025-10-13 14:02:55 +08:00
hkfires
0aa8706ef7 feat(config): Treat empty BaseURL for Codex keys as deletion 2025-10-13 13:48:27 +08:00
hkfires
5fd4a8b974 feat(config): Remove OpenAI providers with empty BaseURL 2025-10-13 13:48:27 +08:00
hkfires
06e6f0a5f2 refactor(watcher): Extract config change logging to new function 2025-10-13 13:48:27 +08:00
Luis Pater
80f6d6fe7c chore(watcher): add YAML serialization for config change tracking and improve quota-exceeded debug logs 2025-10-13 13:32:43 +08:00
Luis Pater
3be6175aec chore(auth): add debug log for iflow token response body 2025-10-13 09:12:45 +08:00
Luis Pater
599986495b feat(translator): enhance OpenAI Gemini request handling for mixed content
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Replaced `contentParts` with `aggregatedParts` to support mixed content (text and inline data).
- Introduced `textBuilder` for efficient text concatenation.
- Added support for inline data processing, including base64-encoded image URLs.
- Updated `msg["content"]` logic to handle both plain text and mixed content scenarios.
2025-10-13 02:15:55 +08:00
23 changed files with 2200 additions and 227 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Example environment configuration for CLIProxyAPI.
# Copy this file to `.env` and uncomment the variables you need.
#
# NOTE: Environment variables are only required when using remote storage options.
# For local file-based storage (default), no environment variables need to be set.
# ------------------------------------------------------------------------------
# Management Web UI
# ------------------------------------------------------------------------------
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
# ------------------------------------------------------------------------------
# Postgres Token Store (optional)
# ------------------------------------------------------------------------------
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
# PGSTORE_SCHEMA=public
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
# ------------------------------------------------------------------------------
# Git-Backed Config Store (optional)
# ------------------------------------------------------------------------------
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
# GITSTORE_GIT_USERNAME=git-user
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
# ------------------------------------------------------------------------------
# Object Store Token Store (optional)
# ------------------------------------------------------------------------------
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
# OBJECTSTORE_BUCKET=cli-proxy-config
# OBJECTSTORE_ACCESS_KEY=your_access_key
# OBJECTSTORE_SECRET_KEY=your_secret_key
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore

32
.gitignore vendored
View File

@@ -1,18 +1,32 @@
# Binaries
cli-proxy-api
*.exe
# Configuration
config.yaml
.env
# Generated content
bin/*
docs/*
logs/*
conv/*
temp/*
pgstore/*
gitstore/*
objectstore/*
static/*
# Authentication data
auths/*
!auths/.gitkeep
.vscode/*
.claude/*
.serena/*
# Documentation
docs/*
AGENTS.md
CLAUDE.md
GEMINI.md
*.exe
temp/*
cli-proxy-api
static/*
.env
# Tooling metadata
.vscode/*
.claude/*
.serena/*

View File

@@ -429,8 +429,6 @@ To enable this feature, set the `GITSTORE_GIT_URL` environment variable to the U
| `GITSTORE_GIT_USERNAME` | No | | The username for Git authentication. |
| `GITSTORE_GIT_TOKEN` | No | | The personal access token (or password) for Git authentication. |
**How it Works**
1. **Cloning:** On startup, the application clones the remote Git repository to the `GITSTORE_LOCAL_PATH`.
@@ -438,6 +436,47 @@ To enable this feature, set the `GITSTORE_GIT_URL` environment variable to the U
3. **Bootstrapping:** If `config/config.yaml` does not exist in the repository, the application will copy the local `config.example.yaml` to that location, commit, and push it to the remote repository as an initial configuration. You must have `config.example.yaml` available.
4. **Token Sync:** The `auth-dir` is also managed within this repository. Any changes to authentication tokens (e.g., through a new login) are automatically committed and pushed to the remote Git repository.
### PostgreSQL-backed Configuration and Token Store
You can also persist configuration and authentication data in PostgreSQL when running CLIProxyAPI in hosted environments that favor managed databases over local files.
**Environment Variables**
| Variable | Required | Default | Description |
|-----------------------|----------|-----------------------|---------------------------------------------------------------------------------------------------------------|
| `MANAGEMENT_PASSWORD` | Yes | | Password for the management web UI (required when remote management is enabled). |
| `PGSTORE_DSN` | Yes | | PostgreSQL connection string (e.g. `postgresql://user:pass@host:5432/db`). |
| `PGSTORE_SCHEMA` | No | public | Schema where the tables will be created. Leave empty to use the default schema. |
| `PGSTORE_LOCAL_PATH` | No | Current working directory | Root directory for the local mirror; the server writes to `<value>/pgstore`. If unset and CWD is unavailable, `/tmp/pgstore` is used. |
**How it Works**
1. **Initialization:** On startup the server connects via `PGSTORE_DSN`, ensures the schema exists, and creates the `config_store` / `auth_store` tables when missing.
2. **Local Mirror:** A writable cache at `<PGSTORE_LOCAL_PATH or CWD>/pgstore` mirrors `config/config.yaml` and `auths/` so the rest of the application can reuse the existing file-based logic.
3. **Bootstrapping:** If no configuration row exists, `config.example.yaml` seeds the database using the fixed identifier `config`.
4. **Token Sync:** Changes flow both ways—file updates are written to PostgreSQL and database records are mirrored back to disk so watchers and management APIs continue to operate.
### Object Storage-backed Configuration and Token Store
An S3-compatible object storage service can host configuration and authentication records.
**Environment Variables**
| Variable | Required | Default | Description |
|--------------------------|----------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------|
| `OBJECTSTORE_ENDPOINT` | Yes | | Object storage endpoint. Include `http://` or `https://` to force the protocol (omitted scheme → HTTPS). |
| `OBJECTSTORE_BUCKET` | Yes | | Bucket that stores `config/config.yaml` and `auths/*.json`. |
| `OBJECTSTORE_ACCESS_KEY` | Yes | | Access key ID for the object storage account. |
| `OBJECTSTORE_SECRET_KEY` | Yes | | Secret key for the object storage account. |
| `OBJECTSTORE_LOCAL_PATH` | No | Current working directory | Root directory for the local mirror; the server writes to `<value>/objectstore`. If unset, defaults to current CWD. |
**How it Works**
1. **Startup:** The endpoint is parsed (respecting any scheme prefix), a MinIO-compatible client is created in path-style mode, and the bucket is created when missing.
2. **Local Mirror:** A writable cache at `<OBJECTSTORE_LOCAL_PATH or CWD>/objectstore` mirrors `config/config.yaml` and `auths/`.
3. **Bootstrapping:** When `config/config.yaml` is absent in the bucket, the server copies `config.example.yaml`, uploads it, and uses it as the initial configuration.
4. **Sync:** Changes to configuration or auth files are uploaded to the bucket, and remote updates are mirrored back to disk, keeping watchers and management APIs in sync.
### OpenAI Compatibility Providers
Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-compatibility`.

View File

@@ -449,6 +449,47 @@ openai-compatibility:
3. **引导:** 如果仓库中不存在 `config/config.yaml`,应用程序会将本地的 `config.example.yaml` 复制到该位置,然后提交并推送到远程仓库作为初始配置。您必须确保 `config.example.yaml` 文件可用。
4. **令牌同步:** `auth-dir` 也在此仓库中管理。对身份验证令牌的任何更改(例如,通过新的登录)都会自动提交并推送到远程 Git 仓库。
### PostgreSQL 支持的配置与令牌存储
在托管环境中运行服务时,可以选择使用 PostgreSQL 来保存配置与令牌,借助托管数据库减轻本地文件管理压力。
**环境变量**
| 变量 | 必需 | 默认值 | 描述 |
|-------------------------|----|---------------|----------------------------------------------------------------------|
| `MANAGEMENT_PASSWORD` | 是 | | 管理面板密码(启用远程管理时必需)。 |
| `PGSTORE_DSN` | 是 | | PostgreSQL 连接串,例如 `postgresql://user:pass@host:5432/db`。 |
| `PGSTORE_SCHEMA` | 否 | public | 创建表时使用的 schema留空则使用默认 schema。 |
| `PGSTORE_LOCAL_PATH` | 否 | 当前工作目录 | 本地镜像根目录,服务将在 `<值>/pgstore` 下写入缓存;若无法获取工作目录则退回 `/tmp/pgstore`。 |
**工作原理**
1. **初始化:** 启动时通过 `PGSTORE_DSN` 连接数据库,确保 schema 存在,并在缺失时创建 `config_store` 与 `auth_store`。
2. **本地镜像:** 在 `<PGSTORE_LOCAL_PATH 或当前工作目录>/pgstore` 下建立可写缓存,复用 `config/config.yaml` 与 `auths/` 目录。
3. **引导:** 若数据库中无配置记录,会使用 `config.example.yaml` 初始化,并以固定标识 `config` 写入。
4. **令牌同步:** 配置与令牌的更改会写入 PostgreSQL同时数据库中的内容也会反向同步至本地镜像便于文件监听与管理接口继续工作。
### 对象存储驱动的配置与令牌存储
可以选择使用 S3 兼容的对象存储来托管配置与鉴权数据。
**环境变量**
| 变量 | 是否必填 | 默认值 | 说明 |
|--------------------------|----------|--------------------|--------------------------------------------------------------------------------------------------------------------------|
| `OBJECTSTORE_ENDPOINT` | 是 | | 对象存储访问端点。可带 `http://` 或 `https://` 前缀指定协议(省略则默认 HTTPS。 |
| `OBJECTSTORE_BUCKET` | 是 | | 用于存放 `config/config.yaml` 与 `auths/*.json` 的 Bucket 名称。 |
| `OBJECTSTORE_ACCESS_KEY` | 是 | | 对象存储账号的访问密钥 ID。 |
| `OBJECTSTORE_SECRET_KEY` | 是 | | 对象存储账号的访问密钥 Secret。 |
| `OBJECTSTORE_LOCAL_PATH` | 否 | 当前工作目录 (CWD) | 本地镜像根目录;服务会写入到 `<值>/objectstore`。 |
**工作流程**
1. **启动阶段:** 解析端点地址(识别协议前缀),创建 MinIO 兼容客户端并使用 Path-Style 模式,如 Bucket 不存在会自动创建。
2. **本地镜像:** 在 `<OBJECTSTORE_LOCAL_PATH 或当前工作目录>/objectstore` 维护可写缓存,同步 `config/config.yaml` 与 `auths/`。
3. **初始化:** 若 Bucket 中缺少配置文件,将以 `config.example.yaml` 为模板生成 `config/config.yaml` 并上传。
4. **双向同步:** 本地变更会上传到对象存储,同时远端对象也会拉回到本地,保证文件监听、管理 API 与 CLI 命令行为一致。
### OpenAI 兼容上游提供商
通过 `openai-compatibility` 配置上游 OpenAI 兼容提供商(例如 OpenRouter

View File

@@ -9,10 +9,13 @@ import (
"flag"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
@@ -101,13 +104,25 @@ func main() {
var cfg *config.Config
var isCloudDeploy bool
var (
gitStoreLocalPath string
useGitStore bool
gitStoreRemoteURL string
gitStoreUser string
gitStorePassword string
gitStoreInst *store.GitTokenStore
gitStoreRoot string
usePostgresStore bool
pgStoreDSN string
pgStoreSchema string
pgStoreLocalPath string
pgStoreInst *store.PostgresStore
useGitStore bool
gitStoreRemoteURL string
gitStoreUser string
gitStorePassword string
gitStoreLocalPath string
gitStoreInst *store.GitTokenStore
gitStoreRoot string
useObjectStore bool
objectStoreEndpoint string
objectStoreAccess string
objectStoreSecret string
objectStoreBucket string
objectStoreLocalPath string
objectStoreInst *store.ObjectTokenStore
)
wd, err := os.Getwd()
@@ -115,6 +130,13 @@ func main() {
log.Fatalf("failed to get working directory: %v", err)
}
// 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 {
@@ -125,6 +147,19 @@ func main() {
}
return "", false
}
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
}
useGitStore = false
}
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
useGitStore = true
gitStoreRemoteURL = value
@@ -138,6 +173,22 @@ func main() {
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
gitStoreLocalPath = 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
@@ -147,13 +198,100 @@ func main() {
}
// Determine and load the configuration file.
// If gitstore is configured, load from the cloned repository; otherwise use the provided path or default.
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
var configFilePath string
if useGitStore {
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.Fatalf("failed to initialize postgres token store: %v", err)
}
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.Fatalf("failed to bootstrap postgres-backed config: %v", errBootstrap)
}
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 {
objectStoreRoot := objectStoreLocalPath
if objectStoreRoot == "" {
objectStoreRoot = wd
}
objectStoreRoot = filepath.Join(objectStoreRoot, "objectstore")
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
useSSL := true
if strings.Contains(resolvedEndpoint, "://") {
parsed, errParse := url.Parse(resolvedEndpoint)
if errParse != nil {
log.Fatalf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
}
switch strings.ToLower(parsed.Scheme) {
case "http":
useSSL = false
case "https":
useSSL = true
default:
log.Fatalf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
}
if parsed.Host == "" {
log.Fatalf("object store endpoint %q is missing host information", objectStoreEndpoint)
}
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.Fatalf("failed to initialize object token store: %v", err)
}
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.Fatalf("failed to bootstrap object-backed config: %v", errBootstrap)
}
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 == "" {
gitStoreLocalPath = wd
}
gitStoreRoot = filepath.Join(gitStoreLocalPath, "remote")
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
authDir := filepath.Join(gitStoreRoot, "auths")
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
gitStoreInst.SetBaseDir(authDir)
@@ -172,7 +310,7 @@ func main() {
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
log.Fatalf("failed to bootstrap git-backed config: %v", errCopy)
}
if errCommit := gitStoreInst.CommitConfig(context.Background()); errCommit != nil {
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
log.Fatalf("failed to commit initial git-backed config: %v", errCommit)
}
log.Infof("git-backed config initialized from template: %s", configFilePath)
@@ -245,7 +383,11 @@ func main() {
}
// Register the shared token store once so all components use the same persistence backend.
if useGitStore {
if usePostgresStore {
sdkAuth.RegisterTokenStore(pgStoreInst)
} else if useObjectStore {
sdkAuth.RegisterTokenStore(objectStoreInst)
} else if useGitStore {
sdkAuth.RegisterTokenStore(gitStoreInst)
} else {
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())

View File

@@ -10,6 +10,8 @@ services:
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
container_name: cli-proxy-api
# env_file:
# - .env
environment:
DEPLOY: ${DEPLOY:-}
ports:

14
go.mod
View File

@@ -7,7 +7,10 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.17.3
github.com/joho/godotenv v1.5.1
github.com/jackc/pgx/v5 v5.7.6
github.com/klauspost/compress v1.17.4
github.com/minio/minio-go/v7 v7.0.66
github.com/sirupsen/logrus v1.9.3
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/tidwall/gjson v1.18.0
@@ -29,6 +32,7 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@@ -39,22 +43,30 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

27
go.sum
View File

@@ -23,6 +23,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -62,12 +64,25 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -82,6 +97,12 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -95,6 +116,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -137,6 +160,8 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
@@ -153,6 +178,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -227,7 +227,14 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
for i := range arr {
normalizeOpenAICompatibilityEntry(&arr[i])
}
h.cfg.OpenAICompatibility = arr
// Filter out providers with empty base-url -> remove provider entirely
filtered := make([]config.OpenAICompatibility, 0, len(arr))
for i := range arr {
if strings.TrimSpace(arr[i].BaseURL) != "" {
filtered = append(filtered, arr[i])
}
}
h.cfg.OpenAICompatibility = filtered
h.persist(c)
}
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
@@ -241,6 +248,32 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
return
}
normalizeOpenAICompatibilityEntry(body.Value)
// If base-url becomes empty, delete the provider instead of updating
if strings.TrimSpace(body.Value.BaseURL) == "" {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:*body.Index], h.cfg.OpenAICompatibility[*body.Index+1:]...)
h.persist(c)
return
}
if body.Name != nil {
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
removed := false
for i := range h.cfg.OpenAICompatibility {
if !removed && h.cfg.OpenAICompatibility[i].Name == *body.Name {
removed = true
continue
}
out = append(out, h.cfg.OpenAICompatibility[i])
}
if removed {
h.cfg.OpenAICompatibility = out
h.persist(c)
return
}
}
c.JSON(404, gin.H{"error": "item not found"})
return
}
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility[*body.Index] = *body.Value
h.persist(c)
@@ -302,7 +335,17 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
}
arr = obj.Items
}
h.cfg.CodexKey = arr
// Filter out codex entries with empty base-url (treat as removed)
filtered := make([]config.CodexKey, 0, len(arr))
for i := range arr {
entry := arr[i]
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
if entry.BaseURL == "" {
continue
}
filtered = append(filtered, entry)
}
h.cfg.CodexKey = filtered
h.persist(c)
}
func (h *Handler) PatchCodexKey(c *gin.Context) {
@@ -315,19 +358,44 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
h.cfg.CodexKey[*body.Index] = *body.Value
h.persist(c)
return
}
if body.Match != nil {
for i := range h.cfg.CodexKey {
if h.cfg.CodexKey[i].APIKey == *body.Match {
h.cfg.CodexKey[i] = *body.Value
// If base-url becomes empty, delete instead of update
if strings.TrimSpace(body.Value.BaseURL) == "" {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:*body.Index], h.cfg.CodexKey[*body.Index+1:]...)
h.persist(c)
return
}
if body.Match != nil {
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
removed := false
for i := range h.cfg.CodexKey {
if !removed && h.cfg.CodexKey[i].APIKey == *body.Match {
removed = true
continue
}
out = append(out, h.cfg.CodexKey[i])
}
if removed {
h.cfg.CodexKey = out
h.persist(c)
return
}
}
} else {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
h.cfg.CodexKey[*body.Index] = *body.Value
h.persist(c)
return
}
if body.Match != nil {
for i := range h.cfg.CodexKey {
if h.cfg.CodexKey[i].APIKey == *body.Match {
h.cfg.CodexKey[i] = *body.Value
h.persist(c)
return
}
}
}
}
c.JSON(404, gin.H{"error": "item not found"})
}
@@ -359,6 +427,8 @@ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
if entry == nil {
return
}
// Trim base-url; empty base-url indicates provider should be removed by sanitization
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
for i := range entry.APIKeyEntries {
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)

View File

@@ -138,6 +138,7 @@ func (ia *IFlowAuth) doTokenRequest(ctx context.Context, req *http.Request) (*IF
}
if tokenResp.AccessToken == "" {
log.Debug(string(body))
return nil, fmt.Errorf("iflow token: missing access token in response")
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"syscall"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
@@ -207,10 +208,55 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Sync request authentication providers with inline API keys for backwards compatibility.
syncInlineAccessProvider(&cfg)
// Sanitize OpenAI compatibility providers: drop entries without base-url
sanitizeOpenAICompatibility(&cfg)
// Sanitize Codex keys: drop entries without base-url
sanitizeCodexKeys(&cfg)
// Return the populated configuration struct.
return &cfg, nil
}
// sanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
// not actionable, specifically those missing a BaseURL. It trims whitespace before
// evaluation and preserves the relative order of remaining entries.
func sanitizeOpenAICompatibility(cfg *Config) {
if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
return
}
out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))
for i := range cfg.OpenAICompatibility {
e := cfg.OpenAICompatibility[i]
e.Name = strings.TrimSpace(e.Name)
e.BaseURL = strings.TrimSpace(e.BaseURL)
if e.BaseURL == "" {
// Skip providers with no base-url; treated as removed
continue
}
out = append(out, e)
}
cfg.OpenAICompatibility = out
}
// sanitizeCodexKeys removes Codex API key entries missing a BaseURL.
// It trims whitespace and preserves order for remaining entries.
func sanitizeCodexKeys(cfg *Config) {
if cfg == nil || len(cfg.CodexKey) == 0 {
return
}
out := make([]CodexKey, 0, len(cfg.CodexKey))
for i := range cfg.CodexKey {
e := cfg.CodexKey[i]
e.BaseURL = strings.TrimSpace(e.BaseURL)
if e.BaseURL == "" {
continue
}
out = append(out, e)
}
cfg.CodexKey = out
}
func syncInlineAccessProvider(cfg *Config) {
if cfg == nil {
return
@@ -280,6 +326,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
// Merge generated into original in-place, preserving comments/order of existing nodes.
mergeMappingPreserve(original.Content[0], generated.Content[0])
normalizeCollectionNodeStyles(original.Content[0])
// Write back.
f, err := os.Create(configFile)
@@ -520,3 +567,30 @@ func removeMapKey(mapNode *yaml.Node, key string) {
}
}
}
// normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping
// lists and maps readable. Empty sequences retain flow style ([]) so empty list markers
// remain compact.
func normalizeCollectionNodeStyles(node *yaml.Node) {
if node == nil {
return
}
switch node.Kind {
case yaml.MappingNode:
node.Style = 0
for i := range node.Content {
normalizeCollectionNodeStyles(node.Content[i])
}
case yaml.SequenceNode:
if len(node.Content) == 0 {
node.Style = yaml.FlowStyle
} else {
node.Style = 0
}
for i := range node.Content {
normalizeCollectionNodeStyles(node.Content[i])
}
default:
// Scalars keep their existing style to preserve quoting
}
}

View File

@@ -4,10 +4,12 @@ import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
"github.com/tidwall/gjson"
@@ -18,20 +20,23 @@ type usageReporter struct {
model string
authID string
apiKey string
source string
requestedAt time.Time
once sync.Once
}
func newUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *usageReporter {
apiKey := apiKeyFromContext(ctx)
reporter := &usageReporter{
provider: provider,
model: model,
requestedAt: time.Now(),
apiKey: apiKey,
source: util.HideAPIKey(resolveUsageSource(auth, apiKey)),
}
if auth != nil {
reporter.authID = auth.ID
}
reporter.apiKey = apiKeyFromContext(ctx)
return reporter
}
@@ -52,6 +57,7 @@ func (r *usageReporter) publish(ctx context.Context, detail usage.Detail) {
usage.PublishRecord(ctx, usage.Record{
Provider: r.provider,
Model: r.model,
Source: r.source,
APIKey: r.apiKey,
AuthID: r.authID,
RequestedAt: r.requestedAt,
@@ -81,6 +87,30 @@ func apiKeyFromContext(ctx context.Context) string {
return ""
}
func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string {
if auth != nil {
if _, value := auth.AccountInfo(); value != "" {
return strings.TrimSpace(value)
}
if auth.Metadata != nil {
if email, ok := auth.Metadata["email"].(string); ok {
if trimmed := strings.TrimSpace(email); trimmed != "" {
return trimmed
}
}
}
if auth.Attributes != nil {
if key := strings.TrimSpace(auth.Attributes["api_key"]); key != "" {
return key
}
}
}
if trimmed := strings.TrimSpace(ctxAPIKey); trimmed != "" {
return trimmed
}
return ""
}
func parseCodexUsage(data []byte) (usage.Detail, bool) {
usageNode := gjson.ParseBytes(data).Get("response.usage")
if !usageNode.Exists() {

View File

@@ -359,9 +359,9 @@ func (s *GitTokenStore) Delete(_ context.Context, id string) error {
return nil
}
// CommitPaths commits and pushes the provided paths to the remote repository.
// PersistAuthFiles commits and pushes the provided paths to the remote repository.
// It no-ops when the store is not fully configured or when there are no paths.
func (s *GitTokenStore) CommitPaths(_ context.Context, message string, paths ...string) error {
func (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, paths ...string) error {
if len(paths) == 0 {
return nil
}
@@ -652,8 +652,8 @@ func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch p
return nil
}
// CommitConfig commits and pushes configuration changes to git.
func (s *GitTokenStore) CommitConfig(_ context.Context) error {
// PersistConfig commits and pushes configuration changes to git.
func (s *GitTokenStore) PersistConfig(_ context.Context) error {
if err := s.EnsureRepository(); err != nil {
return err
}

View File

@@ -0,0 +1,618 @@
package store
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
const (
objectStoreConfigKey = "config/config.yaml"
objectStoreAuthPrefix = "auths"
)
// ObjectStoreConfig captures configuration for the object storage-backed token store.
type ObjectStoreConfig struct {
Endpoint string
Bucket string
AccessKey string
SecretKey string
Region string
Prefix string
LocalRoot string
UseSSL bool
PathStyle bool
}
// ObjectTokenStore persists configuration and authentication metadata using an S3-compatible object storage backend.
// Files are mirrored to a local workspace so existing file-based flows continue to operate.
type ObjectTokenStore struct {
client *minio.Client
cfg ObjectStoreConfig
spoolRoot string
configPath string
authDir string
mu sync.Mutex
}
// NewObjectTokenStore initializes an object storage backed token store.
func NewObjectTokenStore(cfg ObjectStoreConfig) (*ObjectTokenStore, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
cfg.Bucket = strings.TrimSpace(cfg.Bucket)
cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.Prefix = strings.Trim(cfg.Prefix, "/")
if cfg.Endpoint == "" {
return nil, fmt.Errorf("object store: endpoint is required")
}
if cfg.Bucket == "" {
return nil, fmt.Errorf("object store: bucket is required")
}
if cfg.AccessKey == "" {
return nil, fmt.Errorf("object store: access key is required")
}
if cfg.SecretKey == "" {
return nil, fmt.Errorf("object store: secret key is required")
}
root := strings.TrimSpace(cfg.LocalRoot)
if root == "" {
if cwd, err := os.Getwd(); err == nil {
root = filepath.Join(cwd, "objectstore")
} else {
root = filepath.Join(os.TempDir(), "objectstore")
}
}
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("object store: resolve spool directory: %w", err)
}
configDir := filepath.Join(absRoot, "config")
authDir := filepath.Join(absRoot, "auths")
if err = os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("object store: create config directory: %w", err)
}
if err = os.MkdirAll(authDir, 0o700); err != nil {
return nil, fmt.Errorf("object store: create auth directory: %w", err)
}
options := &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
}
if cfg.PathStyle {
options.BucketLookup = minio.BucketLookupPath
}
client, err := minio.New(cfg.Endpoint, options)
if err != nil {
return nil, fmt.Errorf("object store: create client: %w", err)
}
return &ObjectTokenStore{
client: client,
cfg: cfg,
spoolRoot: absRoot,
configPath: filepath.Join(configDir, "config.yaml"),
authDir: authDir,
}, nil
}
// SetBaseDir implements the optional interface used by authenticators; it is a no-op because
// the object store controls its own workspace.
func (s *ObjectTokenStore) SetBaseDir(string) {}
// ConfigPath returns the managed configuration file path inside the spool directory.
func (s *ObjectTokenStore) ConfigPath() string {
if s == nil {
return ""
}
return s.configPath
}
// AuthDir returns the local directory containing mirrored auth files.
func (s *ObjectTokenStore) AuthDir() string {
if s == nil {
return ""
}
return s.authDir
}
// Bootstrap ensures the target bucket exists and synchronizes data from the object storage backend.
func (s *ObjectTokenStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {
if s == nil {
return fmt.Errorf("object store: not initialized")
}
if err := s.ensureBucket(ctx); err != nil {
return err
}
if err := s.syncConfigFromBucket(ctx, exampleConfigPath); err != nil {
return err
}
if err := s.syncAuthFromBucket(ctx); err != nil {
return err
}
return nil
}
// Save persists authentication metadata to disk and uploads it to the object storage backend.
func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("object store: auth is nil")
}
path, err := s.resolveAuthPath(auth)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("object store: missing file path attribute for %s", auth.ID)
}
if auth.Disabled {
if _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {
return "", nil
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", fmt.Errorf("object store: create auth directory: %w", err)
}
switch {
case auth.Storage != nil:
if err = auth.Storage.SaveTokenToFile(path); err != nil {
return "", err
}
case auth.Metadata != nil:
raw, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return "", fmt.Errorf("object store: marshal metadata: %w", errMarshal)
}
if existing, errRead := os.ReadFile(path); errRead == nil {
if jsonEqual(existing, raw) {
return path, nil
}
} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {
return "", fmt.Errorf("object store: read existing metadata: %w", errRead)
}
tmp := path + ".tmp"
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
return "", fmt.Errorf("object store: write temp auth file: %w", errWrite)
}
if errRename := os.Rename(tmp, path); errRename != nil {
return "", fmt.Errorf("object store: rename auth file: %w", errRename)
}
default:
return "", fmt.Errorf("object store: nothing to persist for %s", auth.ID)
}
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes["path"] = path
if strings.TrimSpace(auth.FileName) == "" {
auth.FileName = auth.ID
}
if err = s.uploadAuth(ctx, path); err != nil {
return "", err
}
return path, nil
}
// List enumerates auth JSON files from the mirrored workspace.
func (s *ObjectTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {
dir := strings.TrimSpace(s.AuthDir())
if dir == "" {
return nil, fmt.Errorf("object store: auth directory not configured")
}
entries := make([]*cliproxyauth.Auth, 0, 32)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
return nil
}
auth, err := s.readAuthFile(path, dir)
if err != nil {
log.WithError(err).Warnf("object store: skip auth %s", path)
return nil
}
if auth != nil {
entries = append(entries, auth)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("object store: walk auth directory: %w", err)
}
return entries, nil
}
// Delete removes an auth file locally and remotely.
func (s *ObjectTokenStore) Delete(ctx context.Context, id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("object store: id is empty")
}
path, err := s.resolveDeletePath(id)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("object store: delete auth file: %w", err)
}
if err = s.deleteAuthObject(ctx, path); err != nil {
return err
}
return nil
}
// PersistAuthFiles uploads the provided auth files to the object storage backend.
func (s *ObjectTokenStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {
if len(paths) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range paths {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
abs := trimmed
if !filepath.IsAbs(abs) {
abs = filepath.Join(s.authDir, trimmed)
}
if err := s.uploadAuth(ctx, abs); err != nil {
return err
}
}
return nil
}
// PersistConfig uploads the local configuration file to the object storage backend.
func (s *ObjectTokenStore) PersistConfig(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.configPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return s.deleteObject(ctx, objectStoreConfigKey)
}
return fmt.Errorf("object store: read config file: %w", err)
}
if len(data) == 0 {
return s.deleteObject(ctx, objectStoreConfigKey)
}
return s.putObject(ctx, objectStoreConfigKey, data, "application/x-yaml")
}
func (s *ObjectTokenStore) ensureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
if err != nil {
return fmt.Errorf("object store: check bucket: %w", err)
}
if exists {
return nil
}
if err = s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{Region: s.cfg.Region}); err != nil {
return fmt.Errorf("object store: create bucket: %w", err)
}
return nil
}
func (s *ObjectTokenStore) syncConfigFromBucket(ctx context.Context, example string) error {
key := s.prefixedKey(objectStoreConfigKey)
_, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{})
switch {
case err == nil:
object, errGet := s.client.GetObject(ctx, s.cfg.Bucket, key, minio.GetObjectOptions{})
if errGet != nil {
return fmt.Errorf("object store: fetch config: %w", errGet)
}
defer object.Close()
data, errRead := io.ReadAll(object)
if errRead != nil {
return fmt.Errorf("object store: read config: %w", errRead)
}
if errWrite := os.WriteFile(s.configPath, normalizeLineEndingsBytes(data), 0o600); errWrite != nil {
return fmt.Errorf("object store: write config: %w", errWrite)
}
case isObjectNotFound(err):
if _, statErr := os.Stat(s.configPath); errors.Is(statErr, fs.ErrNotExist) {
if example != "" {
if errCopy := misc.CopyConfigTemplate(example, s.configPath); errCopy != nil {
return fmt.Errorf("object store: copy example config: %w", errCopy)
}
} else {
if errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {
return fmt.Errorf("object store: prepare config directory: %w", errCreate)
}
if errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {
return fmt.Errorf("object store: create empty config: %w", errWrite)
}
}
}
data, errRead := os.ReadFile(s.configPath)
if errRead != nil {
return fmt.Errorf("object store: read local config: %w", errRead)
}
if len(data) > 0 {
if errPut := s.putObject(ctx, objectStoreConfigKey, data, "application/x-yaml"); errPut != nil {
return errPut
}
}
default:
return fmt.Errorf("object store: stat config: %w", err)
}
return nil
}
func (s *ObjectTokenStore) syncAuthFromBucket(ctx context.Context) error {
if err := os.RemoveAll(s.authDir); err != nil {
return fmt.Errorf("object store: reset auth directory: %w", err)
}
if err := os.MkdirAll(s.authDir, 0o700); err != nil {
return fmt.Errorf("object store: recreate auth directory: %w", err)
}
prefix := s.prefixedKey(objectStoreAuthPrefix + "/")
objectCh := s.client.ListObjects(ctx, s.cfg.Bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
})
for object := range objectCh {
if object.Err != nil {
return fmt.Errorf("object store: list auth objects: %w", object.Err)
}
rel := strings.TrimPrefix(object.Key, prefix)
if rel == "" || strings.HasSuffix(rel, "/") {
continue
}
relPath := filepath.FromSlash(rel)
if filepath.IsAbs(relPath) {
log.WithField("key", object.Key).Warn("object store: skip auth outside mirror")
continue
}
cleanRel := filepath.Clean(relPath)
if cleanRel == "." || cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(os.PathSeparator)) {
log.WithField("key", object.Key).Warn("object store: skip auth outside mirror")
continue
}
local := filepath.Join(s.authDir, cleanRel)
if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil {
return fmt.Errorf("object store: prepare auth subdir: %w", err)
}
reader, errGet := s.client.GetObject(ctx, s.cfg.Bucket, object.Key, minio.GetObjectOptions{})
if errGet != nil {
return fmt.Errorf("object store: download auth %s: %w", object.Key, errGet)
}
data, errRead := io.ReadAll(reader)
_ = reader.Close()
if errRead != nil {
return fmt.Errorf("object store: read auth %s: %w", object.Key, errRead)
}
if errWrite := os.WriteFile(local, data, 0o600); errWrite != nil {
return fmt.Errorf("object store: write auth %s: %w", local, errWrite)
}
}
return nil
}
func (s *ObjectTokenStore) uploadAuth(ctx context.Context, path string) error {
if path == "" {
return nil
}
rel, err := filepath.Rel(s.authDir, path)
if err != nil {
return fmt.Errorf("object store: resolve auth relative path: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return s.deleteAuthObject(ctx, path)
}
return fmt.Errorf("object store: read auth file: %w", err)
}
if len(data) == 0 {
return s.deleteAuthObject(ctx, path)
}
key := objectStoreAuthPrefix + "/" + filepath.ToSlash(rel)
return s.putObject(ctx, key, data, "application/json")
}
func (s *ObjectTokenStore) deleteAuthObject(ctx context.Context, path string) error {
if path == "" {
return nil
}
rel, err := filepath.Rel(s.authDir, path)
if err != nil {
return fmt.Errorf("object store: resolve auth relative path: %w", err)
}
key := objectStoreAuthPrefix + "/" + filepath.ToSlash(rel)
return s.deleteObject(ctx, key)
}
func (s *ObjectTokenStore) putObject(ctx context.Context, key string, data []byte, contentType string) error {
if len(data) == 0 {
return s.deleteObject(ctx, key)
}
fullKey := s.prefixedKey(key)
reader := bytes.NewReader(data)
_, err := s.client.PutObject(ctx, s.cfg.Bucket, fullKey, reader, int64(len(data)), minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return fmt.Errorf("object store: put object %s: %w", fullKey, err)
}
return nil
}
func (s *ObjectTokenStore) deleteObject(ctx context.Context, key string) error {
fullKey := s.prefixedKey(key)
err := s.client.RemoveObject(ctx, s.cfg.Bucket, fullKey, minio.RemoveObjectOptions{})
if err != nil {
if isObjectNotFound(err) {
return nil
}
return fmt.Errorf("object store: delete object %s: %w", fullKey, err)
}
return nil
}
func (s *ObjectTokenStore) prefixedKey(key string) string {
key = strings.TrimLeft(key, "/")
if s.cfg.Prefix == "" {
return key
}
return strings.TrimLeft(s.cfg.Prefix+"/"+key, "/")
}
func (s *ObjectTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("object store: auth is nil")
}
if auth.Attributes != nil {
if path := strings.TrimSpace(auth.Attributes["path"]); path != "" {
if filepath.IsAbs(path) {
return path, nil
}
return filepath.Join(s.authDir, path), nil
}
}
fileName := strings.TrimSpace(auth.FileName)
if fileName == "" {
fileName = strings.TrimSpace(auth.ID)
}
if fileName == "" {
return "", fmt.Errorf("object store: auth %s missing filename", auth.ID)
}
if !strings.HasSuffix(strings.ToLower(fileName), ".json") {
fileName += ".json"
}
return filepath.Join(s.authDir, fileName), nil
}
func (s *ObjectTokenStore) resolveDeletePath(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("object store: id is empty")
}
// Absolute paths are honored as-is; callers must ensure they point inside the mirror.
if filepath.IsAbs(id) {
return id, nil
}
// Treat any non-absolute id (including nested like "team/foo") as relative to the mirror authDir.
// Normalize separators and guard against path traversal.
clean := filepath.Clean(filepath.FromSlash(id))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("object store: invalid auth identifier %s", id)
}
// Ensure .json suffix.
if !strings.HasSuffix(strings.ToLower(clean), ".json") {
clean += ".json"
}
return filepath.Join(s.authDir, clean), nil
}
func (s *ObjectTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
if len(data) == 0 {
return nil, nil
}
metadata := make(map[string]any)
if err = json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("unmarshal auth json: %w", err)
}
provider := strings.TrimSpace(valueAsString(metadata["type"]))
if provider == "" {
provider = "unknown"
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat auth file: %w", err)
}
rel, errRel := filepath.Rel(baseDir, path)
if errRel != nil {
rel = filepath.Base(path)
}
rel = normalizeAuthID(rel)
attr := map[string]string{"path": path}
if email := strings.TrimSpace(valueAsString(metadata["email"])); email != "" {
attr["email"] = email
}
auth := &cliproxyauth.Auth{
ID: rel,
Provider: provider,
FileName: rel,
Label: labelFor(metadata),
Status: cliproxyauth.StatusActive,
Attributes: attr,
Metadata: metadata,
CreatedAt: info.ModTime(),
UpdatedAt: info.ModTime(),
LastRefreshedAt: time.Time{},
NextRefreshAfter: time.Time{},
}
return auth, nil
}
func normalizeLineEndingsBytes(data []byte) []byte {
replaced := bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'})
return bytes.ReplaceAll(replaced, []byte{'\r'}, []byte{'\n'})
}
func isObjectNotFound(err error) bool {
if err == nil {
return false
}
resp := minio.ToErrorResponse(err)
if resp.StatusCode == http.StatusNotFound {
return true
}
switch resp.Code {
case "NoSuchKey", "NotFound", "NoSuchBucket":
return true
}
return false
}

View File

@@ -0,0 +1,665 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
const (
defaultConfigTable = "config_store"
defaultAuthTable = "auth_store"
defaultConfigKey = "config"
)
// PostgresStoreConfig captures configuration required to initialize a Postgres-backed store.
type PostgresStoreConfig struct {
DSN string
Schema string
ConfigTable string
AuthTable string
SpoolDir string
}
// PostgresStore persists configuration and authentication metadata using PostgreSQL as backend
// while mirroring data to a local workspace so existing file-based workflows continue to operate.
type PostgresStore struct {
db *sql.DB
cfg PostgresStoreConfig
spoolRoot string
configPath string
authDir string
mu sync.Mutex
}
// NewPostgresStore establishes a connection to PostgreSQL and prepares the local workspace.
func NewPostgresStore(ctx context.Context, cfg PostgresStoreConfig) (*PostgresStore, error) {
trimmedDSN := strings.TrimSpace(cfg.DSN)
if trimmedDSN == "" {
return nil, fmt.Errorf("postgres store: DSN is required")
}
cfg.DSN = trimmedDSN
if cfg.ConfigTable == "" {
cfg.ConfigTable = defaultConfigTable
}
if cfg.AuthTable == "" {
cfg.AuthTable = defaultAuthTable
}
spoolRoot := strings.TrimSpace(cfg.SpoolDir)
if spoolRoot == "" {
if cwd, err := os.Getwd(); err == nil {
spoolRoot = filepath.Join(cwd, "pgstore")
} else {
spoolRoot = filepath.Join(os.TempDir(), "pgstore")
}
}
absSpool, err := filepath.Abs(spoolRoot)
if err != nil {
return nil, fmt.Errorf("postgres store: resolve spool directory: %w", err)
}
configDir := filepath.Join(absSpool, "config")
authDir := filepath.Join(absSpool, "auths")
if err = os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("postgres store: create config directory: %w", err)
}
if err = os.MkdirAll(authDir, 0o700); err != nil {
return nil, fmt.Errorf("postgres store: create auth directory: %w", err)
}
db, err := sql.Open("pgx", cfg.DSN)
if err != nil {
return nil, fmt.Errorf("postgres store: open database connection: %w", err)
}
if err = db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres store: ping database: %w", err)
}
store := &PostgresStore{
db: db,
cfg: cfg,
spoolRoot: absSpool,
configPath: filepath.Join(configDir, "config.yaml"),
authDir: authDir,
}
return store, nil
}
// Close releases the underlying database connection.
func (s *PostgresStore) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// EnsureSchema creates the required tables (and schema when provided).
func (s *PostgresStore) EnsureSchema(ctx context.Context) error {
if s == nil || s.db == nil {
return fmt.Errorf("postgres store: not initialized")
}
if schema := strings.TrimSpace(s.cfg.Schema); schema != "" {
query := fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdentifier(schema))
if _, err := s.db.ExecContext(ctx, query); err != nil {
return fmt.Errorf("postgres store: create schema: %w", err)
}
}
configTable := s.fullTableName(s.cfg.ConfigTable)
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`, configTable)); err != nil {
return fmt.Errorf("postgres store: create config table: %w", err)
}
authTable := s.fullTableName(s.cfg.AuthTable)
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
id TEXT PRIMARY KEY,
content JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`, authTable)); err != nil {
return fmt.Errorf("postgres store: create auth table: %w", err)
}
return nil
}
// Bootstrap synchronizes configuration and auth records between PostgreSQL and the local workspace.
func (s *PostgresStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {
if err := s.EnsureSchema(ctx); err != nil {
return err
}
if err := s.syncConfigFromDatabase(ctx, exampleConfigPath); err != nil {
return err
}
if err := s.syncAuthFromDatabase(ctx); err != nil {
return err
}
return nil
}
// ConfigPath returns the managed configuration file path inside the spool directory.
func (s *PostgresStore) ConfigPath() string {
if s == nil {
return ""
}
return s.configPath
}
// AuthDir returns the local directory containing mirrored auth files.
func (s *PostgresStore) AuthDir() string {
if s == nil {
return ""
}
return s.authDir
}
// WorkDir exposes the root spool directory used for mirroring.
func (s *PostgresStore) WorkDir() string {
if s == nil {
return ""
}
return s.spoolRoot
}
// SetBaseDir implements the optional interface used by authenticators; it is a no-op because
// the Postgres-backed store controls its own workspace.
func (s *PostgresStore) SetBaseDir(string) {}
// Save persists authentication metadata to disk and PostgreSQL.
func (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("postgres store: auth is nil")
}
path, err := s.resolveAuthPath(auth)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("postgres store: missing file path attribute for %s", auth.ID)
}
if auth.Disabled {
if _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {
return "", nil
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", fmt.Errorf("postgres store: create auth directory: %w", err)
}
switch {
case auth.Storage != nil:
if err = auth.Storage.SaveTokenToFile(path); err != nil {
return "", err
}
case auth.Metadata != nil:
raw, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return "", fmt.Errorf("postgres store: marshal metadata: %w", errMarshal)
}
if existing, errRead := os.ReadFile(path); errRead == nil {
if jsonEqual(existing, raw) {
return path, nil
}
} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {
return "", fmt.Errorf("postgres store: read existing metadata: %w", errRead)
}
tmp := path + ".tmp"
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
return "", fmt.Errorf("postgres store: write temp auth file: %w", errWrite)
}
if errRename := os.Rename(tmp, path); errRename != nil {
return "", fmt.Errorf("postgres store: rename auth file: %w", errRename)
}
default:
return "", fmt.Errorf("postgres store: nothing to persist for %s", auth.ID)
}
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes["path"] = path
if strings.TrimSpace(auth.FileName) == "" {
auth.FileName = auth.ID
}
relID, err := s.relativeAuthID(path)
if err != nil {
return "", err
}
if err = s.upsertAuthRecord(ctx, relID, path); err != nil {
return "", err
}
return path, nil
}
// List enumerates all auth records stored in PostgreSQL.
func (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {
query := fmt.Sprintf("SELECT id, content, created_at, updated_at FROM %s ORDER BY id", s.fullTableName(s.cfg.AuthTable))
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("postgres store: list auth: %w", err)
}
defer rows.Close()
auths := make([]*cliproxyauth.Auth, 0, 32)
for rows.Next() {
var (
id string
payload string
createdAt time.Time
updatedAt time.Time
)
if err = rows.Scan(&id, &payload, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("postgres store: scan auth row: %w", err)
}
path, errPath := s.absoluteAuthPath(id)
if errPath != nil {
log.WithError(errPath).Warnf("postgres store: skipping auth %s outside spool", id)
continue
}
metadata := make(map[string]any)
if err = json.Unmarshal([]byte(payload), &metadata); err != nil {
log.WithError(err).Warnf("postgres store: skipping auth %s with invalid json", id)
continue
}
provider := strings.TrimSpace(valueAsString(metadata["type"]))
if provider == "" {
provider = "unknown"
}
attr := map[string]string{"path": path}
if email := strings.TrimSpace(valueAsString(metadata["email"])); email != "" {
attr["email"] = email
}
auth := &cliproxyauth.Auth{
ID: normalizeAuthID(id),
Provider: provider,
FileName: normalizeAuthID(id),
Label: labelFor(metadata),
Status: cliproxyauth.StatusActive,
Attributes: attr,
Metadata: metadata,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
LastRefreshedAt: time.Time{},
NextRefreshAfter: time.Time{},
}
auths = append(auths, auth)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("postgres store: iterate auth rows: %w", err)
}
return auths, nil
}
// Delete removes an auth file and the corresponding database record.
func (s *PostgresStore) Delete(ctx context.Context, id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("postgres store: id is empty")
}
path, err := s.resolveDeletePath(id)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("postgres store: delete auth file: %w", err)
}
relID, err := s.relativeAuthID(path)
if err != nil {
return err
}
return s.deleteAuthRecord(ctx, relID)
}
// PersistAuthFiles stores the provided auth file changes in PostgreSQL.
func (s *PostgresStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {
if len(paths) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range paths {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
relID, err := s.relativeAuthID(trimmed)
if err != nil {
// Attempt to resolve absolute path under authDir.
abs := trimmed
if !filepath.IsAbs(abs) {
abs = filepath.Join(s.authDir, trimmed)
}
relID, err = s.relativeAuthID(abs)
if err != nil {
log.WithError(err).Warnf("postgres store: ignoring auth path %s", trimmed)
continue
}
trimmed = abs
}
if err = s.syncAuthFile(ctx, relID, trimmed); err != nil {
return err
}
}
return nil
}
// PersistConfig mirrors the local configuration file to PostgreSQL.
func (s *PostgresStore) PersistConfig(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.configPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return s.deleteConfigRecord(ctx)
}
return fmt.Errorf("postgres store: read config file: %w", err)
}
return s.persistConfig(ctx, data)
}
// syncConfigFromDatabase writes the database-stored config to disk or seeds the database from template.
func (s *PostgresStore) syncConfigFromDatabase(ctx context.Context, exampleConfigPath string) error {
query := fmt.Sprintf("SELECT content FROM %s WHERE id = $1", s.fullTableName(s.cfg.ConfigTable))
var content string
err := s.db.QueryRowContext(ctx, query, defaultConfigKey).Scan(&content)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, errStat := os.Stat(s.configPath); errors.Is(errStat, fs.ErrNotExist) {
if exampleConfigPath != "" {
if errCopy := misc.CopyConfigTemplate(exampleConfigPath, s.configPath); errCopy != nil {
return fmt.Errorf("postgres store: copy example config: %w", errCopy)
}
} else {
if errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {
return fmt.Errorf("postgres store: prepare config directory: %w", errCreate)
}
if errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {
return fmt.Errorf("postgres store: create empty config: %w", errWrite)
}
}
}
data, errRead := os.ReadFile(s.configPath)
if errRead != nil {
return fmt.Errorf("postgres store: read local config: %w", errRead)
}
if errPersist := s.persistConfig(ctx, data); errPersist != nil {
return errPersist
}
case err != nil:
return fmt.Errorf("postgres store: load config from database: %w", err)
default:
if err = os.MkdirAll(filepath.Dir(s.configPath), 0o700); err != nil {
return fmt.Errorf("postgres store: prepare config directory: %w", err)
}
normalized := normalizeLineEndings(content)
if err = os.WriteFile(s.configPath, []byte(normalized), 0o600); err != nil {
return fmt.Errorf("postgres store: write config to spool: %w", err)
}
}
return nil
}
// syncAuthFromDatabase populates the local auth directory from PostgreSQL data.
func (s *PostgresStore) syncAuthFromDatabase(ctx context.Context) error {
query := fmt.Sprintf("SELECT id, content FROM %s", s.fullTableName(s.cfg.AuthTable))
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return fmt.Errorf("postgres store: load auth from database: %w", err)
}
defer rows.Close()
if err = os.RemoveAll(s.authDir); err != nil {
return fmt.Errorf("postgres store: reset auth directory: %w", err)
}
if err = os.MkdirAll(s.authDir, 0o700); err != nil {
return fmt.Errorf("postgres store: recreate auth directory: %w", err)
}
for rows.Next() {
var (
id string
payload string
)
if err = rows.Scan(&id, &payload); err != nil {
return fmt.Errorf("postgres store: scan auth row: %w", err)
}
path, errPath := s.absoluteAuthPath(id)
if errPath != nil {
log.WithError(errPath).Warnf("postgres store: skipping auth %s outside spool", id)
continue
}
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("postgres store: create auth subdir: %w", err)
}
if err = os.WriteFile(path, []byte(payload), 0o600); err != nil {
return fmt.Errorf("postgres store: write auth file: %w", err)
}
}
if err = rows.Err(); err != nil {
return fmt.Errorf("postgres store: iterate auth rows: %w", err)
}
return nil
}
func (s *PostgresStore) syncAuthFile(ctx context.Context, relID, path string) error {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return s.deleteAuthRecord(ctx, relID)
}
return fmt.Errorf("postgres store: read auth file: %w", err)
}
if len(data) == 0 {
return s.deleteAuthRecord(ctx, relID)
}
return s.persistAuth(ctx, relID, data)
}
func (s *PostgresStore) upsertAuthRecord(ctx context.Context, relID, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("postgres store: read auth file: %w", err)
}
if len(data) == 0 {
return s.deleteAuthRecord(ctx, relID)
}
return s.persistAuth(ctx, relID, data)
}
func (s *PostgresStore) persistAuth(ctx context.Context, relID string, data []byte) error {
jsonPayload := json.RawMessage(data)
query := fmt.Sprintf(`
INSERT INTO %s (id, content, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (id)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
`, s.fullTableName(s.cfg.AuthTable))
if _, err := s.db.ExecContext(ctx, query, relID, jsonPayload); err != nil {
return fmt.Errorf("postgres store: upsert auth record: %w", err)
}
return nil
}
func (s *PostgresStore) deleteAuthRecord(ctx context.Context, relID string) error {
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.fullTableName(s.cfg.AuthTable))
if _, err := s.db.ExecContext(ctx, query, relID); err != nil {
return fmt.Errorf("postgres store: delete auth record: %w", err)
}
return nil
}
func (s *PostgresStore) persistConfig(ctx context.Context, data []byte) error {
query := fmt.Sprintf(`
INSERT INTO %s (id, content, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (id)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
`, s.fullTableName(s.cfg.ConfigTable))
normalized := normalizeLineEndings(string(data))
if _, err := s.db.ExecContext(ctx, query, defaultConfigKey, normalized); err != nil {
return fmt.Errorf("postgres store: upsert config: %w", err)
}
return nil
}
func (s *PostgresStore) deleteConfigRecord(ctx context.Context) error {
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.fullTableName(s.cfg.ConfigTable))
if _, err := s.db.ExecContext(ctx, query, defaultConfigKey); err != nil {
return fmt.Errorf("postgres store: delete config: %w", err)
}
return nil
}
func (s *PostgresStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("postgres store: auth is nil")
}
if auth.Attributes != nil {
if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
return p, nil
}
}
if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
if filepath.IsAbs(fileName) {
return fileName, nil
}
return filepath.Join(s.authDir, fileName), nil
}
if auth.ID == "" {
return "", fmt.Errorf("postgres store: missing id")
}
if filepath.IsAbs(auth.ID) {
return auth.ID, nil
}
return filepath.Join(s.authDir, filepath.FromSlash(auth.ID)), nil
}
func (s *PostgresStore) resolveDeletePath(id string) (string, error) {
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
return id, nil
}
return filepath.Join(s.authDir, filepath.FromSlash(id)), nil
}
func (s *PostgresStore) relativeAuthID(path string) (string, error) {
if s == nil {
return "", fmt.Errorf("postgres store: store not initialized")
}
if !filepath.IsAbs(path) {
path = filepath.Join(s.authDir, path)
}
clean := filepath.Clean(path)
rel, err := filepath.Rel(s.authDir, clean)
if err != nil {
return "", fmt.Errorf("postgres store: compute relative path: %w", err)
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("postgres store: path %s outside managed directory", path)
}
return filepath.ToSlash(rel), nil
}
func (s *PostgresStore) absoluteAuthPath(id string) (string, error) {
if s == nil {
return "", fmt.Errorf("postgres store: store not initialized")
}
clean := filepath.Clean(filepath.FromSlash(id))
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("postgres store: invalid auth identifier %s", id)
}
path := filepath.Join(s.authDir, clean)
rel, err := filepath.Rel(s.authDir, path)
if err != nil {
return "", err
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("postgres store: resolved auth path escapes auth directory")
}
return path, nil
}
func (s *PostgresStore) fullTableName(name string) string {
if strings.TrimSpace(s.cfg.Schema) == "" {
return quoteIdentifier(name)
}
return quoteIdentifier(s.cfg.Schema) + "." + quoteIdentifier(name)
}
func quoteIdentifier(identifier string) string {
replaced := strings.ReplaceAll(identifier, "\"", "\"\"")
return "\"" + replaced + "\""
}
func valueAsString(v any) string {
switch t := v.(type) {
case string:
return t
case fmt.Stringer:
return t.String()
default:
return ""
}
}
func labelFor(metadata map[string]any) string {
if metadata == nil {
return ""
}
if v := strings.TrimSpace(valueAsString(metadata["label"])); v != "" {
return v
}
if v := strings.TrimSpace(valueAsString(metadata["email"])); v != "" {
return v
}
if v := strings.TrimSpace(valueAsString(metadata["project_id"])); v != "" {
return v
}
return ""
}
func normalizeAuthID(id string) string {
return filepath.ToSlash(filepath.Clean(id))
}
func normalizeLineEndings(s string) string {
if s == "" {
return s
}
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return s
}

View File

@@ -143,21 +143,63 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
switch typ {
case "message":
// Determine role from content type (input_text=user, output_text=assistant)
// Determine role and construct Claude-compatible content parts.
var role string
var text strings.Builder
var textAggregate strings.Builder
var partsJSON []string
hasImage := false
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
parts.ForEach(func(_, part gjson.Result) bool {
ptype := part.Get("type").String()
if ptype == "input_text" || ptype == "output_text" {
switch ptype {
case "input_text", "output_text":
if t := part.Get("text"); t.Exists() {
text.WriteString(t.String())
txt := t.String()
textAggregate.WriteString(txt)
contentPart := `{"type":"text","text":""}`
contentPart, _ = sjson.Set(contentPart, "text", txt)
partsJSON = append(partsJSON, contentPart)
}
if ptype == "input_text" {
role = "user"
} else if ptype == "output_text" {
} else {
role = "assistant"
}
case "input_image":
url := part.Get("image_url").String()
if url == "" {
url = part.Get("url").String()
}
if url != "" {
var contentPart string
if strings.HasPrefix(url, "data:") {
trimmed := strings.TrimPrefix(url, "data:")
mediaAndData := strings.SplitN(trimmed, ";base64,", 2)
mediaType := "application/octet-stream"
data := ""
if len(mediaAndData) == 2 {
if mediaAndData[0] != "" {
mediaType = mediaAndData[0]
}
data = mediaAndData[1]
}
if data != "" {
contentPart = `{"type":"image","source":{"type":"base64","media_type":"","data":""}}`
contentPart, _ = sjson.Set(contentPart, "source.media_type", mediaType)
contentPart, _ = sjson.Set(contentPart, "source.data", data)
}
} else {
contentPart = `{"type":"image","source":{"type":"url","url":""}}`
contentPart, _ = sjson.Set(contentPart, "source.url", url)
}
if contentPart != "" {
partsJSON = append(partsJSON, contentPart)
if role == "" {
role = "user"
}
hasImage = true
}
}
}
return true
})
@@ -174,14 +216,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
}
if text.Len() > 0 || role == "system" {
if len(partsJSON) > 0 {
msg := `{"role":"","content":[]}`
msg, _ = sjson.Set(msg, "role", role)
if len(partsJSON) == 1 && !hasImage {
// Preserve legacy behavior for single text content
msg, _ = sjson.Delete(msg, "content")
textPart := gjson.Parse(partsJSON[0])
msg, _ = sjson.Set(msg, "content", textPart.Get("text").String())
} else {
for _, partJSON := range partsJSON {
msg, _ = sjson.SetRaw(msg, "content.-1", partJSON)
}
}
out, _ = sjson.SetRaw(out, "messages.-1", msg)
} else if textAggregate.Len() > 0 || role == "system" {
msg := `{"role":"","content":""}`
msg, _ = sjson.Set(msg, "role", role)
if text.Len() > 0 {
msg, _ = sjson.Set(msg, "content", text.String())
} else {
msg, _ = sjson.Set(msg, "content", "")
}
msg, _ = sjson.Set(msg, "content", textAggregate.String())
out, _ = sjson.SetRaw(out, "messages.-1", msg)
}

View File

@@ -68,36 +68,79 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
for i := 0; i < len(messageResults); i++ {
messageResult := messageResults[i]
messageRole := messageResult.Get("role").String()
newMessage := func() string {
msg := `{"type": "message","role":"","content":[]}`
msg, _ = sjson.Set(msg, "role", messageRole)
return msg
}
message := newMessage()
contentIndex := 0
hasContent := false
flushMessage := func() {
if hasContent {
template, _ = sjson.SetRaw(template, "input.-1", message)
message = newMessage()
contentIndex = 0
hasContent = false
}
}
appendTextContent := func(text string) {
partType := "input_text"
if messageRole == "assistant" {
partType = "output_text"
}
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", contentIndex), partType)
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.text", contentIndex), text)
contentIndex++
hasContent = true
}
appendImageContent := func(dataURL string) {
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", contentIndex), "input_image")
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.image_url", contentIndex), dataURL)
contentIndex++
hasContent = true
}
messageContentsResult := messageResult.Get("content")
if messageContentsResult.IsArray() {
messageContentResults := messageContentsResult.Array()
for j := 0; j < len(messageContentResults); j++ {
messageContentResult := messageContentResults[j]
messageContentTypeResult := messageContentResult.Get("type")
contentType := messageContentTypeResult.String()
contentType := messageContentResult.Get("type").String()
if contentType == "text" {
// Handle text content by creating appropriate message structure.
message := `{"type": "message","role":"","content":[]}`
messageRole := messageResult.Get("role").String()
message, _ = sjson.Set(message, "role", messageRole)
partType := "input_text"
if messageRole == "assistant" {
partType = "output_text"
switch contentType {
case "text":
appendTextContent(messageContentResult.Get("text").String())
case "image":
sourceResult := messageContentResult.Get("source")
if sourceResult.Exists() {
data := sourceResult.Get("data").String()
if data == "" {
data = sourceResult.Get("base64").String()
}
if data != "" {
mediaType := sourceResult.Get("media_type").String()
if mediaType == "" {
mediaType = sourceResult.Get("mime_type").String()
}
if mediaType == "" {
mediaType = "application/octet-stream"
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
appendImageContent(dataURL)
}
}
currentIndex := len(gjson.Get(message, "content").Array())
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", currentIndex), partType)
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.text", currentIndex), messageContentResult.Get("text").String())
template, _ = sjson.SetRaw(template, "input.-1", message)
} else if contentType == "tool_use" {
// Handle tool use content by creating function call message.
case "tool_use":
flushMessage()
functionCallMessage := `{"type":"function_call"}`
functionCallMessage, _ = sjson.Set(functionCallMessage, "call_id", messageContentResult.Get("id").String())
{
// Shorten tool name if needed based on declared tools
name := messageContentResult.Get("name").String()
toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
if short, ok := toolMap[name]; ok {
@@ -109,28 +152,18 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
}
functionCallMessage, _ = sjson.Set(functionCallMessage, "arguments", messageContentResult.Get("input").Raw)
template, _ = sjson.SetRaw(template, "input.-1", functionCallMessage)
} else if contentType == "tool_result" {
// Handle tool result content by creating function call output message.
case "tool_result":
flushMessage()
functionCallOutputMessage := `{"type":"function_call_output"}`
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "call_id", messageContentResult.Get("tool_use_id").String())
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
template, _ = sjson.SetRaw(template, "input.-1", functionCallOutputMessage)
}
}
flushMessage()
} else if messageContentsResult.Type == gjson.String {
// Handle string content by creating appropriate message structure.
message := `{"type": "message","role":"","content":[]}`
messageRole := messageResult.Get("role").String()
message, _ = sjson.Set(message, "role", messageRole)
partType := "input_text"
if messageRole == "assistant" {
partType = "output_text"
}
message, _ = sjson.Set(message, "content.0.type", partType)
message, _ = sjson.Set(message, "content.0.text", messageContentsResult.String())
template, _ = sjson.SetRaw(template, "input.-1", message)
appendTextContent(messageContentsResult.String())
flushMessage()
}
}
@@ -153,6 +186,12 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
shortMap := buildShortNameMap(names)
for i := 0; i < len(toolResults); i++ {
toolResult := toolResults[i]
// Special handling: map Claude web search tool to Codex web_search
if toolResult.Get("type").String() == "web_search_20250305" {
// Replace the tool content entirely with {"type":"web_search"}
template, _ = sjson.SetRaw(template, "tools.-1", `{"type":"web_search"}`)
continue
}
tool := toolResult.Raw
tool, _ = sjson.Set(tool, "type", "function")
// Apply shortened name if needed

View File

@@ -40,7 +40,7 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,
inputResults = inputResult.Array()
} else if inputResult.Type == gjson.String {
newInput := `[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`
newInput, _ = sjson.Set(newInput, "0.content.0.text", inputResult.String())
newInput, _ = sjson.SetRaw(newInput, "0.content.0.text", inputResult.Raw)
inputResults = gjson.Parse(newInput).Array()
}
} else {

View File

@@ -1,7 +1,6 @@
package responses
import (
"bufio"
"bytes"
"context"
"fmt"
@@ -12,6 +11,7 @@ import (
// ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
// to OpenAI Responses SSE events (response.*).
func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
@@ -21,7 +21,8 @@ func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string
rawJSON, _ = sjson.SetBytes(rawJSON, "response.instructions", gjson.GetBytes(originalRequestRawJSON, "instructions").String())
}
}
return []string{fmt.Sprintf("data: %s", string(rawJSON))}
out := fmt.Sprintf("data: %s", string(rawJSON))
return []string{out}
}
return []string{string(rawJSON)}
}
@@ -29,31 +30,13 @@ func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string
// ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON
// from a non-streaming OpenAI Chat Completions response.
func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
buffer := make([]byte, 20_971_520)
scanner.Buffer(buffer, 20_971_520)
dataTag := []byte("data:")
for scanner.Scan() {
line := scanner.Bytes()
if !bytes.HasPrefix(line, dataTag) {
continue
}
line = bytes.TrimSpace(line[5:])
rootResult := gjson.ParseBytes(line)
// Verify this is a response.completed event
if rootResult.Get("type").String() != "response.completed" {
continue
}
responseResult := rootResult.Get("response")
template := responseResult.Raw
template, _ = sjson.Set(template, "instructions", gjson.GetBytes(originalRequestRawJSON, "instructions").String())
return template
rootResult := gjson.ParseBytes(rawJSON)
// Verify this is a response.completed event
if rootResult.Get("type").String() != "response.completed" {
return ""
}
return ""
responseResult := rootResult.Get("response")
template := responseResult.Raw
template, _ = sjson.Set(template, "instructions", gjson.GetBytes(originalRequestRawJSON, "instructions").String())
return template
}

View File

@@ -9,6 +9,7 @@ import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"strings"
@@ -100,14 +101,40 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
"content": "",
}
var contentParts []string
var textBuilder strings.Builder
var aggregatedParts []interface{}
onlyTextContent := true
var toolCalls []interface{}
if parts.Exists() && parts.IsArray() {
parts.ForEach(func(_, part gjson.Result) bool {
// Handle text parts
if text := part.Get("text"); text.Exists() {
contentParts = append(contentParts, text.String())
formattedText := text.String()
textBuilder.WriteString(formattedText)
aggregatedParts = append(aggregatedParts, map[string]interface{}{
"type": "text",
"text": formattedText,
})
}
// Handle inline data (e.g., images)
if inlineData := part.Get("inlineData"); inlineData.Exists() {
onlyTextContent = false
mimeType := inlineData.Get("mimeType").String()
if mimeType == "" {
mimeType = "application/octet-stream"
}
data := inlineData.Get("data").String()
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
aggregatedParts = append(aggregatedParts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": imageURL,
},
})
}
// Handle function calls (Gemini) -> tool calls (OpenAI)
@@ -175,8 +202,12 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
// Set content
if len(contentParts) > 0 {
msg["content"] = strings.Join(contentParts, "")
if len(aggregatedParts) > 0 {
if onlyTextContent {
msg["content"] = textBuilder.String()
} else {
msg["content"] = aggregatedParts
}
}
// Set tool calls if any

View File

@@ -89,6 +89,7 @@ type modelStats struct {
// RequestDetail stores the timestamp and token usage for a single request.
type RequestDetail struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Tokens TokenStats `json:"tokens"`
}
@@ -188,7 +189,11 @@ func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record)
stats = &apiStats{Models: make(map[string]*modelStats)}
s.apis[statsKey] = stats
}
s.updateAPIStats(stats, modelName, RequestDetail{Timestamp: timestamp, Tokens: detail})
s.updateAPIStats(stats, modelName, RequestDetail{
Timestamp: timestamp,
Source: record.Source,
Tokens: detail,
})
s.requestsByDay[dayKey]++
s.requestsByHour[hourKey]++

View File

@@ -20,45 +20,45 @@ import (
"time"
"github.com/fsnotify/fsnotify"
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
// "github.com/router-for-me/CLIProxyAPI/v6/internal/client"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
// "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"gopkg.in/yaml.v3"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
// "github.com/tidwall/gjson"
)
// gitCommitter captures the subset of git-backed token store capabilities used by the watcher.
type gitCommitter interface {
CommitConfig(ctx context.Context) error
CommitPaths(ctx context.Context, message string, paths ...string) error
// storePersister captures persistence-capable token store methods used by the watcher.
type storePersister interface {
PersistConfig(ctx context.Context) error
PersistAuthFiles(ctx context.Context, message string, paths ...string) error
}
type authDirProvider interface {
AuthDir() string
}
// Watcher manages file watching for configuration and authentication files
type Watcher struct {
configPath string
authDir string
config *config.Config
clientsMutex sync.RWMutex
reloadCallback func(*config.Config)
watcher *fsnotify.Watcher
lastAuthHashes map[string]string
lastConfigHash string
authQueue chan<- AuthUpdate
currentAuths map[string]*coreauth.Auth
dispatchMu sync.Mutex
dispatchCond *sync.Cond
pendingUpdates map[string]AuthUpdate
pendingOrder []string
dispatchCancel context.CancelFunc
gitCommitter gitCommitter
configPath string
authDir string
config *config.Config
clientsMutex sync.RWMutex
reloadCallback func(*config.Config)
watcher *fsnotify.Watcher
lastAuthHashes map[string]string
lastConfigHash string
authQueue chan<- AuthUpdate
currentAuths map[string]*coreauth.Auth
dispatchMu sync.Mutex
dispatchCond *sync.Cond
pendingUpdates map[string]AuthUpdate
pendingOrder []string
dispatchCancel context.CancelFunc
storePersister storePersister
mirroredAuthDir string
oldConfigYaml []byte
}
type stableIDGenerator struct {
@@ -131,9 +131,15 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config))
}
w.dispatchCond = sync.NewCond(&w.dispatchMu)
if store := sdkAuth.GetTokenStore(); store != nil {
if committer, ok := store.(gitCommitter); ok {
w.gitCommitter = committer
log.Debug("gitstore mode detected; watcher will commit changes to remote repository")
if persister, ok := store.(storePersister); ok {
w.storePersister = persister
log.Debug("persistence-capable token store detected; watcher will propagate persisted changes")
}
if provider, ok := store.(authDirProvider); ok {
if fixed := strings.TrimSpace(provider.AuthDir()); fixed != "" {
w.mirroredAuthDir = fixed
log.Debugf("mirrored auth directory locked to %s", fixed)
}
}
}
return w, nil
@@ -174,6 +180,7 @@ func (w *Watcher) SetConfig(cfg *config.Config) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
w.config = cfg
w.oldConfigYaml, _ = yaml.Marshal(cfg)
}
// SetAuthUpdateQueue sets the queue used to emit auth updates.
@@ -349,21 +356,21 @@ func (w *Watcher) stopDispatch() {
w.clientsMutex.Unlock()
}
func (w *Watcher) commitConfigAsync() {
if w == nil || w.gitCommitter == nil {
func (w *Watcher) persistConfigAsync() {
if w == nil || w.storePersister == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := w.gitCommitter.CommitConfig(ctx); err != nil {
log.Errorf("failed to commit config change: %v", err)
if err := w.storePersister.PersistConfig(ctx); err != nil {
log.Errorf("failed to persist config change: %v", err)
}
}()
}
func (w *Watcher) commitAuthAsync(message string, paths ...string) {
if w == nil || w.gitCommitter == nil {
func (w *Watcher) persistAuthAsync(message string, paths ...string) {
if w == nil || w.storePersister == nil {
return
}
filtered := make([]string, 0, len(paths))
@@ -378,8 +385,8 @@ func (w *Watcher) commitAuthAsync(message string, paths ...string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := w.gitCommitter.CommitPaths(ctx, message, filtered...); err != nil {
log.Errorf("failed to commit auth changes: %v", err)
if err := w.storePersister.PersistAuthFiles(ctx, message, filtered...); err != nil {
log.Errorf("failed to persist auth changes: %v", err)
}
}()
}
@@ -488,7 +495,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
w.clientsMutex.Lock()
w.lastConfigHash = finalHash
w.clientsMutex.Unlock()
w.commitConfigAsync()
w.persistConfigAsync()
}
return
}
@@ -521,14 +528,20 @@ func (w *Watcher) reloadConfig() bool {
return false
}
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(newConfig.AuthDir); errResolveAuthDir != nil {
log.Errorf("failed to resolve auth directory from config: %v", errResolveAuthDir)
if w.mirroredAuthDir != "" {
newConfig.AuthDir = w.mirroredAuthDir
} else {
newConfig.AuthDir = resolvedAuthDir
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(newConfig.AuthDir); errResolveAuthDir != nil {
log.Errorf("failed to resolve auth directory from config: %v", errResolveAuthDir)
} else {
newConfig.AuthDir = resolvedAuthDir
}
}
w.clientsMutex.Lock()
oldConfig := w.config
var oldConfig *config.Config
_ = yaml.Unmarshal(w.oldConfigYaml, &oldConfig)
w.oldConfigYaml, _ = yaml.Marshal(newConfig)
w.config = newConfig
w.clientsMutex.Unlock()
@@ -540,71 +553,16 @@ func (w *Watcher) reloadConfig() bool {
log.Debugf("log level updated - debug mode changed from %t to %t", oldConfig.Debug, newConfig.Debug)
}
// Log configuration changes in debug mode
// Log configuration changes in debug mode, only when there are material diffs
if oldConfig != nil {
log.Debugf("config changes detected:")
if oldConfig.Port != newConfig.Port {
log.Debugf(" port: %d -> %d", oldConfig.Port, newConfig.Port)
}
if oldConfig.AuthDir != newConfig.AuthDir {
log.Debugf(" auth-dir: %s -> %s", oldConfig.AuthDir, newConfig.AuthDir)
}
if oldConfig.Debug != newConfig.Debug {
log.Debugf(" debug: %t -> %t", oldConfig.Debug, newConfig.Debug)
}
if oldConfig.ProxyURL != newConfig.ProxyURL {
log.Debugf(" proxy-url: %s -> %s", oldConfig.ProxyURL, newConfig.ProxyURL)
}
if oldConfig.RequestLog != newConfig.RequestLog {
log.Debugf(" request-log: %t -> %t", oldConfig.RequestLog, newConfig.RequestLog)
}
if oldConfig.RequestRetry != newConfig.RequestRetry {
log.Debugf(" request-retry: %d -> %d", oldConfig.RequestRetry, newConfig.RequestRetry)
}
if len(oldConfig.APIKeys) != len(newConfig.APIKeys) {
log.Debugf(" api-keys count: %d -> %d", len(oldConfig.APIKeys), len(newConfig.APIKeys))
}
if len(oldConfig.GlAPIKey) != len(newConfig.GlAPIKey) {
log.Debugf(" generative-language-api-key count: %d -> %d", len(oldConfig.GlAPIKey), len(newConfig.GlAPIKey))
}
if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) {
log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey))
}
if len(oldConfig.CodexKey) != len(newConfig.CodexKey) {
log.Debugf(" codex-api-key count: %d -> %d", len(oldConfig.CodexKey), len(newConfig.CodexKey))
}
if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote {
log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote)
}
if oldConfig.RemoteManagement.SecretKey != newConfig.RemoteManagement.SecretKey {
switch {
case oldConfig.RemoteManagement.SecretKey == "" && newConfig.RemoteManagement.SecretKey != "":
log.Debug(" remote-management.secret-key: created")
case oldConfig.RemoteManagement.SecretKey != "" && newConfig.RemoteManagement.SecretKey == "":
log.Debug(" remote-management.secret-key: deleted")
default:
log.Debug(" remote-management.secret-key: updated")
}
if newConfig.RemoteManagement.SecretKey == "" {
log.Info("management routes will be disabled after secret key removal")
} else {
log.Info("management routes will be enabled after secret key update")
}
}
if oldConfig.RemoteManagement.DisableControlPanel != newConfig.RemoteManagement.DisableControlPanel {
log.Debugf(" remote-management.disable-control-panel: %t -> %t", oldConfig.RemoteManagement.DisableControlPanel, newConfig.RemoteManagement.DisableControlPanel)
}
if oldConfig.LoggingToFile != newConfig.LoggingToFile {
log.Debugf(" logging-to-file: %t -> %t", oldConfig.LoggingToFile, newConfig.LoggingToFile)
}
if oldConfig.UsageStatisticsEnabled != newConfig.UsageStatisticsEnabled {
log.Debugf(" usage-statistics-enabled: %t -> %t", oldConfig.UsageStatisticsEnabled, newConfig.UsageStatisticsEnabled)
}
if changes := diffOpenAICompatibility(oldConfig.OpenAICompatibility, newConfig.OpenAICompatibility); len(changes) > 0 {
log.Debugf(" openai-compatibility:")
for _, change := range changes {
log.Debugf(" %s", change)
details := buildConfigChangeDetails(oldConfig, newConfig)
if len(details) > 0 {
log.Debugf("config changes detected:")
for _, d := range details {
log.Debugf(" %s", d)
}
} else {
log.Debugf("no material config field changes detected")
}
}
@@ -740,7 +698,7 @@ func (w *Watcher) addOrUpdateClient(path string) {
log.Debugf("triggering server update callback after add/update")
w.reloadCallback(cfg)
}
w.commitAuthAsync(fmt.Sprintf("Sync auth %s", filepath.Base(path)), path)
w.persistAuthAsync(fmt.Sprintf("Sync auth %s", filepath.Base(path)), path)
}
// removeClient handles the removal of a single client.
@@ -758,7 +716,7 @@ func (w *Watcher) removeClient(path string) {
log.Debugf("triggering server update callback after removal")
w.reloadCallback(cfg)
}
w.commitAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path)
w.persistAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path)
}
// SnapshotCombinedClients returns a snapshot of current combined clients.
@@ -1209,3 +1167,138 @@ func openAICompatKey(entry config.OpenAICompatibility, index int) (string, strin
}
return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1)
}
// buildConfigChangeDetails computes a redacted, human-readable list of config changes.
// It avoids printing secrets (like API keys) and focuses on structural or non-sensitive fields.
func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
changes := make([]string, 0, 16)
if oldCfg == nil || newCfg == nil {
return changes
}
// Simple scalars
if oldCfg.Port != newCfg.Port {
changes = append(changes, fmt.Sprintf("port: %d -> %d", oldCfg.Port, newCfg.Port))
}
if oldCfg.AuthDir != newCfg.AuthDir {
changes = append(changes, fmt.Sprintf("auth-dir: %s -> %s", oldCfg.AuthDir, newCfg.AuthDir))
}
if oldCfg.Debug != newCfg.Debug {
changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug))
}
if oldCfg.LoggingToFile != newCfg.LoggingToFile {
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
}
if oldCfg.UsageStatisticsEnabled != newCfg.UsageStatisticsEnabled {
changes = append(changes, fmt.Sprintf("usage-statistics-enabled: %t -> %t", oldCfg.UsageStatisticsEnabled, newCfg.UsageStatisticsEnabled))
}
if oldCfg.RequestLog != newCfg.RequestLog {
changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog))
}
if oldCfg.RequestRetry != newCfg.RequestRetry {
changes = append(changes, fmt.Sprintf("request-retry: %d -> %d", oldCfg.RequestRetry, newCfg.RequestRetry))
}
if oldCfg.ProxyURL != newCfg.ProxyURL {
changes = append(changes, fmt.Sprintf("proxy-url: %s -> %s", oldCfg.ProxyURL, newCfg.ProxyURL))
}
// Quota-exceeded behavior
if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {
changes = append(changes, fmt.Sprintf("quota-exceeded.switch-project: %t -> %t", oldCfg.QuotaExceeded.SwitchProject, newCfg.QuotaExceeded.SwitchProject))
}
if oldCfg.QuotaExceeded.SwitchPreviewModel != newCfg.QuotaExceeded.SwitchPreviewModel {
changes = append(changes, fmt.Sprintf("quota-exceeded.switch-preview-model: %t -> %t", oldCfg.QuotaExceeded.SwitchPreviewModel, newCfg.QuotaExceeded.SwitchPreviewModel))
}
// API keys (redacted) and counts
if len(oldCfg.APIKeys) != len(newCfg.APIKeys) {
changes = append(changes, fmt.Sprintf("api-keys count: %d -> %d", len(oldCfg.APIKeys), len(newCfg.APIKeys)))
} else if !reflect.DeepEqual(trimStrings(oldCfg.APIKeys), trimStrings(newCfg.APIKeys)) {
changes = append(changes, "api-keys: values updated (count unchanged, redacted)")
}
if len(oldCfg.GlAPIKey) != len(newCfg.GlAPIKey) {
changes = append(changes, fmt.Sprintf("generative-language-api-key count: %d -> %d", len(oldCfg.GlAPIKey), len(newCfg.GlAPIKey)))
} else if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) {
changes = append(changes, "generative-language-api-key: values updated (count unchanged, redacted)")
}
// Claude keys (do not print key material)
if len(oldCfg.ClaudeKey) != len(newCfg.ClaudeKey) {
changes = append(changes, fmt.Sprintf("claude-api-key count: %d -> %d", len(oldCfg.ClaudeKey), len(newCfg.ClaudeKey)))
} else {
for i := range oldCfg.ClaudeKey {
if i >= len(newCfg.ClaudeKey) {
break
}
o := oldCfg.ClaudeKey[i]
n := newCfg.ClaudeKey[i]
if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {
changes = append(changes, fmt.Sprintf("claude[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
}
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("claude[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
}
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
}
}
}
// Codex keys (do not print key material)
if len(oldCfg.CodexKey) != len(newCfg.CodexKey) {
changes = append(changes, fmt.Sprintf("codex-api-key count: %d -> %d", len(oldCfg.CodexKey), len(newCfg.CodexKey)))
} else {
for i := range oldCfg.CodexKey {
if i >= len(newCfg.CodexKey) {
break
}
o := oldCfg.CodexKey[i]
n := newCfg.CodexKey[i]
if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {
changes = append(changes, fmt.Sprintf("codex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
}
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("codex[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
}
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i))
}
}
}
// Remote management (never print the key)
if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote {
changes = append(changes, fmt.Sprintf("remote-management.allow-remote: %t -> %t", oldCfg.RemoteManagement.AllowRemote, newCfg.RemoteManagement.AllowRemote))
}
if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel {
changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel))
}
if oldCfg.RemoteManagement.SecretKey != newCfg.RemoteManagement.SecretKey {
switch {
case oldCfg.RemoteManagement.SecretKey == "" && newCfg.RemoteManagement.SecretKey != "":
changes = append(changes, "remote-management.secret-key: created")
case oldCfg.RemoteManagement.SecretKey != "" && newCfg.RemoteManagement.SecretKey == "":
changes = append(changes, "remote-management.secret-key: deleted")
default:
changes = append(changes, "remote-management.secret-key: updated")
}
}
// OpenAI compatibility providers (summarized)
if compat := diffOpenAICompatibility(oldCfg.OpenAICompatibility, newCfg.OpenAICompatibility); len(compat) > 0 {
changes = append(changes, "openai-compatibility:")
for _, c := range compat {
changes = append(changes, " "+c)
}
}
return changes
}
func trimStrings(in []string) []string {
out := make([]string, len(in))
for i := range in {
out[i] = strings.TrimSpace(in[i])
}
return out
}

View File

@@ -14,6 +14,7 @@ type Record struct {
Model string
APIKey string
AuthID string
Source string
RequestedAt time.Time
Detail Detail
}