mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 12:30:50 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d89a22aa0 | ||
|
|
9319602812 | ||
|
|
8e95c5e0a8 | ||
|
|
93f0e65cef | ||
|
|
c75e524fe5 | ||
|
|
f58d0faf8c | ||
|
|
df3b00621a | ||
|
|
72cb2689e8 | ||
|
|
ade279d1f2 | ||
|
|
9c5ac2927a | ||
|
|
7980f055fa | ||
|
|
eb2549a782 | ||
|
|
c419264a70 | ||
|
|
6b23e2da74 | ||
|
|
5ab0854b5b | ||
|
|
15981aa412 | ||
|
|
ac4f52c532 | ||
|
|
84fa497169 | ||
|
|
b641d90287 | ||
|
|
32d01a6a7c | ||
|
|
9ef76dcc61 | ||
|
|
4576f9915b | ||
|
|
c945e35983 | ||
|
|
1cd275f4c1 | ||
|
|
4bc1ed6031 | ||
|
|
78989d6c0d | ||
|
|
d6aa1e5ba0 | ||
|
|
50c1c50dbd | ||
|
|
5123cfd47e | ||
|
|
9072accc43 | ||
|
|
0d8134aabe | ||
|
|
4fdbdf7925 | ||
|
|
50c84485c3 | ||
|
|
f335aeeedb | ||
|
|
32a8102d71 | ||
|
|
61f6a612e3 | ||
|
|
42087d5387 | ||
|
|
f2710c03ab | ||
|
|
39abde2413 | ||
|
|
0aa8706ef7 | ||
|
|
5fd4a8b974 | ||
|
|
06e6f0a5f2 | ||
|
|
80f6d6fe7c | ||
|
|
3be6175aec | ||
|
|
599986495b | ||
|
|
cb83985cc7 | ||
|
|
6ec028808f |
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Example environment configuration for CLIProxyAPI.
|
||||
# Copy this file to `.env` and uncomment the variables you need.
|
||||
#
|
||||
# NOTE: Environment variables are only required when using remote storage options.
|
||||
# For local file-based storage (default), no environment variables need to be set.
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Management Web UI
|
||||
# ------------------------------------------------------------------------------
|
||||
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Postgres Token Store (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
|
||||
# PGSTORE_SCHEMA=public
|
||||
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Git-Backed Config Store (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
|
||||
# GITSTORE_GIT_USERNAME=git-user
|
||||
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
|
||||
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Object Store Token Store (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
|
||||
# OBJECTSTORE_BUCKET=cli-proxy-config
|
||||
# OBJECTSTORE_ACCESS_KEY=your_access_key
|
||||
# OBJECTSTORE_SECRET_KEY=your_secret_key
|
||||
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -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/*
|
||||
|
||||
72
README.md
72
README.md
@@ -82,6 +82,8 @@ A web-based management center for CLIProxyAPI.
|
||||
|
||||
Set `remote-management.disable-control-panel` to `true` if you prefer to host the management UI elsewhere; the server will skip downloading `management.html` and `/management.html` will return 404.
|
||||
|
||||
You can set the `MANAGEMENT_STATIC_PATH` environment variable to choose the directory where `management.html` is stored.
|
||||
|
||||
### Authentication
|
||||
|
||||
You can authenticate for Gemini, OpenAI, Claude, Qwen, and/or iFlow. All can coexist in the same `auth-dir` and will be load balanced.
|
||||
@@ -421,12 +423,13 @@ To enable this feature, set the `GITSTORE_GIT_URL` environment variable to the U
|
||||
|
||||
**Environment Variables**
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| ----------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `GITSTORE_GIT_URL` | Yes | | The HTTPS URL of the Git repository to use. |
|
||||
| `GITSTORE_LOCAL_PATH` | No | Current working directory | The local path where the Git repository will be cloned. Inside Docker, this defaults to `/CLIProxyAPI`. |
|
||||
| `GITSTORE_GIT_USERNAME` | No | | The username for Git authentication. |
|
||||
| `GITSTORE_GIT_TOKEN` | No | | The personal access token (or password) for Git authentication. |
|
||||
| Variable | Required | Default | Description |
|
||||
|-------------------------|----------|---------------------------|---------------------------------------------------------------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | Yes | | The password for management webui. |
|
||||
| `GITSTORE_GIT_URL` | Yes | | The HTTPS URL of the Git repository to use. |
|
||||
| `GITSTORE_LOCAL_PATH` | No | Current working directory | The local path where the Git repository will be cloned. Inside Docker, this defaults to `/CLIProxyAPI`. |
|
||||
| `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**
|
||||
|
||||
@@ -435,6 +438,48 @@ 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 |
|
||||
|--------------------------|----------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | Yes | | Password for the management web UI (required when remote management is enabled). |
|
||||
| `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`.
|
||||
@@ -487,21 +532,6 @@ And you can always use Gemini CLI with `CODE_ASSIST_ENDPOINT` set to `http://127
|
||||
|
||||
The `auth-dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing.
|
||||
|
||||
### Request Authentication Providers
|
||||
|
||||
Configure inbound authentication through the `auth.providers` section. The built-in `config-api-key` provider works with inline keys:
|
||||
|
||||
```
|
||||
auth:
|
||||
providers:
|
||||
- name: default
|
||||
type: config-api-key
|
||||
api-keys:
|
||||
- your-api-key-1
|
||||
```
|
||||
|
||||
Clients should send requests with an `Authorization: Bearer your-api-key-1` header (or `X-Goog-Api-Key`, `X-Api-Key`, or `?key=` as before). The legacy top-level `api-keys` array is still accepted and automatically synced to the default provider for backwards compatibility.
|
||||
|
||||
### Official Generative Language API
|
||||
|
||||
The `generative-language-api-key` parameter allows you to define a list of API keys that can be used to authenticate requests to the official Generative Language API.
|
||||
|
||||
72
README_CN.md
72
README_CN.md
@@ -96,6 +96,8 @@ CLIProxyAPI 的基于 Web 的管理中心。
|
||||
|
||||
如果希望自行托管管理页面,可在配置中将 `remote-management.disable-control-panel` 设为 `true`,服务器将停止下载 `management.html`,并让 `/management.html` 返回 404。
|
||||
|
||||
可以通过设置环境变量 `MANAGEMENT_STATIC_PATH` 来指定 `management.html` 的存储目录。
|
||||
|
||||
### 身份验证
|
||||
|
||||
您可以分别为 Gemini、OpenAI、Claude、Qwen 和 iFlow 进行身份验证,它们可同时存在于同一个 `auth-dir` 中并参与负载均衡。
|
||||
@@ -434,12 +436,13 @@ openai-compatibility:
|
||||
|
||||
**环境变量**
|
||||
|
||||
| 变量 | 必需 | 默认值 | 描述 |
|
||||
| ----------------------- | ---- | --------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `GITSTORE_GIT_URL` | 是 | | 要使用的 Git 仓库的 HTTPS URL。 |
|
||||
| `GITSTORE_LOCAL_PATH` | 否 | 当前工作目录 | 将克隆 Git 仓库的本地路径。在 Docker 内部,此路径默认为 `/CLIProxyAPI`。 |
|
||||
| `GITSTORE_GIT_USERNAME` | 否 | | 用于 Git 身份验证的用户名。 |
|
||||
| `GITSTORE_GIT_TOKEN` | 否 | | 用于 Git 身份验证的个人访问令牌(或密码)。 |
|
||||
| 变量 | 必需 | 默认值 | 描述 |
|
||||
|-------------------------|----|--------|----------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | 是 | | 管理面板密码 |
|
||||
| `GITSTORE_GIT_URL` | 是 | | 要使用的 Git 仓库的 HTTPS URL。 |
|
||||
| `GITSTORE_LOCAL_PATH` | 否 | 当前工作目录 | 将克隆 Git 仓库的本地路径。在 Docker 内部,此路径默认为 `/CLIProxyAPI`。 |
|
||||
| `GITSTORE_GIT_USERNAME` | 否 | | 用于 Git 身份验证的用户名。 |
|
||||
| `GITSTORE_GIT_TOKEN` | 否 | | 用于 Git 身份验证的个人访问令牌(或密码)。 |
|
||||
|
||||
**工作原理**
|
||||
|
||||
@@ -448,6 +451,48 @@ 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 兼容的对象存储来托管配置与鉴权数据。
|
||||
|
||||
**环境变量**
|
||||
|
||||
| 变量 | 是否必填 | 默认值 | 说明 |
|
||||
|--------------------------|----------|--------------------|--------------------------------------------------------------------------------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | 是 | | 管理面板密码(启用远程管理时必需)。 |
|
||||
| `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)。
|
||||
@@ -495,21 +540,6 @@ openai-compatibility:
|
||||
|
||||
`auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。
|
||||
|
||||
### 请求鉴权提供方
|
||||
|
||||
通过 `auth.providers` 配置接入请求鉴权。内置的 `config-api-key` 提供方支持内联密钥:
|
||||
|
||||
```
|
||||
auth:
|
||||
providers:
|
||||
- name: default
|
||||
type: config-api-key
|
||||
api-keys:
|
||||
- your-api-key-1
|
||||
```
|
||||
|
||||
调用时可在 `Authorization` 标头中携带密钥(或继续使用 `X-Goog-Api-Key`、`X-Api-Key`、查询参数 `key`)。为了兼容旧版本,顶层的 `api-keys` 字段仍然可用,并会自动同步到默认的 `config-api-key` 提供方。
|
||||
|
||||
### 官方生成式语言 API
|
||||
|
||||
`generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
14
go.mod
@@ -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
27
go.sum
@@ -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=
|
||||
|
||||
@@ -1150,126 +1150,50 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start callback server"})
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer stopCallbackForwarder(iflowauth.CallbackPort)
|
||||
fmt.Println("Waiting for authentication...")
|
||||
|
||||
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state))
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
var resultMap map[string]string
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Println("Authentication failed: timeout waiting for callback")
|
||||
return
|
||||
}
|
||||
if data, errR := os.ReadFile(waitFile); errR == nil {
|
||||
_ = os.Remove(waitFile)
|
||||
_ = json.Unmarshal(data, &resultMap)
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Printf("Authentication failed: %s\n", errStr)
|
||||
return
|
||||
}
|
||||
if resultState := strings.TrimSpace(resultMap["state"]); resultState != state {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Println("Authentication failed: state mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(resultMap["code"])
|
||||
if code == "" {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Println("Authentication failed: code missing")
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)
|
||||
if errExchange != nil {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Printf("Authentication failed: %v\n", errExchange)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStorage := authSvc.CreateTokenStorage(tokenData)
|
||||
identifier := strings.TrimSpace(tokenStorage.Email)
|
||||
if identifier == "" {
|
||||
identifier = fmt.Sprintf("iflow-%d", time.Now().UnixMilli())
|
||||
tokenStorage.Email = identifier
|
||||
}
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("iflow-%s.json", identifier),
|
||||
Provider: "iflow",
|
||||
FileName: fmt.Sprintf("iflow-%s.json", identifier),
|
||||
Storage: tokenStorage,
|
||||
Metadata: map[string]any{"email": identifier, "api_key": tokenStorage.APIKey},
|
||||
Attributes: map[string]string{"api_key": tokenStorage.APIKey},
|
||||
}
|
||||
|
||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||
if errSave != nil {
|
||||
oauthStatus[state] = "Failed to save authentication tokens"
|
||||
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||
if tokenStorage.APIKey != "" {
|
||||
fmt.Println("API key obtained and saved")
|
||||
}
|
||||
fmt.Println("You can now use iFlow services through this CLI")
|
||||
delete(oauthStatus, state)
|
||||
}()
|
||||
|
||||
oauthStatus[state] = ""
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||
return
|
||||
}
|
||||
|
||||
oauthServer := iflowauth.NewOAuthServer(iflowauth.CallbackPort)
|
||||
if err := oauthServer.Start(); err != nil {
|
||||
oauthStatus[state] = "Failed to start authentication server"
|
||||
log.Errorf("Failed to start iFlow OAuth server: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start local oauth server"})
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if isWebUI {
|
||||
defer stopCallbackForwarder(iflowauth.CallbackPort)
|
||||
}
|
||||
fmt.Println("Waiting for authentication...")
|
||||
defer func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := oauthServer.Stop(stopCtx); err != nil {
|
||||
log.Warnf("Failed to stop iFlow OAuth server: %v", err)
|
||||
|
||||
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state))
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
var resultMap map[string]string
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Println("Authentication failed: timeout waiting for callback")
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if err != nil {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Printf("Authentication failed: %v\n", err)
|
||||
return
|
||||
if data, errR := os.ReadFile(waitFile); errR == nil {
|
||||
_ = os.Remove(waitFile)
|
||||
_ = json.Unmarshal(data, &resultMap)
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Printf("Authentication failed: %s\n", result.Error)
|
||||
fmt.Printf("Authentication failed: %s\n", errStr)
|
||||
return
|
||||
}
|
||||
|
||||
if result.State != state {
|
||||
if resultState := strings.TrimSpace(resultMap["state"]); resultState != state {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Println("Authentication failed: state mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, result.Code, redirectURI)
|
||||
code := strings.TrimSpace(resultMap["code"])
|
||||
if code == "" {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Println("Authentication failed: code missing")
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)
|
||||
if errExchange != nil {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
fmt.Printf("Authentication failed: %v\n", errExchange)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -37,6 +38,7 @@ type Handler struct {
|
||||
localPassword string
|
||||
allowRemoteOverride bool
|
||||
envSecret string
|
||||
logDir string
|
||||
}
|
||||
|
||||
// NewHandler creates a new management handler instance.
|
||||
@@ -68,6 +70,19 @@ func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageSt
|
||||
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
||||
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
||||
|
||||
// SetLogDirectory updates the directory where main.log should be looked up.
|
||||
func (h *Handler) SetLogDirectory(dir string) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
if !filepath.IsAbs(dir) {
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
dir = abs
|
||||
}
|
||||
}
|
||||
h.logDir = dir
|
||||
}
|
||||
|
||||
// Middleware enforces access control for management endpoints.
|
||||
// All requests (local and remote) require a valid management key.
|
||||
// Additionally, remote access requires allow-remote-management=true.
|
||||
|
||||
344
internal/api/handlers/management/logs.go
Normal file
344
internal/api/handlers/management/logs.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogFileName = "main.log"
|
||||
logScannerInitialBuffer = 64 * 1024
|
||||
logScannerMaxBuffer = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
// GetLogs returns log lines with optional incremental loading.
|
||||
func (h *Handler) GetLogs(c *gin.Context) {
|
||||
if h == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
||||
return
|
||||
}
|
||||
if h.cfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
||||
return
|
||||
}
|
||||
if !h.cfg.LoggingToFile {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
logDir := h.logDirectory()
|
||||
if strings.TrimSpace(logDir) == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.collectLogFiles(logDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cutoff := parseCutoff(c.Query("after"))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lines": []string{},
|
||||
"line-count": 0,
|
||||
"latest-timestamp": cutoff,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := parseCutoff(c.Query("after"))
|
||||
acc := newLogAccumulator(cutoff)
|
||||
for i := range files {
|
||||
if errProcess := acc.consumeFile(files[i]); errProcess != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lines, total, latest := acc.result()
|
||||
if latest == 0 || latest < cutoff {
|
||||
latest = cutoff
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lines": lines,
|
||||
"line-count": total,
|
||||
"latest-timestamp": latest,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteLogs removes all rotated log files and truncates the active log.
|
||||
func (h *Handler) DeleteLogs(c *gin.Context) {
|
||||
if h == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
||||
return
|
||||
}
|
||||
if h.cfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
||||
return
|
||||
}
|
||||
if !h.cfg.LoggingToFile {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
dir := h.logDirectory()
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
removed := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
fullPath := filepath.Join(dir, name)
|
||||
if name == defaultLogFileName {
|
||||
if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)})
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isRotatedLogFile(name) {
|
||||
if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)})
|
||||
return
|
||||
}
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Logs cleared successfully",
|
||||
"removed": removed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) logDirectory() string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
if h.logDir != "" {
|
||||
return h.logDir
|
||||
}
|
||||
if h.configFilePath != "" {
|
||||
dir := filepath.Dir(h.configFilePath)
|
||||
if dir != "" && dir != "." {
|
||||
return filepath.Join(dir, "logs")
|
||||
}
|
||||
}
|
||||
return "logs"
|
||||
}
|
||||
|
||||
func (h *Handler) collectLogFiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
order int64
|
||||
}
|
||||
cands := make([]candidate, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if name == defaultLogFileName {
|
||||
cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})
|
||||
continue
|
||||
}
|
||||
if order, ok := rotationOrder(name); ok {
|
||||
cands = append(cands, candidate{path: filepath.Join(dir, name), order: order})
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })
|
||||
paths := make([]string, 0, len(cands))
|
||||
for i := len(cands) - 1; i >= 0; i-- {
|
||||
paths = append(paths, cands[i].path)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
type logAccumulator struct {
|
||||
cutoff int64
|
||||
lines []string
|
||||
total int
|
||||
latest int64
|
||||
include bool
|
||||
}
|
||||
|
||||
func newLogAccumulator(cutoff int64) *logAccumulator {
|
||||
return &logAccumulator{
|
||||
cutoff: cutoff,
|
||||
lines: make([]string, 0, 256),
|
||||
}
|
||||
}
|
||||
|
||||
func (acc *logAccumulator) consumeFile(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
buf := make([]byte, 0, logScannerInitialBuffer)
|
||||
scanner.Buffer(buf, logScannerMaxBuffer)
|
||||
for scanner.Scan() {
|
||||
acc.addLine(scanner.Text())
|
||||
}
|
||||
if errScan := scanner.Err(); errScan != nil {
|
||||
return errScan
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (acc *logAccumulator) addLine(raw string) {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
acc.total++
|
||||
ts := parseTimestamp(line)
|
||||
if ts > acc.latest {
|
||||
acc.latest = ts
|
||||
}
|
||||
if ts > 0 {
|
||||
acc.include = acc.cutoff == 0 || ts > acc.cutoff
|
||||
if acc.cutoff == 0 || acc.include {
|
||||
acc.lines = append(acc.lines, line)
|
||||
}
|
||||
return
|
||||
}
|
||||
if acc.cutoff == 0 || acc.include {
|
||||
acc.lines = append(acc.lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
func (acc *logAccumulator) result() ([]string, int, int64) {
|
||||
if acc.lines == nil {
|
||||
acc.lines = []string{}
|
||||
}
|
||||
return acc.lines, acc.total, acc.latest
|
||||
}
|
||||
|
||||
func parseCutoff(raw string) int64 {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
ts, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || ts <= 0 {
|
||||
return 0
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func parseTimestamp(line string) int64 {
|
||||
if strings.HasPrefix(line, "[") {
|
||||
line = line[1:]
|
||||
}
|
||||
if len(line) < 19 {
|
||||
return 0
|
||||
}
|
||||
candidate := line[:19]
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func isRotatedLogFile(name string) bool {
|
||||
if _, ok := rotationOrder(name); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rotationOrder(name string) (int64, bool) {
|
||||
if order, ok := numericRotationOrder(name); ok {
|
||||
return order, true
|
||||
}
|
||||
if order, ok := timestampRotationOrder(name); ok {
|
||||
return order, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func numericRotationOrder(name string) (int64, bool) {
|
||||
if !strings.HasPrefix(name, defaultLogFileName+".") {
|
||||
return 0, false
|
||||
}
|
||||
suffix := strings.TrimPrefix(name, defaultLogFileName+".")
|
||||
if suffix == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(suffix)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return int64(n), true
|
||||
}
|
||||
|
||||
func timestampRotationOrder(name string) (int64, bool) {
|
||||
ext := filepath.Ext(defaultLogFileName)
|
||||
base := strings.TrimSuffix(defaultLogFileName, ext)
|
||||
if base == "" {
|
||||
return 0, false
|
||||
}
|
||||
prefix := base + "-"
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
clean := strings.TrimPrefix(name, prefix)
|
||||
if strings.HasSuffix(clean, ".gz") {
|
||||
clean = strings.TrimSuffix(clean, ".gz")
|
||||
}
|
||||
if ext != "" {
|
||||
if !strings.HasSuffix(clean, ext) {
|
||||
return 0, false
|
||||
}
|
||||
clean = strings.TrimSuffix(clean, ext)
|
||||
}
|
||||
if clean == "" {
|
||||
return 0, false
|
||||
}
|
||||
if idx := strings.IndexByte(clean, '.'); idx != -1 {
|
||||
clean = clean[:idx]
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return math.MaxInt64 - parsed.Unix(), true
|
||||
}
|
||||
@@ -19,7 +19,12 @@ import (
|
||||
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/v0/management") || path == "/keep-alive" {
|
||||
shouldLog := false
|
||||
if strings.HasPrefix(path, "/v1") {
|
||||
shouldLog = true
|
||||
}
|
||||
|
||||
if !shouldLog {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const oauthCallbackSuccessHTML = `<html><head><meta charset="utf-8"><title>Authentication successful</title><script>setTimeout(function(){window.close();},5000);</script></head><body><h1>Authentication successful!</h1><p>You can close this window.</p><p>This window will close automatically in 5 seconds.</p></body></html>`
|
||||
@@ -116,6 +117,10 @@ type Server struct {
|
||||
// cfg holds the current server configuration.
|
||||
cfg *config.Config
|
||||
|
||||
// oldConfigYaml stores a YAML snapshot of the previous configuration for change detection.
|
||||
// This prevents issues when the config object is modified in place by Management API.
|
||||
oldConfigYaml []byte
|
||||
|
||||
// accessManager handles request authentication providers.
|
||||
accessManager *sdkaccess.Manager
|
||||
|
||||
@@ -220,12 +225,15 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
currentPath: wd,
|
||||
envManagementSecret: envManagementSecret,
|
||||
}
|
||||
// Save initial YAML snapshot
|
||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
s.applyAccessConfig(nil, cfg)
|
||||
// Initialize management handler
|
||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||
if optionState.localPassword != "" {
|
||||
s.mgmt.SetLocalPassword(optionState.localPassword)
|
||||
}
|
||||
s.mgmt.SetLogDirectory(filepath.Join(s.currentPath, "logs"))
|
||||
s.localPassword = optionState.localPassword
|
||||
|
||||
// Setup routes
|
||||
@@ -404,6 +412,8 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
|
||||
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
|
||||
|
||||
mgmt.GET("/logs", s.mgmt.GetLogs)
|
||||
mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
|
||||
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
|
||||
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
|
||||
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
|
||||
@@ -457,7 +467,6 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
println(s.currentPath)
|
||||
filePath := managementasset.FilePath(s.currentPath)
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
@@ -655,7 +664,11 @@ func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
|
||||
// - clients: The new slice of AI service clients
|
||||
// - cfg: The new application configuration
|
||||
func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
oldCfg := s.cfg
|
||||
// Reconstruct old config from YAML snapshot to avoid reference sharing issues
|
||||
var oldCfg *config.Config
|
||||
if len(s.oldConfigYaml) > 0 {
|
||||
_ = yaml.Unmarshal(s.oldConfigYaml, &oldCfg)
|
||||
}
|
||||
|
||||
// Update request logger enabled state if it has changed
|
||||
previousRequestLog := false
|
||||
@@ -736,6 +749,8 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
s.cfg = cfg
|
||||
// Save YAML snapshot for next comparison
|
||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
||||
|
||||
if !cfg.RemoteManagement.DisableControlPanel {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ type releaseResponse struct {
|
||||
|
||||
// StaticDir resolves the directory that stores the management control panel asset.
|
||||
func StaticDir(configFilePath string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("MANAGEMENT_STATIC_PATH")); override != "" {
|
||||
cleaned := filepath.Clean(override)
|
||||
if strings.EqualFold(filepath.Base(cleaned), managementAssetName) {
|
||||
return filepath.Dir(cleaned)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
configFilePath = strings.TrimSpace(configFilePath)
|
||||
if configFilePath == "" {
|
||||
return ""
|
||||
@@ -74,6 +82,14 @@ func StaticDir(configFilePath string) string {
|
||||
|
||||
// FilePath resolves the absolute path to the management control panel asset.
|
||||
func FilePath(configFilePath string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("MANAGEMENT_STATIC_PATH")); override != "" {
|
||||
cleaned := filepath.Clean(override)
|
||||
if strings.EqualFold(filepath.Base(cleaned), managementAssetName) {
|
||||
return cleaned
|
||||
}
|
||||
return filepath.Join(cleaned, ManagementFileName)
|
||||
}
|
||||
|
||||
dir := StaticDir(configFilePath)
|
||||
if dir == "" {
|
||||
return ""
|
||||
|
||||
@@ -8,6 +8,15 @@ import "time"
|
||||
// GetClaudeModels returns the standard Claude model definitions
|
||||
func GetClaudeModels() []*ModelInfo {
|
||||
return []*ModelInfo{
|
||||
|
||||
{
|
||||
ID: "claude-haiku-4-5-20251001",
|
||||
Object: "model",
|
||||
Created: 1759276800, // 2025-10-01
|
||||
OwnedBy: "anthropic",
|
||||
Type: "claude",
|
||||
DisplayName: "Claude 4.5 Haiku",
|
||||
},
|
||||
{
|
||||
ID: "claude-sonnet-4-5-20250929",
|
||||
Object: "model",
|
||||
|
||||
@@ -143,6 +143,31 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// If from == to (Claude → Claude), directly forward the SSE stream without translation
|
||||
if from == to {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
appendAPIResponseChunk(ctx, e.cfg, line)
|
||||
if detail, ok := parseClaudeStreamUsage(line); ok {
|
||||
reporter.publish(ctx, detail)
|
||||
}
|
||||
// Forward the line as-is to preserve SSE format
|
||||
cloned := make([]byte, len(line)+1)
|
||||
copy(cloned, line)
|
||||
cloned[len(line)] = '\n'
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: cloned}
|
||||
}
|
||||
if err = scanner.Err(); err != nil {
|
||||
out <- cliproxyexecutor.StreamChunk{Err: err}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For other formats, use translation
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
|
||||
@@ -60,7 +60,11 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini-cli")
|
||||
budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata)
|
||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||
if hasOverride {
|
||||
basePayload = util.ApplyGeminiCLIThinkingConfig(basePayload, budgetOverride, includeOverride)
|
||||
}
|
||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||
|
||||
action := "generateContent"
|
||||
@@ -149,7 +153,11 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini-cli")
|
||||
budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata)
|
||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||
if hasOverride {
|
||||
basePayload = util.ApplyGeminiCLIThinkingConfig(basePayload, budgetOverride, includeOverride)
|
||||
}
|
||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||
|
||||
projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id"))
|
||||
@@ -292,8 +300,12 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
||||
var lastStatus int
|
||||
var lastBody []byte
|
||||
|
||||
budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata)
|
||||
for _, attemptModel := range models {
|
||||
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
|
||||
if hasOverride {
|
||||
payload = util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
|
||||
}
|
||||
payload = deleteJSONField(payload, "project")
|
||||
payload = deleteJSONField(payload, "model")
|
||||
payload = disableGeminiThinkingConfig(payload, attemptModel)
|
||||
|
||||
@@ -77,6 +77,9 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini")
|
||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok {
|
||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
||||
}
|
||||
body = disableGeminiThinkingConfig(body, req.Model)
|
||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
||||
|
||||
@@ -136,6 +139,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini")
|
||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok {
|
||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
||||
}
|
||||
body = disableGeminiThinkingConfig(body, req.Model)
|
||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
||||
|
||||
@@ -208,6 +214,9 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini")
|
||||
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok {
|
||||
translatedReq = util.ApplyGeminiThinkingConfig(translatedReq, budgetOverride, includeOverride)
|
||||
}
|
||||
translatedReq = disableGeminiThinkingConfig(translatedReq, req.Model)
|
||||
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
|
||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
618
internal/store/objectstore.go
Normal file
618
internal/store/objectstore.go
Normal 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
|
||||
}
|
||||
665
internal/store/postgresstore.go
Normal file
665
internal/store/postgresstore.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
||||
|
||||
// Process the main content part of the response.
|
||||
partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
|
||||
hasFunctionCall := false
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
@@ -118,6 +119,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
hasFunctionCall = true
|
||||
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
|
||||
functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++
|
||||
@@ -169,6 +171,11 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
||||
}
|
||||
}
|
||||
|
||||
if hasFunctionCall {
|
||||
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
||||
}
|
||||
|
||||
return []string{template}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
||||
|
||||
// Process the main content part of the response.
|
||||
partsResult := gjson.GetBytes(rawJSON, "candidates.0.content.parts")
|
||||
hasFunctionCall := false
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
@@ -121,6 +122,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
hasFunctionCall = true
|
||||
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
|
||||
functionCallIndex := (*param).(*convertGeminiResponseToOpenAIChatParams).FunctionIndex
|
||||
(*param).(*convertGeminiResponseToOpenAIChatParams).FunctionIndex++
|
||||
@@ -172,6 +174,11 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
||||
}
|
||||
}
|
||||
|
||||
if hasFunctionCall {
|
||||
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
||||
}
|
||||
|
||||
return []string{template}
|
||||
}
|
||||
|
||||
@@ -231,6 +238,7 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
|
||||
|
||||
// Process the main content part of the response.
|
||||
partsResult := gjson.GetBytes(rawJSON, "candidates.0.content.parts")
|
||||
hasFunctionCall := false
|
||||
if partsResult.IsArray() {
|
||||
partsResults := partsResult.Array()
|
||||
for i := 0; i < len(partsResults); i++ {
|
||||
@@ -252,6 +260,7 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
|
||||
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
||||
} else if functionCallResult.Exists() {
|
||||
// Append function call content to the tool_calls array.
|
||||
hasFunctionCall = true
|
||||
toolCallsResult := gjson.Get(template, "choices.0.message.tool_calls")
|
||||
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
|
||||
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
|
||||
@@ -297,5 +306,10 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
|
||||
}
|
||||
}
|
||||
|
||||
if hasFunctionCall {
|
||||
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ type ConvertOpenAIResponseToAnthropicParams struct {
|
||||
ContentBlocksStopped bool
|
||||
// Track if message_delta has been sent
|
||||
MessageDeltaSent bool
|
||||
// Track if message_start has been sent
|
||||
MessageStarted bool
|
||||
}
|
||||
|
||||
// ToolCallAccumulator holds the state for accumulating tool call data
|
||||
@@ -84,20 +86,12 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR
|
||||
return convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams))
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Check if this is a streaming chunk or non-streaming response
|
||||
objectType := root.Get("object").String()
|
||||
|
||||
if objectType == "chat.completion.chunk" {
|
||||
// Handle streaming response
|
||||
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
|
||||
} else if objectType == "chat.completion" {
|
||||
// Handle non-streaming response
|
||||
streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
|
||||
if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {
|
||||
return convertOpenAINonStreamingToAnthropic(rawJSON)
|
||||
} else {
|
||||
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// convertOpenAIStreamingChunkToAnthropic converts OpenAI streaming chunk to Anthropic streaming events
|
||||
@@ -118,7 +112,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
|
||||
// Check if this is the first chunk (has role)
|
||||
if delta := root.Get("choices.0.delta"); delta.Exists() {
|
||||
if role := delta.Get("role"); role.Exists() && role.String() == "assistant" {
|
||||
if role := delta.Get("role"); role.Exists() && role.String() == "assistant" && !param.MessageStarted {
|
||||
// Send message_start event
|
||||
messageStart := map[string]interface{}{
|
||||
"type": "message_start",
|
||||
@@ -138,6 +132,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
}
|
||||
messageStartJSON, _ := json.Marshal(messageStart)
|
||||
results = append(results, "event: message_start\ndata: "+string(messageStartJSON)+"\n\n")
|
||||
param.MessageStarted = true
|
||||
|
||||
// Don't send content_block_start for text here - wait for actual content
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,8 +97,8 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
|
||||
var results []string
|
||||
|
||||
choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
|
||||
// Base Gemini response template
|
||||
template := `{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"STOP","index":0}]}`
|
||||
// Base Gemini response template without finishReason; set when known
|
||||
template := `{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`
|
||||
|
||||
// Set model if available
|
||||
if model := root.Get("model"); model.Exists() {
|
||||
@@ -514,8 +514,8 @@ func tryParseNumber(s string) (interface{}, bool) {
|
||||
func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Base Gemini response template
|
||||
out := `{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"STOP","index":0}]}`
|
||||
// Base Gemini response template without finishReason; set when known
|
||||
out := `{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`
|
||||
|
||||
// Set model if available
|
||||
if model := root.Get("model"); model.Exists() {
|
||||
|
||||
@@ -67,9 +67,20 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
}
|
||||
|
||||
rawJSON = bytes.TrimSpace(rawJSON)
|
||||
if len(rawJSON) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
obj := root.Get("object").String()
|
||||
if obj != "chat.completion.chunk" {
|
||||
obj := root.Get("object")
|
||||
if obj.Exists() && obj.String() != "" && obj.String() != "chat.completion.chunk" {
|
||||
return []string{}
|
||||
}
|
||||
if !root.Get("choices").Exists() || !root.Get("choices").IsArray() {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]++
|
||||
|
||||
181
internal/util/gemini_thinking.go
Normal file
181
internal/util/gemini_thinking.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
GeminiThinkingBudgetMetadataKey = "gemini_thinking_budget"
|
||||
GeminiIncludeThoughtsMetadataKey = "gemini_include_thoughts"
|
||||
GeminiOriginalModelMetadataKey = "gemini_original_model"
|
||||
)
|
||||
|
||||
func ParseGeminiThinkingSuffix(model string) (string, *int, *bool, bool) {
|
||||
if model == "" {
|
||||
return model, nil, nil, false
|
||||
}
|
||||
lower := strings.ToLower(model)
|
||||
if !strings.HasPrefix(lower, "gemini-") {
|
||||
return model, nil, nil, false
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, "-nothinking") {
|
||||
base := model[:len(model)-len("-nothinking")]
|
||||
budgetValue := 0
|
||||
if strings.HasPrefix(lower, "gemini-2.5-pro") {
|
||||
budgetValue = 128
|
||||
}
|
||||
include := false
|
||||
return base, &budgetValue, &include, true
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(lower, "-thinking-")
|
||||
if idx == -1 {
|
||||
return model, nil, nil, false
|
||||
}
|
||||
|
||||
digits := model[idx+len("-thinking-"):]
|
||||
if digits == "" {
|
||||
return model, nil, nil, false
|
||||
}
|
||||
end := len(digits)
|
||||
for i := 0; i < len(digits); i++ {
|
||||
if digits[i] < '0' || digits[i] > '9' {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if end == 0 {
|
||||
return model, nil, nil, false
|
||||
}
|
||||
valueStr := digits[:end]
|
||||
value, err := strconv.Atoi(valueStr)
|
||||
if err != nil {
|
||||
return model, nil, nil, false
|
||||
}
|
||||
base := model[:idx]
|
||||
budgetValue := value
|
||||
return base, &budgetValue, nil, true
|
||||
}
|
||||
|
||||
func ApplyGeminiThinkingConfig(body []byte, budget *int, includeThoughts *bool) []byte {
|
||||
if budget == nil && includeThoughts == nil {
|
||||
return body
|
||||
}
|
||||
updated := body
|
||||
if budget != nil {
|
||||
valuePath := "generationConfig.thinkingConfig.thinkingBudget"
|
||||
rewritten, err := sjson.SetBytes(updated, valuePath, *budget)
|
||||
if err == nil {
|
||||
updated = rewritten
|
||||
}
|
||||
}
|
||||
if includeThoughts != nil {
|
||||
valuePath := "generationConfig.thinkingConfig.include_thoughts"
|
||||
rewritten, err := sjson.SetBytes(updated, valuePath, *includeThoughts)
|
||||
if err == nil {
|
||||
updated = rewritten
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func ApplyGeminiCLIThinkingConfig(body []byte, budget *int, includeThoughts *bool) []byte {
|
||||
if budget == nil && includeThoughts == nil {
|
||||
return body
|
||||
}
|
||||
updated := body
|
||||
if budget != nil {
|
||||
valuePath := "request.generationConfig.thinkingConfig.thinkingBudget"
|
||||
rewritten, err := sjson.SetBytes(updated, valuePath, *budget)
|
||||
if err == nil {
|
||||
updated = rewritten
|
||||
}
|
||||
}
|
||||
if includeThoughts != nil {
|
||||
valuePath := "request.generationConfig.thinkingConfig.include_thoughts"
|
||||
rewritten, err := sjson.SetBytes(updated, valuePath, *includeThoughts)
|
||||
if err == nil {
|
||||
updated = rewritten
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func GeminiThinkingFromMetadata(metadata map[string]any) (*int, *bool, bool) {
|
||||
if len(metadata) == 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
var (
|
||||
budgetPtr *int
|
||||
includePtr *bool
|
||||
matched bool
|
||||
)
|
||||
if rawBudget, ok := metadata[GeminiThinkingBudgetMetadataKey]; ok {
|
||||
switch v := rawBudget.(type) {
|
||||
case int:
|
||||
budget := v
|
||||
budgetPtr = &budget
|
||||
matched = true
|
||||
case int32:
|
||||
budget := int(v)
|
||||
budgetPtr = &budget
|
||||
matched = true
|
||||
case int64:
|
||||
budget := int(v)
|
||||
budgetPtr = &budget
|
||||
matched = true
|
||||
case float64:
|
||||
budget := int(v)
|
||||
budgetPtr = &budget
|
||||
matched = true
|
||||
case json.Number:
|
||||
if val, err := v.Int64(); err == nil {
|
||||
budget := int(val)
|
||||
budgetPtr = &budget
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawInclude, ok := metadata[GeminiIncludeThoughtsMetadataKey]; ok {
|
||||
switch v := rawInclude.(type) {
|
||||
case bool:
|
||||
include := v
|
||||
includePtr = &include
|
||||
matched = true
|
||||
case string:
|
||||
if parsed, err := strconv.ParseBool(v); err == nil {
|
||||
include := parsed
|
||||
includePtr = &include
|
||||
matched = true
|
||||
}
|
||||
case json.Number:
|
||||
if val, err := v.Int64(); err == nil {
|
||||
include := val != 0
|
||||
includePtr = &include
|
||||
matched = true
|
||||
}
|
||||
case int:
|
||||
include := v != 0
|
||||
includePtr = &include
|
||||
matched = true
|
||||
case int32:
|
||||
include := v != 0
|
||||
includePtr = &include
|
||||
matched = true
|
||||
case int64:
|
||||
include := v != 0
|
||||
includePtr = &include
|
||||
matched = true
|
||||
case float64:
|
||||
include := v != 0
|
||||
includePtr = &include
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
return budgetPtr, includePtr, matched
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -197,33 +198,65 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
|
||||
}
|
||||
|
||||
func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
|
||||
// v6.1: Intelligent Buffered Streamer strategy
|
||||
// Enhanced buffering with larger buffer size (16KB) and longer flush interval (120ms).
|
||||
// Smart flush only when buffer is sufficiently filled (≥50%), dramatically reducing
|
||||
// flush frequency from ~12.5Hz to ~5-8Hz while maintaining low latency.
|
||||
writer := bufio.NewWriterSize(c.Writer, 16*1024) // 4KB → 16KB
|
||||
ticker := time.NewTicker(120 * time.Millisecond) // 80ms → 120ms
|
||||
defer ticker.Stop()
|
||||
|
||||
var chunkIdx int
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
// Context cancelled, flush any remaining data before exit
|
||||
_ = writer.Flush()
|
||||
cancel(c.Request.Context().Err())
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Smart flush: only flush when buffer has sufficient data (≥50% full)
|
||||
// This reduces flush frequency while ensuring data flows naturally
|
||||
buffered := writer.Buffered()
|
||||
if buffered >= 8*1024 { // At least 8KB (50% of 16KB buffer)
|
||||
if err := writer.Flush(); err != nil {
|
||||
// Error flushing, cancel and return
|
||||
cancel(err)
|
||||
return
|
||||
}
|
||||
flusher.Flush() // Also flush the underlying http.ResponseWriter
|
||||
}
|
||||
|
||||
case chunk, ok := <-data:
|
||||
if !ok {
|
||||
flusher.Flush()
|
||||
// Stream ended, flush remaining data
|
||||
_ = writer.Flush()
|
||||
cancel(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(chunk, []byte("event:")) {
|
||||
_, _ = c.Writer.Write([]byte("\n"))
|
||||
// Forward the complete SSE event block directly (already formatted by the translator).
|
||||
// The translator returns a complete SSE-compliant event block, including event:, data:, and separators.
|
||||
// The handler just needs to forward it without reassembly.
|
||||
if len(chunk) > 0 {
|
||||
_, _ = writer.Write(chunk)
|
||||
}
|
||||
chunkIdx++
|
||||
|
||||
_, _ = c.Writer.Write(chunk)
|
||||
_, _ = c.Writer.Write([]byte("\n"))
|
||||
|
||||
flusher.Flush()
|
||||
case errMsg, ok := <-errs:
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if errMsg != nil {
|
||||
h.WriteErrorResponse(c, errMsg)
|
||||
flusher.Flush()
|
||||
// An error occurred: emit as a proper SSE error event
|
||||
errorBytes, _ := json.Marshal(h.toClaudeError(errMsg))
|
||||
_, _ = writer.WriteString("event: error\n")
|
||||
_, _ = writer.WriteString("data: ")
|
||||
_, _ = writer.Write(errorBytes)
|
||||
_, _ = writer.WriteString("\n\n")
|
||||
_ = writer.Flush()
|
||||
}
|
||||
var execErr error
|
||||
if errMsg != nil {
|
||||
@@ -231,7 +264,26 @@ func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.
|
||||
}
|
||||
cancel(execErr)
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type claudeErrorDetail struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type claudeErrorResponse struct {
|
||||
Type string `json:"type"`
|
||||
Error claudeErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
func (h *ClaudeCodeAPIHandler) toClaudeError(msg *interfaces.ErrorMessage) claudeErrorResponse {
|
||||
return claudeErrorResponse{
|
||||
Type: "error",
|
||||
Error: claudeErrorDetail{
|
||||
Type: "api_error",
|
||||
Message: msg.Error.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,20 +133,27 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
|
||||
// ExecuteWithAuthManager executes a non-streaming request via the core auth manager.
|
||||
// This path is the only supported execution route.
|
||||
func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||
providers := util.GetProviderName(modelName)
|
||||
normalizedModel, metadata := normalizeModelMetadata(modelName)
|
||||
providers := util.GetProviderName(normalizedModel)
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Model: normalizedModel,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
}
|
||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
||||
req.Metadata = cloned
|
||||
}
|
||||
opts := coreexecutor.Options{
|
||||
Stream: false,
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
}
|
||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
||||
opts.Metadata = cloned
|
||||
}
|
||||
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err}
|
||||
@@ -157,20 +164,27 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.
|
||||
// This path is the only supported execution route.
|
||||
func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||
providers := util.GetProviderName(modelName)
|
||||
normalizedModel, metadata := normalizeModelMetadata(modelName)
|
||||
providers := util.GetProviderName(normalizedModel)
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Model: normalizedModel,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
}
|
||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
||||
req.Metadata = cloned
|
||||
}
|
||||
opts := coreexecutor.Options{
|
||||
Stream: false,
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
}
|
||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
||||
opts.Metadata = cloned
|
||||
}
|
||||
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err}
|
||||
@@ -181,7 +195,8 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.
|
||||
// This path is the only supported execution route.
|
||||
func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
||||
providers := util.GetProviderName(modelName)
|
||||
normalizedModel, metadata := normalizeModelMetadata(modelName)
|
||||
providers := util.GetProviderName(normalizedModel)
|
||||
if len(providers) == 0 {
|
||||
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||
errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
@@ -189,15 +204,21 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
return nil, errChan
|
||||
}
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Model: normalizedModel,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
}
|
||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
||||
req.Metadata = cloned
|
||||
}
|
||||
opts := coreexecutor.Options{
|
||||
Stream: true,
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
}
|
||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
||||
opts.Metadata = cloned
|
||||
}
|
||||
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||
@@ -232,6 +253,34 @@ func cloneBytes(src []byte) []byte {
|
||||
return dst
|
||||
}
|
||||
|
||||
func normalizeModelMetadata(modelName string) (string, map[string]any) {
|
||||
baseModel, budget, include, matched := util.ParseGeminiThinkingSuffix(modelName)
|
||||
if !matched {
|
||||
return baseModel, nil
|
||||
}
|
||||
metadata := map[string]any{
|
||||
util.GeminiOriginalModelMetadataKey: modelName,
|
||||
}
|
||||
if budget != nil {
|
||||
metadata[util.GeminiThinkingBudgetMetadataKey] = *budget
|
||||
}
|
||||
if include != nil {
|
||||
metadata[util.GeminiIncludeThoughtsMetadataKey] = *include
|
||||
}
|
||||
return baseModel, metadata
|
||||
}
|
||||
|
||||
func cloneMetadata(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]any, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
||||
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
@@ -14,6 +14,7 @@ type Record struct {
|
||||
Model string
|
||||
APIKey string
|
||||
AuthID string
|
||||
Source string
|
||||
RequestedAt time.Time
|
||||
Detail Detail
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user