Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 60 additions & 93 deletions README_FA.md
Original file line number Diff line number Diff line change
@@ -1,133 +1,100 @@
# cliproxyapi++ 🚀

[![Go Report Card](https://goreportcard.com/badge/github.com/KooshaPari/cliproxyapi-plusplus)](https://goreportcard.com/report/github.com/KooshaPari/cliproxyapi-plusplus)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/kooshapari/cliproxyapi-plusplus.svg)](https://hub.docker.com/r/kooshapari/cliproxyapi-plusplus)
[![GitHub Release](https://img.shields.io/github/v/release/KooshaPari/cliproxyapi-plusplus)](https://github.com/KooshaPari/cliproxyapi-plusplus/releases)
# CLIProxyAPI Plus

[English](README.md) | 中文

**cliproxyapi++** 是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的高性能、经过安全加固的终极分支版本。它秉持“纵深防御”的开发理念和“库优先”的架构设计,为多种主流及私有大模型提供 OpenAI 兼容接口,并具备企业级稳定性。

---

## 🏆 深度对比:`++` 版本的优势
这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。

为什么选择 **cliproxyapi++** 而不是主线版本?虽然主线版本专注于开源社区的稳定性,但 `++` 版本则是为高并发、生产级环境而设计的,在安全性、自动化生命周期管理和广泛的提供商支持方面具有显著优势
所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系

### 📊 功能对比矩阵
该 Plus 版本的主线功能与主线功能强制同步。

| 功能特性 | 主线版本 | CLIProxyAPI+ | **cliproxyapi++** |
| :--- | :---: | :---: | :---: |
| **核心代理逻辑** | ✅ | ✅ | ✅ |
| **基础模型支持** | ✅ | ✅ | ✅ |
| **标准 Web UI** | ❌ | ✅ | ✅ |
| **高级认证 (Kiro/Copilot)** | ❌ | ⚠️ | ✅ **(完整支持)** |
| **后台令牌自动刷新** | ❌ | ❌ | ✅ **(自动刷新)** |
| **安全加固** | 基础 | 基础 | ✅ **(企业级)** |
| **频率限制与冷却** | ❌ | ❌ | ✅ **(智能路由)** |
| **发布标签协议** | ❌ | ⚠️ | ✅ **(支持 `v<版本>` 与 `v<版本>-<批次>` 标签流)** |
| **发布自动化流程** | ⚠️ | ⚠️ | ✅ **(标签推送触发 GoReleaser 并刷新发布信息)** |
| **发布批次自动生成** | ⚠️ | ⚠️ | ✅ **(向 `main` 分支推送会触发 `releasebatch --mode create --target main`,自动打批次标签并发布版本)** |
| **发布说明自动生成** | ❌ | ⚠️ | ✅ **(通过 `releasebatch` 聚合提交并更新 release notes)** |
| **签名/信任链** | ❌ | ❌ | ⚠️ **(当前仅有 `checksums.txt`,尚未接入证书签名)** |
| **CLI 登录与配置治理** | ⚠️ | ⚠️ | ✅ **(支持 `--login` 系列与 `--thegent-login=<provider>`,并提供 `--setup` 向导)** |
| **核心逻辑复用** | `internal/` | `internal/` | ✅ **(`pkg/llmproxy`)** |
| **CI/CD 流水线** | 基础 | 基础 | ✅ **(签名/多架构)** |
## 与主线版本版本差异

---
- 新增 GitHub Copilot 支持(OAuth 登录),由[em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供
- 新增 Kiro (AWS CodeWhisperer) 支持 (OAuth 登录), 由[fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration)、[Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)提供

## 🔍 技术差异与安全加固
## 新增功能 (Plus 增强版)

### 1. 架构演进:`pkg/llmproxy`
主线版本将核心逻辑保留在 `internal/` 目录下(这会导致外部 Go 项目无法直接导入),而 **cliproxyapi++** 已将整个翻译和代理引擎重构为清晰、公开的 `pkg/llmproxy` 库。
* **可复用性**: 您可以直接在自己的 Go 应用程序中导入代理逻辑。
* **解耦**: 实现了配置管理与执行逻辑的严格分离。
- **OAuth Web 认证**: 基于浏览器的 Kiro OAuth 登录,提供美观的 Web UI
- **请求限流器**: 内置请求限流,防止 API 滥用
- **后台令牌刷新**: 过期前 10 分钟自动刷新令牌
- **监控指标**: 请求指标收集,用于监控和调试
- **设备指纹**: 设备指纹生成,增强安全性
- **冷却管理**: 智能冷却机制,应对 API 速率限制
- **用量检查器**: 实时用量监控和配额管理
- **模型转换器**: 跨供应商的统一模型名称转换
- **UTF-8 流处理**: 改进的流式响应处理

### 2. 企业级身份认证与生命周期管理
* **完整 GitHub Copilot 集成**: 不仅仅是 API 包装。`++` 包含完整的 OAuth 设备流登录、每个凭据的额度追踪以及智能会话管理。
* **Kiro (AWS CodeWhisperer) 2.0**: 提供定制化的 Web 界面 (`/v0/oauth/kiro`),支持通过浏览器进行 AWS Builder ID 和 Identity Center 登录。
* **后台令牌刷新**: 专门的后台服务实时监控令牌状态,并在过期前 10 分钟自动刷新,确保智能体任务零停机。
## Kiro 认证

### 3. 安全加固(“纵深防御”)
* **路径保护 (Path Guard)**: 定制的 GitHub Action 工作流 (`pr-path-guard`),防止在 PR 过程中对关键的 `internal/translator/` 逻辑进行任何未经授权的修改。
* **设备指纹**: 生成唯一且不可变的设备标识符,以满足严格的提供商安全检查,防止账号被标记。
* **加固的 Docker 基础镜像**: 基于经过审计的 Alpine 3.22.0 层构建,仅包含最少软件包,显著降低了潜在的攻击面。
### 网页端 OAuth 登录

### 4. 高规模运营支持
* **智能冷却机制**: 自动化的“冷却”系统可检测提供商端的频率限制,并智能地暂停对特定供应商的请求,同时将流量路由至其他可用节点。
* **统一模型转换器**: 复杂的映射层,允许您请求 `claude-3-5-sonnet`,而由代理自动处理目标供应商(如 Vertex、AWS、Anthropic 等)的具体协议要求。
访问 Kiro OAuth 网页认证界面:

---
```
http://your-server:8080/v0/oauth/kiro
```

## 🚀 快速开始
提供基于浏览器的 Kiro (AWS CodeWhisperer) OAuth 认证流程,支持:
- AWS Builder ID 登录
- AWS Identity Center (IDC) 登录
- 从 Kiro IDE 导入令牌

### 先决条件
- 已安装 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/)
- 或安装 [Go 1.26+](https://golang.org/dl/)
## Docker 快速部署

### 一键部署 (Docker)
### 一键部署

```bash
# 设置部署目录
mkdir -p ~/cliproxy && cd ~/cliproxy
curl -o config.yaml https://raw.githubusercontent.com/KooshaPari/cliproxyapi-plusplus/main/config.example.yaml
# 创建部署目录
mkdir -p ~/cli-proxy && cd ~/cli-proxy

# 创建 compose 文件
# 创建 docker-compose.yml
cat > docker-compose.yml << 'EOF'
services:
cliproxy:
image: KooshaPari/cliproxyapi-plusplus:latest
container_name: cliproxyapi++
ports: ["8317:8317"]
cli-proxy-api:
image: eceasy/cli-proxy-api-plus:latest
container_name: cli-proxy-api-plus
ports:
- "8317:8317"
volumes:
- ./config.yaml:/CLIProxyAPI/config.yaml
- ./auths:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs
restart: unless-stopped
EOF

docker compose up -d
```
# 下载示例配置
curl -o config.yaml https://raw.githubusercontent.com/router-for-me/CLIProxyAPIPlus/main/config.example.yaml

---

## 🛠️ 高级用法
# 拉取并启动
docker compose pull && docker compose up -d
```

### 扩展的供应商支持
`cliproxyapi++` 开箱即用地支持海量模型注册:
* **直接接入**: Claude, Gemini, OpenAI, Mistral, Groq, DeepSeek.
* **聚合器**: OpenRouter, Together AI, Fireworks AI, Novita AI, SiliconFlow.
* **私有协议**: Kiro (AWS), GitHub Copilot, Roo Code, Kilo AI, MiniMax.
### 配置说明

### API 规范
代理提供两个主要的 API 表面:
1. **OpenAI 兼容接口**: `/v1/chat/completions` 和 `/v1/models`。
2. **管理接口**:
* `GET /v0/config`: 查看当前(支持热重载)的配置。
* `GET /v0/oauth/kiro`: 交互式 Kiro 认证界面。
* `GET /v0/logs`: 实时日志查看。
启动前请编辑 `config.yaml`:

---
```yaml
# 基本配置示例
server:
port: 8317

## 🤝 贡献指南
# 在此添加你的供应商配置
```

我们维持严格的质量门禁,以保持项目的“加固”状态:
1. **代码风格**: 必须通过 `golangci-lint` 检查,且无任何警告。
2. **测试覆盖**: 所有的翻译器逻辑必须包含单元测试。
3. **治理**: 对 `pkg/` 核心逻辑的修改需要先在 Issue 中进行讨论。
### 更新到最新版本

请参阅 **[CONTRIBUTING.md](CONTRIBUTING.md)** 了解更多详情。
```bash
cd ~/cli-proxy
docker compose pull && docker compose up -d
```

---
## 贡献

## 📜 开源协议
该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。

本项目根据 MIT 许可证发行。详情请参阅 [LICENSE](LICENSE) 文件
如果需要提交任何非第三方供应商支持的 Pull Request,请提交到[主线](https://github.com/router-for-me/CLIProxyAPI)版本

---
## 许可证

<p align="center">
<b>为现代智能体技术栈打造的加固级 AI 基础设施。</b><br>
由社区倾力打造 ❤️
</p>
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
// out, _ = sjson.Set(out, "max_output_tokens", v.Value())
// }

// Map reasoning effort; fall back to variant if reasoning_effort missing.
// Map reasoning effort; support flat legacy field and variant fallback.
if v := gjson.GetBytes(rawJSON, "reasoning_effort"); v.Exists() {
out, _ = sjson.Set(out, "reasoning.effort", v.Value())
} else if v := gjson.GetBytes(rawJSON, `reasoning\.effort`); v.Exists() {
out, _ = sjson.Set(out, "reasoning.effort", v.Value())
} else if v := gjson.GetBytes(rawJSON, "variant"); v.Exists() {
effort := strings.ToLower(strings.TrimSpace(v.String()))
if effort == "" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ func TestConvertOpenAIRequestToCodex_UsesVariantFallbackWhenReasoningEffortMissi
}
}

func TestConvertOpenAIRequestToCodex_UsesLegacyFlatReasoningEffortField(t *testing.T) {
input := []byte(`{
"model": "gpt-4o",
"messages": [{"role":"user","content":"hello"}],
"reasoning.effort": "low"
}`)
got := ConvertOpenAIRequestToCodex("gpt-4o", input, false)
res := gjson.ParseBytes(got)

if gotEffort := res.Get("reasoning.effort").String(); gotEffort != "low" {
t.Fatalf("expected reasoning.effort to use legacy flat field low, got %s", gotEffort)
}
}

func TestConvertOpenAIRequestToCodex_UsesReasoningEffortBeforeVariant(t *testing.T) {
input := []byte(`{
"model": "gpt-4o",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,27 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
}

// Map reasoning controls.
//
// Priority:
// 1. reasoning.effort object field
// 2. flat legacy field "reasoning.effort"
// 3. variant
if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() {
effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))
if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
} else if reasoningEffort := root.Get(`reasoning\.effort`); reasoningEffort.Exists() {
effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))
if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
} else if variant := root.Get("variant"); variant.Exists() && variant.Type == gjson.String {
effort := strings.ToLower(strings.TrimSpace(variant.String()))
if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}

// Convert tool_choice if present
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,29 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletionsToolChoice(t *testi
t.Fatalf("tool_choice should be object, got %s", res.Get("tool_choice").Type.String())
}
}

