From 34c28f662de0ecdce470f28eec025a5c2f477147 Mon Sep 17 00:00:00 2001 From: liangshengfeng Date: Thu, 16 Apr 2026 09:58:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(proxy):=20=E6=94=AF=E6=8C=81=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=8C=87=E5=AE=9A=E7=AB=AF=E7=82=B9=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加端点解析器功能,允许客户端通过以下方式指定端点: - HTTP 头部:X-CCN-Endpoint 或 X-Endpoint-Name - 特殊模型名格式:@endpoint-name/model-name - 查询参数:?endpoint=name 优先级:HTTP 头部 > 特殊模型名 > 查询参数 > 默认轮询 docs: 添加项目文档和更新 gitignore - 添加 CLAUDE.md 项目指南文档,包含项目概述、开发命令、架构说明 - 更新 .gitignore 忽略 cmd/server/ccnexus/ 构建目录 docker容器配置修改 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + CLAUDE.md | 191 ++++++++++++++++++++++++++++ cmd/server/Dockerfile | 9 +- cmd/server/docker-compose.yml | 2 +- internal/proxy/endpoint_resolver.go | 157 +++++++++++++++++++++++ internal/proxy/proxy.go | 58 ++++++++- 6 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/proxy/endpoint_resolver.go diff --git a/.gitignore b/.gitignore index 0b6dfa14..312fd1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ node_modules/ # Logs *.log +/cmd/server/ccnexus/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0152ee9e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,191 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +ccNexus 是一个智能 API 端点轮换代理,专为 Claude Code 和 Codex CLI 设计。 + +**核心功能:** +- 多端点轮换与自动故障转移 +- API 格式转换(Claude ↔ OpenAI ↔ Gemini) +- Codex Token Pool 管理(自动轮换、刷新、失效隔离) +- 实时统计与监控 +- WebDAV 云同步 + +**两种运行模式:** +- **桌面模式**:基于 Wails v2 的跨平台 GUI 应用(`cmd/desktop/`) +- **服务器模式**:无头 HTTP API 代理(`cmd/server/`) + +## 常用开发命令 + +### 开发与构建 +```bash +# 桌面应用开发模式(支持热重载) +cd cmd/desktop && wails dev + +# 构建桌面应用(指定平台) +wails build -platform linux/amd64 # Linux +wails build -platform darwin/amd64 # macOS +wails build -platform windows/amd64 # Windows + +# 构建服务器 +cd cmd/server && go build -ldflags="-s -w" -o ccnexus-server . + +# 运行服务器 +cd cmd/server && go run main.go +``` + +### 测试 +```bash +# 运行所有测试 +go test ./... -count=1 + +# 运行特定目录的测试 +cd internal/proxy && go test -v ./... +cd internal/transformer/convert && go test -v ./... +``` + +### Docker +```bash +# 构建镜像 +docker build -f cmd/server/Dockerfile -t ccnexus . + +# 使用 docker-compose +cd cmd/server && docker-compose up -d +``` + +### 代码质量 +```bash +go fmt ./... # 格式化代码 +go vet ./... # 静态分析 +go mod tidy # 清理依赖 +``` + +## 核心架构 + +### 目录结构 +``` +ccNexus/ +├── cmd/ +│ ├── desktop/ # 桌面应用入口(Wails) +│ │ ├── frontend/ # Vue.js 前端 +│ │ └── main.go # 桌面应用入口 +│ └── server/ # 服务器模式入口 +│ └── main.go # 服务器入口 +└── internal/ + ├── proxy/ # HTTP 代理核心 + ├── transformer/ # API 格式转换器 + ├── storage/ # SQLite 数据存储 + ├── config/ # 配置管理 + ├── webdav/ # WebDAV 同步 + ├── logger/ # 日志系统 + └── tray/ # 系统托盘(桌面模式) +``` + +### 关键组件 + +**代理层** (`internal/proxy/proxy.go`) +- 管理多个 API 端点,自动故障转移 +- 跟踪当前端点和活动请求 +- 使用连接池优化的 HTTP 客户端 +- 处理流式和非流式响应 + +**转换器** (`internal/transformer/`) +- 在不同 API 格式之间转换请求和响应 +- 支持流式传输的增量转换 +- 处理工具调用和函数调用 +- 类型定义:`internal/transformer/types.go` + +**存储层** (`internal/storage/sqlite.go`) +- SQLite WAL 模式数据库 +- 管理端点、凭证、使用统计、应用配置 +- 线程安全操作 + +### 关键文件路径 +- 数据库:`~/.ccNexus/ccnexus.db` +- 配置常量:`internal/config/config.go`(第 13-20 行:认证模式和端点 URL) +- 代理路由:`internal/proxy/proxy.go`(第 108-114 行) + +## 端点配置 + +### 认证模式(`internal/config/config.go`) +- `api_key`:标准 API 密钥认证 +- `token_pool`:Token 池(自动轮换) +- `codex_token_pool`:Codex Token Pool(使用 ChatGPT 后端) + +### 转换器类型 +- `claude`:Claude API +- `openai`:OpenAI Chat API +- `openai2`:OpenAI Response API +- `gemini`:Google Gemini API + +### 端点配置规则 +在 `internal/config/config.go` 的 `ApplyEndpointAuthModeRules` 函数中定义: +- Codex Token Pool 自动设置 API URL 和转换器 +- Token Pool 模式会清空 APIKey +- URL 标准化处理 + +## API 端点 + +代理服务器提供以下端点(`internal/proxy/proxy.go` 第 108-114 行): +- `/` - 主代理路由(所有 API 请求) +- `/v1/messages/count_tokens` - Token 计数 +- `/v1/models` - 模型列表(带缓存) +- `/health` - 健康检查 +- `/stats` - 统计数据 + +## 环境变量 + +服务器模式支持以下环境变量(`cmd/server/main.go`): +- `CCNEXUS_PORT` - 覆盖默认端口 +- `CCNEXUS_LOG_LEVEL` - 日志级别 +- `CCNEXUS_DB_PATH` - 数据库路径 +- `CCNEXUS_DATA_DIR` - 数据目录 +- `CCNEXUS_BASIC_AUTH_USERNAME` - Basic Auth 用户名 +- `CCNEXUS_BASIC_AUTH_PASSWORD` - Basic Auth 密码 + +## 依赖 + +- Go 1.24+ +- Wails v2(桌面模式) +- Node.js 18+(前端开发) +- SQLite(modernc.org/sqlite,纯 Go 实现) + +## 代码规范 + +**静态函数命名**:所有静态函数必须使用 `__` 前缀表示内部可见性 + +```c +// 符合规范 +static int __internal_helper_function(int param) { + return param + 1; +} + +// 不符合规范 +static int internal_helper_function(int param) { + return param + 1; +} +``` + +**变量声明**:所有局部变量必须在函数体开头声明,并在声明时显式初始化 + +```c +// 符合规范 +int function_name(void) { + int ret = 0; + int value = 0; + char buffer[256] = {0}; + char *ptr = NULL; + + /* 可执行语句 */ + ret = do_something(); +} + +// 不符合规范 +int function_name(void) { + int ret = 0; + ret = do_something(); + int value = 0; /* 错误:在可执行语句后声明 */ +} +``` diff --git a/cmd/server/Dockerfile b/cmd/server/Dockerfile index 90d9bbb2..70de5b67 100644 --- a/cmd/server/Dockerfile +++ b/cmd/server/Dockerfile @@ -9,8 +9,13 @@ WORKDIR /app # Copy module definition first for caching COPY go.mod ./ +# 启用 Go Modules +# go env -w GO111MODULE=on +# 配置国内七牛云代理(最稳定) +# go env -w GOPROXY=https://goproxy.cn,direct + # Download dependencies (go.sum will be generated after tidy) -RUN go mod download +RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && go mod download # Copy source code COPY . ./ @@ -22,7 +27,7 @@ RUN go mod tidy RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o ccnexus-server ./cmd/server # Runtime stage -FROM alpine:3.19 +FROM alpine:latest # Install runtime dependencies RUN apk add --no-cache ca-certificates sqlite-libs tzdata wget diff --git a/cmd/server/docker-compose.yml b/cmd/server/docker-compose.yml index eafe15f8..c43572f1 100644 --- a/cmd/server/docker-compose.yml +++ b/cmd/server/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "3021:3000" volumes: - - /data/ccnexus/:/data + - ./ccnexus/:/data environment: - CCNEXUS_PORT=3000 - CCNEXUS_DATA_DIR=/data diff --git a/internal/proxy/endpoint_resolver.go b/internal/proxy/endpoint_resolver.go new file mode 100644 index 00000000..ca769852 --- /dev/null +++ b/internal/proxy/endpoint_resolver.go @@ -0,0 +1,157 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/logger" +) + +// EndpointResolver 负责从 HTTP 请求中解析客户端指定的端点 +// 按优先级解析:HTTP Header → 特殊模型名格式 → 查询参数 +type EndpointResolver struct { + getEndpointsFunc func() []config.Endpoint // 动态获取端点列表的函数 +} + +// NewEndpointResolver 创建新的端点解析器 +// getEndpointsFunc 用于动态获取最新的端点列表 +func NewEndpointResolver(endpoints []config.Endpoint) *EndpointResolver { + // 闭包捕获端点切片 + eps := endpoints + return &EndpointResolver{ + getEndpointsFunc: func() []config.Endpoint { + return eps + }, + } +} + +// NewEndpointResolverWithFunc 创建一个使用动态函数的解析器 +func NewEndpointResolverWithFunc(getEndpointsFunc func() []config.Endpoint) *EndpointResolver { + return &EndpointResolver{ + getEndpointsFunc: getEndpointsFunc, + } +} + +// ResolveEndpoint 从请求中解析端点,按优先级处理 +// 返回:解析到的端点(可能为 nil),模型覆盖值(可能为空),错误信息 +func (r *EndpointResolver) ResolveEndpoint(req *http.Request, bodyBytes []byte) (*config.Endpoint, string, error) { + // 获取最新的端点列表 + endpoints := r.getEndpointsFunc() + + // 优先级 1: HTTP 头部 + if endpointName := r.parseEndpointFromHeader(req); endpointName != "" { + endpoint := r.findEndpointByName(endpointName, endpoints) + if endpoint == nil { + return nil, "", fmt.Errorf("指定的端点 '%s' 不存在或未启用", endpointName) + } + logger.Debug("[Resolver] 通过 HTTP 头部指定端点: %s", endpointName) + return endpoint, "", nil + } + + // 优先级 2: 特殊模型名格式 + var streamReq struct { + Model string `json:"model"` + } + if len(bodyBytes) > 0 { + json.Unmarshal(bodyBytes, &streamReq) + } + modelName := strings.TrimSpace(streamReq.Model) + + if modelName != "" && strings.HasPrefix(modelName, "@") { + endpointName, modelOverride := r.parseEndpointFromModel(modelName) + endpoint := r.findEndpointByName(endpointName, endpoints) + if endpoint == nil { + return nil, "", fmt.Errorf("指定的端点 '%s' 不存在或未启用", endpointName) + } + logger.Debug("[Resolver] 通过模型名格式指定端点: %s, 模型: %s", endpointName, modelOverride) + return endpoint, modelOverride, nil + } + + // 优先级 3: 查询参数 + if endpointName := r.parseEndpointFromQuery(req); endpointName != "" { + endpoint := r.findEndpointByName(endpointName, endpoints) + if endpoint == nil { + return nil, "", fmt.Errorf("指定的端点 '%s' 不存在或未启用", endpointName) + } + logger.Debug("[Resolver] 通过查询参数指定端点: %s", endpointName) + return endpoint, "", nil + } + + // 没有指定端点,使用默认轮询机制 + return nil, "", nil +} + +// parseEndpointFromHeader 从 HTTP 头部解析端点 +// 支持的头部: X-CCN-Endpoint, X-Endpoint-Name +func (r *EndpointResolver) parseEndpointFromHeader(req *http.Request) string { + // 优先检查 X-CCN-Endpoint + if name := strings.TrimSpace(req.Header.Get("X-CCN-Endpoint")); name != "" { + return name + } + // 其次检查 X-Endpoint-Name + if name := strings.TrimSpace(req.Header.Get("X-Endpoint-Name")); name != "" { + return name + } + return "" +} + +// parseEndpointFromModel 从模型名解析端点(支持 @endpoint 格式) +// 格式: +// @endpoint-name/model-name → 返回 (endpoint-name, model-name) +// @endpoint-name → 返回 (endpoint-name, "") +func (r *EndpointResolver) parseEndpointFromModel(model string) (string, string) { + model = strings.TrimSpace(model) + if !strings.HasPrefix(model, "@") { + return "", "" + } + + // 移除 @ 前缀 + model = model[1:] + + // 查找斜杠分隔符 + slashIndex := strings.Index(model, "/") + if slashIndex == -1 { + // 格式: @endpoint-name + endpointName := strings.TrimSpace(model) + return endpointName, "" + } + + // 格式: @endpoint-name/model-name + endpointName := strings.TrimSpace(model[:slashIndex]) + modelName := strings.TrimSpace(model[slashIndex+1:]) + return endpointName, modelName +} + +// parseEndpointFromQuery 从查询参数解析端点 +// 支持的参数: endpoint, ep +func (r *EndpointResolver) parseEndpointFromQuery(req *http.Request) string { + // 优先检查 endpoint + if name := strings.TrimSpace(req.URL.Query().Get("endpoint")); name != "" { + return name + } + // 其次检查 ep + if name := strings.TrimSpace(req.URL.Query().Get("ep")); name != "" { + return name + } + return "" +} + +// findEndpointByName 根据名称查找端点(不区分大小写) +// 只返回已启用的端点 +func (r *EndpointResolver) findEndpointByName(name string, endpoints []config.Endpoint) *config.Endpoint { + targetName := strings.ToLower(strings.TrimSpace(name)) + + for i := range endpoints { + endpoint := &endpoints[i] + if !endpoint.Enabled { + continue + } + if strings.ToLower(strings.TrimSpace(endpoint.Name)) == targetName { + return endpoint + } + } + return nil +} \ No newline at end of file diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index e04b3c19..962a7796 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -48,6 +48,7 @@ type Proxy struct { ctxMu sync.RWMutex // protects context maps onEndpointSuccess func(endpointName string) // callback when endpoint request succeeds modelsCache *ModelsCache // Cache for /v1/models endpoint + resolver *EndpointResolver // 端点解析器,用于解析客户端指定的端点 } // New creates a new Proxy instance @@ -81,6 +82,7 @@ func New(cfg *config.Config, statsStorage StatsStorage, sqliteStorage *storage.S endpointCtx: make(map[string]context.Context), endpointCancel: make(map[string]context.CancelFunc), modelsCache: NewModelsCache(cfg.ModelsCacheTTL), + resolver: NewEndpointResolverWithFunc(cfg.GetEndpoints), } } @@ -335,6 +337,9 @@ func (p *Proxy) handleProxy(w http.ResponseWriter, r *http.Request) { } json.Unmarshal(bodyBytes, &streamReq) + // 在解析时记录原始模型名称,用于后续处理 + // originalModelName := strings.TrimSpace(streamReq.Model) + endpoints := p.getEnabledEndpoints() if len(endpoints) == 0 { logger.Error("No enabled endpoints available") @@ -342,13 +347,47 @@ func (p *Proxy) handleProxy(w http.ResponseWriter, r *http.Request) { return } + // 尝试解析客户端指定的端点 + specifiedEndpoint, modelOverride, resolveErr := p.resolver.ResolveEndpoint(r, bodyBytes) + if resolveErr != nil { + // 端点指定错误,返回错误响应 + logger.Warn("端点解析失败: %v", resolveErr) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + errorResp := map[string]interface{}{ + "error": map[string]interface{}{ + "type": "invalid_request_error", + "message": resolveErr.Error(), + }, + } + if jsonBytes, err := json.Marshal(errorResp); err == nil { + w.Write(jsonBytes) + } + return + } + + // 如果指定了端点,使用该端点;否则使用轮询机制 + var useSpecificEndpoint bool + if specifiedEndpoint != nil { + useSpecificEndpoint = true + logger.Debug("[Resolver] 使用指定端点: %s", specifiedEndpoint.Name) + } + maxRetries := p.computeMaxRetries(endpoints) endpointAttempts := 0 lastEndpointName := "" refreshedCredentialAttempts := make(map[int64]bool) for retry := 0; retry < maxRetries; retry++ { - endpoint := p.getCurrentEndpoint() + var endpoint config.Endpoint + if useSpecificEndpoint { + // 使用指定的端点,不进行轮询 + endpoint = *specifiedEndpoint + } else { + // 使用轮询机制 + endpoint = p.getCurrentEndpoint() + } + if endpoint.Name == "" { http.Error(w, "No enabled endpoints available", http.StatusServiceUnavailable) return @@ -373,7 +412,7 @@ func (p *Proxy) handleProxy(w http.ResponseWriter, r *http.Request) { logger.Warn("[%s] Failed to select token pool credential: %v", endpoint.Name, err) p.stats.RecordError(endpoint.Name) p.markRequestInactive(endpoint.Name) - if endpointAttempts >= 2 { + if endpointAttempts >= 2 && !useSpecificEndpoint { p.rotateEndpoint() endpointAttempts = 0 } @@ -383,7 +422,7 @@ func (p *Proxy) handleProxy(w http.ResponseWriter, r *http.Request) { logger.Warn("[%s] No usable token in token pool", endpoint.Name) p.stats.RecordError(endpoint.Name) p.markRequestInactive(endpoint.Name) - if endpointAttempts >= 2 { + if endpointAttempts >= 2 && !useSpecificEndpoint { p.rotateEndpoint() endpointAttempts = 0 } @@ -444,6 +483,12 @@ func (p *Proxy) handleProxy(w http.ResponseWriter, r *http.Request) { logger.DebugLog("[%s] Transformer: %s", endpoint.Name, transformerName) logger.DebugLog("[%s] Transformed Request: %s", endpoint.Name, string(transformedBody)) + // 如果有模型覆盖值,应用到转换后的请求体中 + if modelOverride != "" { + transformedBody = overrideModelInPayload(transformedBody, modelOverride) + logger.DebugLog("[%s] 应用模型覆盖后的请求: %s", endpoint.Name, string(transformedBody)) + } + cleanedBody, err := cleanIncompleteToolCalls(transformedBody) if err != nil { logger.Warn("[%s] Failed to clean tool calls: %v", endpoint.Name, err) @@ -454,8 +499,13 @@ func (p *Proxy) handleProxy(w http.ResponseWriter, r *http.Request) { transformedBody = overrideModelInPayload(transformedBody, endpoint.Model) } + // 处理模型名称:优先使用模型覆盖值,然后是请求中的模型,最后是端点配置的模型 modelName := strings.TrimSpace(streamReq.Model) - if modelName == "" || (authMode == config.AuthModeCodexTokenPool && strings.TrimSpace(endpoint.Model) != "") { + if modelOverride != "" { + // 使用解析器提供的模型覆盖值 + modelName = modelOverride + logger.Debug("[%s] 使用模型覆盖值: %s", endpoint.Name, modelName) + } else if modelName == "" || (authMode == config.AuthModeCodexTokenPool && strings.TrimSpace(endpoint.Model) != "") { modelName = endpoint.Model } From 58b8ea0a915e742974b49544716bd3f23e4c971e Mon Sep 17 00:00:00 2001 From: liangshengfeng Date: Thu, 16 Apr 2026 10:39:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(webui):=20=E6=B7=BB=E5=8A=A0=20WebUI?= =?UTF-8?q?=20=E4=B8=AD=E8=8B=B1=E6=96=87=E5=88=87=E6=8D=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 i18n 国际化系统 (ui/js/utils/i18n.js) - 添加中文翻译文件 (ui/js/i18n/zh-CN.js) - 添加英文翻译文件 (ui/js/i18n/en.js) - 在侧边栏添加语言切换按钮 (默认显示中文) - 支持语言切换时自动重新渲染页面 - 语言选择持久化到 localStorage - 更新所有组件支持国际化:Dashboard、Endpoints、Statistics、Testing Co-Authored-By: Claude Opus 4.6 --- cmd/server/webui/ui/index.html | 15 +- .../webui/ui/js/components/dashboard.js | 33 ++- .../webui/ui/js/components/endpoints.js | 243 +++++++++--------- cmd/server/webui/ui/js/components/stats.js | 39 +-- cmd/server/webui/ui/js/components/testing.js | 45 ++-- cmd/server/webui/ui/js/i18n/en.js | 217 ++++++++++++++++ cmd/server/webui/ui/js/i18n/zh-CN.js | 217 ++++++++++++++++ cmd/server/webui/ui/js/main.js | 56 ++++ cmd/server/webui/ui/js/utils/i18n.js | 43 ++++ 9 files changed, 736 insertions(+), 172 deletions(-) create mode 100644 cmd/server/webui/ui/js/i18n/en.js create mode 100644 cmd/server/webui/ui/js/i18n/zh-CN.js create mode 100644 cmd/server/webui/ui/js/utils/i18n.js diff --git a/cmd/server/webui/ui/index.html b/cmd/server/webui/ui/index.html index 386cfcc5..d772c854 100644 --- a/cmd/server/webui/ui/index.html +++ b/cmd/server/webui/ui/index.html @@ -13,28 +13,31 @@