From 89e93a1674743762526a8c934f03fd5a00ecd9d5 Mon Sep 17 00:00:00 2001 From: CJACK Date: Wed, 18 Feb 2026 00:38:38 +0800 Subject: [PATCH] feat: Improve configuration loading robustness, add Vercel-specific fallbacks, and update documentation for `config.json` best practices. --- .env.example | 3 ++ API.en.md | 23 ++++++++ API.md | 23 ++++++++ DEPLOY.en.md | 59 ++++++++++++++++++-- DEPLOY.md | 59 ++++++++++++++++++-- README.MD | 42 +++++++++++++-- README.en.md | 42 +++++++++++++-- internal/config/config.go | 99 ++++++++++++++++++++++++++++++---- internal/config/config_test.go | 51 ++++++++++++++++++ 9 files changed, 370 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index 21a4d2a..d63f133 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,9 @@ DS2API_ADMIN_KEY=admin # Option C: Base64 encoded JSON (recommended for Vercel env var) # DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0= +# +# Generate from local config.json: +# DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" # --------------------------------------------------------------- # Paths (optional) diff --git a/API.en.md b/API.en.md index e570dee..1203e12 100644 --- a/API.en.md +++ b/API.en.md @@ -9,6 +9,7 @@ This document describes the actual behavior of the current Go codebase. ## Table of Contents - [Basics](#basics) +- [Configuration Best Practice](#configuration-best-practice) - [Authentication](#authentication) - [Route Index](#route-index) - [Health Endpoints](#health-endpoints) @@ -31,6 +32,28 @@ This document describes the actual behavior of the current Go codebase. --- +## Configuration Best Practice + +Use `config.json` as the single source of truth: + +```bash +cp config.example.json config.json +# Edit config.json (keys/accounts) +``` + +Use it per deployment mode: + +- Local run: read `config.json` directly +- Docker / Vercel: generate Base64 from `config.json`, then set `DS2API_CONFIG_JSON` + +```bash +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" +``` + +For Vercel one-click bootstrap, you can set only `DS2API_ADMIN_KEY` first, then import config at `/admin` and sync env vars from the "Vercel Sync" page. + +--- + ## Authentication ### Business Endpoints (`/v1/*`, `/anthropic/*`) diff --git a/API.md b/API.md index 6be7f65..f57f0a8 100644 --- a/API.md +++ b/API.md @@ -9,6 +9,7 @@ ## 目录 - [基础信息](#基础信息) +- [配置最佳实践](#配置最佳实践) - [鉴权规则](#鉴权规则) - [路由总览](#路由总览) - [健康检查](#健康检查) @@ -31,6 +32,28 @@ --- +## 配置最佳实践 + +推荐把 `config.json` 作为唯一配置源: + +```bash +cp config.example.json config.json +# 编辑 config.json(keys/accounts) +``` + +按部署方式使用: + +- 本地运行:直接读取 `config.json` +- Docker / Vercel:从 `config.json` 生成 Base64,填入 `DS2API_CONFIG_JSON` + +```bash +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" +``` + +Vercel 一键部署可先只填 `DS2API_ADMIN_KEY`,部署后在 `/admin` 导入配置,再通过 “Vercel 同步” 写回环境变量。 + +--- + ## 鉴权规则 ### 业务接口(`/v1/*`、`/anthropic/*`) diff --git a/DEPLOY.en.md b/DEPLOY.en.md index b7caf8c..8a62c98 100644 --- a/DEPLOY.en.md +++ b/DEPLOY.en.md @@ -33,6 +33,17 @@ Config source (choose one): - **File**: `config.json` (recommended for local/Docker) - **Environment variable**: `DS2API_CONFIG_JSON` (recommended for Vercel; supports raw JSON or Base64) +Unified recommendation (best practice): + +```bash +cp config.example.json config.json +# Edit config.json +``` + +Use `config.json` as the single source of truth: +- Local run: read `config.json` directly +- Docker / Vercel: generate `DS2API_CONFIG_JSON` (Base64) from `config.json` and inject it + --- ## 1. Local Run @@ -99,11 +110,15 @@ go build -o ds2api ./cmd/ds2api ### 2.1 Basic Steps ```bash -# Copy and edit environment +# Copy env template cp .env.example .env -# Edit .env, at minimum set: + +# Generate single-line Base64 from config.json +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" + +# Edit .env and set: # DS2API_ADMIN_KEY=your-admin-key -# DS2API_CONFIG_JSON={"keys":[...],"accounts":[...]} +# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON} # Start docker-compose up -d @@ -167,15 +182,49 @@ If container logs look normal but the admin panel is unreachable, check these fi 1. **Fork** the repo to your GitHub account 2. **Import** the project on Vercel -3. **Set environment variables** (at minimum): +3. **Set environment variables** (minimum required: one variable): | Variable | Description | | --- | --- | | `DS2API_ADMIN_KEY` | Admin key (required) | - | `DS2API_CONFIG_JSON` | Config content, raw JSON or Base64 (required) | + | `DS2API_CONFIG_JSON` | Config content, raw JSON or Base64 (optional, recommended) | 4. **Deploy** +### 3.1.1 Recommended Input (avoid `DS2API_CONFIG_JSON` mistakes) + +If you prefer faster one-click bootstrap, you can leave `DS2API_CONFIG_JSON` empty first, then open `/admin` after deployment, import config, and sync it back to Vercel env vars from the "Vercel Sync" page. + +Recommended: in repo root, copy the template first and fill your real accounts: + +```bash +cp config.example.json config.json +# Edit config.json +``` + +Do not hand-edit large JSON directly in Vercel. Generate Base64 locally and paste it: + +```bash +# Run in repo root +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" +echo "$DS2API_CONFIG_JSON" +``` + +If you choose to preconfigure before first deploy, set these vars in Vercel Project Settings -> Environment Variables: + +```text +DS2API_ADMIN_KEY=replace-with-a-strong-secret +DS2API_CONFIG_JSON= +``` + +Optional but recommended (for WebUI one-click Vercel sync): + +```text +VERCEL_TOKEN=your-vercel-token +VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx +VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts +``` + ### 3.2 Optional Environment Variables | Variable | Description | Default | diff --git a/DEPLOY.md b/DEPLOY.md index b7fbf9a..e5b0630 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -33,6 +33,17 @@ - **文件方式**:`config.json`(推荐本地/Docker 使用) - **环境变量方式**:`DS2API_CONFIG_JSON`(推荐 Vercel 使用,支持 JSON 字符串或 Base64 编码) +统一建议(最优实践): + +```bash +cp config.example.json config.json +# 编辑 config.json +``` + +建议把 `config.json` 作为唯一配置源: +- 本地运行:直接读 `config.json` +- Docker / Vercel:从 `config.json` 生成 `DS2API_CONFIG_JSON`(Base64)注入环境变量 + --- ## 一、本地运行 @@ -99,11 +110,15 @@ go build -o ds2api ./cmd/ds2api ### 2.1 基本步骤 ```bash -# 复制并编辑环境变量 +# 复制环境变量模板 cp .env.example .env -# 编辑 .env,至少设置: + +# 从 config.json 生成单行 Base64 +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" + +# 编辑 .env(请改成你的强密码),设置: # DS2API_ADMIN_KEY=your-admin-key -# DS2API_CONFIG_JSON={"keys":[...],"accounts":[...]} +# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON} # 启动 docker-compose up -d @@ -167,15 +182,49 @@ healthcheck: 1. **Fork 仓库**到你的 GitHub 账号 2. **在 Vercel 上导入项目** -3. **配置环境变量**(至少设置以下两项): +3. **配置环境变量**(最少只需设置以下一项): | 变量 | 说明 | | --- | --- | | `DS2API_ADMIN_KEY` | 管理密钥(必填) | - | `DS2API_CONFIG_JSON` | 配置内容,JSON 字符串或 Base64 编码(必填) | + | `DS2API_CONFIG_JSON` | 配置内容,JSON 字符串或 Base64 编码(可选,建议) | 4. **部署** +### 3.1.1 推荐填写方式(避免 `DS2API_CONFIG_JSON` 填错) + +如果你想先完成一键部署,也可以先不填 `DS2API_CONFIG_JSON`,部署后进入 `/admin` 导入配置,再在「Vercel 同步」里写回环境变量。 + +建议先在仓库目录复制示例配置,再按实际账号填写: + +```bash +cp config.example.json config.json +# 编辑 config.json +``` + +不要在 Vercel 面板里手写复杂 JSON,建议本地生成 Base64 后粘贴: + +```bash +# 在仓库根目录执行 +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" +echo "$DS2API_CONFIG_JSON" +``` + +如果你选择在部署前就预置配置,请在 Vercel Project Settings -> Environment Variables 配置: + +```text +DS2API_ADMIN_KEY=请替换为强密码 +DS2API_CONFIG_JSON=上一步生成的一整行 Base64 +``` + +可选但推荐(用于 WebUI 一键同步 Vercel 配置): + +```text +VERCEL_TOKEN=你的 Vercel Token +VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx +VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空 +``` + ### 3.2 可选环境变量 | 变量 | 说明 | 默认值 | diff --git a/README.MD b/README.MD index b438b75..3517a55 100644 --- a/README.MD +++ b/README.MD @@ -88,6 +88,19 @@ flowchart LR ## 快速开始 +### 通用第一步(所有部署方式) + +把 `config.json` 作为唯一配置源(推荐做法): + +```bash +cp config.example.json config.json +# 编辑 config.json +``` + +后续部署建议: +- 本地运行:直接读取 `config.json` +- Docker / Vercel:由 `config.json` 生成 `DS2API_CONFIG_JSON`(Base64)注入环境变量 + ### 方式一:本地运行 **前置要求**:Go 1.24+,Node.js 20+(仅在需要构建 WebUI 时) @@ -112,14 +125,20 @@ go run ./cmd/ds2api ### 方式二:Docker 运行 ```bash -# 1. 配置环境变量 +# 1. 准备环境变量文件 cp .env.example .env -# 编辑 .env -# 2. 启动 +# 2. 从 config.json 生成 DS2API_CONFIG_JSON(单行 Base64) +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" + +# 3. 编辑 .env,设置: +# DS2API_ADMIN_KEY=请替换为强密码 +# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON} + +# 4. 启动 docker-compose up -d -# 3. 查看日志 +# 5. 查看日志 docker-compose logs -f ``` @@ -129,9 +148,22 @@ docker-compose logs -f 1. Fork 仓库到自己的 GitHub 2. 在 Vercel 上导入项目 -3. 配置环境变量(至少设置 `DS2API_ADMIN_KEY` 和 `DS2API_CONFIG_JSON`) +3. 配置环境变量(最少设置 `DS2API_ADMIN_KEY`;推荐同时设置 `DS2API_CONFIG_JSON`) 4. 部署 +建议先在仓库目录复制模板并填写: + +```bash +cp config.example.json config.json +# 编辑 config.json +``` + +推荐:先本地把 `config.json` 转成 Base64,再粘贴到 `DS2API_CONFIG_JSON`,避免 JSON 格式错误: + +```bash +base64 < config.json | tr -d '\n' +``` + > **流式说明**:`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`(Node Runtime)以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。 详细部署说明请参阅 [部署指南](DEPLOY.md)。 diff --git a/README.en.md b/README.en.md index bbad73b..d1a91a1 100644 --- a/README.en.md +++ b/README.en.md @@ -88,6 +88,19 @@ In addition, `/anthropic/v1/models` now includes historical Claude 1.x/2.x/3.x/4 ## Quick Start +### Universal First Step (all deployment modes) + +Use `config.json` as the single source of truth (recommended): + +```bash +cp config.example.json config.json +# Edit config.json +``` + +Recommended per deployment mode: +- Local run: read `config.json` directly +- Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON` + ### Option 1: Local Run **Prerequisites**: Go 1.24+, Node.js 20+ (only if building WebUI locally) @@ -112,14 +125,20 @@ Default URL: `http://localhost:5001` ### Option 2: Docker ```bash -# 1. Configure environment +# 1. Prepare env file cp .env.example .env -# Edit .env -# 2. Start +# 2. Generate DS2API_CONFIG_JSON from config.json (single-line Base64) +DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" + +# 3. Edit .env and set: +# DS2API_ADMIN_KEY=replace-with-a-strong-secret +# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON} + +# 4. Start docker-compose up -d -# 3. View logs +# 5. View logs docker-compose logs -f ``` @@ -129,9 +148,22 @@ Rebuild after updates: `docker-compose up -d --build` 1. Fork this repo to your GitHub account 2. Import the project on Vercel -3. Set environment variables (minimum: `DS2API_ADMIN_KEY` and `DS2API_CONFIG_JSON`) +3. Set environment variables (minimum: `DS2API_ADMIN_KEY`; recommended to also set `DS2API_CONFIG_JSON`) 4. Deploy +Recommended first step in repo root: + +```bash +cp config.example.json config.json +# Edit config.json +``` + +Recommended: convert `config.json` to Base64 locally, then paste into `DS2API_CONFIG_JSON` to avoid JSON formatting mistakes: + +```bash +base64 < config.json | tr -d '\n' +``` + > **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling. For detailed deployment instructions, see the [Deployment Guide](DEPLOY.en.md). diff --git a/internal/config/config.go b/internal/config/config.go index 691df6d..b4058c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "log/slog" "os" "path/filepath" @@ -101,17 +102,29 @@ func (c *Config) UnmarshalJSON(b []byte) error { for k, v := range raw { switch k { case "keys": - _ = json.Unmarshal(v, &c.Keys) + if err := json.Unmarshal(v, &c.Keys); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "accounts": - _ = json.Unmarshal(v, &c.Accounts) + if err := json.Unmarshal(v, &c.Accounts); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "claude_mapping": - _ = json.Unmarshal(v, &c.ClaudeMapping) + if err := json.Unmarshal(v, &c.ClaudeMapping); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "claude_model_mapping": - _ = json.Unmarshal(v, &c.ClaudeModelMap) + if err := json.Unmarshal(v, &c.ClaudeModelMap); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "_vercel_sync_hash": - _ = json.Unmarshal(v, &c.VercelSyncHash) + if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "_vercel_sync_time": - _ = json.Unmarshal(v, &c.VercelSyncTime) + if err := json.Unmarshal(v, &c.VercelSyncTime); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } default: var anyVal any if err := json.Unmarshal(v, &anyVal); err == nil { @@ -233,30 +246,94 @@ func loadConfig() (Config, bool, error) { content, err := os.ReadFile(ConfigPath()) if err != nil { + if IsVercel() { + // Vercel one-click deploy may start without a writable/present config file. + // Keep an in-memory config so users can bootstrap via WebUI then sync env. + return Config{}, true, nil + } return Config{}, false, err } var cfg Config if err := json.Unmarshal(content, &cfg); err != nil { return Config{}, false, err } + if IsVercel() { + // Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors. + return cfg, true, nil + } return cfg, false, nil } func parseConfigString(raw string) (Config, error) { var cfg Config - if err := json.Unmarshal([]byte(raw), &cfg); err == nil { - return cfg, nil + candidates := []string{raw} + if normalized := normalizeConfigInput(raw); normalized != raw { + candidates = append(candidates, normalized) } - decoded, err := base64.StdEncoding.DecodeString(raw) + for _, candidate := range candidates { + if err := json.Unmarshal([]byte(candidate), &cfg); err == nil { + return cfg, nil + } + } + + base64Input := candidates[len(candidates)-1] + decoded, err := decodeConfigBase64(base64Input) if err != nil { - return Config{}, err + return Config{}, fmt.Errorf("invalid DS2API_CONFIG_JSON: %w", err) } if err := json.Unmarshal(decoded, &cfg); err != nil { - return Config{}, err + return Config{}, fmt.Errorf("invalid DS2API_CONFIG_JSON decoded JSON: %w", err) } return cfg, nil } +func normalizeConfigInput(raw string) string { + normalized := strings.TrimSpace(raw) + if normalized == "" { + return normalized + } + for { + changed := false + if len(normalized) >= 2 { + first := normalized[0] + last := normalized[len(normalized)-1] + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + normalized = strings.TrimSpace(normalized[1 : len(normalized)-1]) + changed = true + } + } + if strings.HasPrefix(strings.ToLower(normalized), "base64:") { + normalized = strings.TrimSpace(normalized[len("base64:"):]) + changed = true + } + if !changed { + break + } + } + return strings.TrimSpace(normalized) +} + +func decodeConfigBase64(raw string) ([]byte, error) { + encodings := []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } + var lastErr error + for _, enc := range encodings { + decoded, err := enc.DecodeString(raw) + if err == nil { + return decoded, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("base64 decode failed") +} + func (s *Store) Snapshot() Config { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 58a8a2a..a409fd7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/base64" "strings" "testing" ) @@ -70,3 +71,53 @@ func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T) t.Fatalf("expected find by old identifier alias") } } + +func TestLoadStoreRejectsInvalidFieldType(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":"not-array","accounts":[]}`) + store := LoadStore() + if len(store.Keys()) != 0 || len(store.Accounts()) != 0 { + t.Fatalf("expected empty store when config type is invalid") + } +} + +func TestParseConfigStringSupportsQuotedBase64Prefix(t *testing.T) { + rawJSON := `{"keys":["k1"],"accounts":[{"email":"u@example.com","password":"p"}]}` + b64 := base64.StdEncoding.EncodeToString([]byte(rawJSON)) + cfg, err := parseConfigString(`"base64:` + b64 + `"`) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if len(cfg.Keys) != 1 || cfg.Keys[0] != "k1" { + t.Fatalf("unexpected keys: %#v", cfg.Keys) + } +} + +func TestParseConfigStringSupportsRawURLBase64(t *testing.T) { + rawJSON := `{"keys":["k-url"],"accounts":[]}` + b64 := base64.RawURLEncoding.EncodeToString([]byte(rawJSON)) + cfg, err := parseConfigString(b64) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if len(cfg.Keys) != 1 || cfg.Keys[0] != "k-url" { + t.Fatalf("unexpected keys: %#v", cfg.Keys) + } +} + +func TestLoadConfigOnVercelWithoutConfigFileFallsBackToMemory(t *testing.T) { + t.Setenv("VERCEL", "1") + t.Setenv("DS2API_CONFIG_JSON", "") + t.Setenv("CONFIG_JSON", "") + t.Setenv("DS2API_CONFIG_PATH", "testdata/does-not-exist.json") + + cfg, fromEnv, err := loadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !fromEnv { + t.Fatalf("expected fromEnv=true for vercel fallback") + } + if len(cfg.Keys) != 0 || len(cfg.Accounts) != 0 { + t.Fatalf("expected empty bootstrap config, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts)) + } +}