func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsLegacyReasoningEffort(t *testing.T) {
input := []byte(`{
"model":"gpt-4.1",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"ping"}]}],
"reasoning.effort":"low"
}`)

output := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-4.1", input, false)
if got := gjson.GetBytes(output, "reasoning_effort").String(); got != "low" {
t.Fatalf("expected reasoning_effort low from legacy flat field, got %q", got)
}
}

func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsVariantFallback(t *testing.T) {
input := []byte(`{
"model":"gpt-4.1",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"ping"}]}],
"variant":"medium"
}`)

output := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-4.1", input, false)
if got := gjson.GetBytes(output, "reasoning_effort").String(); got != "medium" {
t.Fatalf("expected reasoning_effort medium from variant, got %q", got)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ import (
"github.com/tidwall/sjson"
)

func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
return originalRequestRawJSON
}
if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
return requestRawJSON
}
return nil
}

type oaiToResponsesStateReasoning struct {
ReasoningID string
ReasoningData string
Expand Down Expand Up @@ -488,9 +498,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
completed, _ = sjson.Set(completed, "sequence_number", nextSeq())
completed, _ = sjson.Set(completed, "response.id", st.ResponseID)
completed, _ = sjson.Set(completed, "response.created_at", st.Created)
// Inject original request fields into response as per docs/response.completed.json
if requestRawJSON != nil {
req := gjson.ParseBytes(requestRawJSON)
// Inject original request fields into response as per docs/response.completed.json.
reqRawJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
if reqRawJSON != nil {
req := gjson.ParseBytes(reqRawJSON)
if v := req.Get("instructions"); v.Exists() {
completed, _ = sjson.Set(completed, "response.instructions", v.String())
}
Expand Down Expand Up @@ -664,8 +675,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
resp, _ = sjson.Set(resp, "created_at", created)

// Echo request fields when available (aligns with streaming path behavior)
if len(requestRawJSON) > 0 {
req := gjson.ParseBytes(requestRawJSON)
reqRawJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
if reqRawJSON != nil {
req := gjson.ParseBytes(reqRawJSON)
if v := req.Get("instructions"); v.Exists() {
resp, _ = sjson.Set(resp, "instructions", v.String())
}
Expand Down Expand Up @@ -743,8 +755,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
// Detect and capture reasoning content if present
rcText := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content").String()
includeReasoning := rcText != ""
if !includeReasoning && len(requestRawJSON) > 0 {
includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists()
if !includeReasoning && reqRawJSON != nil {
includeReasoning = gjson.GetBytes(reqRawJSON, "reasoning").Exists()
}
if includeReasoning {
rid := strings.TrimPrefix(id, "resp_")
Expand Down
Loading
Loading