Compare commits
22 Commits
master
...
refactor/i
| Author | SHA1 | Date |
|---|---|---|
|
|
f9a92b0bfa | |
|
|
3dc49bfc7d | |
|
|
9e2a10b614 | |
|
|
3387887fb9 | |
|
|
f90d72b279 | |
|
|
081f404f77 | |
|
|
7e0b7a970c | |
|
|
8f1b7159ed | |
|
|
e379c79bd1 | |
|
|
ef4b6519f5 | |
|
|
7b82986dca | |
|
|
e5f19c243b | |
|
|
83418d5e76 | |
|
|
5866a5b9c9 | |
|
|
270accfd75 | |
|
|
f9f3c5fb42 | |
|
|
d4fcb8d3b8 | |
|
|
3a005ea02e | |
|
|
80d7a4bb29 | |
|
|
294bd74a43 | |
|
|
8b6abbbba7 | |
|
|
b18e3d82f0 |
40
.env.example
40
.env.example
|
|
@ -1,40 +0,0 @@
|
|||
# ──────────────────────────────────────────────────────────────
|
||||
# cursor-api-proxy 設定範例
|
||||
# 複製為 .env 後填入你的設定:cp .env.example .env
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# ── 伺服器設定 ────────────────────────────────────────────────
|
||||
# Docker 模式固定使用 0.0.0.0;本機直接執行時可改 127.0.0.1
|
||||
CURSOR_BRIDGE_HOST=0.0.0.0
|
||||
CURSOR_BRIDGE_PORT=8766
|
||||
CURSOR_BRIDGE_API_KEY=
|
||||
CURSOR_BRIDGE_TIMEOUT_MS=3600000
|
||||
CURSOR_BRIDGE_MULTI_PORT=false
|
||||
CURSOR_BRIDGE_VERBOSE=false
|
||||
|
||||
# ── Agent / 模型設定 ──────────────────────────────────────────
|
||||
# Docker 模式:容器內 agent 路徑(預設掛載至 /usr/local/bin/agent)
|
||||
CURSOR_AGENT_BIN=/usr/local/bin/agent
|
||||
CURSOR_BRIDGE_DEFAULT_MODEL=auto
|
||||
CURSOR_BRIDGE_STRICT_MODEL=true
|
||||
CURSOR_BRIDGE_MAX_MODE=false
|
||||
CURSOR_BRIDGE_FORCE=false
|
||||
CURSOR_BRIDGE_APPROVE_MCPS=false
|
||||
|
||||
# ── 工作區與帳號 ──────────────────────────────────────────────
|
||||
CURSOR_BRIDGE_WORKSPACE=/workspace
|
||||
CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE=true
|
||||
# 多帳號設定目錄(逗號分隔),留空則自動探索 ~/.cursor-api-proxy/accounts/
|
||||
CURSOR_CONFIG_DIRS=
|
||||
|
||||
# ── TLS / HTTPS(選用)────────────────────────────────────────
|
||||
CURSOR_BRIDGE_TLS_CERT=
|
||||
CURSOR_BRIDGE_TLS_KEY=
|
||||
|
||||
# ── Docker 專用:宿主機路徑對映 ──────────────────────────────
|
||||
# 宿主機上 Cursor agent 二進位檔的實際路徑
|
||||
CURSOR_AGENT_HOST_BIN=/usr/local/bin/agent
|
||||
# 宿主機上的帳號資料目錄(會掛載至容器的 /root/.cursor-api-proxy)
|
||||
CURSOR_ACCOUNTS_DIR=~/.cursor-api-proxy
|
||||
# 宿主機上要掛載進容器的工作區目錄
|
||||
WORKSPACE_DIR=/tmp/workspace
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
.idea/
|
||||
cursor-api-proxy*
|
||||
.env
|
||||
.env
|
||||
.opencode
|
||||
|
|
|
|||
|
|
@ -0,0 +1,462 @@
|
|||
# REFACTOR TASKS
|
||||
|
||||
重構任務拆分,支援 git worktree 並行開發。
|
||||
|
||||
---
|
||||
|
||||
## Task Overview
|
||||
|
||||
### 並行策略
|
||||
|
||||
```
|
||||
時間軸 ──────────────────────────────────────────────────────────────►
|
||||
|
||||
Task 0: Init (必須先完成)
|
||||
│
|
||||
├── Task 1: Domain Layer ─────────────────────────┐
|
||||
│ │
|
||||
│ ┌── Task 2: Infrastructure Layer ────────────┤── 並行
|
||||
│ │ │
|
||||
│ └── Task 3: Repository Layer ────────────────┘
|
||||
│ (依賴 Task 1)
|
||||
│
|
||||
├── Task 4: Provider Layer ──────────────────────┐
|
||||
│ (依賴 Task 1) │
|
||||
│ │── 可並行
|
||||
├── Task 5: Usecase Layer ───────────────────────┤
|
||||
│ (依賴 Task 3) │
|
||||
│ │
|
||||
├── Task 6: Adapter Layer ───────────────────────┘
|
||||
│ (依賴 Task 1)
|
||||
│
|
||||
├── Task 7: Internal Layer ──────────────────────┐
|
||||
│ (整合所有,必須最後) │
|
||||
│ │── 序列
|
||||
├── Task 8: CLI Tools │
|
||||
│ │
|
||||
└── Task 9: Cleanup & Tests ────────────────────┘
|
||||
```
|
||||
|
||||
### Worktree 分支規劃
|
||||
|
||||
| 分支名稱 | 基於 | 任務 | 可並行 |
|
||||
|---------|------|------|--------|
|
||||
| `refactor/init` | `master` | Task 0 | ❌ |
|
||||
| `refactor/domain` | `refactor/init` | Task 1 | ✅ |
|
||||
| `refactor/infrastructure` | `refactor/init` | Task 2 | ✅ |
|
||||
| `refactor/repository` | `refactor/domain` | Task 3 | ✅ |
|
||||
| `refactor/provider` | `refactor/domain` | Task 4 | ✅ |
|
||||
| `refactor/usecase` | `refactor/repository` | Task 5 | ✅ |
|
||||
| `refactor/adapter` | `refactor/domain` | Task 6 | ✅ |
|
||||
| `refactor/internal` | 合併所有 | Task 7 | ❌ |
|
||||
| `refactor/cli` | `refactor/init` | Task 8 | ✅ |
|
||||
| `refactor/cleanup` | 合併所有 | Task 9 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Task 0: 初始化
|
||||
|
||||
### 分支
|
||||
`refactor/init`
|
||||
|
||||
### 依賴
|
||||
無(必須先完成)
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **0.1** 更新 go.mod (5min)
|
||||
- `go get github.com/zeromicro/go-zero@latest`
|
||||
- `go mod tidy`
|
||||
|
||||
- [ ] **0.2** 建立目錄 (1min)
|
||||
- `mkdir -p api etc`
|
||||
|
||||
- [ ] **0.3** 建立 `api/chat.api` (15min)
|
||||
- 定義 API types
|
||||
- 定義 routes
|
||||
|
||||
- [ ] **0.4** 建立 `etc/chat.yaml` (5min)
|
||||
- 配置參數
|
||||
|
||||
- [ ] **0.5** 更新 Makefile (10min)
|
||||
- 新增 goctl 命令
|
||||
|
||||
- [ ] **0.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Domain Layer
|
||||
|
||||
### 分支
|
||||
`refactor/domain`
|
||||
|
||||
### 依賴
|
||||
Task 0 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **1.1** 建立目錄結構 (1min)
|
||||
- `pkg/domain/entity`
|
||||
- `pkg/domain/repository`
|
||||
- `pkg/domain/usecase`
|
||||
- `pkg/domain/const`
|
||||
|
||||
- [ ] **1.2** `entity/message.go` (10min)
|
||||
- Message, Tool, ToolFunction, ToolCall
|
||||
|
||||
- [ ] **1.3** `entity/chunk.go` (5min)
|
||||
- StreamChunk, ChunkType
|
||||
|
||||
- [ ] **1.4** `entity/account.go` (5min)
|
||||
- Account, AccountStat
|
||||
|
||||
- [ ] **1.5** `repository/account.go` (10min)
|
||||
- AccountPool interface
|
||||
|
||||
- [ ] **1.6** `repository/provider.go` (5min)
|
||||
- Provider interface
|
||||
|
||||
- [ ] **1.7** `usecase/chat.go` (15min)
|
||||
- ChatUsecase interface
|
||||
|
||||
- [ ] **1.8** `usecase/agent.go` (5min)
|
||||
- AgentRunner interface
|
||||
|
||||
- [ ] **1.9** `const/models.go` (10min)
|
||||
- Model 常數
|
||||
|
||||
- [ ] **1.10** `const/errors.go` (5min)
|
||||
- 錯誤定義
|
||||
|
||||
- [ ] **1.11** 提交 (2min)
|
||||
|
||||
**預估時間**: ~2h
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Infrastructure Layer
|
||||
|
||||
### 分支
|
||||
`refactor/infrastructure`
|
||||
|
||||
### 依賴
|
||||
Task 0 完成(可與 Task 1 並行)
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **2.1** 建立目錄 (2min)
|
||||
- `pkg/infrastructure/{process,parser,httputil,logger,env,workspace,winlimit}`
|
||||
|
||||
- [ ] **2.2** 遷移 process (10min)
|
||||
- runner.go, kill_unix.go, kill_windows.go, process_test.go
|
||||
|
||||
- [ ] **2.3** 遷移 parser (5min)
|
||||
- stream.go, stream_test.go
|
||||
|
||||
- [ ] **2.4** 遷移 httputil (5min)
|
||||
- httputil.go, httputil_test.go
|
||||
|
||||
- [ ] **2.5** 遷移 logger (5min)
|
||||
- logger.go
|
||||
|
||||
- [ ] **2.6** 遷移 env (5min)
|
||||
- env.go, env_test.go
|
||||
|
||||
- [ ] **2.7** 遷移 workspace (5min)
|
||||
- workspace.go
|
||||
|
||||
- [ ] **2.8** 遷移 winlimit (5min)
|
||||
- winlimit.go, winlimit_test.go
|
||||
|
||||
- [ ] **2.9** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **2.10** 提交 (2min)
|
||||
|
||||
**預估時間**: ~1h
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Repository Layer
|
||||
|
||||
### 分支
|
||||
`refactor/repository`
|
||||
|
||||
### 依賴
|
||||
Task 1 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **3.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **3.2** 遷移 account.go (20min)
|
||||
- AccountPool 實作
|
||||
- 移除全局變數
|
||||
|
||||
- [ ] **3.3** 遷移 provider.go (10min)
|
||||
- Provider 工廠
|
||||
|
||||
- [ ] **3.4** 遷移測試 (5min)
|
||||
|
||||
- [ ] **3.5** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **3.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~1h
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Provider Layer
|
||||
|
||||
### 分支
|
||||
`refactor/provider`
|
||||
|
||||
### 依賴
|
||||
Task 1 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **4.1** 建立目錄 (1min)
|
||||
- `pkg/provider/cursor`
|
||||
- `pkg/provider/geminiweb`
|
||||
|
||||
- [ ] **4.2** 遷移 cursor provider (5min)
|
||||
|
||||
- [ ] **4.3** 遷移 geminiweb provider (10min)
|
||||
|
||||
- [ ] **4.4** 更新 import (5min)
|
||||
|
||||
- [ ] **4.5** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **4.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Usecase Layer
|
||||
|
||||
### 分支
|
||||
`refactor/usecase`
|
||||
|
||||
### 依賴
|
||||
Task 3 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **5.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **5.2** 建立 chat.go (30min)
|
||||
- 核心聊天邏輯
|
||||
|
||||
- [ ] **5.3** 遷移 agent.go (20min)
|
||||
- runner, token, cmdargs, maxmode
|
||||
|
||||
- [ ] **5.4** 遷移 sanitizer (10min)
|
||||
|
||||
- [ ] **5.5** 遷移 toolcall (10min)
|
||||
|
||||
- [ ] **5.6** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **5.7** 提交 (2min)
|
||||
|
||||
**預估時間**: ~2h
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Adapter Layer
|
||||
|
||||
### 分支
|
||||
`refactor/adapter`
|
||||
|
||||
### 依賴
|
||||
Task 1 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **6.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **6.2** 遷移 openai adapter (10min)
|
||||
|
||||
- [ ] **6.3** 遷移 anthropic adapter (10min)
|
||||
|
||||
- [ ] **6.4** 更新 import (5min)
|
||||
|
||||
- [ ] **6.5** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **6.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Internal Layer
|
||||
|
||||
### 分支
|
||||
`refactor/internal`
|
||||
|
||||
### 依賴
|
||||
Task 1-6 全部完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **7.1** 合併所有分支 (5min)
|
||||
|
||||
- [ ] **7.2** 更新 config/config.go (15min)
|
||||
- 使用 rest.RestConf
|
||||
|
||||
- [ ] **7.3** 建立 svc/servicecontext.go (30min)
|
||||
- DI 容器
|
||||
|
||||
- [ ] **7.4** 建立 logic/ (1h)
|
||||
- chatcompletionlogic.go
|
||||
- geminichatlogic.go
|
||||
- anthropiclogic.go
|
||||
- healthlogic.go
|
||||
- modelslogic.go
|
||||
|
||||
- [ ] **7.5** 建立 handler/ (1h)
|
||||
- 自訂 SSE handler
|
||||
|
||||
- [ ] **7.6** 建立 middleware/ (20min)
|
||||
- auth.go
|
||||
- recovery.go
|
||||
|
||||
- [ ] **7.7** 建立 types/ (5min)
|
||||
- goctl 生成
|
||||
|
||||
- [ ] **7.8** 更新 import (30min)
|
||||
- 批量更新
|
||||
|
||||
- [ ] **7.9** 驗證編譯 (10min)
|
||||
|
||||
- [ ] **7.10** 提交 (2min)
|
||||
|
||||
**預估時間**: ~4h
|
||||
|
||||
---
|
||||
|
||||
## Task 8: CLI Tools
|
||||
|
||||
### 分支
|
||||
`refactor/cli`
|
||||
|
||||
### 依賴
|
||||
Task 0 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **8.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **8.2** 遷移 CLI 工具 (10min)
|
||||
|
||||
- [ ] **8.3** 遷移 gemini-login (5min)
|
||||
|
||||
- [ ] **8.4** 更新 import (5min)
|
||||
|
||||
- [ ] **8.5** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Cleanup & Tests
|
||||
|
||||
### 分支
|
||||
`refactor/cleanup`
|
||||
|
||||
### 依賴
|
||||
Task 7 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **9.1** 移除舊目錄 (5min)
|
||||
|
||||
- [ ] **9.2** 更新 import (30min)
|
||||
- 批量 sed
|
||||
|
||||
- [ ] **9.3** 建立 cmd/chat/chat.go (10min)
|
||||
|
||||
- [ ] **9.4** SSE 整合測試 (2h)
|
||||
|
||||
- [ ] **9.5** 回歸測試 (1h)
|
||||
|
||||
- [ ] **9.6** 更新 README (15min)
|
||||
|
||||
- [ ] **9.7** 提交 (2min)
|
||||
|
||||
**預估時間**: ~4h
|
||||
|
||||
---
|
||||
|
||||
## 並行執行計劃
|
||||
|
||||
### Wave 1 (可完全並行)
|
||||
```
|
||||
Terminal 1: Task 0 (init) → 30min
|
||||
Terminal 2: (等待 Task 0)
|
||||
```
|
||||
|
||||
### Wave 2 (可完全並行)
|
||||
```
|
||||
Terminal 1: Task 1 (domain) → 2h
|
||||
Terminal 2: Task 2 (infrastructure) → 1h
|
||||
Terminal 3: Task 8 (cli) → 30min
|
||||
```
|
||||
|
||||
### Wave 3 (可部分並行)
|
||||
```
|
||||
Terminal 1: Task 3 (repository) → 1h (依賴 Task 1)
|
||||
Terminal 2: Task 4 (provider) → 30min (依賴 Task 1)
|
||||
Terminal 3: Task 6 (adapter) → 30min (依賴 Task 1)
|
||||
Terminal 4: (等待 Task 3)
|
||||
```
|
||||
|
||||
### Wave 4 (可部分並行)
|
||||
```
|
||||
Terminal 1: Task 5 (usecase) → 2h (依賴 Task 3)
|
||||
Terminal 2: (等待 Task 5)
|
||||
```
|
||||
|
||||
### Wave 5 (序列)
|
||||
```
|
||||
Task 7 (internal) → 4h
|
||||
Task 9 (cleanup) → 4h
|
||||
```
|
||||
|
||||
**總時間估計**:
|
||||
- 完全序列: ~15h
|
||||
- 並行執行: ~9h
|
||||
- 節省: ~40%
|
||||
|
||||
---
|
||||
|
||||
## Git Worktree 指令
|
||||
|
||||
```bash
|
||||
# 創建 worktrees
|
||||
git worktree add ../worktrees/init -b refactor/init
|
||||
git worktree add ../worktrees/domain -b refactor/domain
|
||||
git worktree add ../worktrees/infrastructure -b refactor/infrastructure
|
||||
git worktree add ../worktrees/repository -b refactor/repository
|
||||
git worktree add ../worktrees/provider -b refactor/provider
|
||||
git worktree add ../worktrees/usecase -b refactor/usecase
|
||||
git worktree add ../worktrees/adapter -b refactor/adapter
|
||||
git worktree add ../worktrees/cli -b refactor/cli
|
||||
|
||||
# 並行工作
|
||||
cd ../worktrees/domain && # Terminal 1
|
||||
cd ../worktrees/infrastructure && # Terminal 2
|
||||
cd ../worktrees/cli && # Terminal 3
|
||||
|
||||
# 清理 worktrees
|
||||
git worktree remove ../worktrees/init
|
||||
git worktree remove ../worktrees/domain
|
||||
# ... 等等
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文件版本**: v1.0
|
||||
**建立日期**: 2026-04-03
|
||||
20
Makefile
20
Makefile
|
|
@ -3,6 +3,26 @@
|
|||
# 編輯下方變數,然後執行 make env 產生 .env 檔
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# ── go-zero 代碼生成 ───────────────────────────
|
||||
.PHONY: api api-doc gen fmt lint
|
||||
|
||||
api:
|
||||
goctl api go -api api/chat.api -dir . --style go_zero
|
||||
|
||||
api-doc:
|
||||
goctl api doc -api api/chat.api -dir docs/api/
|
||||
|
||||
gen: api
|
||||
go mod tidy
|
||||
go fmt ./...
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
go fmt ./...
|
||||
|
||||
# ── 伺服器設定 ─────────────────────────────────
|
||||
HOST ?= 127.0.0.1
|
||||
PORT ?= 8766
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Cursor API Proxy"
|
||||
desc: "OpenAI-compatible API proxy for Cursor/Gemini"
|
||||
author: "cursor-api-proxy"
|
||||
version: "1.0"
|
||||
)
|
||||
|
||||
// ============ Types ============
|
||||
type (
|
||||
// Health
|
||||
HealthRequest {}
|
||||
HealthResponse {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
// Models
|
||||
ModelsRequest {}
|
||||
ModelsResponse {
|
||||
Object string `json:"object"`
|
||||
Data []ModelData `json:"data"`
|
||||
}
|
||||
ModelData {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
// Chat Completions
|
||||
ChatCompletionRequest {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Stream bool `json:"stream,optional"`
|
||||
Tools []Tool `json:"tools,optional"`
|
||||
Functions []Function `json:"functions,optional"`
|
||||
MaxTokens int `json:"max_tokens,optional"`
|
||||
Temperature float64 `json:"temperature,optional"`
|
||||
}
|
||||
Message {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
Tool {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
ToolFunction {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters interface{} `json:"parameters"`
|
||||
}
|
||||
Function {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,optional"`
|
||||
Parameters interface{} `json:"parameters,optional"`
|
||||
}
|
||||
ChatCompletionResponse {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
Choice {
|
||||
Index int `json:"index"`
|
||||
Message RespMessage `json:"message,optional"`
|
||||
Delta Delta `json:"delta,optional"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
RespMessage {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,optional"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,optional"`
|
||||
}
|
||||
Delta {
|
||||
Role string `json:"role,optional"`
|
||||
Content string `json:"content,optional"`
|
||||
ReasoningContent string `json:"reasoning_content,optional"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,optional"`
|
||||
}
|
||||
ToolCall {
|
||||
Index int `json:"index"`
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
FunctionCall {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
Usage {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
// Anthropic Messages
|
||||
AnthropicRequest {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream,optional"`
|
||||
System string `json:"system,optional"`
|
||||
}
|
||||
AnthropicResponse {
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
Usage AnthropicUsage `json:"usage"`
|
||||
}
|
||||
ContentBlock {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,optional"`
|
||||
}
|
||||
AnthropicUsage {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
)
|
||||
|
||||
// ============ Routes ============
|
||||
@server (
|
||||
prefix: /v1
|
||||
group: chat
|
||||
)
|
||||
service chat-api {
|
||||
@handler Health
|
||||
get /health returns (HealthResponse)
|
||||
|
||||
@handler Models
|
||||
get /v1/models returns (ModelsResponse)
|
||||
|
||||
@handler ChatCompletions
|
||||
post /v1/chat/completions (ChatCompletionRequest)
|
||||
|
||||
@handler AnthropicMessages
|
||||
post /v1/messages (AnthropicRequest)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/handler"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/conf"
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
var configFile = flag.String("f", "etc/chat-api.yaml", "the config file")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(*configFile, &c)
|
||||
|
||||
server := rest.MustNewServer(c.RestConf)
|
||||
defer server.Stop()
|
||||
|
||||
ctx := svc.NewServiceContext(c)
|
||||
handler.RegisterHandlers(server, ctx)
|
||||
|
||||
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
|
||||
server.Start()
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/agent"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"cursor-api-proxy/pkg/usecase"
|
||||
)
|
||||
|
||||
type AccountInfo struct {
|
||||
|
|
@ -86,7 +87,7 @@ func ReadAccountInfo(name, configDir string) AccountInfo {
|
|||
}
|
||||
|
||||
func HandleAccountsList() error {
|
||||
accountsDir := agent.AccountsDir()
|
||||
accountsDir := usecase.AccountsDir()
|
||||
|
||||
entries, err := os.ReadDir(accountsDir)
|
||||
if err != nil {
|
||||
|
|
@ -108,7 +109,7 @@ func HandleAccountsList() error {
|
|||
|
||||
fmt.Print("Cursor Accounts:\n\n")
|
||||
|
||||
keychainToken := agent.ReadKeychainToken()
|
||||
keychainToken := usecase.ReadKeychainToken()
|
||||
|
||||
for i, name := range names {
|
||||
configDir := filepath.Join(accountsDir, name)
|
||||
|
|
@ -117,7 +118,7 @@ func HandleAccountsList() error {
|
|||
fmt.Printf(" %d. %s\n", i+1, name)
|
||||
|
||||
if info.Authenticated {
|
||||
cachedToken := agent.ReadCachedToken(configDir)
|
||||
cachedToken := usecase.ReadCachedToken(configDir)
|
||||
keychainMatchesAccount := keychainToken != "" && info.AuthID != "" && TokenSub(keychainToken) == info.AuthID
|
||||
token := cachedToken
|
||||
if token == "" && keychainMatchesAccount {
|
||||
|
|
@ -178,7 +179,7 @@ func HandleLogout(accountName string) error {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
accountsDir := agent.AccountsDir()
|
||||
accountsDir := usecase.AccountsDir()
|
||||
configDir := filepath.Join(accountsDir, accountName)
|
||||
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
|
|
@ -2,8 +2,6 @@ package cmd
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"cursor-api-proxy/internal/agent"
|
||||
"cursor-api-proxy/internal/env"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -12,6 +10,9 @@ import (
|
|||
"regexp"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"cursor-api-proxy/pkg/usecase"
|
||||
)
|
||||
|
||||
var loginURLRe = regexp.MustCompile(`https://cursor\.com/loginDeepControl.*?redirectTarget=cli`)
|
||||
|
|
@ -25,7 +26,7 @@ func HandleLogin(accountName string, proxies []string) error {
|
|||
accountName = fmt.Sprintf("account-%d", time.Now().UnixMilli()%10000)
|
||||
}
|
||||
|
||||
accountsDir := agent.AccountsDir()
|
||||
accountsDir := usecase.AccountsDir()
|
||||
configDir := filepath.Join(accountsDir, accountName)
|
||||
dirWasNew := !fileExists(configDir)
|
||||
|
||||
|
|
@ -110,9 +111,9 @@ func HandleLogin(accountName string, proxies []string) error {
|
|||
}
|
||||
|
||||
// Cache keychain token for this account
|
||||
token := agent.ReadKeychainToken()
|
||||
token := usecase.ReadKeychainToken()
|
||||
if token != "" {
|
||||
agent.WriteCachedToken(configDir, token)
|
||||
usecase.WriteCachedToken(configDir, token)
|
||||
}
|
||||
|
||||
fmt.Printf("\nAccount '%s' saved — it will be auto-discovered when you start the proxy.\n", accountName)
|
||||
|
|
@ -2,8 +2,8 @@ package main
|
|||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/env"
|
||||
"cursor-api-proxy/internal/providers/geminiweb"
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"cursor-api-proxy/pkg/provider/geminiweb"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,462 @@
|
|||
# REFACTOR TASKS
|
||||
|
||||
重構任務拆分,支援 git worktree 並行開發。
|
||||
|
||||
---
|
||||
|
||||
## Task Overview
|
||||
|
||||
### 並行策略
|
||||
|
||||
```
|
||||
時間軸 ──────────────────────────────────────────────────────────────►
|
||||
|
||||
Task 0: Init (必須先完成)
|
||||
│
|
||||
├── Task 1: Domain Layer ─────────────────────────┐
|
||||
│ │
|
||||
│ ┌── Task 2: Infrastructure Layer ────────────┤── 並行
|
||||
│ │ │
|
||||
│ └── Task 3: Repository Layer ────────────────┘
|
||||
│ (依賴 Task 1)
|
||||
│
|
||||
├── Task 4: Provider Layer ──────────────────────┐
|
||||
│ (依賴 Task 1) │
|
||||
│ │── 可並行
|
||||
├── Task 5: Usecase Layer ───────────────────────┤
|
||||
│ (依賴 Task 3) │
|
||||
│ │
|
||||
├── Task 6: Adapter Layer ───────────────────────┘
|
||||
│ (依賴 Task 1)
|
||||
│
|
||||
├── Task 7: Internal Layer ──────────────────────┐
|
||||
│ (整合所有,必須最後) │
|
||||
│ │── 序列
|
||||
├── Task 8: CLI Tools │
|
||||
│ │
|
||||
└── Task 9: Cleanup & Tests ────────────────────┘
|
||||
```
|
||||
|
||||
### Worktree 分支規劃
|
||||
|
||||
| 分支名稱 | 基於 | 任務 | 可並行 |
|
||||
|---------|------|------|--------|
|
||||
| `refactor/init` | `master` | Task 0 | ❌ |
|
||||
| `refactor/domain` | `refactor/init` | Task 1 | ✅ |
|
||||
| `refactor/infrastructure` | `refactor/init` | Task 2 | ✅ |
|
||||
| `refactor/repository` | `refactor/domain` | Task 3 | ✅ |
|
||||
| `refactor/provider` | `refactor/domain` | Task 4 | ✅ |
|
||||
| `refactor/usecase` | `refactor/repository` | Task 5 | ✅ |
|
||||
| `refactor/adapter` | `refactor/domain` | Task 6 | ✅ |
|
||||
| `refactor/internal` | 合併所有 | Task 7 | ❌ |
|
||||
| `refactor/cli` | `refactor/init` | Task 8 | ✅ |
|
||||
| `refactor/cleanup` | 合併所有 | Task 9 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Task 0: 初始化
|
||||
|
||||
### 分支
|
||||
`refactor/init`
|
||||
|
||||
### 依賴
|
||||
無(必須先完成)
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **0.1** 更新 go.mod (5min)
|
||||
- `go get github.com/zeromicro/go-zero@latest`
|
||||
- `go mod tidy`
|
||||
|
||||
- [ ] **0.2** 建立目錄 (1min)
|
||||
- `mkdir -p api etc`
|
||||
|
||||
- [ ] **0.3** 建立 `api/chat.api` (15min)
|
||||
- 定義 API types
|
||||
- 定義 routes
|
||||
|
||||
- [ ] **0.4** 建立 `etc/chat.yaml` (5min)
|
||||
- 配置參數
|
||||
|
||||
- [ ] **0.5** 更新 Makefile (10min)
|
||||
- 新增 goctl 命令
|
||||
|
||||
- [ ] **0.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Domain Layer
|
||||
|
||||
### 分支
|
||||
`refactor/domain`
|
||||
|
||||
### 依賴
|
||||
Task 0 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **1.1** 建立目錄結構 (1min)
|
||||
- `pkg/domain/entity`
|
||||
- `pkg/domain/repository`
|
||||
- `pkg/domain/usecase`
|
||||
- `pkg/domain/const`
|
||||
|
||||
- [ ] **1.2** `entity/message.go` (10min)
|
||||
- Message, Tool, ToolFunction, ToolCall
|
||||
|
||||
- [ ] **1.3** `entity/chunk.go` (5min)
|
||||
- StreamChunk, ChunkType
|
||||
|
||||
- [ ] **1.4** `entity/account.go` (5min)
|
||||
- Account, AccountStat
|
||||
|
||||
- [ ] **1.5** `repository/account.go` (10min)
|
||||
- AccountPool interface
|
||||
|
||||
- [ ] **1.6** `repository/provider.go` (5min)
|
||||
- Provider interface
|
||||
|
||||
- [ ] **1.7** `usecase/chat.go` (15min)
|
||||
- ChatUsecase interface
|
||||
|
||||
- [ ] **1.8** `usecase/agent.go` (5min)
|
||||
- AgentRunner interface
|
||||
|
||||
- [ ] **1.9** `const/models.go` (10min)
|
||||
- Model 常數
|
||||
|
||||
- [ ] **1.10** `const/errors.go` (5min)
|
||||
- 錯誤定義
|
||||
|
||||
- [ ] **1.11** 提交 (2min)
|
||||
|
||||
**預估時間**: ~2h
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Infrastructure Layer
|
||||
|
||||
### 分支
|
||||
`refactor/infrastructure`
|
||||
|
||||
### 依賴
|
||||
Task 0 完成(可與 Task 1 並行)
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **2.1** 建立目錄 (2min)
|
||||
- `pkg/infrastructure/{process,parser,httputil,logger,env,workspace,winlimit}`
|
||||
|
||||
- [ ] **2.2** 遷移 process (10min)
|
||||
- runner.go, kill_unix.go, kill_windows.go, process_test.go
|
||||
|
||||
- [ ] **2.3** 遷移 parser (5min)
|
||||
- stream.go, stream_test.go
|
||||
|
||||
- [ ] **2.4** 遷移 httputil (5min)
|
||||
- httputil.go, httputil_test.go
|
||||
|
||||
- [ ] **2.5** 遷移 logger (5min)
|
||||
- logger.go
|
||||
|
||||
- [ ] **2.6** 遷移 env (5min)
|
||||
- env.go, env_test.go
|
||||
|
||||
- [ ] **2.7** 遷移 workspace (5min)
|
||||
- workspace.go
|
||||
|
||||
- [ ] **2.8** 遷移 winlimit (5min)
|
||||
- winlimit.go, winlimit_test.go
|
||||
|
||||
- [ ] **2.9** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **2.10** 提交 (2min)
|
||||
|
||||
**預估時間**: ~1h
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Repository Layer
|
||||
|
||||
### 分支
|
||||
`refactor/repository`
|
||||
|
||||
### 依賴
|
||||
Task 1 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **3.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **3.2** 遷移 account.go (20min)
|
||||
- AccountPool 實作
|
||||
- 移除全局變數
|
||||
|
||||
- [ ] **3.3** 遷移 provider.go (10min)
|
||||
- Provider 工廠
|
||||
|
||||
- [ ] **3.4** 遷移測試 (5min)
|
||||
|
||||
- [ ] **3.5** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **3.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~1h
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Provider Layer
|
||||
|
||||
### 分支
|
||||
`refactor/provider`
|
||||
|
||||
### 依賴
|
||||
Task 1 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **4.1** 建立目錄 (1min)
|
||||
- `pkg/provider/cursor`
|
||||
- `pkg/provider/geminiweb`
|
||||
|
||||
- [ ] **4.2** 遷移 cursor provider (5min)
|
||||
|
||||
- [ ] **4.3** 遷移 geminiweb provider (10min)
|
||||
|
||||
- [ ] **4.4** 更新 import (5min)
|
||||
|
||||
- [ ] **4.5** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **4.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Usecase Layer
|
||||
|
||||
### 分支
|
||||
`refactor/usecase`
|
||||
|
||||
### 依賴
|
||||
Task 3 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **5.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **5.2** 建立 chat.go (30min)
|
||||
- 核心聊天邏輯
|
||||
|
||||
- [ ] **5.3** 遷移 agent.go (20min)
|
||||
- runner, token, cmdargs, maxmode
|
||||
|
||||
- [ ] **5.4** 遷移 sanitizer (10min)
|
||||
|
||||
- [ ] **5.5** 遷移 toolcall (10min)
|
||||
|
||||
- [ ] **5.6** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **5.7** 提交 (2min)
|
||||
|
||||
**預估時間**: ~2h
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Adapter Layer
|
||||
|
||||
### 分支
|
||||
`refactor/adapter`
|
||||
|
||||
### 依賴
|
||||
Task 1 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **6.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **6.2** 遷移 openai adapter (10min)
|
||||
|
||||
- [ ] **6.3** 遷移 anthropic adapter (10min)
|
||||
|
||||
- [ ] **6.4** 更新 import (5min)
|
||||
|
||||
- [ ] **6.5** 驗證編譯 (5min)
|
||||
|
||||
- [ ] **6.6** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Internal Layer
|
||||
|
||||
### 分支
|
||||
`refactor/internal`
|
||||
|
||||
### 依賴
|
||||
Task 1-6 全部完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **7.1** 合併所有分支 (5min)
|
||||
|
||||
- [ ] **7.2** 更新 config/config.go (15min)
|
||||
- 使用 rest.RestConf
|
||||
|
||||
- [ ] **7.3** 建立 svc/servicecontext.go (30min)
|
||||
- DI 容器
|
||||
|
||||
- [ ] **7.4** 建立 logic/ (1h)
|
||||
- chatcompletionlogic.go
|
||||
- geminichatlogic.go
|
||||
- anthropiclogic.go
|
||||
- healthlogic.go
|
||||
- modelslogic.go
|
||||
|
||||
- [ ] **7.5** 建立 handler/ (1h)
|
||||
- 自訂 SSE handler
|
||||
|
||||
- [ ] **7.6** 建立 middleware/ (20min)
|
||||
- auth.go
|
||||
- recovery.go
|
||||
|
||||
- [ ] **7.7** 建立 types/ (5min)
|
||||
- goctl 生成
|
||||
|
||||
- [ ] **7.8** 更新 import (30min)
|
||||
- 批量更新
|
||||
|
||||
- [ ] **7.9** 驗證編譯 (10min)
|
||||
|
||||
- [ ] **7.10** 提交 (2min)
|
||||
|
||||
**預估時間**: ~4h
|
||||
|
||||
---
|
||||
|
||||
## Task 8: CLI Tools
|
||||
|
||||
### 分支
|
||||
`refactor/cli`
|
||||
|
||||
### 依賴
|
||||
Task 0 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **8.1** 建立目錄 (1min)
|
||||
|
||||
- [ ] **8.2** 遷移 CLI 工具 (10min)
|
||||
|
||||
- [ ] **8.3** 遷移 gemini-login (5min)
|
||||
|
||||
- [ ] **8.4** 更新 import (5min)
|
||||
|
||||
- [ ] **8.5** 提交 (2min)
|
||||
|
||||
**預估時間**: ~30min
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Cleanup & Tests
|
||||
|
||||
### 分支
|
||||
`refactor/cleanup`
|
||||
|
||||
### 依賴
|
||||
Task 7 完成
|
||||
|
||||
### 小任務
|
||||
|
||||
- [ ] **9.1** 移除舊目錄 (5min)
|
||||
|
||||
- [ ] **9.2** 更新 import (30min)
|
||||
- 批量 sed
|
||||
|
||||
- [ ] **9.3** 建立 cmd/chat/chat.go (10min)
|
||||
|
||||
- [ ] **9.4** SSE 整合測試 (2h)
|
||||
|
||||
- [ ] **9.5** 回歸測試 (1h)
|
||||
|
||||
- [ ] **9.6** 更新 README (15min)
|
||||
|
||||
- [ ] **9.7** 提交 (2min)
|
||||
|
||||
**預估時間**: ~4h
|
||||
|
||||
---
|
||||
|
||||
## 並行執行計劃
|
||||
|
||||
### Wave 1 (可完全並行)
|
||||
```
|
||||
Terminal 1: Task 0 (init) → 30min
|
||||
Terminal 2: (等待 Task 0)
|
||||
```
|
||||
|
||||
### Wave 2 (可完全並行)
|
||||
```
|
||||
Terminal 1: Task 1 (domain) → 2h
|
||||
Terminal 2: Task 2 (infrastructure) → 1h
|
||||
Terminal 3: Task 8 (cli) → 30min
|
||||
```
|
||||
|
||||
### Wave 3 (可部分並行)
|
||||
```
|
||||
Terminal 1: Task 3 (repository) → 1h (依賴 Task 1)
|
||||
Terminal 2: Task 4 (provider) → 30min (依賴 Task 1)
|
||||
Terminal 3: Task 6 (adapter) → 30min (依賴 Task 1)
|
||||
Terminal 4: (等待 Task 3)
|
||||
```
|
||||
|
||||
### Wave 4 (可部分並行)
|
||||
```
|
||||
Terminal 1: Task 5 (usecase) → 2h (依賴 Task 3)
|
||||
Terminal 2: (等待 Task 5)
|
||||
```
|
||||
|
||||
### Wave 5 (序列)
|
||||
```
|
||||
Task 7 (internal) → 4h
|
||||
Task 9 (cleanup) → 4h
|
||||
```
|
||||
|
||||
**總時間估計**:
|
||||
- 完全序列: ~15h
|
||||
- 並行執行: ~9h
|
||||
- 節省: ~40%
|
||||
|
||||
---
|
||||
|
||||
## Git Worktree 指令
|
||||
|
||||
```bash
|
||||
# 創建 worktrees
|
||||
git worktree add ../worktrees/init -b refactor/init
|
||||
git worktree add ../worktrees/domain -b refactor/domain
|
||||
git worktree add ../worktrees/infrastructure -b refactor/infrastructure
|
||||
git worktree add ../worktrees/repository -b refactor/repository
|
||||
git worktree add ../worktrees/provider -b refactor/provider
|
||||
git worktree add ../worktrees/usecase -b refactor/usecase
|
||||
git worktree add ../worktrees/adapter -b refactor/adapter
|
||||
git worktree add ../worktrees/cli -b refactor/cli
|
||||
|
||||
# 並行工作
|
||||
cd ../worktrees/domain && # Terminal 1
|
||||
cd ../worktrees/infrastructure && # Terminal 2
|
||||
cd ../worktrees/cli && # Terminal 3
|
||||
|
||||
# 清理 worktrees
|
||||
git worktree remove ../worktrees/init
|
||||
git worktree remove ../worktrees/domain
|
||||
# ... 等等
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文件版本**: v1.0
|
||||
**建立日期**: 2026-04-03
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
# TODOS
|
||||
|
||||
重構 cursor-api-proxy → go-zero + DDD Architecture 的待辦事項。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: API 定義與骨架生成
|
||||
|
||||
### DONE
|
||||
- [x] 建立 `api/chat.api` 定義檔
|
||||
- [x] 建立 `etc/chat.yaml` 配置檔
|
||||
- [x] 生成代碼骨架
|
||||
- [x] 移動 `chat.go` 到 `cmd/chat/`
|
||||
|
||||
### TODO
|
||||
|
||||
#### TODO-1: 全局變數遷移清單
|
||||
- **What**: 建立全局變數到 ServiceContext 的遷移清單
|
||||
- **Why**: 現有代碼有多個全局變數,遷移時容易遺漏
|
||||
- **Files**:
|
||||
- `internal/pool/pool.go:36-38` → `globalPool`, `globalMu` → ServiceContext
|
||||
- `internal/process/process.go:117` → `MaxModeFn` → ServiceContext
|
||||
- `internal/handlers/chat.go:28-29` → `rateLimitRe`, `retryAfterRe` → ServiceContext 或常數
|
||||
- `internal/models/cursormap.go:8,47,51` → 正則表達式常數化
|
||||
- **Decision**: ServiceContext 注入
|
||||
- **Effort**: human ~2h / CC ~30min
|
||||
- **Depends on**: Phase 2 (Domain 層建立)
|
||||
- **Status**: pending
|
||||
|
||||
#### TODO-2: go.mod 更新
|
||||
- **What**: 添加 go-zero 依賴到 go.mod
|
||||
- **Why**: 現有 go.mod 沒有 go-zero 依賴
|
||||
- **Command**: `go get github.com/zeromicro/go-zero@latest`
|
||||
- **Decision**: 使用最新穩定版
|
||||
- **Effort**: human ~5min / CC ~1min
|
||||
- **Depends on**: Phase 1 開始前
|
||||
- **Status**: pending
|
||||
|
||||
#### TODO-3: Makefile 更新
|
||||
- **What**: 更新 Makefile 以支援 go-zero 的建置流程
|
||||
- **Why**: 需要新增 goctl 命令和整合現有 env/run 命令
|
||||
- **Commands to add**:
|
||||
```makefile
|
||||
.PHONY: api
|
||||
api:
|
||||
goctl api go -api api/chat.api -dir . --style go_zero
|
||||
|
||||
.PHONY: api-doc
|
||||
api-doc:
|
||||
goctl api doc -api api/chat.api -dir docs/
|
||||
|
||||
.PHONY: gen
|
||||
gen: api
|
||||
go mod tidy
|
||||
```
|
||||
- **Decision**: 需要追蹤
|
||||
- **Effort**: human ~30min / CC ~10min
|
||||
- **Depends on**: Phase 1 (API 定義與骨架生成)
|
||||
- **Status**: pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Domain 層建立
|
||||
|
||||
### DONE
|
||||
- [ ] 建立 `pkg/domain/entity/`
|
||||
- [ ] 建立 `pkg/domain/repository/`
|
||||
- [ ] 建立 `pkg/domain/usecase/`
|
||||
- [ ] 建立 `pkg/domain/const/`
|
||||
|
||||
### TODO
|
||||
|
||||
#### TODO-4: import 循環依賴檢測
|
||||
- **What**: 在每個 Phase 完成後執行 `go build ./...` 檢測循環依賴
|
||||
- **Why**: DDD 架構分層容易產生循環依賴
|
||||
- **Potential cycles**:
|
||||
- `pkg/usecase` ↔ `pkg/domain/usecase`
|
||||
- `pkg/repository` ↔ `pkg/domain/repository`
|
||||
- **Command**: `go build ./... && go test ./... -run=none`
|
||||
- **Depends on**: 每個 Phase 完成後
|
||||
- **Status**: pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Internal 層重組
|
||||
|
||||
### DONE
|
||||
- [ ] 更新 `internal/config/config.go`
|
||||
- [ ] 建立 `internal/svc/servicecontext.go`
|
||||
- [ ] 建立 `internal/logic/`
|
||||
- [ ] 建立 `internal/handler/`
|
||||
- [ ] 建立 `internal/middleware/`
|
||||
|
||||
### TODO
|
||||
|
||||
#### TODO-5: SSE 整合測試
|
||||
- **What**: 增加 SSE streaming 的端對端測試
|
||||
- **Why**: SSE 是核心功能,自訂 handler 容易出錯,沒有測試覆蓋
|
||||
- **Test cases**:
|
||||
1. SSE streaming 請求正常返回
|
||||
2. SSE client disconnect 正確處理
|
||||
3. SSE timeout 正確處理
|
||||
4. 非串流請求轉 SSE 格式
|
||||
- **Implementation**:
|
||||
```go
|
||||
// tests/integration/sse_test.go
|
||||
func TestSSEStreaming(t *testing.T) {
|
||||
// 使用 httptest 模擬 SSE 客戶端
|
||||
// 驗證 data: [DONE] 正確返回
|
||||
}
|
||||
```
|
||||
- **Decision**: 使用 `rest.WithCustom` 路由
|
||||
- **Effort**: human ~2h / CC ~30min
|
||||
- **Depends on**: Phase 8 完成(Internal 層重組)
|
||||
- **Status**: pending
|
||||
|
||||
#### TODO-6: SSE Handler 實作
|
||||
- **What**: 使用 `rest.WithCustom` 實作 SSE streaming handler
|
||||
- **Why**: go-zero 標準 handler 不支援 SSE,需要自訂
|
||||
- **Implementation**:
|
||||
```go
|
||||
// internal/handler/chat_handler.go
|
||||
func NewChatHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// SSE 設定
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
// 委託給 usecase
|
||||
svcCtx.ChatUsecase.Stream(r.Context(), input, callback)
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Decision**: 使用 `rest.WithCustom` 路由
|
||||
- **Effort**: human ~2h / CC ~30min
|
||||
- **Depends on**: Phase 6 (Usecase 層建立)
|
||||
- **Status**: pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: 清理與測試
|
||||
|
||||
### DONE
|
||||
- [ ] 移除舊目錄
|
||||
- [ ] 更新 import 路徑
|
||||
- [ ] 執行測試
|
||||
|
||||
### TODO
|
||||
|
||||
#### TODO-7: 測試文件遷移
|
||||
- **What**: 測試文件跟隨源碼遷移到 pkg/
|
||||
- **Why**: 測試應該與源碼在同一目錄
|
||||
- **Files to migrate**:
|
||||
- `internal/httputil/httputil_test.go` → `pkg/infrastructure/httputil/`
|
||||
- `internal/config/config_test.go` → `internal/config/` (保留)
|
||||
- `internal/sanitize/sanitize_test.go` → `pkg/usecase/`
|
||||
- `internal/models/cursormap_test.go` → `pkg/domain/const/`
|
||||
- `internal/models/cursorcli_test.go` → `pkg/domain/const/`
|
||||
- `internal/parser/stream_test.go` → `pkg/infrastructure/parser/`
|
||||
- `internal/env/env_test.go` → `pkg/infrastructure/env/`
|
||||
- `internal/winlimit/winlimit_test.go` → `pkg/infrastructure/winlimit/`
|
||||
- `internal/anthropic/anthropic_test.go` → `pkg/adapter/anthropic/`
|
||||
- `internal/pool/pool_test.go` → `pkg/repository/`
|
||||
- `internal/openai/openai_test.go` → `pkg/adapter/openai/`
|
||||
- `internal/process/process_test.go` → `pkg/infrastructure/process/`
|
||||
- **Decision**: 測試遷移到 pkg/
|
||||
- **Effort**: human ~1h / CC ~10min
|
||||
- **Depends on**: Phase 3-7 完成
|
||||
- **Status**: pending
|
||||
|
||||
#### TODO-8: ServiceContext 單例 Pool
|
||||
- **What**: AccountPool 使用單例模式,透過 sync.Once 確保只初始化一次
|
||||
- **Why**: 避免每次請求創建新 Pool 的開銷
|
||||
- **Implementation**:
|
||||
```go
|
||||
// pkg/repository/account.go
|
||||
var (
|
||||
globalPool *AccountPool
|
||||
globalPoolOnce sync.Once
|
||||
)
|
||||
|
||||
func GetAccountPool(configDirs []string) *AccountPool {
|
||||
globalPoolOnce.Do(func() {
|
||||
globalPool = NewAccountPool(configDirs)
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
```
|
||||
- **Decision**: 使用單例 Pool
|
||||
- **Effort**: human ~30min / CC ~10min
|
||||
- **Depends on**: Phase 4 (Repository 層實作)
|
||||
- **Status**: pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 獨立 TODO
|
||||
|
||||
### TODO-9: 回歸測試自動化
|
||||
- **What**: 建立自動化回歸測試腳本
|
||||
- **Why**: 確保每次遷移後功能正常
|
||||
- **Script**:
|
||||
```bash
|
||||
# scripts/regression-test.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Health check ==="
|
||||
curl -s http://localhost:8080/health | jq .
|
||||
|
||||
echo "=== Models list ==="
|
||||
curl -s http://localhost:8080/v1/models | jq .
|
||||
|
||||
echo "=== Chat completion (non-streaming) ==="
|
||||
curl -s -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":false}' | jq .
|
||||
|
||||
echo "=== Chat completion (streaming) ==="
|
||||
curl -s -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}'
|
||||
```
|
||||
- **Depends on**: Phase 10 完成
|
||||
- **Status**: pending
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| TODO | Phase | Effort | Status |
|
||||
|------|-------|--------|--------|
|
||||
| TODO-1: 全局變數遷移清單 | Phase 2 | 2h | pending |
|
||||
| TODO-2: go.mod 更新 | Phase 1 | 5min | pending |
|
||||
| TODO-3: Makefile 更新 | Phase 1 | 30min | pending |
|
||||
| TODO-4: import 循環依賴檢測 | Each Phase | 5min | pending |
|
||||
| TODO-5: SSE 整合測試 | Phase 8 | 2h | pending |
|
||||
| TODO-6: SSE Handler 實作 | Phase 8 | 2h | pending |
|
||||
| TODO-7: 測試文件遷移 | Phase 10 | 1h | pending |
|
||||
| TODO-8: ServiceContext 單例 Pool | Phase 4 | 30min | pending |
|
||||
| TODO-9: 回歸測試自動化 | Phase 10 | 30min | pending |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Graph
|
||||
|
||||
```
|
||||
Phase 1 (API 定義)
|
||||
│
|
||||
├── TODO-2: go.mod 更新 (必须在開始前完成)
|
||||
├── TODO-3: Makefile 更新
|
||||
│
|
||||
▼
|
||||
Phase 2 (Domain 層)
|
||||
│
|
||||
├── TODO-1: 全局變數遷移清單
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 3 (Infrastructure 層)
|
||||
│
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 4 (Repository 層)
|
||||
│
|
||||
├── TODO-8: ServiceContext 單例 Pool
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 5 (Provider 層)
|
||||
│
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 6 (Usecase 層)
|
||||
│
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 7 (Adapter 層)
|
||||
│
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 8 (Internal 層)
|
||||
│
|
||||
├── TODO-5: SSE 整合測試
|
||||
├── TODO-6: SSE Handler 實作
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 9 (CLI 工具)
|
||||
│
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
Phase 10 (清理與測試)
|
||||
│
|
||||
├── TODO-7: 測試文件遷移
|
||||
├── TODO-9: 回歸測試自動化
|
||||
├── TODO-4: import 循環依賴檢測
|
||||
│
|
||||
▼
|
||||
完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文件版本**: v1.0
|
||||
**建立日期**: 2026-04-03
|
||||
**最後更新**: 2026-04-03
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
```mermaid
|
||||
.
|
||||
├── build/ # [Infrastructure] 存放 Dockerfile 與建置相關腳本
|
||||
├── docker-compose.yml # [Infrastructure] 本地開發環境編排 (Mongo, Redis, etc.)
|
||||
├── etc/ # [Configuration] 存放各環境的 yaml 設定檔範本
|
||||
├── generate/ # [Contract] Interface First 定義區
|
||||
│ └── api/ # 存放 .api 原始定義,作為服務間的通訊契約
|
||||
│ └── database/ # 如果有必要,請幫我放建立db 的檔案
|
||||
├── internal/ # [Framework Layer] 強依賴 go-zero 框架的實作區
|
||||
│ ├── config/ # 框架層的 Config mapping
|
||||
│ ├── logic/ # Adapter: 負責將框架 Request 轉接至 pkg/usecase
|
||||
│ ├── server/ # Transport: gRPC/HTTP Server 實作 (僅處理協議)
|
||||
│ └── svc/ # DI Center: 依賴注入中心,管理全域 Resource (DB, Client)
|
||||
├── pkg/ # [Core Domain] 核心業務邏輯 (不依賴 go-zero 框架)
|
||||
│ ├── domain/ # <Domain Layer>
|
||||
│ │ ├── entity/ # 純粹的業務物件 (POJO/POCO),不含資料庫標籤
|
||||
│ │ ├── repository/ # Repository Interface: 定義資料存取規範 (DIP)
|
||||
│ │ ├── const/ # 常數
|
||||
│ │ └── usecase/ # Usecase Interface: 定義業務功能的 API 契約
|
||||
│ ├── repository/ # <Infrastructure Layer>
|
||||
│ │ ├── *_test.go # 使用 Testcontainers (Real DB) 進行整合測試
|
||||
│ │ └── *.go # 實作 domain/repository 接口 (MongoDB/Redis)
|
||||
│ └── usecase/ # <Application Layer>
|
||||
│ ├── *_test.go # 核心業務邏輯的 Unit Test (使用 Mock)
|
||||
│ └── *.go # 實作業務流程,協調 Repository 與 Utils
|
||||
├── Makefile # [Automation] 封裝 protoc, test, build 等常用指令
|
||||
├── go.mod # [Dependency]
|
||||
└── main.go # [Entry] 服務啟動進入點
|
||||
```
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
Name: api-proxy
|
||||
Host: 0.0.0.0
|
||||
Port: 8766
|
||||
Timeout: 3600000
|
||||
|
||||
# Cursor Agent 配置
|
||||
AgentBin: /Users/daniel/.local/bin/agent
|
||||
DefaultModel: claude-4.5-sonnet
|
||||
Provider: cursor
|
||||
TimeoutMs: 3600000
|
||||
|
||||
# 多帳號池配置
|
||||
ConfigDirs:
|
||||
- ~/.cursor-api-proxy/accounts/default
|
||||
MultiPort: false
|
||||
|
||||
# TLS 憑證(可選)
|
||||
TLSCertPath: ""
|
||||
TLSKeyPath: ""
|
||||
|
||||
# 日誌
|
||||
SessionsLogPath: ""
|
||||
# Verbose 使用 RestConf 預設值
|
||||
|
||||
# Gemini Web Provider 配置
|
||||
GeminiAccountDir: ~/.cursor-api-proxy/gemini-accounts
|
||||
GeminiBrowserVisible: false
|
||||
GeminiMaxSessions: 10
|
||||
|
||||
# 工作區配置
|
||||
Workspace: ""
|
||||
ChatOnlyWorkspace: true
|
||||
WinCmdlineMax: 32768
|
||||
|
||||
# Agent 行為
|
||||
Force: false
|
||||
ApproveMcps: false
|
||||
MaxMode: false
|
||||
StrictModel: true
|
||||
|
||||
# API Key(可選,留空則不驗證)
|
||||
RequiredKey: ""
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
Name: chat-api
|
||||
Host: ${CURSOR_BRIDGE_HOST:0.0.0.0}
|
||||
Port: ${CURSOR_BRIDGE_PORT:8080}
|
||||
|
||||
# API Key 驗證(可選)
|
||||
# Auth:
|
||||
# AccessSecret: ${CURSOR_API_KEY:}
|
||||
# AccessExpire: 86400
|
||||
|
||||
# Cursor 配置
|
||||
AgentBin: ${CURSOR_AGENT_BIN:cursor-agent}
|
||||
DefaultModel: ${CURSOR_DEFAULT_MODEL:claude-3.5-sonnet}
|
||||
Provider: ${CURSOR_PROVIDER:cursor}
|
||||
|
||||
# 超時設定
|
||||
TimeoutMs: ${CURSOR_TIMEOUT_MS:300000}
|
||||
|
||||
# 多帳號池
|
||||
ConfigDirs:
|
||||
- ${HOME}/.cursor-api-proxy/accounts/default
|
||||
MultiPort: false
|
||||
|
||||
# TLS
|
||||
TLSCertPath: ${CURSOR_TLS_CERT_PATH:}
|
||||
TLSKeyPath: ${CURSOR_TLS_KEY_PATH:}
|
||||
|
||||
# 日誌
|
||||
SessionsLogPath: ${CURSOR_SESSIONS_LOG_PATH:}
|
||||
Verbose: ${CURSOR_VERBOSE:false}
|
||||
|
||||
# Gemini 設定
|
||||
GeminiAccountDir: ${GEMINI_ACCOUNT_DIR:}
|
||||
GeminiBrowserVisible: ${GEMINI_BROWSER_VISIBLE:false}
|
||||
GeminiMaxSessions: ${GEMINI_MAX_SESSIONS:10}
|
||||
|
||||
# 工作區設定
|
||||
Workspace: ${CURSOR_WORKSPACE:}
|
||||
ChatOnlyWorkspace: ${CURSOR_CHAT_ONLY_WORKSPACE:true}
|
||||
WinCmdlineMax: ${CURSOR_WIN_CMDLINE_MAX:32768}
|
||||
|
||||
# Agent 設定
|
||||
Force: ${CURSOR_FORCE:false}
|
||||
ApproveMcps: ${CURSOR_APPROVE_MCPS:false}
|
||||
MaxMode: ${CURSOR_MAX_MODE:false}
|
||||
StrictModel: ${CURSOR_STRICT_MODEL:true}
|
||||
46
go.mod
46
go.mod
|
|
@ -3,26 +3,68 @@ module cursor-api-proxy
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/playwright-community/playwright-go v0.5700.1
|
||||
github.com/zeromicro/go-zero v1.10.1
|
||||
modernc.org/sqlite v1.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
|
||||
github.com/go-rod/rod v0.116.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/grafana/pyroscope-go v1.2.8 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/playwright-community/playwright-go v0.5700.1 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/titanous/json5 v1.0.0 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
|
|
|||
131
go.sum
131
go.sum
|
|
@ -1,44 +1,148 @@
|
|||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
|
||||
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M=
|
||||
github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U=
|
||||
github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
|
||||
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
|
||||
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/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
|
||||
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU=
|
||||
github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
|
|
@ -51,6 +155,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -61,6 +167,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
@ -78,6 +185,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
|
@ -85,8 +194,30 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
|
||||
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
package apitypes
|
||||
|
||||
type Message struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string
|
||||
Function ToolFunction
|
||||
}
|
||||
|
||||
type ToolFunction struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters interface{}
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments string
|
||||
}
|
||||
|
||||
type StreamChunk struct {
|
||||
Type ChunkType
|
||||
Text string
|
||||
Thinking string
|
||||
ToolCall *ToolCall
|
||||
Done bool
|
||||
}
|
||||
|
||||
type ChunkType int
|
||||
|
||||
const (
|
||||
ChunkText ChunkType = iota
|
||||
ChunkThinking
|
||||
ChunkToolCall
|
||||
ChunkDone
|
||||
)
|
||||
|
|
@ -1,9 +1,57 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/env"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
// Config for go-zero (generated by goctl)
|
||||
type Config struct {
|
||||
rest.RestConf
|
||||
|
||||
// Cursor 配置
|
||||
AgentBin string
|
||||
DefaultModel string
|
||||
Provider string
|
||||
TimeoutMs int
|
||||
|
||||
// 多帳號池
|
||||
ConfigDirs []string
|
||||
MultiPort bool
|
||||
|
||||
// TLS
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
|
||||
// 日誌
|
||||
SessionsLogPath string
|
||||
// Verbose is inherited from rest.RestConf
|
||||
|
||||
// Gemini
|
||||
GeminiAccountDir string
|
||||
GeminiBrowserVisible bool
|
||||
GeminiMaxSessions int
|
||||
|
||||
// 工作區
|
||||
Workspace string
|
||||
ChatOnlyWorkspace bool
|
||||
WinCmdlineMax int
|
||||
|
||||
// Agent
|
||||
Force bool
|
||||
ApproveMcps bool
|
||||
MaxMode bool
|
||||
StrictModel bool
|
||||
|
||||
// API Key
|
||||
RequiredKey string
|
||||
}
|
||||
|
||||
// BridgeConfig for backward compatibility with existing code
|
||||
type BridgeConfig struct {
|
||||
AgentBin string
|
||||
Host string
|
||||
|
|
@ -31,6 +79,58 @@ type BridgeConfig struct {
|
|||
GeminiMaxSessions int
|
||||
}
|
||||
|
||||
// ToBridgeConfig converts Config to BridgeConfig
|
||||
func (c Config) ToBridgeConfig() BridgeConfig {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
home = os.Getenv("USERPROFILE")
|
||||
}
|
||||
|
||||
configDirs := c.ConfigDirs
|
||||
if len(configDirs) == 0 {
|
||||
configDirs = []string{filepath.Join(home, ".cursor-api-proxy", "accounts", "default")}
|
||||
} else {
|
||||
for i, dir := range configDirs {
|
||||
if len(dir) > 0 && dir[0] == '~' {
|
||||
configDirs[i] = filepath.Join(home, dir[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
geminiDir := c.GeminiAccountDir
|
||||
if geminiDir != "" && geminiDir[0] == '~' {
|
||||
geminiDir = filepath.Join(home, geminiDir[1:])
|
||||
}
|
||||
|
||||
return BridgeConfig{
|
||||
AgentBin: c.AgentBin,
|
||||
Host: c.Host,
|
||||
Port: c.Port,
|
||||
RequiredKey: c.RequiredKey,
|
||||
DefaultModel: c.DefaultModel,
|
||||
Mode: "ask",
|
||||
Provider: c.Provider,
|
||||
Force: c.Force,
|
||||
ApproveMcps: c.ApproveMcps,
|
||||
StrictModel: c.StrictModel,
|
||||
Workspace: c.Workspace,
|
||||
TimeoutMs: c.TimeoutMs,
|
||||
TLSCertPath: c.TLSCertPath,
|
||||
TLSKeyPath: c.TLSKeyPath,
|
||||
SessionsLogPath: c.SessionsLogPath,
|
||||
ChatOnlyWorkspace: c.ChatOnlyWorkspace,
|
||||
Verbose: c.Verbose,
|
||||
MaxMode: c.MaxMode,
|
||||
ConfigDirs: configDirs,
|
||||
MultiPort: c.MultiPort,
|
||||
WinCmdlineMax: c.WinCmdlineMax,
|
||||
GeminiAccountDir: geminiDir,
|
||||
GeminiBrowserVisible: c.GeminiBrowserVisible,
|
||||
GeminiMaxSessions: c.GeminiMaxSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadBridgeConfig loads config from environment (for backward compatibility)
|
||||
func LoadBridgeConfig(e env.EnvSource, cwd string) BridgeConfig {
|
||||
loaded := env.LoadEnvConfig(e, cwd)
|
||||
return BridgeConfig{
|
||||
|
|
|
|||
|
|
@ -1,123 +1,60 @@
|
|||
package config_test
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/env"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
)
|
||||
|
||||
func TestLoadBridgeConfig_Defaults(t *testing.T) {
|
||||
cfg := config.LoadBridgeConfig(env.EnvSource{}, "/workspace")
|
||||
func TestConfigToBridgeConfig(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
|
||||
if cfg.AgentBin != "agent" {
|
||||
t.Errorf("AgentBin = %q, want %q", cfg.AgentBin, "agent")
|
||||
bc := cfg.ToBridgeConfig()
|
||||
|
||||
if bc.Host != "" {
|
||||
t.Errorf("Host = %q, want empty", bc.Host)
|
||||
}
|
||||
if cfg.Host != "127.0.0.1" {
|
||||
t.Errorf("Host = %q, want %q", cfg.Host, "127.0.0.1")
|
||||
}
|
||||
if cfg.Port != 8765 {
|
||||
t.Errorf("Port = %d, want 8765", cfg.Port)
|
||||
}
|
||||
if cfg.RequiredKey != "" {
|
||||
t.Errorf("RequiredKey = %q, want empty", cfg.RequiredKey)
|
||||
}
|
||||
if cfg.DefaultModel != "auto" {
|
||||
t.Errorf("DefaultModel = %q, want %q", cfg.DefaultModel, "auto")
|
||||
}
|
||||
if cfg.Force {
|
||||
t.Error("Force should be false")
|
||||
}
|
||||
if cfg.ApproveMcps {
|
||||
t.Error("ApproveMcps should be false")
|
||||
}
|
||||
if !cfg.StrictModel {
|
||||
t.Error("StrictModel should be true")
|
||||
}
|
||||
if cfg.Mode != "ask" {
|
||||
t.Errorf("Mode = %q, want %q", cfg.Mode, "ask")
|
||||
}
|
||||
if cfg.Workspace != "/workspace" {
|
||||
t.Errorf("Workspace = %q, want /workspace", cfg.Workspace)
|
||||
}
|
||||
if !cfg.ChatOnlyWorkspace {
|
||||
t.Error("ChatOnlyWorkspace should be true")
|
||||
}
|
||||
if cfg.WinCmdlineMax != 30000 {
|
||||
t.Errorf("WinCmdlineMax = %d, want 30000", cfg.WinCmdlineMax)
|
||||
if bc.Mode != "ask" {
|
||||
t.Errorf("Mode = %q, want ask", bc.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBridgeConfig_FromEnv(t *testing.T) {
|
||||
e := env.EnvSource{
|
||||
"CURSOR_AGENT_BIN": "/usr/bin/agent",
|
||||
"CURSOR_BRIDGE_HOST": "0.0.0.0",
|
||||
"CURSOR_BRIDGE_PORT": "9999",
|
||||
"CURSOR_BRIDGE_API_KEY": "sk-secret",
|
||||
"CURSOR_BRIDGE_DEFAULT_MODEL": "org/claude-3-opus",
|
||||
"CURSOR_BRIDGE_FORCE": "true",
|
||||
"CURSOR_BRIDGE_APPROVE_MCPS": "yes",
|
||||
"CURSOR_BRIDGE_STRICT_MODEL": "false",
|
||||
"CURSOR_BRIDGE_WORKSPACE": "./my-workspace",
|
||||
"CURSOR_BRIDGE_TIMEOUT_MS": "60000",
|
||||
"CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE": "false",
|
||||
"CURSOR_BRIDGE_VERBOSE": "1",
|
||||
"CURSOR_BRIDGE_TLS_CERT": "./certs/test.crt",
|
||||
"CURSOR_BRIDGE_TLS_KEY": "./certs/test.key",
|
||||
func TestConfigToBridgeConfigWithValues(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
AgentBin: "cursor",
|
||||
DefaultModel: "claude-3.5-sonnet",
|
||||
Provider: "cursor",
|
||||
TimeoutMs: 300000,
|
||||
Force: true,
|
||||
ApproveMcps: true,
|
||||
StrictModel: true,
|
||||
Workspace: "/tmp/test",
|
||||
ChatOnlyWorkspace: true,
|
||||
GeminiAccountDir: "/tmp/gemini",
|
||||
GeminiMaxSessions: 5,
|
||||
}
|
||||
cfg := config.LoadBridgeConfig(e, "/tmp/project")
|
||||
|
||||
if cfg.AgentBin != "/usr/bin/agent" {
|
||||
t.Errorf("AgentBin = %q, want /usr/bin/agent", cfg.AgentBin)
|
||||
bc := cfg.ToBridgeConfig()
|
||||
|
||||
if bc.AgentBin != "cursor" {
|
||||
t.Errorf("AgentBin = %q, want cursor", bc.AgentBin)
|
||||
}
|
||||
if cfg.Host != "0.0.0.0" {
|
||||
t.Errorf("Host = %q, want 0.0.0.0", cfg.Host)
|
||||
if bc.DefaultModel != "claude-3.5-sonnet" {
|
||||
t.Errorf("DefaultModel = %q, want claude-3.5-sonnet", bc.DefaultModel)
|
||||
}
|
||||
if cfg.Port != 9999 {
|
||||
t.Errorf("Port = %d, want 9999", cfg.Port)
|
||||
if bc.TimeoutMs != 300000 {
|
||||
t.Errorf("TimeoutMs = %d, want 300000", bc.TimeoutMs)
|
||||
}
|
||||
if cfg.RequiredKey != "sk-secret" {
|
||||
t.Errorf("RequiredKey = %q, want sk-secret", cfg.RequiredKey)
|
||||
}
|
||||
if cfg.DefaultModel != "claude-3-opus" {
|
||||
t.Errorf("DefaultModel = %q, want claude-3-opus", cfg.DefaultModel)
|
||||
}
|
||||
if !cfg.Force {
|
||||
if !bc.Force {
|
||||
t.Error("Force should be true")
|
||||
}
|
||||
if !cfg.ApproveMcps {
|
||||
if !bc.ApproveMcps {
|
||||
t.Error("ApproveMcps should be true")
|
||||
}
|
||||
if cfg.StrictModel {
|
||||
t.Error("StrictModel should be false")
|
||||
if !bc.StrictModel {
|
||||
t.Error("StrictModel should be true")
|
||||
}
|
||||
if !filepath.IsAbs(cfg.Workspace) {
|
||||
t.Errorf("Workspace should be absolute, got %q", cfg.Workspace)
|
||||
}
|
||||
if !strings.Contains(cfg.Workspace, "my-workspace") {
|
||||
t.Errorf("Workspace %q should contain 'my-workspace'", cfg.Workspace)
|
||||
}
|
||||
if cfg.TimeoutMs != 60000 {
|
||||
t.Errorf("TimeoutMs = %d, want 60000", cfg.TimeoutMs)
|
||||
}
|
||||
if cfg.ChatOnlyWorkspace {
|
||||
t.Error("ChatOnlyWorkspace should be false")
|
||||
}
|
||||
if !cfg.Verbose {
|
||||
t.Error("Verbose should be true")
|
||||
}
|
||||
if cfg.TLSCertPath != "/tmp/project/certs/test.crt" {
|
||||
t.Errorf("TLSCertPath = %q, want /tmp/project/certs/test.crt", cfg.TLSCertPath)
|
||||
}
|
||||
if cfg.TLSKeyPath != "/tmp/project/certs/test.key" {
|
||||
t.Errorf("TLSKeyPath = %q, want /tmp/project/certs/test.key", cfg.TLSKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBridgeConfig_WideHost(t *testing.T) {
|
||||
cfg := config.LoadBridgeConfig(env.EnvSource{"CURSOR_BRIDGE_HOST": "0.0.0.0"}, "/workspace")
|
||||
if cfg.Host != "0.0.0.0" {
|
||||
t.Errorf("Host = %q, want 0.0.0.0", cfg.Host)
|
||||
if bc.Mode != "ask" {
|
||||
t.Errorf("Mode = %q, want ask", bc.Mode)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"cursor-api-proxy/internal/logic/chat"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
"cursor-api-proxy/internal/types"
|
||||
|
||||
"cursor-api-proxy/pkg/infrastructure/httputil"
|
||||
)
|
||||
|
||||
func AnthropicMessagesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Read raw body first
|
||||
rawBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"type": "invalid_request_error", "message": "failed to read body"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req types.AnthropicRequest
|
||||
if err := json.Unmarshal(rawBody, &req); err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"type": "invalid_request_error", "message": "invalid JSON body"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
l := chat.NewAnthropicMessagesLogic(r.Context(), svcCtx)
|
||||
if req.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
_ = l.AnthropicMessagesStream(&req, w, r.Method, r.URL.Path)
|
||||
} else {
|
||||
err := l.AnthropicMessages(&req, w, r.Method, r.URL.Path)
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"type": "api_error", "message": err.Error()},
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"cursor-api-proxy/internal/logic/chat"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
"cursor-api-proxy/internal/types"
|
||||
|
||||
"cursor-api-proxy/pkg/infrastructure/httputil"
|
||||
)
|
||||
|
||||
func ChatCompletionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Read raw body first
|
||||
rawBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"message": "failed to read body", "code": "bad_request"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req types.ChatCompletionRequest
|
||||
if err := json.Unmarshal(rawBody, &req); err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"message": "invalid JSON body", "code": "bad_request"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
l := chat.NewChatCompletionsLogic(r.Context(), svcCtx)
|
||||
if req.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
_ = l.ChatCompletionsStream(&req, w, r.Method, r.URL.Path)
|
||||
} else {
|
||||
resp, err := l.ChatCompletions(&req)
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": err.Error(), "code": "internal_error"},
|
||||
}, nil)
|
||||
} else {
|
||||
httputil.WriteJSON(w, 200, resp, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cursor-api-proxy/internal/logic/chat"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func HealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
l := chat.NewHealthLogic(r.Context(), svcCtx)
|
||||
resp, err := l.Health()
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cursor-api-proxy/internal/logic/chat"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func ModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
l := chat.NewModelsLogic(r.Context(), svcCtx)
|
||||
resp, err := l.Models()
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// Code generated by goctl. DO NOT EDIT.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
chat "cursor-api-proxy/internal/handler/chat"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/health",
|
||||
Handler: chat.HealthHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/v1/models",
|
||||
Handler: chat.ModelsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/v1/chat/completions",
|
||||
Handler: chat.ChatCompletionsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/v1/messages",
|
||||
Handler: chat.AnthropicMessagesHandler(serverCtx),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,577 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/agent"
|
||||
"cursor-api-proxy/internal/anthropic"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/httputil"
|
||||
"cursor-api-proxy/internal/logger"
|
||||
"cursor-api-proxy/internal/models"
|
||||
"cursor-api-proxy/internal/openai"
|
||||
"cursor-api-proxy/internal/parser"
|
||||
"cursor-api-proxy/internal/pool"
|
||||
"cursor-api-proxy/internal/sanitize"
|
||||
"cursor-api-proxy/internal/toolcall"
|
||||
"cursor-api-proxy/internal/winlimit"
|
||||
"cursor-api-proxy/internal/workspace"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config.BridgeConfig, ph pool.PoolHandle, lastModelRef *string, rawBody, method, pathname, remoteAddress string) {
|
||||
var req anthropic.MessagesRequest
|
||||
if err := json.Unmarshal([]byte(rawBody), &req); err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"type": "invalid_request_error", "message": "invalid JSON body"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
requested := openai.NormalizeModelID(req.Model)
|
||||
model := ResolveModel(requested, lastModelRef, cfg)
|
||||
|
||||
var rawMap map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(rawBody), &rawMap)
|
||||
|
||||
cleanSystem := sanitize.SanitizeSystem(req.System)
|
||||
|
||||
rawMessages := make([]interface{}, len(req.Messages))
|
||||
for i, m := range req.Messages {
|
||||
rawMessages[i] = map[string]interface{}{"role": m.Role, "content": m.Content}
|
||||
}
|
||||
cleanRawMessages := sanitize.SanitizeMessages(rawMessages)
|
||||
|
||||
var cleanMessages []anthropic.MessageParam
|
||||
for _, raw := range cleanRawMessages {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
role, _ := m["role"].(string)
|
||||
cleanMessages = append(cleanMessages, anthropic.MessageParam{Role: role, Content: m["content"]})
|
||||
}
|
||||
}
|
||||
|
||||
toolsText := openai.ToolsToSystemText(req.Tools, nil)
|
||||
var systemWithTools interface{}
|
||||
if toolsText != "" {
|
||||
sysStr := ""
|
||||
switch v := cleanSystem.(type) {
|
||||
case string:
|
||||
sysStr = v
|
||||
}
|
||||
if sysStr != "" {
|
||||
systemWithTools = sysStr + "\n\n" + toolsText
|
||||
} else {
|
||||
systemWithTools = toolsText
|
||||
}
|
||||
} else {
|
||||
systemWithTools = cleanSystem
|
||||
}
|
||||
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(cleanMessages, systemWithTools)
|
||||
|
||||
if req.MaxTokens == 0 {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"type": "invalid_request_error", "message": "max_tokens is required"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
cursorModel := models.ResolveToCursorModel(model)
|
||||
if cursorModel == "" {
|
||||
cursorModel = model
|
||||
}
|
||||
|
||||
var trafficMsgs []logger.TrafficMessage
|
||||
if s := systemToString(cleanSystem); s != "" {
|
||||
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: "system", Content: s})
|
||||
}
|
||||
for _, m := range cleanMessages {
|
||||
text := contentToString(m.Content)
|
||||
if text != "" {
|
||||
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: m.Role, Content: text})
|
||||
}
|
||||
}
|
||||
logger.LogTrafficRequest(cfg.Verbose, model, trafficMsgs, req.Stream)
|
||||
|
||||
headerWs := r.Header.Get("x-cursor-workspace")
|
||||
ws := workspace.ResolveWorkspace(cfg, headerWs)
|
||||
|
||||
fixedArgs := agent.BuildAgentFixedArgs(cfg, ws.WorkspaceDir, cursorModel, req.Stream)
|
||||
fit := winlimit.FitPromptToWinCmdline(cfg.AgentBin, fixedArgs, prompt, cfg.WinCmdlineMax, ws.WorkspaceDir)
|
||||
|
||||
if cfg.Verbose {
|
||||
if len(prompt) > 200 {
|
||||
logger.LogDebug("model=%s prompt_len=%d prompt_preview=%q", cursorModel, len(prompt), prompt[:200]+"...")
|
||||
} else {
|
||||
logger.LogDebug("model=%s prompt_len=%d prompt=%q", cursorModel, len(prompt), prompt)
|
||||
}
|
||||
logger.LogDebug("cmd_args=%v", fit.Args)
|
||||
}
|
||||
|
||||
if !fit.OK {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"type": "api_error", "message": fit.Error},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
if fit.Truncated {
|
||||
logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength)
|
||||
}
|
||||
|
||||
cmdArgs := fit.Args
|
||||
msgID := "msg_" + uuid.New().String()
|
||||
|
||||
var truncatedHeaders map[string]string
|
||||
if fit.Truncated {
|
||||
truncatedHeaders = map[string]string{"X-Cursor-Proxy-Prompt-Truncated": "true"}
|
||||
}
|
||||
|
||||
hasTools := len(req.Tools) > 0
|
||||
var toolNames map[string]bool
|
||||
if hasTools {
|
||||
toolNames = toolcall.CollectToolNames(req.Tools)
|
||||
}
|
||||
|
||||
if req.Stream {
|
||||
httputil.WriteSSEHeaders(w, truncatedHeaders)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
writeEvent := func(evt interface{}) {
|
||||
data, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
var accumulated string
|
||||
var accumulatedThinking string
|
||||
var chunkNum int
|
||||
var p parser.Parser
|
||||
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "message_start",
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": []interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
if hasTools {
|
||||
toolCallMarkerRe := regexp.MustCompile(`行政法规|<function_calls>`)
|
||||
var toolCallMode bool
|
||||
|
||||
textBlockOpen := false
|
||||
textBlockIndex := 0
|
||||
thinkingOpen := false
|
||||
thinkingBlockIndex := 0
|
||||
blockCount := 0
|
||||
|
||||
p = parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
chunkNum++
|
||||
logger.LogStreamChunk(model, text, chunkNum)
|
||||
|
||||
if toolCallMode {
|
||||
return
|
||||
}
|
||||
if toolCallMarkerRe.MatchString(text) {
|
||||
if textBlockOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": textBlockIndex})
|
||||
textBlockOpen = false
|
||||
}
|
||||
if thinkingOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": thinkingBlockIndex})
|
||||
thinkingOpen = false
|
||||
}
|
||||
toolCallMode = true
|
||||
return
|
||||
}
|
||||
if !textBlockOpen && !thinkingOpen {
|
||||
textBlockIndex = blockCount
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": textBlockIndex,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
textBlockOpen = true
|
||||
blockCount++
|
||||
}
|
||||
if thinkingOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": thinkingBlockIndex})
|
||||
thinkingOpen = false
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": textBlockIndex,
|
||||
"delta": map[string]string{"type": "text_delta", "text": text},
|
||||
})
|
||||
},
|
||||
func(thinking string) {
|
||||
accumulatedThinking += thinking
|
||||
chunkNum++
|
||||
if toolCallMode {
|
||||
return
|
||||
}
|
||||
if !thinkingOpen {
|
||||
thinkingBlockIndex = blockCount
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": thinkingBlockIndex,
|
||||
"content_block": map[string]string{"type": "thinking", "thinking": ""},
|
||||
})
|
||||
thinkingOpen = true
|
||||
blockCount++
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": thinkingBlockIndex,
|
||||
"delta": map[string]string{"type": "thinking_delta", "thinking": thinking},
|
||||
})
|
||||
},
|
||||
func() {
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
|
||||
parsed := toolcall.ExtractToolCalls(accumulated, toolNames)
|
||||
|
||||
blockIndex := 0
|
||||
if thinkingOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": thinkingBlockIndex})
|
||||
thinkingOpen = false
|
||||
}
|
||||
|
||||
if parsed.HasToolCalls() {
|
||||
if textBlockOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": textBlockIndex})
|
||||
blockIndex = textBlockIndex + 1
|
||||
}
|
||||
if parsed.TextContent != "" && !textBlockOpen && !toolCallMode {
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start", "index": blockIndex,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta", "index": blockIndex,
|
||||
"delta": map[string]string{"type": "text_delta", "text": parsed.TextContent},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex})
|
||||
blockIndex++
|
||||
}
|
||||
for _, tc := range parsed.ToolCalls {
|
||||
toolID := "toolu_" + uuid.New().String()[:12]
|
||||
var inputObj interface{}
|
||||
_ = json.Unmarshal([]byte(tc.Arguments), &inputObj)
|
||||
if inputObj == nil {
|
||||
inputObj = map[string]interface{}{}
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start", "index": blockIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "tool_use", "id": toolID, "name": tc.Name, "input": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta", "index": blockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta", "partial_json": tc.Arguments,
|
||||
},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex})
|
||||
blockIndex++
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": "tool_use", "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": 0},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "message_stop"})
|
||||
} else {
|
||||
if textBlockOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": textBlockIndex})
|
||||
} else if accumulated != "" {
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start", "index": blockIndex,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta", "index": blockIndex,
|
||||
"delta": map[string]string{"type": "text_delta", "text": accumulated},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex})
|
||||
blockIndex++
|
||||
} else {
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start", "index": blockIndex,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex})
|
||||
blockIndex++
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": 0},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "message_stop"})
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// 非 tools 模式:即時串流 thinking 和 text
|
||||
blockCount := 0
|
||||
thinkingOpen := false
|
||||
textOpen := false
|
||||
|
||||
p = parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
chunkNum++
|
||||
logger.LogStreamChunk(model, text, chunkNum)
|
||||
if thinkingOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockCount - 1})
|
||||
thinkingOpen = false
|
||||
}
|
||||
if !textOpen {
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": blockCount,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
textOpen = true
|
||||
blockCount++
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": blockCount - 1,
|
||||
"delta": map[string]string{"type": "text_delta", "text": text},
|
||||
})
|
||||
},
|
||||
func(thinking string) {
|
||||
accumulatedThinking += thinking
|
||||
chunkNum++
|
||||
if !thinkingOpen {
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": blockCount,
|
||||
"content_block": map[string]string{"type": "thinking", "thinking": ""},
|
||||
})
|
||||
thinkingOpen = true
|
||||
blockCount++
|
||||
}
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": blockCount - 1,
|
||||
"delta": map[string]string{"type": "thinking_delta", "thinking": thinking},
|
||||
})
|
||||
},
|
||||
func() {
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
|
||||
if thinkingOpen {
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockCount - 1})
|
||||
thinkingOpen = false
|
||||
}
|
||||
if !textOpen {
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": blockCount,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
blockCount++
|
||||
}
|
||||
writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockCount - 1})
|
||||
writeEvent(map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": 0},
|
||||
})
|
||||
writeEvent(map[string]interface{}{"type": "message_stop"})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
configDir := ph.GetNextConfigDir()
|
||||
logger.LogAccountAssigned(configDir)
|
||||
ph.ReportRequestStart(configDir)
|
||||
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, true)
|
||||
streamStart := time.Now().UnixMilli()
|
||||
|
||||
ctx := r.Context()
|
||||
wrappedParser := func(line string) {
|
||||
logger.LogRawLine(line)
|
||||
p.Parse(line)
|
||||
}
|
||||
result, err := agent.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, ctx)
|
||||
|
||||
if ctx.Err() == nil {
|
||||
p.Flush()
|
||||
}
|
||||
|
||||
latencyMs := time.Now().UnixMilli() - streamStart
|
||||
ph.ReportRequestEnd(configDir)
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
|
||||
} else if ctx.Err() == context.Canceled {
|
||||
logger.LogClientDisconnect(method, pathname, model, latencyMs)
|
||||
} else if err == nil && isRateLimited(result.Stderr) {
|
||||
ph.ReportRateLimit(configDir, extractRetryAfterMs(result.Stderr))
|
||||
}
|
||||
|
||||
if err != nil || (result.Code != 0 && ctx.Err() == nil) {
|
||||
ph.ReportRequestError(configDir, latencyMs)
|
||||
if err != nil {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, err.Error())
|
||||
} else {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, result.Code, result.Stderr)
|
||||
}
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, result.Code)
|
||||
} else if ctx.Err() == nil {
|
||||
ph.ReportRequestSuccess(configDir, latencyMs)
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, 0)
|
||||
}
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
return
|
||||
}
|
||||
|
||||
configDir := ph.GetNextConfigDir()
|
||||
logger.LogAccountAssigned(configDir)
|
||||
ph.ReportRequestStart(configDir)
|
||||
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, false)
|
||||
syncStart := time.Now().UnixMilli()
|
||||
|
||||
out, err := agent.RunAgentSync(cfg, ws.WorkspaceDir, cmdArgs, ws.TempDir, configDir, r.Context())
|
||||
syncLatency := time.Now().UnixMilli() - syncStart
|
||||
ph.ReportRequestEnd(configDir)
|
||||
|
||||
ctx := r.Context()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
|
||||
httputil.WriteJSON(w, 504, map[string]interface{}{
|
||||
"error": map[string]string{"type": "api_error", "message": fmt.Sprintf("request timed out after %dms", cfg.TimeoutMs)},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
if ctx.Err() == context.Canceled {
|
||||
logger.LogClientDisconnect(method, pathname, model, syncLatency)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ph.ReportRequestError(configDir, syncLatency)
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
logger.LogRequestDone(method, pathname, model, syncLatency, -1)
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"type": "api_error", "message": err.Error()},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if isRateLimited(out.Stderr) {
|
||||
ph.ReportRateLimit(configDir, extractRetryAfterMs(out.Stderr))
|
||||
}
|
||||
|
||||
if out.Code != 0 {
|
||||
ph.ReportRequestError(configDir, syncLatency)
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
errMsg := logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, out.Code, out.Stderr)
|
||||
logger.LogRequestDone(method, pathname, model, syncLatency, out.Code)
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"type": "api_error", "message": errMsg},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ph.ReportRequestSuccess(configDir, syncLatency)
|
||||
content := strings.TrimSpace(out.Stdout)
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, content, false)
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
logger.LogRequestDone(method, pathname, model, syncLatency, 0)
|
||||
|
||||
if hasTools {
|
||||
parsed := toolcall.ExtractToolCalls(content, toolNames)
|
||||
if parsed.HasToolCalls() {
|
||||
var contentBlocks []map[string]interface{}
|
||||
if parsed.TextContent != "" {
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "text", "text": parsed.TextContent,
|
||||
})
|
||||
}
|
||||
for _, tc := range parsed.ToolCalls {
|
||||
toolID := "toolu_" + uuid.New().String()[:12]
|
||||
var inputObj interface{}
|
||||
_ = json.Unmarshal([]byte(tc.Arguments), &inputObj)
|
||||
if inputObj == nil {
|
||||
inputObj = map[string]interface{}{}
|
||||
}
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "tool_use", "id": toolID, "name": tc.Name, "input": inputObj,
|
||||
})
|
||||
}
|
||||
httputil.WriteJSON(w, 200, map[string]interface{}{
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": contentBlocks,
|
||||
"model": model,
|
||||
"stop_reason": "tool_use",
|
||||
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
||||
}, truncatedHeaders)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
httputil.WriteJSON(w, 200, map[string]interface{}{
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]string{{"type": "text", "text": content}},
|
||||
"model": model,
|
||||
"stop_reason": "end_turn",
|
||||
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
||||
}, truncatedHeaders)
|
||||
}
|
||||
|
||||
func systemToString(system interface{}) string {
|
||||
switch v := system.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
result := ""
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok && m["type"] == "text" {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
result += t
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contentToString(content interface{}) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
result := ""
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok && m["type"] == "text" {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
result += t
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/agent"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/httputil"
|
||||
"cursor-api-proxy/internal/logger"
|
||||
"cursor-api-proxy/internal/models"
|
||||
"cursor-api-proxy/internal/openai"
|
||||
"cursor-api-proxy/internal/parser"
|
||||
"cursor-api-proxy/internal/pool"
|
||||
"cursor-api-proxy/internal/sanitize"
|
||||
"cursor-api-proxy/internal/toolcall"
|
||||
"cursor-api-proxy/internal/winlimit"
|
||||
"cursor-api-proxy/internal/workspace"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var rateLimitRe = regexp.MustCompile(`(?i)\b429\b|rate.?limit|too many requests`)
|
||||
var retryAfterRe = regexp.MustCompile(`(?i)retry-after:\s*(\d+)`)
|
||||
|
||||
func isRateLimited(stderr string) bool {
|
||||
return rateLimitRe.MatchString(stderr)
|
||||
}
|
||||
|
||||
func extractRetryAfterMs(stderr string) int64 {
|
||||
if m := retryAfterRe.FindStringSubmatch(stderr); len(m) > 1 {
|
||||
if secs, err := strconv.ParseInt(m[1], 10, 64); err == nil && secs > 0 {
|
||||
return secs * 1000
|
||||
}
|
||||
}
|
||||
return 60000
|
||||
}
|
||||
|
||||
func HandleChatCompletions(w http.ResponseWriter, r *http.Request, cfg config.BridgeConfig, ph pool.PoolHandle, lastModelRef *string, rawBody, method, pathname, remoteAddress string) {
|
||||
var bodyMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rawBody), &bodyMap); err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"message": "invalid JSON body", "code": "bad_request"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
rawModel, _ := bodyMap["model"].(string)
|
||||
requested := openai.NormalizeModelID(rawModel)
|
||||
model := ResolveModel(requested, lastModelRef, cfg)
|
||||
cursorModel := models.ResolveToCursorModel(model)
|
||||
if cursorModel == "" {
|
||||
cursorModel = model
|
||||
}
|
||||
|
||||
var messages []interface{}
|
||||
if m, ok := bodyMap["messages"].([]interface{}); ok {
|
||||
messages = m
|
||||
}
|
||||
|
||||
var tools []interface{}
|
||||
if t, ok := bodyMap["tools"].([]interface{}); ok {
|
||||
tools = t
|
||||
}
|
||||
var funcs []interface{}
|
||||
if f, ok := bodyMap["functions"].([]interface{}); ok {
|
||||
funcs = f
|
||||
}
|
||||
|
||||
cleanMessages := sanitize.SanitizeMessages(messages)
|
||||
|
||||
toolsText := openai.ToolsToSystemText(tools, funcs)
|
||||
messagesWithTools := cleanMessages
|
||||
if toolsText != "" {
|
||||
messagesWithTools = append([]interface{}{map[string]interface{}{"role": "system", "content": toolsText}}, cleanMessages...)
|
||||
}
|
||||
prompt := openai.BuildPromptFromMessages(messagesWithTools)
|
||||
|
||||
var trafficMsgs []logger.TrafficMessage
|
||||
for _, raw := range cleanMessages {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
role, _ := m["role"].(string)
|
||||
content := openai.MessageContentToText(m["content"])
|
||||
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: role, Content: content})
|
||||
}
|
||||
}
|
||||
|
||||
isStream := false
|
||||
if s, ok := bodyMap["stream"].(bool); ok {
|
||||
isStream = s
|
||||
}
|
||||
|
||||
logger.LogTrafficRequest(cfg.Verbose, model, trafficMsgs, isStream)
|
||||
|
||||
headerWs := r.Header.Get("x-cursor-workspace")
|
||||
ws := workspace.ResolveWorkspace(cfg, headerWs)
|
||||
|
||||
promptLen := len(prompt)
|
||||
if cfg.Verbose {
|
||||
if promptLen > 200 {
|
||||
logger.LogDebug("model=%s prompt_len=%d prompt_start=%q", cursorModel, promptLen, prompt[:200])
|
||||
} else {
|
||||
logger.LogDebug("model=%s prompt_len=%d prompt=%q", cursorModel, promptLen, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
fixedArgs := agent.BuildAgentFixedArgs(cfg, ws.WorkspaceDir, cursorModel, isStream)
|
||||
fit := winlimit.FitPromptToWinCmdline(cfg.AgentBin, fixedArgs, prompt, cfg.WinCmdlineMax, ws.WorkspaceDir)
|
||||
|
||||
if cfg.Verbose {
|
||||
logger.LogDebug("cmd=%s args=%v", cfg.AgentBin, fit.Args)
|
||||
}
|
||||
|
||||
if !fit.OK {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": fit.Error, "code": "windows_cmdline_limit"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
if fit.Truncated {
|
||||
logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength)
|
||||
}
|
||||
|
||||
cmdArgs := fit.Args
|
||||
id := "chatcmpl_" + uuid.New().String()
|
||||
created := time.Now().Unix()
|
||||
|
||||
var truncatedHeaders map[string]string
|
||||
if fit.Truncated {
|
||||
truncatedHeaders = map[string]string{"X-Cursor-Proxy-Prompt-Truncated": "true"}
|
||||
}
|
||||
|
||||
hasTools := len(tools) > 0 || len(funcs) > 0
|
||||
var toolNames map[string]bool
|
||||
if hasTools {
|
||||
toolNames = toolcall.CollectToolNames(tools)
|
||||
for _, f := range funcs {
|
||||
if fm, ok := f.(map[string]interface{}); ok {
|
||||
if name, ok := fm["name"].(string); ok {
|
||||
toolNames[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isStream {
|
||||
httputil.WriteSSEHeaders(w, truncatedHeaders)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
var accumulated string
|
||||
var chunkNum int
|
||||
var p parser.Parser
|
||||
|
||||
// toolCallMarkerRe 偵測 tool call 開頭標記,一旦出現就停止即時輸出並進入累積模式
|
||||
toolCallMarkerRe := regexp.MustCompile(`<tool_call>|<function_calls>`)
|
||||
if hasTools {
|
||||
var toolCallMode bool // 是否已進入 tool call 累積模式
|
||||
p = parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
chunkNum++
|
||||
logger.LogStreamChunk(model, text, chunkNum)
|
||||
if toolCallMode {
|
||||
// 已進入累積模式,不即時輸出
|
||||
return
|
||||
}
|
||||
if toolCallMarkerRe.MatchString(text) {
|
||||
// 偵測到 tool call 標記,切換為累積模式
|
||||
toolCallMode = true
|
||||
return
|
||||
}
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func(_ string) {}, // thinking ignored in tools mode
|
||||
func() {
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
|
||||
parsed := toolcall.ExtractToolCalls(accumulated, toolNames)
|
||||
|
||||
if parsed.HasToolCalls() {
|
||||
if parsed.TextContent != "" && toolCallMode {
|
||||
// 已有部分 text 被即時輸出,只補發剩餘的
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{"role": "assistant", "content": parsed.TextContent}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
for i, tc := range parsed.ToolCalls {
|
||||
callID := "call_" + uuid.New().String()[:8]
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"index": i,
|
||||
"id": callID,
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": tc.Name,
|
||||
"arguments": tc.Arguments,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "tool_calls"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
} else {
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "stop"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
p = parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
chunkNum++
|
||||
logger.LogStreamChunk(model, text, chunkNum)
|
||||
chunk := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func(thinking string) {
|
||||
chunk := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{"reasoning_content": thinking}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func() {
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "stop"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
configDir := ph.GetNextConfigDir()
|
||||
logger.LogAccountAssigned(configDir)
|
||||
ph.ReportRequestStart(configDir)
|
||||
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, true)
|
||||
streamStart := time.Now().UnixMilli()
|
||||
|
||||
ctx := r.Context()
|
||||
wrappedParser := func(line string) {
|
||||
logger.LogRawLine(line)
|
||||
p.Parse(line)
|
||||
}
|
||||
result, err := agent.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, ctx)
|
||||
|
||||
// agent 結束後,若未收到 result/success 訊號,強制 flush 以確保 SSE stream 正確結尾
|
||||
if ctx.Err() == nil {
|
||||
p.Flush()
|
||||
}
|
||||
|
||||
latencyMs := time.Now().UnixMilli() - streamStart
|
||||
ph.ReportRequestEnd(configDir)
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
|
||||
} else if ctx.Err() == context.Canceled {
|
||||
logger.LogClientDisconnect(method, pathname, model, latencyMs)
|
||||
} else if err == nil && isRateLimited(result.Stderr) {
|
||||
ph.ReportRateLimit(configDir, extractRetryAfterMs(result.Stderr))
|
||||
}
|
||||
|
||||
if err != nil || (result.Code != 0 && ctx.Err() == nil) {
|
||||
ph.ReportRequestError(configDir, latencyMs)
|
||||
if err != nil {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, err.Error())
|
||||
} else {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, result.Code, result.Stderr)
|
||||
}
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, result.Code)
|
||||
} else if ctx.Err() == nil {
|
||||
ph.ReportRequestSuccess(configDir, latencyMs)
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, 0)
|
||||
}
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
return
|
||||
}
|
||||
|
||||
configDir := ph.GetNextConfigDir()
|
||||
logger.LogAccountAssigned(configDir)
|
||||
ph.ReportRequestStart(configDir)
|
||||
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, false)
|
||||
syncStart := time.Now().UnixMilli()
|
||||
|
||||
out, err := agent.RunAgentSync(cfg, ws.WorkspaceDir, cmdArgs, ws.TempDir, configDir, r.Context())
|
||||
syncLatency := time.Now().UnixMilli() - syncStart
|
||||
ph.ReportRequestEnd(configDir)
|
||||
|
||||
ctx := r.Context()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
|
||||
httputil.WriteJSON(w, 504, map[string]interface{}{
|
||||
"error": map[string]string{"message": fmt.Sprintf("request timed out after %dms", cfg.TimeoutMs), "code": "timeout"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
if ctx.Err() == context.Canceled {
|
||||
logger.LogClientDisconnect(method, pathname, model, syncLatency)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ph.ReportRequestError(configDir, syncLatency)
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
logger.LogRequestDone(method, pathname, model, syncLatency, -1)
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": err.Error(), "code": "cursor_cli_error"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if isRateLimited(out.Stderr) {
|
||||
ph.ReportRateLimit(configDir, extractRetryAfterMs(out.Stderr))
|
||||
}
|
||||
|
||||
if out.Code != 0 {
|
||||
ph.ReportRequestError(configDir, syncLatency)
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
errMsg := logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, out.Code, out.Stderr)
|
||||
logger.LogRequestDone(method, pathname, model, syncLatency, out.Code)
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": errMsg, "code": "cursor_cli_error"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ph.ReportRequestSuccess(configDir, syncLatency)
|
||||
content := strings.TrimSpace(out.Stdout)
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, content, false)
|
||||
logger.LogAccountStats(cfg.Verbose, ph.GetStats())
|
||||
logger.LogRequestDone(method, pathname, model, syncLatency, 0)
|
||||
|
||||
if hasTools {
|
||||
parsed := toolcall.ExtractToolCalls(content, toolNames)
|
||||
if parsed.HasToolCalls() {
|
||||
msg := map[string]interface{}{"role": "assistant"}
|
||||
if parsed.TextContent != "" {
|
||||
msg["content"] = parsed.TextContent
|
||||
} else {
|
||||
msg["content"] = nil
|
||||
}
|
||||
var tcArr []map[string]interface{}
|
||||
for _, tc := range parsed.ToolCalls {
|
||||
callID := "call_" + uuid.New().String()[:8]
|
||||
tcArr = append(tcArr, map[string]interface{}{
|
||||
"id": callID,
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": tc.Name,
|
||||
"arguments": tc.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
msg["tool_calls"] = tcArr
|
||||
httputil.WriteJSON(w, 200, map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "message": msg, "finish_reason": "tool_calls"},
|
||||
},
|
||||
"usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}, truncatedHeaders)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
httputil.WriteJSON(w, 200, map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"message": map[string]string{"role": "assistant", "content": content},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
"usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}, truncatedHeaders)
|
||||
}
|
||||
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/apitypes"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/httputil"
|
||||
"cursor-api-proxy/internal/logger"
|
||||
"cursor-api-proxy/internal/providers/geminiweb"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func HandleGeminiChatCompletions(w http.ResponseWriter, r *http.Request, cfg config.BridgeConfig, rawBody, method, pathname, remoteAddress string) {
|
||||
_ = context.Background() // 確保 context 被使用
|
||||
var bodyMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rawBody), &bodyMap); err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"message": "invalid JSON body", "code": "bad_request"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
rawModel, _ := bodyMap["model"].(string)
|
||||
if rawModel == "" {
|
||||
rawModel = "gemini-2.0-flash"
|
||||
}
|
||||
|
||||
var messages []interface{}
|
||||
if m, ok := bodyMap["messages"].([]interface{}); ok {
|
||||
messages = m
|
||||
}
|
||||
|
||||
isStream := false
|
||||
if s, ok := bodyMap["stream"].(bool); ok {
|
||||
isStream = s
|
||||
}
|
||||
|
||||
// 轉換 messages 為 apitypes.Message
|
||||
var apiMessages []apitypes.Message
|
||||
for _, m := range messages {
|
||||
if msgMap, ok := m.(map[string]interface{}); ok {
|
||||
role, _ := msgMap["role"].(string)
|
||||
content := ""
|
||||
if c, ok := msgMap["content"].(string); ok {
|
||||
content = c
|
||||
}
|
||||
apiMessages = append(apiMessages, apitypes.Message{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogRequestStart(method, pathname, rawModel, cfg.TimeoutMs, isStream)
|
||||
start := time.Now().UnixMilli()
|
||||
|
||||
// 創建 Gemini provider (使用 Playwright)
|
||||
provider, provErr := geminiweb.NewPlaywrightProvider(cfg)
|
||||
if provErr != nil {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, provErr.Error())
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": provErr.Error(), "code": "provider_error"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if isStream {
|
||||
httputil.WriteSSEHeaders(w, nil)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
id := "chatcmpl_" + uuid.New().String()
|
||||
created := time.Now().Unix()
|
||||
var accumulated string
|
||||
|
||||
err := provider.Generate(r.Context(), rawModel, apiMessages, nil, func(chunk apitypes.StreamChunk) {
|
||||
if chunk.Type == apitypes.ChunkText {
|
||||
accumulated += chunk.Text
|
||||
respChunk := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": rawModel,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"delta": map[string]string{"content": chunk.Text},
|
||||
"finish_reason": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(respChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
} else if chunk.Type == apitypes.ChunkThinking {
|
||||
respChunk := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": rawModel,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"delta": map[string]interface{}{"reasoning_content": chunk.Thinking},
|
||||
"finish_reason": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(respChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
} else if chunk.Type == apitypes.ChunkDone {
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": rawModel,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"delta": map[string]interface{}{},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
latencyMs := time.Now().UnixMilli() - start
|
||||
if err != nil {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, err.Error())
|
||||
logger.LogRequestDone(method, pathname, rawModel, latencyMs, -1)
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogTrafficResponse(cfg.Verbose, rawModel, accumulated, true)
|
||||
logger.LogRequestDone(method, pathname, rawModel, latencyMs, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// 非串流模式
|
||||
var resultText string
|
||||
var resultThinking string
|
||||
|
||||
err := provider.Generate(r.Context(), rawModel, apiMessages, nil, func(chunk apitypes.StreamChunk) {
|
||||
if chunk.Type == apitypes.ChunkText {
|
||||
resultText += chunk.Text
|
||||
} else if chunk.Type == apitypes.ChunkThinking {
|
||||
resultThinking += chunk.Thinking
|
||||
}
|
||||
})
|
||||
|
||||
latencyMs := time.Now().UnixMilli() - start
|
||||
|
||||
if err != nil {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, err.Error())
|
||||
logger.LogRequestDone(method, pathname, rawModel, latencyMs, -1)
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": err.Error(), "code": "gemini_error"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogTrafficResponse(cfg.Verbose, rawModel, resultText, false)
|
||||
logger.LogRequestDone(method, pathname, rawModel, latencyMs, 0)
|
||||
|
||||
id := "chatcmpl_" + uuid.New().String()
|
||||
created := time.Now().Unix()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": rawModel,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"message": map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": resultText,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
"usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}
|
||||
|
||||
httputil.WriteJSON(w, 200, resp, nil)
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/httputil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleHealth(w http.ResponseWriter, r *http.Request, version string, cfg config.BridgeConfig) {
|
||||
httputil.WriteJSON(w, 200, map[string]interface{}{
|
||||
"ok": true,
|
||||
"version": version,
|
||||
"workspace": cfg.Workspace,
|
||||
"mode": cfg.Mode,
|
||||
"defaultModel": cfg.DefaultModel,
|
||||
"force": cfg.Force,
|
||||
"approveMcps": cfg.ApproveMcps,
|
||||
"strictModel": cfg.StrictModel,
|
||||
}, nil)
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/httputil"
|
||||
"cursor-api-proxy/internal/models"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const modelCacheTTLMs = 5 * 60 * 1000
|
||||
|
||||
type ModelCache struct {
|
||||
At int64
|
||||
Models []models.CursorCliModel
|
||||
}
|
||||
|
||||
type ModelCacheRef struct {
|
||||
mu sync.Mutex
|
||||
cache *ModelCache
|
||||
inflight bool
|
||||
waiters []chan struct{}
|
||||
}
|
||||
|
||||
func (ref *ModelCacheRef) HandleModels(w http.ResponseWriter, r *http.Request, cfg config.BridgeConfig) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
ref.mu.Lock()
|
||||
if ref.cache != nil && now-ref.cache.At <= modelCacheTTLMs {
|
||||
cache := ref.cache
|
||||
ref.mu.Unlock()
|
||||
writeModels(w, cache.Models)
|
||||
return
|
||||
}
|
||||
|
||||
if ref.inflight {
|
||||
// Wait for the in-flight fetch
|
||||
ch := make(chan struct{}, 1)
|
||||
ref.waiters = append(ref.waiters, ch)
|
||||
ref.mu.Unlock()
|
||||
<-ch
|
||||
ref.mu.Lock()
|
||||
cache := ref.cache
|
||||
ref.mu.Unlock()
|
||||
writeModels(w, cache.Models)
|
||||
return
|
||||
}
|
||||
|
||||
ref.inflight = true
|
||||
ref.mu.Unlock()
|
||||
|
||||
fetched, err := models.ListCursorCliModels(cfg.AgentBin, 60000)
|
||||
|
||||
ref.mu.Lock()
|
||||
ref.inflight = false
|
||||
if err == nil {
|
||||
ref.cache = &ModelCache{At: time.Now().UnixMilli(), Models: fetched}
|
||||
}
|
||||
waiters := ref.waiters
|
||||
ref.waiters = nil
|
||||
ref.mu.Unlock()
|
||||
|
||||
for _, ch := range waiters {
|
||||
ch <- struct{}{}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": err.Error(), "code": "models_fetch_error"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
writeModels(w, fetched)
|
||||
}
|
||||
|
||||
func writeModels(w http.ResponseWriter, mods []models.CursorCliModel) {
|
||||
cursorModels := make([]map[string]interface{}, len(mods))
|
||||
for i, m := range mods {
|
||||
cursorModels[i] = map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"object": "model",
|
||||
"owned_by": "cursor",
|
||||
"name": m.Name,
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]string, len(mods))
|
||||
for i, m := range mods {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
aliases := models.GetAnthropicModelAliases(ids)
|
||||
for _, a := range aliases {
|
||||
cursorModels = append(cursorModels, map[string]interface{}{
|
||||
"id": a.ID,
|
||||
"object": "model",
|
||||
"owned_by": "cursor",
|
||||
"name": a.Name,
|
||||
})
|
||||
}
|
||||
|
||||
httputil.WriteJSON(w, 200, map[string]interface{}{
|
||||
"object": "list",
|
||||
"data": cursorModels,
|
||||
}, nil)
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import "cursor-api-proxy/internal/config"
|
||||
|
||||
func ResolveModel(requested string, lastModelRef *string, cfg config.BridgeConfig) string {
|
||||
isAuto := requested == "auto"
|
||||
var explicitModel string
|
||||
if requested != "" && !isAuto {
|
||||
explicitModel = requested
|
||||
}
|
||||
if explicitModel != "" {
|
||||
*lastModelRef = explicitModel
|
||||
}
|
||||
if isAuto {
|
||||
return "auto"
|
||||
}
|
||||
if explicitModel != "" {
|
||||
return explicitModel
|
||||
}
|
||||
if cfg.StrictModel && *lastModelRef != "" {
|
||||
return *lastModelRef
|
||||
}
|
||||
if *lastModelRef != "" {
|
||||
return *lastModelRef
|
||||
}
|
||||
return cfg.DefaultModel
|
||||
}
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/internal/svc"
|
||||
apitypes "cursor-api-proxy/internal/types"
|
||||
"cursor-api-proxy/pkg/adapter/anthropic"
|
||||
"cursor-api-proxy/pkg/adapter/openai"
|
||||
"cursor-api-proxy/pkg/domain/types"
|
||||
"cursor-api-proxy/pkg/infrastructure/httputil"
|
||||
"cursor-api-proxy/pkg/infrastructure/logger"
|
||||
"cursor-api-proxy/pkg/infrastructure/parser"
|
||||
"cursor-api-proxy/pkg/infrastructure/winlimit"
|
||||
"cursor-api-proxy/pkg/infrastructure/workspace"
|
||||
"cursor-api-proxy/pkg/usecase"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type AnthropicMessagesLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewAnthropicMessagesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnthropicMessagesLogic {
|
||||
return &AnthropicMessagesLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AnthropicMessagesLogic) resolveModel(requested string, lastModelRef *string) string {
|
||||
cfg := l.svcCtx.Config
|
||||
isAuto := requested == "auto"
|
||||
var explicitModel string
|
||||
if requested != "" && !isAuto {
|
||||
explicitModel = requested
|
||||
}
|
||||
if explicitModel != "" {
|
||||
*lastModelRef = explicitModel
|
||||
}
|
||||
if isAuto {
|
||||
return "auto"
|
||||
}
|
||||
if explicitModel != "" {
|
||||
return explicitModel
|
||||
}
|
||||
if cfg.StrictModel && *lastModelRef != "" {
|
||||
return *lastModelRef
|
||||
}
|
||||
if *lastModelRef != "" {
|
||||
return *lastModelRef
|
||||
}
|
||||
return cfg.DefaultModel
|
||||
}
|
||||
|
||||
func (l *AnthropicMessagesLogic) AnthropicMessages(req *apitypes.AnthropicRequest, w http.ResponseWriter, method, pathname string) error {
|
||||
return fmt.Errorf("non-streaming not implemented for Anthropic Messages API, use stream=true")
|
||||
}
|
||||
|
||||
func (l *AnthropicMessagesLogic) AnthropicMessagesStream(req *apitypes.AnthropicRequest, w http.ResponseWriter, method, pathname string) error {
|
||||
cfg := l.svcCtx.Config.ToBridgeConfig()
|
||||
|
||||
requested := openai.NormalizeModelID(req.Model)
|
||||
model := l.resolveModel(requested, l.svcCtx.LastModel)
|
||||
cursorModel := types.ResolveToCursorModel(model)
|
||||
if cursorModel == "" {
|
||||
cursorModel = model
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
cleanMessages := convertAnthropicMessagesToInterface(req.Messages)
|
||||
cleanMessages = usecase.SanitizeMessages(cleanMessages)
|
||||
|
||||
// Build prompt
|
||||
systemText := req.System
|
||||
var systemWithTools interface{} = systemText
|
||||
if len(req.Tools) > 0 {
|
||||
toolsText := openai.ToolsToSystemText(convertToolsToInterface(req.Tools), nil)
|
||||
if systemText != "" {
|
||||
systemWithTools = systemText + "\n\n" + toolsText
|
||||
} else {
|
||||
systemWithTools = toolsText
|
||||
}
|
||||
}
|
||||
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(convertToAnthropicParams(cleanMessages), systemWithTools)
|
||||
|
||||
// Validate max_tokens
|
||||
if req.MaxTokens == 0 {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"type": "invalid_request_error", "message": "max_tokens is required"},
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log traffic
|
||||
var trafficMsgs []logger.TrafficMessage
|
||||
if systemText != "" {
|
||||
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: "system", Content: systemText})
|
||||
}
|
||||
for _, m := range cleanMessages {
|
||||
if mm, ok := m.(map[string]interface{}); ok {
|
||||
role, _ := mm["role"].(string)
|
||||
content := openai.MessageContentToText(mm["content"])
|
||||
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: role, Content: content})
|
||||
}
|
||||
}
|
||||
logger.LogTrafficRequest(cfg.Verbose, model, trafficMsgs, true)
|
||||
|
||||
// Resolve workspace
|
||||
ws := workspace.ResolveWorkspace(cfg, "")
|
||||
|
||||
// Build command args
|
||||
if cfg.Verbose {
|
||||
logger.LogDebug("model=%s prompt_len=%d", cursorModel, len(prompt))
|
||||
}
|
||||
|
||||
maxCmdline := cfg.WinCmdlineMax
|
||||
if maxCmdline == 0 {
|
||||
maxCmdline = 32768
|
||||
}
|
||||
fixedArgs := usecase.BuildAgentFixedArgs(cfg, ws.WorkspaceDir, cursorModel, true)
|
||||
fit := winlimit.FitPromptToWinCmdline(cfg.AgentBin, fixedArgs, prompt, maxCmdline, ws.WorkspaceDir)
|
||||
|
||||
if cfg.Verbose {
|
||||
logger.LogDebug("cmd_args=%v", fit.Args)
|
||||
}
|
||||
|
||||
if !fit.OK {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"type": "api_error", "message": fit.Error},
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
if fit.Truncated {
|
||||
logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength)
|
||||
}
|
||||
|
||||
cmdArgs := fit.Args
|
||||
msgID := "msg_" + uuid.New().String()
|
||||
|
||||
var truncatedHeaders map[string]string
|
||||
if fit.Truncated {
|
||||
truncatedHeaders = map[string]string{"X-Cursor-Proxy-Prompt-Truncated": "true"}
|
||||
}
|
||||
|
||||
hasTools := len(req.Tools) > 0
|
||||
var toolNames map[string]bool
|
||||
if hasTools {
|
||||
toolNames = usecase.CollectToolNames(convertToolsToInterface(req.Tools))
|
||||
}
|
||||
|
||||
// Write SSE headers
|
||||
httputil.WriteSSEHeaders(w, truncatedHeaders)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
var p parser.Parser
|
||||
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "message_start",
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": []interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
if hasTools {
|
||||
p = createAnthropicToolParser(w, flusher, model, toolNames, cfg.Verbose)
|
||||
} else {
|
||||
p = createAnthropicStreamParser(w, flusher, model, cfg.Verbose)
|
||||
}
|
||||
|
||||
configDir := l.svcCtx.AccountPool.GetNextConfigDir()
|
||||
logger.LogAccountAssigned(configDir)
|
||||
l.svcCtx.AccountPool.ReportRequestStart(configDir)
|
||||
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, true)
|
||||
streamStart := time.Now().UnixMilli()
|
||||
|
||||
wrappedParser := func(line string) {
|
||||
logger.LogRawLine(line)
|
||||
p.Parse(line)
|
||||
}
|
||||
result, err := usecase.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, l.ctx)
|
||||
|
||||
if l.ctx.Err() == nil {
|
||||
p.Flush()
|
||||
}
|
||||
|
||||
latencyMs := time.Now().UnixMilli() - streamStart
|
||||
l.svcCtx.AccountPool.ReportRequestEnd(configDir)
|
||||
|
||||
if l.ctx.Err() == context.DeadlineExceeded {
|
||||
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
|
||||
} else if l.ctx.Err() == context.Canceled {
|
||||
logger.LogClientDisconnect(method, pathname, model, latencyMs)
|
||||
} else if err == nil && isRateLimited(result.Stderr) {
|
||||
l.svcCtx.AccountPool.ReportRateLimit(configDir, extractRetryAfterMs(result.Stderr))
|
||||
}
|
||||
|
||||
if err != nil || (result.Code != 0 && l.ctx.Err() == nil) {
|
||||
l.svcCtx.AccountPool.ReportRequestError(configDir, latencyMs)
|
||||
errMsg := "unknown error"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", -1, errMsg)
|
||||
} else {
|
||||
errMsg = result.Stderr
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", result.Code, result.Stderr)
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": map[string]interface{}{"type": "api_error", "message": errMsg},
|
||||
})
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, result.Code)
|
||||
} else if l.ctx.Err() == nil {
|
||||
l.svcCtx.AccountPool.ReportRequestSuccess(configDir, latencyMs)
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, 0)
|
||||
}
|
||||
logger.LogAccountStats(cfg.Verbose, l.svcCtx.AccountPool.GetStats())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAnthropicStreamParser(w http.ResponseWriter, flusher http.Flusher, model string, verbose bool) parser.Parser {
|
||||
var textBlockOpen bool
|
||||
var textBlockIndex int
|
||||
var thinkingOpen bool
|
||||
var thinkingBlockIndex int
|
||||
var blockCount int
|
||||
|
||||
return parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
if verbose {
|
||||
logger.LogStreamChunk(model, text, 0)
|
||||
}
|
||||
if !textBlockOpen && !thinkingOpen {
|
||||
textBlockIndex = blockCount
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": textBlockIndex,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
textBlockOpen = true
|
||||
blockCount++
|
||||
}
|
||||
if thinkingOpen {
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_stop", "index": thinkingBlockIndex,
|
||||
})
|
||||
thinkingOpen = false
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": textBlockIndex,
|
||||
"delta": map[string]string{"type": "text_delta", "text": text},
|
||||
})
|
||||
},
|
||||
func(thinking string) {
|
||||
if verbose {
|
||||
logger.LogStreamChunk(model, thinking, 0)
|
||||
}
|
||||
if !thinkingOpen {
|
||||
thinkingBlockIndex = blockCount
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": thinkingBlockIndex,
|
||||
"content_block": map[string]string{"type": "thinking", "thinking": ""},
|
||||
})
|
||||
thinkingOpen = true
|
||||
blockCount++
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": thinkingBlockIndex,
|
||||
"delta": map[string]string{"type": "thinking_delta", "thinking": thinking},
|
||||
})
|
||||
},
|
||||
func() {
|
||||
if textBlockOpen {
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_stop", "index": textBlockIndex,
|
||||
})
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": 0},
|
||||
})
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{"type": "message_stop"})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func createAnthropicToolParser(w http.ResponseWriter, flusher http.Flusher, model string, toolNames map[string]bool, verbose bool) parser.Parser {
|
||||
var accumulated string
|
||||
toolCallMarkerRe := regexp.MustCompile(`行政法规|<function_calls>`)
|
||||
var toolCallMode bool
|
||||
var textBlockOpen bool
|
||||
var textBlockIndex int
|
||||
var blockCount int
|
||||
|
||||
return parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
if verbose {
|
||||
logger.LogStreamChunk(model, text, 0)
|
||||
}
|
||||
if toolCallMode {
|
||||
return
|
||||
}
|
||||
if toolCallMarkerRe.MatchString(text) {
|
||||
if textBlockOpen {
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_stop", "index": textBlockIndex,
|
||||
})
|
||||
textBlockOpen = false
|
||||
}
|
||||
toolCallMode = true
|
||||
return
|
||||
}
|
||||
if !textBlockOpen {
|
||||
textBlockIndex = blockCount
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": textBlockIndex,
|
||||
"content_block": map[string]string{"type": "text", "text": ""},
|
||||
})
|
||||
textBlockOpen = true
|
||||
blockCount++
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": textBlockIndex,
|
||||
"delta": map[string]string{"type": "text_delta", "text": text},
|
||||
})
|
||||
},
|
||||
func(thinking string) {},
|
||||
func() {
|
||||
if verbose {
|
||||
logger.LogTrafficResponse(verbose, model, accumulated, true)
|
||||
}
|
||||
parsed := usecase.ExtractToolCalls(accumulated, toolNames)
|
||||
blockIndex := 0
|
||||
|
||||
if textBlockOpen {
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_stop", "index": textBlockIndex,
|
||||
})
|
||||
blockIndex = textBlockIndex + 1
|
||||
}
|
||||
|
||||
if parsed.HasToolCalls() {
|
||||
for _, tc := range parsed.ToolCalls {
|
||||
toolID := "toolu_" + uuid.New().String()[:12]
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_start", "index": blockIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "tool_use", "id": toolID, "name": tc.Name, "input": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_delta", "index": blockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta", "partial_json": tc.Arguments,
|
||||
},
|
||||
})
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "content_block_stop", "index": blockIndex,
|
||||
})
|
||||
blockIndex++
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": "tool_use", "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": 0},
|
||||
})
|
||||
} else {
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": 0},
|
||||
})
|
||||
}
|
||||
writeAnthropicEvent(w, flusher, map[string]interface{}{"type": "message_stop"})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func writeAnthropicEvent(w http.ResponseWriter, flusher http.Flusher, evt interface{}) {
|
||||
data, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func convertAnthropicMessagesToInterface(msgs []apitypes.Message) []interface{} {
|
||||
result := make([]interface{}, len(msgs))
|
||||
for i, m := range msgs {
|
||||
result[i] = map[string]interface{}{
|
||||
"role": m.Role,
|
||||
"content": m.Content,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertToAnthropicParams(msgs []interface{}) []anthropic.MessageParam {
|
||||
result := make([]anthropic.MessageParam, len(msgs))
|
||||
for i, m := range msgs {
|
||||
if mm, ok := m.(map[string]interface{}); ok {
|
||||
result[i] = anthropic.MessageParam{
|
||||
Role: mm["role"].(string),
|
||||
Content: mm["content"],
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertToolsToInterface(tools []apitypes.Tool) []interface{} {
|
||||
if tools == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]interface{}, len(tools))
|
||||
for i, t := range tools {
|
||||
result[i] = map[string]interface{}{
|
||||
"type": t.Type,
|
||||
"function": map[string]interface{}{
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"parameters": t.Function.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,483 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
apitypes "cursor-api-proxy/internal/types"
|
||||
"cursor-api-proxy/pkg/adapter/openai"
|
||||
"cursor-api-proxy/pkg/domain/types"
|
||||
"cursor-api-proxy/pkg/infrastructure/httputil"
|
||||
"cursor-api-proxy/pkg/infrastructure/logger"
|
||||
"cursor-api-proxy/pkg/infrastructure/parser"
|
||||
"cursor-api-proxy/pkg/infrastructure/winlimit"
|
||||
"cursor-api-proxy/pkg/infrastructure/workspace"
|
||||
"cursor-api-proxy/pkg/usecase"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
var rateLimitRe = regexp.MustCompile(`(?i)\b429\b|rate.?limit|too many requests`)
|
||||
var retryAfterRe = regexp.MustCompile(`(?i)retry-after:\s*(\d+)`)
|
||||
|
||||
func isRateLimited(stderr string) bool {
|
||||
return rateLimitRe.MatchString(stderr)
|
||||
}
|
||||
|
||||
func extractRetryAfterMs(stderr string) int64 {
|
||||
if m := retryAfterRe.FindStringSubmatch(stderr); len(m) > 1 {
|
||||
if secs, err := strconv.ParseInt(m[1], 10, 64); err == nil && secs > 0 {
|
||||
return secs * 1000
|
||||
}
|
||||
}
|
||||
return 60000
|
||||
}
|
||||
|
||||
type ChatCompletionsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewChatCompletionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatCompletionsLogic {
|
||||
return &ChatCompletionsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ChatCompletionsLogic) resolveModel(requested string, lastModelRef *string) string {
|
||||
cfg := l.svcCtx.Config
|
||||
isAuto := requested == "auto"
|
||||
var explicitModel string
|
||||
if requested != "" && !isAuto {
|
||||
explicitModel = requested
|
||||
}
|
||||
if explicitModel != "" {
|
||||
*lastModelRef = explicitModel
|
||||
}
|
||||
if isAuto {
|
||||
return "auto"
|
||||
}
|
||||
if explicitModel != "" {
|
||||
return explicitModel
|
||||
}
|
||||
if cfg.StrictModel && *lastModelRef != "" {
|
||||
return *lastModelRef
|
||||
}
|
||||
if *lastModelRef != "" {
|
||||
return *lastModelRef
|
||||
}
|
||||
return cfg.DefaultModel
|
||||
}
|
||||
|
||||
func (l *ChatCompletionsLogic) ChatCompletions(req *apitypes.ChatCompletionRequest) (*apitypes.ChatCompletionResponse, error) {
|
||||
return nil, fmt.Errorf("non-streaming not yet implemented, use stream=true")
|
||||
}
|
||||
|
||||
func (l *ChatCompletionsLogic) ChatCompletionsStream(req *apitypes.ChatCompletionRequest, w http.ResponseWriter, method, pathname string) error {
|
||||
cfg := configToBridge(l.svcCtx.Config)
|
||||
|
||||
rawModel := req.Model
|
||||
requested := openai.NormalizeModelID(rawModel)
|
||||
lastModelRef := new(string)
|
||||
model := l.resolveModel(requested, lastModelRef)
|
||||
cursorModel := types.ResolveToCursorModel(model)
|
||||
if cursorModel == "" {
|
||||
cursorModel = model
|
||||
}
|
||||
|
||||
messages := convertMessages(req.Messages)
|
||||
tools := convertTools(req.Tools)
|
||||
functions := convertFunctions(req.Functions)
|
||||
|
||||
cleanMessages := usecase.SanitizeMessages(messages)
|
||||
toolsText := openai.ToolsToSystemText(tools, functions)
|
||||
messagesWithTools := cleanMessages
|
||||
if toolsText != "" {
|
||||
messagesWithTools = append([]interface{}{
|
||||
map[string]interface{}{"role": "system", "content": toolsText},
|
||||
}, cleanMessages...)
|
||||
}
|
||||
prompt := openai.BuildPromptFromMessages(messagesWithTools)
|
||||
|
||||
var trafficMsgs []logger.TrafficMessage
|
||||
for _, raw := range cleanMessages {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
role, _ := m["role"].(string)
|
||||
content := openai.MessageContentToText(m["content"])
|
||||
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: role, Content: content})
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogTrafficRequest(cfg.Verbose, model, trafficMsgs, true)
|
||||
|
||||
ws := workspace.ResolveWorkspace(cfg, "")
|
||||
|
||||
promptLen := len(prompt)
|
||||
if cfg.Verbose {
|
||||
if promptLen > 200 {
|
||||
logger.LogDebug("model=%s prompt_len=%d prompt_start=%q", cursorModel, promptLen, prompt[:200])
|
||||
} else {
|
||||
logger.LogDebug("model=%s prompt_len=%d prompt=%q", cursorModel, promptLen, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
maxCmdline := cfg.WinCmdlineMax
|
||||
if maxCmdline == 0 {
|
||||
maxCmdline = 32768
|
||||
}
|
||||
fixedArgs := usecase.BuildAgentFixedArgs(cfg, ws.WorkspaceDir, cursorModel, true)
|
||||
fit := winlimit.FitPromptToWinCmdline(cfg.AgentBin, fixedArgs, prompt, maxCmdline, ws.WorkspaceDir)
|
||||
|
||||
if l.svcCtx.Config.Verbose {
|
||||
logger.LogDebug("cmd=%s args=%v", cfg.AgentBin, fit.Args)
|
||||
}
|
||||
|
||||
if !fit.OK {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": fit.Error, "code": "windows_cmdline_limit"},
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
if fit.Truncated {
|
||||
logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength)
|
||||
}
|
||||
|
||||
cmdArgs := fit.Args
|
||||
id := "chatcmpl_" + uuid.New().String()
|
||||
created := time.Now().Unix()
|
||||
|
||||
var truncatedHeaders map[string]string
|
||||
if fit.Truncated {
|
||||
truncatedHeaders = map[string]string{"X-Cursor-Proxy-Prompt-Truncated": "true"}
|
||||
}
|
||||
|
||||
hasTools := len(tools) > 0 || len(functions) > 0
|
||||
var toolNames map[string]bool
|
||||
if hasTools {
|
||||
toolNames = usecase.CollectToolNames(tools)
|
||||
for _, f := range functions {
|
||||
if fm, ok := f.(map[string]interface{}); ok {
|
||||
if name, ok := fm["name"].(string); ok {
|
||||
toolNames[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
httputil.WriteSSEHeaders(w, truncatedHeaders)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
var accumulated string
|
||||
var chunkNum int
|
||||
var p parser.Parser
|
||||
|
||||
toolCallMarkerRe := regexp.MustCompile(`\x1e|<function_calls>`)
|
||||
if hasTools {
|
||||
var toolCallMode bool
|
||||
p = parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
chunkNum++
|
||||
logger.LogStreamChunk(model, text, chunkNum)
|
||||
if toolCallMode {
|
||||
return
|
||||
}
|
||||
if toolCallMarkerRe.MatchString(text) {
|
||||
toolCallMode = true
|
||||
return
|
||||
}
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func(thinking string) {
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{"reasoning_content": thinking}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func() {
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
|
||||
parsed := usecase.ExtractToolCalls(accumulated, toolNames)
|
||||
|
||||
if parsed.HasToolCalls() {
|
||||
if parsed.TextContent != "" && toolCallMode {
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{"role": "assistant", "content": parsed.TextContent}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
for i, tc := range parsed.ToolCalls {
|
||||
callID := "call_" + uuid.New().String()[:8]
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"index": i,
|
||||
"id": callID,
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": tc.Name,
|
||||
"arguments": tc.Arguments,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "tool_calls"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
} else {
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "stop"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
p = parser.CreateStreamParserWithThinking(
|
||||
func(text string) {
|
||||
accumulated += text
|
||||
chunkNum++
|
||||
logger.LogStreamChunk(model, text, chunkNum)
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func(thinking string) {
|
||||
chunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{"reasoning_content": thinking}, "finish_reason": nil},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
func() {
|
||||
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
|
||||
stopChunk := map[string]interface{}{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "stop"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(stopChunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
configDir := l.svcCtx.AccountPool.GetNextConfigDir()
|
||||
logger.LogAccountAssigned(configDir)
|
||||
l.svcCtx.AccountPool.ReportRequestStart(configDir)
|
||||
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, true)
|
||||
streamStart := time.Now().UnixMilli()
|
||||
|
||||
wrappedParser := func(line string) {
|
||||
logger.LogRawLine(line)
|
||||
p.Parse(line)
|
||||
}
|
||||
result, err := usecase.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, l.ctx)
|
||||
|
||||
if l.ctx.Err() == nil {
|
||||
p.Flush()
|
||||
}
|
||||
|
||||
latencyMs := time.Now().UnixMilli() - streamStart
|
||||
l.svcCtx.AccountPool.ReportRequestEnd(configDir)
|
||||
|
||||
if l.ctx.Err() == context.DeadlineExceeded {
|
||||
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
|
||||
} else if l.ctx.Err() == context.Canceled {
|
||||
logger.LogClientDisconnect(method, pathname, model, latencyMs)
|
||||
} else if err == nil && isRateLimited(result.Stderr) {
|
||||
l.svcCtx.AccountPool.ReportRateLimit(configDir, extractRetryAfterMs(result.Stderr))
|
||||
}
|
||||
|
||||
if err != nil || (result.Code != 0 && l.ctx.Err() == nil) {
|
||||
l.svcCtx.AccountPool.ReportRequestError(configDir, latencyMs)
|
||||
if err != nil {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", -1, err.Error())
|
||||
} else {
|
||||
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", result.Code, result.Stderr)
|
||||
}
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, result.Code)
|
||||
} else if l.ctx.Err() == nil {
|
||||
l.svcCtx.AccountPool.ReportRequestSuccess(configDir, latencyMs)
|
||||
logger.LogRequestDone(method, pathname, model, latencyMs, 0)
|
||||
}
|
||||
logger.LogAccountStats(cfg.Verbose, l.svcCtx.AccountPool.GetStats())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertMessages(msgs []apitypes.Message) []interface{} {
|
||||
result := make([]interface{}, len(msgs))
|
||||
for i, m := range msgs {
|
||||
result[i] = map[string]interface{}{
|
||||
"role": m.Role,
|
||||
"content": m.Content,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertTools(tools []apitypes.Tool) []interface{} {
|
||||
if tools == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]interface{}, len(tools))
|
||||
for i, t := range tools {
|
||||
result[i] = map[string]interface{}{
|
||||
"type": t.Type,
|
||||
"function": map[string]interface{}{
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"parameters": t.Function.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertFunctions(funcs []apitypes.Function) []interface{} {
|
||||
if funcs == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]interface{}, len(funcs))
|
||||
for i, f := range funcs {
|
||||
result[i] = map[string]interface{}{
|
||||
"name": f.Name,
|
||||
"description": f.Description,
|
||||
"parameters": f.Parameters,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func configToBridge(c config.Config) config.BridgeConfig {
|
||||
host := c.Host
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
return config.BridgeConfig{
|
||||
AgentBin: c.AgentBin,
|
||||
Host: host,
|
||||
Port: c.Port,
|
||||
RequiredKey: c.RequiredKey,
|
||||
DefaultModel: c.DefaultModel,
|
||||
Mode: "ask",
|
||||
Provider: c.Provider,
|
||||
Force: c.Force,
|
||||
ApproveMcps: c.ApproveMcps,
|
||||
StrictModel: c.StrictModel,
|
||||
Workspace: c.Workspace,
|
||||
TimeoutMs: c.TimeoutMs,
|
||||
TLSCertPath: c.TLSCertPath,
|
||||
TLSKeyPath: c.TLSKeyPath,
|
||||
SessionsLogPath: c.SessionsLogPath,
|
||||
ChatOnlyWorkspace: c.ChatOnlyWorkspace,
|
||||
Verbose: c.Verbose,
|
||||
MaxMode: c.MaxMode,
|
||||
ConfigDirs: c.ConfigDirs,
|
||||
MultiPort: c.MultiPort,
|
||||
WinCmdlineMax: c.WinCmdlineMax,
|
||||
GeminiAccountDir: c.GeminiAccountDir,
|
||||
GeminiBrowserVisible: c.GeminiBrowserVisible,
|
||||
GeminiMaxSessions: c.GeminiMaxSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// StringsToMapSlice converts string slice for compatibility
|
||||
func StringsToMapSlice(ss []string) []map[string]string {
|
||||
result := make([]map[string]string, len(ss))
|
||||
for i, s := range ss {
|
||||
result[i] = map[string]string{"content": s}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// JoinStrings joins strings with newline
|
||||
func JoinStrings(ss []string) string {
|
||||
return strings.Join(ss, "\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cursor-api-proxy/internal/svc"
|
||||
"cursor-api-proxy/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type HealthLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HealthLogic {
|
||||
return &HealthLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *HealthLogic) Health() (resp *types.HealthResponse, err error) {
|
||||
return &types.HealthResponse{
|
||||
Status: "ok",
|
||||
Version: "1.0.0",
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/internal/svc"
|
||||
apitypes "cursor-api-proxy/internal/types"
|
||||
"cursor-api-proxy/pkg/domain/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
const modelCacheTTLMs = 5 * 60 * 1000
|
||||
|
||||
type ModelCache struct {
|
||||
At int64
|
||||
Models []types.CursorCliModel
|
||||
}
|
||||
|
||||
type ModelCacheRef struct {
|
||||
mu sync.Mutex
|
||||
cache *ModelCache
|
||||
inflight bool
|
||||
waiters []chan struct{}
|
||||
}
|
||||
|
||||
var globalModelCache = &ModelCacheRef{}
|
||||
|
||||
type ModelsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ModelsLogic {
|
||||
return &ModelsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ModelsLogic) Models() (resp *apitypes.ModelsResponse, err error) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
globalModelCache.mu.Lock()
|
||||
if globalModelCache.cache != nil && now-globalModelCache.cache.At <= modelCacheTTLMs {
|
||||
cache := globalModelCache.cache
|
||||
globalModelCache.mu.Unlock()
|
||||
return buildModelsResponse(cache.Models), nil
|
||||
}
|
||||
|
||||
if globalModelCache.inflight {
|
||||
ch := make(chan struct{}, 1)
|
||||
globalModelCache.waiters = append(globalModelCache.waiters, ch)
|
||||
globalModelCache.mu.Unlock()
|
||||
<-ch
|
||||
globalModelCache.mu.Lock()
|
||||
cache := globalModelCache.cache
|
||||
globalModelCache.mu.Unlock()
|
||||
return buildModelsResponse(cache.Models), nil
|
||||
}
|
||||
|
||||
globalModelCache.inflight = true
|
||||
globalModelCache.mu.Unlock()
|
||||
|
||||
fetched, err := types.ListCursorCliModels(l.svcCtx.Config.AgentBin, l.svcCtx.Config.TimeoutMs)
|
||||
|
||||
globalModelCache.mu.Lock()
|
||||
globalModelCache.inflight = false
|
||||
if err == nil {
|
||||
globalModelCache.cache = &ModelCache{At: time.Now().UnixMilli(), Models: fetched}
|
||||
}
|
||||
waiters := globalModelCache.waiters
|
||||
globalModelCache.waiters = nil
|
||||
globalModelCache.mu.Unlock()
|
||||
|
||||
for _, ch := range waiters {
|
||||
ch <- struct{}{}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildModelsResponse(fetched), nil
|
||||
}
|
||||
|
||||
func buildModelsResponse(mods []types.CursorCliModel) *apitypes.ModelsResponse {
|
||||
models := make([]apitypes.ModelData, len(mods))
|
||||
for i, m := range mods {
|
||||
models[i] = apitypes.ModelData{
|
||||
Id: m.ID,
|
||||
Object: "model",
|
||||
OwnedBy: "cursor",
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]string, len(mods))
|
||||
for i, m := range mods {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
aliases := types.GetAnthropicModelAliases(ids)
|
||||
for _, a := range aliases {
|
||||
models = append(models, apitypes.ModelData{
|
||||
Id: a.ID,
|
||||
Object: "model",
|
||||
OwnedBy: "cursor",
|
||||
})
|
||||
}
|
||||
|
||||
return &apitypes.ModelsResponse{
|
||||
Object: "list",
|
||||
Data: models,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/process"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CursorCliModel struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
var modelLineRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._:/-]*)\s+-\s+(.*)$`)
|
||||
var trailingParenRe = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
|
||||
|
||||
func ParseCursorCliModels(output string) []CursorCliModel {
|
||||
lines := strings.Split(output, "\n")
|
||||
seen := make(map[string]CursorCliModel)
|
||||
var order []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
m := modelLineRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id := m[1]
|
||||
rawName := m[2]
|
||||
name := strings.TrimSpace(trailingParenRe.ReplaceAllString(rawName, ""))
|
||||
if name == "" {
|
||||
name = id
|
||||
}
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = CursorCliModel{ID: id, Name: name}
|
||||
order = append(order, id)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]CursorCliModel, 0, len(order))
|
||||
for _, id := range order {
|
||||
result = append(result, seen[id])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ListCursorCliModels(agentBin string, timeoutMs int) ([]CursorCliModel, error) {
|
||||
tmpDir := os.TempDir()
|
||||
result, err := process.Run(agentBin, []string{"--list-models"}, process.RunOptions{
|
||||
Cwd: tmpDir,
|
||||
TimeoutMs: timeoutMs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("agent --list-models failed: %s", strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return ParseCursorCliModels(result.Stdout), nil
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCursorCliModels(t *testing.T) {
|
||||
output := `
|
||||
gpt-4o - GPT-4o (some info)
|
||||
claude-3-5-sonnet - Claude 3.5 Sonnet
|
||||
gpt-4o - GPT-4o duplicate
|
||||
invalid line without dash
|
||||
`
|
||||
result := ParseCursorCliModels(output)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 unique models, got %d: %v", len(result), result)
|
||||
}
|
||||
if result[0].ID != "gpt-4o" {
|
||||
t.Errorf("expected gpt-4o, got %s", result[0].ID)
|
||||
}
|
||||
if result[0].Name != "GPT-4o" {
|
||||
t.Errorf("expected 'GPT-4o', got %s", result[0].Name)
|
||||
}
|
||||
if result[1].ID != "claude-3-5-sonnet" {
|
||||
t.Errorf("expected claude-3-5-sonnet, got %s", result[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCursorCliModelsEmpty(t *testing.T) {
|
||||
result := ParseCursorCliModels("")
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected empty, got %v", result)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetAnthropicModelAliases_StaticOnly(t *testing.T) {
|
||||
aliases := GetAnthropicModelAliases([]string{"sonnet-4.6", "opus-4.5"})
|
||||
if len(aliases) != 2 {
|
||||
t.Fatalf("expected 2 aliases, got %d: %v", len(aliases), aliases)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, a := range aliases {
|
||||
ids[a.ID] = a.Name
|
||||
}
|
||||
if ids["claude-sonnet-4-6"] != "Claude 4.6 Sonnet" {
|
||||
t.Errorf("unexpected name for claude-sonnet-4-6: %s", ids["claude-sonnet-4-6"])
|
||||
}
|
||||
if ids["claude-opus-4-5"] != "Claude 4.5 Opus" {
|
||||
t.Errorf("unexpected name for claude-opus-4-5: %s", ids["claude-opus-4-5"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAnthropicModelAliases_DynamicFallback(t *testing.T) {
|
||||
aliases := GetAnthropicModelAliases([]string{"sonnet-4.7", "opus-5.0-thinking", "gpt-4o"})
|
||||
ids := map[string]string{}
|
||||
for _, a := range aliases {
|
||||
ids[a.ID] = a.Name
|
||||
}
|
||||
if ids["claude-sonnet-4-7"] != "Sonnet 4.7" {
|
||||
t.Errorf("unexpected name for claude-sonnet-4-7: %s", ids["claude-sonnet-4-7"])
|
||||
}
|
||||
if ids["claude-opus-5-0-thinking"] != "Opus 5.0 (Thinking)" {
|
||||
t.Errorf("unexpected name for claude-opus-5-0-thinking: %s", ids["claude-opus-5-0-thinking"])
|
||||
}
|
||||
if _, ok := ids["claude-gpt-4o"]; ok {
|
||||
t.Errorf("gpt-4o should not generate a claude alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAnthropicModelAliases_Mixed(t *testing.T) {
|
||||
aliases := GetAnthropicModelAliases([]string{"sonnet-4.6", "opus-4.7", "gpt-4o"})
|
||||
ids := map[string]string{}
|
||||
for _, a := range aliases {
|
||||
ids[a.ID] = a.Name
|
||||
}
|
||||
// static entry keeps its custom name
|
||||
if ids["claude-sonnet-4-6"] != "Claude 4.6 Sonnet" {
|
||||
t.Errorf("static alias should keep original name, got: %s", ids["claude-sonnet-4-6"])
|
||||
}
|
||||
// dynamic entry uses auto-generated name
|
||||
if ids["claude-opus-4-7"] != "Opus 4.7" {
|
||||
t.Errorf("dynamic alias name mismatch: %s", ids["claude-opus-4-7"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAnthropicModelAliases_UnknownPattern(t *testing.T) {
|
||||
aliases := GetAnthropicModelAliases([]string{"some-unknown-model"})
|
||||
if len(aliases) != 0 {
|
||||
t.Fatalf("expected 0 aliases for unknown pattern, got %d: %v", len(aliases), aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToCursorModel_Static(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"claude-opus-4-6", "opus-4.6"},
|
||||
{"claude-opus-4.6", "opus-4.6"},
|
||||
{"claude-sonnet-4-5", "sonnet-4.5"},
|
||||
{"claude-opus-4-6-thinking", "opus-4.6-thinking"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := ResolveToCursorModel(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("ResolveToCursorModel(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToCursorModel_DynamicFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"claude-opus-4-7", "opus-4.7"},
|
||||
{"claude-sonnet-5-0", "sonnet-5.0"},
|
||||
{"claude-opus-4-7-thinking", "opus-4.7-thinking"},
|
||||
{"claude-sonnet-5-0-thinking", "sonnet-5.0-thinking"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := ResolveToCursorModel(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("ResolveToCursorModel(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToCursorModel_Passthrough(t *testing.T) {
|
||||
tests := []string{"sonnet-4.6", "gpt-4o", "custom-model"}
|
||||
for _, input := range tests {
|
||||
got := ResolveToCursorModel(input)
|
||||
if got != input {
|
||||
t.Errorf("ResolveToCursorModel(%q) = %q, want passthrough %q", input, got, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToCursorModel_Empty(t *testing.T) {
|
||||
if got := ResolveToCursorModel(""); got != "" {
|
||||
t.Errorf("ResolveToCursorModel(\"\") = %q, want empty", got)
|
||||
}
|
||||
if got := ResolveToCursorModel(" "); got != "" {
|
||||
t.Errorf("ResolveToCursorModel(\" \") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDynamicAlias(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want AnthropicAlias
|
||||
ok bool
|
||||
}{
|
||||
{"opus-4.7", AnthropicAlias{"claude-opus-4-7", "Opus 4.7"}, true},
|
||||
{"sonnet-5.0-thinking", AnthropicAlias{"claude-sonnet-5-0-thinking", "Sonnet 5.0 (Thinking)"}, true},
|
||||
{"gpt-4o", AnthropicAlias{}, false},
|
||||
{"invalid", AnthropicAlias{}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := generateDynamicAlias(tc.input)
|
||||
if ok != tc.ok {
|
||||
t.Errorf("generateDynamicAlias(%q) ok = %v, want %v", tc.input, ok, tc.ok)
|
||||
continue
|
||||
}
|
||||
if ok && (got.ID != tc.want.ID || got.Name != tc.want.Name) {
|
||||
t.Errorf("generateDynamicAlias(%q) = {%q, %q}, want {%q, %q}", tc.input, got.ID, got.Name, tc.want.ID, tc.want.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseDynamicAlias(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"claude-opus-4-7", "opus-4.7", true},
|
||||
{"claude-sonnet-5-0-thinking", "sonnet-5.0-thinking", true},
|
||||
{"claude-opus-4-6", "opus-4.6", true},
|
||||
{"claude-opus-4.6", "", false},
|
||||
{"claude-haiku-4-5-20251001", "", false},
|
||||
{"some-model", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := reverseDynamicAlias(tc.input)
|
||||
if ok != tc.ok {
|
||||
t.Errorf("reverseDynamicAlias(%q) ok = %v, want %v", tc.input, ok, tc.ok)
|
||||
continue
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Errorf("reverseDynamicAlias(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/apitypes"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/providers/cursor"
|
||||
"cursor-api-proxy/internal/providers/geminiweb"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Close() error
|
||||
Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) error
|
||||
}
|
||||
|
||||
func NewProvider(cfg config.BridgeConfig) (Provider, error) {
|
||||
providerType := cfg.Provider
|
||||
if providerType == "" {
|
||||
providerType = "cursor"
|
||||
}
|
||||
|
||||
switch providerType {
|
||||
case "cursor":
|
||||
return cursor.NewProvider(cfg), nil
|
||||
case "gemini-web":
|
||||
return geminiweb.NewPlaywrightProvider(cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider: %s", providerType)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/handlers"
|
||||
"cursor-api-proxy/internal/httputil"
|
||||
"cursor-api-proxy/internal/logger"
|
||||
"cursor-api-proxy/internal/pool"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RouterOptions struct {
|
||||
Version string
|
||||
Config config.BridgeConfig
|
||||
ModelCache *handlers.ModelCacheRef
|
||||
LastModel *string
|
||||
Pool pool.PoolHandle
|
||||
}
|
||||
|
||||
func NewRouter(opts RouterOptions) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := opts.Config
|
||||
pathname := r.URL.Path
|
||||
method := r.Method
|
||||
remoteAddress := r.RemoteAddr
|
||||
if r.Header.Get("X-Real-IP") != "" {
|
||||
remoteAddress = r.Header.Get("X-Real-IP")
|
||||
}
|
||||
|
||||
logger.LogIncoming(method, pathname, remoteAddress)
|
||||
|
||||
defer func() {
|
||||
logger.AppendSessionLine(cfg.SessionsLogPath, method, pathname, remoteAddress, 200)
|
||||
}()
|
||||
|
||||
if cfg.RequiredKey != "" {
|
||||
token := httputil.ExtractBearerToken(r)
|
||||
if token != cfg.RequiredKey {
|
||||
httputil.WriteJSON(w, 401, map[string]interface{}{
|
||||
"error": map[string]string{"message": "Invalid API key", "code": "unauthorized"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case method == "GET" && pathname == "/health":
|
||||
handlers.HandleHealth(w, r, opts.Version, cfg)
|
||||
|
||||
case method == "GET" && pathname == "/v1/models":
|
||||
opts.ModelCache.HandleModels(w, r, cfg)
|
||||
|
||||
case method == "POST" && pathname == "/v1/chat/completions":
|
||||
raw, err := httputil.ReadBody(r)
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"message": "failed to read body", "code": "bad_request"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
// 根據 Provider 選擇處理方式
|
||||
provider := cfg.Provider
|
||||
if provider == "" {
|
||||
provider = "cursor"
|
||||
}
|
||||
if provider == "gemini-web" {
|
||||
handlers.HandleGeminiChatCompletions(w, r, cfg, raw, method, pathname, remoteAddress)
|
||||
} else {
|
||||
handlers.HandleChatCompletions(w, r, cfg, opts.Pool, opts.LastModel, raw, method, pathname, remoteAddress)
|
||||
}
|
||||
|
||||
case method == "POST" && pathname == "/v1/messages":
|
||||
raw, err := httputil.ReadBody(r)
|
||||
if err != nil {
|
||||
httputil.WriteJSON(w, 400, map[string]interface{}{
|
||||
"error": map[string]string{"message": "failed to read body", "code": "bad_request"},
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
handlers.HandleAnthropicMessages(w, r, cfg, opts.Pool, opts.LastModel, raw, method, pathname, remoteAddress)
|
||||
|
||||
case (method == "POST" || method == "GET") && pathname == "/v1/completions":
|
||||
httputil.WriteJSON(w, 404, map[string]interface{}{
|
||||
"error": map[string]string{
|
||||
"message": "Legacy completions endpoint is not supported. Use POST /v1/chat/completions instead.",
|
||||
"code": "not_found",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
case pathname == "/v1/embeddings":
|
||||
httputil.WriteJSON(w, 404, map[string]interface{}{
|
||||
"error": map[string]string{
|
||||
"message": "Embeddings are not supported by this proxy.",
|
||||
"code": "not_found",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
default:
|
||||
httputil.WriteJSON(w, 404, map[string]interface{}{
|
||||
"error": map[string]string{"message": "Not found", "code": "not_found"},
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recoveryMiddleware(logPath string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
msg := fmt.Sprintf("%v", rec)
|
||||
fmt.Fprintf(os.Stderr, "[%s] Proxy panic: %s\n", time.Now().UTC().Format(time.RFC3339), msg)
|
||||
line := fmt.Sprintf("%s ERROR %s %s %s %s\n",
|
||||
time.Now().UTC().Format(time.RFC3339), r.Method, r.URL.Path, r.RemoteAddr,
|
||||
msg[:min(200, len(msg))])
|
||||
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
|
||||
_, _ = f.WriteString(line)
|
||||
f.Close()
|
||||
}
|
||||
if !isHeaderWritten(w) {
|
||||
httputil.WriteJSON(w, 500, map[string]interface{}{
|
||||
"error": map[string]string{"message": msg, "code": "internal_error"},
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func isHeaderWritten(w http.ResponseWriter) bool {
|
||||
// Can't reliably detect without wrapping; always try to write
|
||||
return false
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func WrapWithRecovery(logPath string, handler http.HandlerFunc) http.HandlerFunc {
|
||||
return recoveryMiddleware(logPath, handler)
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/handlers"
|
||||
"cursor-api-proxy/internal/pool"
|
||||
"cursor-api-proxy/internal/process"
|
||||
"cursor-api-proxy/internal/logger"
|
||||
"cursor-api-proxy/internal/router"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerOptions struct {
|
||||
Version string
|
||||
Config config.BridgeConfig
|
||||
Pool pool.PoolHandle
|
||||
}
|
||||
|
||||
func StartBridgeServer(opts ServerOptions) []*http.Server {
|
||||
cfg := opts.Config
|
||||
var servers []*http.Server
|
||||
|
||||
if len(cfg.ConfigDirs) > 0 {
|
||||
if cfg.MultiPort {
|
||||
for i, dir := range cfg.ConfigDirs {
|
||||
port := cfg.Port + i
|
||||
subCfg := cfg
|
||||
subCfg.Port = port
|
||||
subCfg.ConfigDirs = []string{dir}
|
||||
subCfg.MultiPort = false
|
||||
subPool := pool.NewAccountPool([]string{dir})
|
||||
srv := startSingleServer(ServerOptions{Version: opts.Version, Config: subCfg, Pool: subPool})
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
pool.InitAccountPool(cfg.ConfigDirs)
|
||||
}
|
||||
|
||||
servers = append(servers, startSingleServer(opts))
|
||||
return servers
|
||||
}
|
||||
|
||||
func startSingleServer(opts ServerOptions) *http.Server {
|
||||
cfg := opts.Config
|
||||
|
||||
modelCache := &handlers.ModelCacheRef{}
|
||||
lastModel := cfg.DefaultModel
|
||||
|
||||
ph := opts.Pool
|
||||
if ph == nil {
|
||||
ph = pool.GlobalPoolHandle{}
|
||||
}
|
||||
handler := router.NewRouter(router.RouterOptions{
|
||||
Version: opts.Version,
|
||||
Config: cfg,
|
||||
ModelCache: modelCache,
|
||||
LastModel: &lastModel,
|
||||
Pool: ph,
|
||||
})
|
||||
handler = router.WrapWithRecovery(cfg.SessionsLogPath, handler)
|
||||
|
||||
useTLS := cfg.TLSCertPath != "" && cfg.TLSKeyPath != ""
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
if useTLS {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.TLSCertPath, cfg.TLSKeyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "TLS error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if useTLS {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
if useTLS {
|
||||
err = srv.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
err = srv.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
if isAddrInUse(err) {
|
||||
fmt.Fprintf(os.Stderr, "❌ Port %d is already in use. Set CURSOR_BRIDGE_PORT to use a different port.\n", cfg.Port)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "❌ Server error: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
logger.LogServerStart(opts.Version, scheme, cfg.Host, cfg.Port, cfg)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func SetupGracefulShutdown(servers []*http.Server, timeoutMs int) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
logger.LogShutdown(sig.String())
|
||||
|
||||
process.KillAllChildProcesses()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for _, srv := range servers {
|
||||
_ = srv.Shutdown(ctx)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
os.Exit(0)
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintln(os.Stderr, "[shutdown] Timed out waiting for connections to drain — forcing exit.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func isAddrInUse(err error) bool {
|
||||
return err != nil && (contains(err.Error(), "address already in use") || contains(err.Error(), "bind: address already in use"))
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsHelper(s, sub))
|
||||
}
|
||||
|
||||
func containsHelper(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
package server_test
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/server"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// freePort 取得一個暫時可用的隨機 port
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
// makeFakeAgentBin 建立一個 shell script,模擬 agent 固定輸出
|
||||
// sync 模式:直接輸出一行文字
|
||||
// stream 模式:輸出 JSON stream 行
|
||||
func makeFakeAgentBin(t *testing.T, syncOutput string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
script := dir + "/agent"
|
||||
content := fmt.Sprintf(`#!/bin/sh
|
||||
# 若有 --stream-json 則輸出 stream 格式
|
||||
for arg; do
|
||||
if [ "$arg" = "--stream-json" ]; then
|
||||
printf '%%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"%s"}]}}'
|
||||
printf '%%s\n' '{"type":"result","subtype":"success"}'
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
# 否則輸出 sync 格式
|
||||
printf '%%s' '%s'
|
||||
`, syncOutput, syncOutput)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// makeFakeAgentBinWithModels 額外支援 --list-models 輸出
|
||||
func makeFakeAgentBinWithModels(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
script := dir + "/agent"
|
||||
content := `#!/bin/sh
|
||||
for arg; do
|
||||
if [ "$arg" = "--list-models" ]; then
|
||||
printf 'claude-3-opus - Claude 3 Opus\n'
|
||||
printf 'claude-3-sonnet - Claude 3 Sonnet\n'
|
||||
exit 0
|
||||
fi
|
||||
if [ "$arg" = "--stream-json" ]; then
|
||||
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}'
|
||||
printf '%s\n' '{"type":"result","subtype":"success"}'
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
printf 'Hello from agent'
|
||||
`
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
func makeTestConfig(agentBin string, port int, overrides ...func(*config.BridgeConfig)) config.BridgeConfig {
|
||||
cfg := config.BridgeConfig{
|
||||
AgentBin: agentBin,
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
DefaultModel: "auto",
|
||||
Mode: "ask",
|
||||
Force: false,
|
||||
ApproveMcps: false,
|
||||
StrictModel: true,
|
||||
Workspace: os.TempDir(),
|
||||
TimeoutMs: 30000,
|
||||
SessionsLogPath: os.TempDir() + "/test-sessions.log",
|
||||
ChatOnlyWorkspace: true,
|
||||
Verbose: false,
|
||||
MaxMode: false,
|
||||
ConfigDirs: []string{},
|
||||
MultiPort: false,
|
||||
WinCmdlineMax: 30000,
|
||||
}
|
||||
for _, fn := range overrides {
|
||||
fn(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func waitListening(t *testing.T, host string, port int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 50*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("server on port %d did not start within %v", port, timeout)
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, method, url, body string, headers map[string]string) (int, string) {
|
||||
t.Helper()
|
||||
var reqBody io.Reader
|
||||
if body != "" {
|
||||
reqBody = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reqBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(data)
|
||||
}
|
||||
|
||||
func TestBridgeServer_Health(t *testing.T) {
|
||||
port := freePort(t)
|
||||
agentBin := makeFakeAgentBinWithModels(t)
|
||||
cfg := makeTestConfig(agentBin, port)
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
waitListening(t, "127.0.0.1", port, 3*time.Second)
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
status, body := doRequest(t, "GET", fmt.Sprintf("http://127.0.0.1:%d/health", port), "", nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body: %s", status, body)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
if result["version"] != "1.0.0" {
|
||||
t.Errorf("version = %v, want 1.0.0", result["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeServer_Models(t *testing.T) {
|
||||
port := freePort(t)
|
||||
agentBin := makeFakeAgentBinWithModels(t)
|
||||
cfg := makeTestConfig(agentBin, port)
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
waitListening(t, "127.0.0.1", port, 3*time.Second)
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
status, body := doRequest(t, "GET", fmt.Sprintf("http://127.0.0.1:%d/v1/models", port), "", nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body: %s", status, body)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
if result["object"] != "list" {
|
||||
t.Errorf("object = %v, want list", result["object"])
|
||||
}
|
||||
data := result["data"].([]interface{})
|
||||
if len(data) < 2 {
|
||||
t.Errorf("data len = %d, want >= 2", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeServer_Unauthorized(t *testing.T) {
|
||||
port := freePort(t)
|
||||
agentBin := makeFakeAgentBinWithModels(t)
|
||||
cfg := makeTestConfig(agentBin, port, func(c *config.BridgeConfig) {
|
||||
c.RequiredKey = "secret123"
|
||||
})
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
waitListening(t, "127.0.0.1", port, 3*time.Second)
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
status, body := doRequest(t, "GET", fmt.Sprintf("http://127.0.0.1:%d/health", port), "", nil)
|
||||
if status != 401 {
|
||||
t.Fatalf("status = %d, want 401; body: %s", status, body)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
errObj := result["error"].(map[string]interface{})
|
||||
if errObj["message"] != "Invalid API key" {
|
||||
t.Errorf("message = %v, want 'Invalid API key'", errObj["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeServer_AuthorizedKey(t *testing.T) {
|
||||
port := freePort(t)
|
||||
agentBin := makeFakeAgentBinWithModels(t)
|
||||
cfg := makeTestConfig(agentBin, port, func(c *config.BridgeConfig) {
|
||||
c.RequiredKey = "secret123"
|
||||
})
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
waitListening(t, "127.0.0.1", port, 3*time.Second)
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
status, _ := doRequest(t, "GET", fmt.Sprintf("http://127.0.0.1:%d/health", port), "", map[string]string{
|
||||
"Authorization": "Bearer secret123",
|
||||
})
|
||||
if status != 200 {
|
||||
t.Errorf("status = %d, want 200", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeServer_NotFound(t *testing.T) {
|
||||
port := freePort(t)
|
||||
agentBin := makeFakeAgentBinWithModels(t)
|
||||
cfg := makeTestConfig(agentBin, port)
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
waitListening(t, "127.0.0.1", port, 3*time.Second)
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
status, body := doRequest(t, "GET", fmt.Sprintf("http://127.0.0.1:%d/unknown", port), "", nil)
|
||||
if status != 404 {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
errObj := result["error"].(map[string]interface{})
|
||||
if errObj["code"] != "not_found" {
|
||||
t.Errorf("code = %v, want not_found", errObj["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeServer_ChatCompletions_Sync(t *testing.T) {
|
||||
port := freePort(t)
|
||||
agentBin := makeFakeAgentBin(t, "Hello from agent")
|
||||
cfg := makeTestConfig(agentBin, port)
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
waitListening(t, "127.0.0.1", port, 3*time.Second)
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
reqBody := `{"model":"claude-3-opus","messages":[{"role":"user","content":"Hi"}]}`
|
||||
status, body := doRequest(t, "POST", fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), reqBody, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body: %s", status, body)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
if result["object"] != "chat.completion" {
|
||||
t.Errorf("object = %v, want chat.completion", result["object"])
|
||||
}
|
||||
choices := result["choices"].([]interface{})
|
||||
msg := choices[0].(map[string]interface{})["message"].(map[string]interface{})
|
||||
if msg["content"] != "Hello from agent" {
|
||||
t.Errorf("content = %v, want 'Hello from agent'", msg["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeServer_MultiPort(t *testing.T) {
|
||||
basePort := freePort(t)
|
||||
agentBin := makeFakeAgentBinWithModels(t)
|
||||
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
cfg := makeTestConfig(agentBin, basePort, func(c *config.BridgeConfig) {
|
||||
c.ConfigDirs = []string{dir1, dir2}
|
||||
c.MultiPort = true
|
||||
})
|
||||
|
||||
srvs := server.StartBridgeServer(server.ServerOptions{Version: "1.0.0", Config: cfg})
|
||||
if len(srvs) != 2 {
|
||||
t.Fatalf("got %d servers, want 2", len(srvs))
|
||||
}
|
||||
|
||||
// 等待兩個 server 啟動(port 可能會衝突,這裡不嚴格測試 port 分配)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
defer func() {
|
||||
for _, s := range srvs {
|
||||
s.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package svc
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
domainrepo "cursor-api-proxy/pkg/domain/repository"
|
||||
"cursor-api-proxy/pkg/repository"
|
||||
)
|
||||
|
||||
type ServiceContext struct {
|
||||
Config config.Config
|
||||
|
||||
// Domain services
|
||||
AccountPool domainrepo.AccountPool
|
||||
|
||||
// Last model for sticky model mode
|
||||
LastModel *string
|
||||
}
|
||||
|
||||
func NewServiceContext(c config.Config) *ServiceContext {
|
||||
accountPool := repository.NewAccountPool(c.ConfigDirs)
|
||||
lastModel := c.DefaultModel
|
||||
|
||||
return &ServiceContext{
|
||||
Config: c,
|
||||
AccountPool: accountPool,
|
||||
LastModel: &lastModel,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
// Code generated by goctl. DO NOT EDIT.
|
||||
// goctl 1.10.1
|
||||
|
||||
package types
|
||||
|
||||
type AnthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream,optional"`
|
||||
System string `json:"system,optional"`
|
||||
Tools []Tool `json:"tools,optional"`
|
||||
}
|
||||
|
||||
type AnthropicResponse struct {
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
Usage AnthropicUsage `json:"usage"`
|
||||
}
|
||||
|
||||
type AnthropicUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Stream bool `json:"stream,optional"`
|
||||
Tools []Tool `json:"tools,optional"`
|
||||
Functions []Function `json:"functions,optional"`
|
||||
MaxTokens int `json:"max_tokens,optional"`
|
||||
Temperature float64 `json:"temperature,optional"`
|
||||
}
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message RespMessage `json:"message,optional"`
|
||||
Delta Delta `json:"delta,optional"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,optional"`
|
||||
}
|
||||
|
||||
type Delta struct {
|
||||
Role string `json:"role,optional"`
|
||||
Content string `json:"content,optional"`
|
||||
ReasoningContent string `json:"reasoning_content,optional"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,optional"`
|
||||
}
|
||||
|
||||
type Function struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,optional"`
|
||||
Parameters interface{} `json:"parameters,optional"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type HealthRequest struct {
|
||||
}
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
type ModelData struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
|
||||
type ModelsRequest struct {
|
||||
}
|
||||
|
||||
type ModelsResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []ModelData `json:"data"`
|
||||
}
|
||||
|
||||
type RespMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,optional"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,optional"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
Index int `json:"index"`
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
type ToolFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
73
main.go
73
main.go
|
|
@ -1,26 +1,56 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/cmd"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/env"
|
||||
"cursor-api-proxy/internal/server"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/handler"
|
||||
"cursor-api-proxy/internal/svc"
|
||||
|
||||
cmd "cursor-api-proxy/cmd/cli"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/conf"
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
const version = "1.0.0"
|
||||
|
||||
var configFile = flag.String("f", "etc/chat-api.yaml", "the config file")
|
||||
|
||||
func main() {
|
||||
args, err := cmd.ParseArgs(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
// Check for CLI commands first (before flag.Parse)
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 {
|
||||
parsed, err := cmd.ParseArgs(args)
|
||||
if err != nil {
|
||||
// Not a CLI command, proceed to HTTP server
|
||||
} else if handleCLICommand(parsed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP server mode (go-zero)
|
||||
flag.Parse()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(*configFile, &c)
|
||||
|
||||
server := rest.MustNewServer(c.RestConf)
|
||||
defer server.Stop()
|
||||
|
||||
ctx := svc.NewServiceContext(c)
|
||||
handler.RegisterHandlers(server, ctx)
|
||||
|
||||
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
|
||||
server.Start()
|
||||
}
|
||||
|
||||
func handleCLICommand(args cmd.ParsedArgs) bool {
|
||||
if args.Help {
|
||||
cmd.PrintHelp(version)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if args.Login {
|
||||
|
|
@ -28,7 +58,7 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if args.Logout {
|
||||
|
|
@ -36,7 +66,7 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if args.AccountsList {
|
||||
|
|
@ -44,7 +74,7 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if args.ResetHwid {
|
||||
|
|
@ -52,22 +82,9 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
e := env.OsEnvToMap()
|
||||
if args.Tailscale {
|
||||
e["CURSOR_BRIDGE_HOST"] = "0.0.0.0"
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
cfg := config.LoadBridgeConfig(e, cwd)
|
||||
|
||||
servers := server.StartBridgeServer(server.ServerOptions{
|
||||
Version: version,
|
||||
Config: cfg,
|
||||
})
|
||||
server.SetupGracefulShutdown(servers, 10000)
|
||||
|
||||
select {}
|
||||
// Not a CLI command
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package anthropic
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/openai"
|
||||
"cursor-api-proxy/pkg/adapter/openai"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package anthropic_test
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/anthropic"
|
||||
"cursor-api-proxy/pkg/adapter/anthropic"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package entity
|
||||
|
||||
// Account represents an account in the pool
|
||||
type Account struct {
|
||||
ConfigDir string
|
||||
ActiveRequests int
|
||||
LastUsed int64
|
||||
RateLimitUntil int64
|
||||
}
|
||||
|
||||
// AccountStat represents account statistics
|
||||
type AccountStat struct {
|
||||
ConfigDir string
|
||||
ActiveRequests int
|
||||
TotalRequests int
|
||||
TotalSuccess int
|
||||
TotalErrors int
|
||||
TotalRateLimits int
|
||||
TotalLatencyMs int64
|
||||
IsRateLimited bool
|
||||
RateLimitUntil int64
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package entity
|
||||
|
||||
// ChunkType represents the type of stream chunk
|
||||
type ChunkType int
|
||||
|
||||
const (
|
||||
ChunkText ChunkType = iota
|
||||
ChunkThinking
|
||||
ChunkToolCall
|
||||
ChunkDone
|
||||
)
|
||||
|
||||
// StreamChunk represents a chunk in SSE streaming
|
||||
type StreamChunk struct {
|
||||
Type ChunkType
|
||||
Text string
|
||||
Thinking string
|
||||
ToolCall *ToolCall
|
||||
Done bool
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package entity
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
Role string
|
||||
Content interface{}
|
||||
}
|
||||
|
||||
// Tool represents a tool definition
|
||||
type Tool struct {
|
||||
Type string
|
||||
Function ToolFunction
|
||||
}
|
||||
|
||||
// ToolFunction represents a tool function definition
|
||||
type ToolFunction struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters interface{}
|
||||
}
|
||||
|
||||
// ToolCall represents a tool call result
|
||||
type ToolCall struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments string
|
||||
}
|
||||
|
||||
// FunctionCall represents a function call
|
||||
type FunctionCall struct {
|
||||
Name string
|
||||
Arguments string
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
)
|
||||
|
||||
// AccountPool defines the interface for account pool management
|
||||
type AccountPool interface {
|
||||
GetNextConfigDir() string
|
||||
ReportRequestStart(configDir string)
|
||||
ReportRequestEnd(configDir string)
|
||||
ReportRequestSuccess(configDir string, latencyMs int64)
|
||||
ReportRequestError(configDir string, latencyMs int64)
|
||||
ReportRateLimit(configDir string, penaltyMs int64)
|
||||
GetStats() []entity.AccountStat
|
||||
Count() int
|
||||
}
|
||||
|
||||
// Provider defines the interface for AI providers
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Generate(ctx context.Context, model string, messages []entity.Message,
|
||||
tools []entity.Tool, callback func(entity.StreamChunk)) error
|
||||
Close() error
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package types
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("invalid request")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrAccountExhausted = errors.New("all accounts exhausted")
|
||||
ErrRateLimited = errors.New("rate limited")
|
||||
ErrTimeout = errors.New("request timeout")
|
||||
ErrClientDisconnect = errors.New("client disconnected")
|
||||
ErrAgentError = errors.New("agent execution error")
|
||||
)
|
||||
|
|
@ -1,10 +1,25 @@
|
|||
package models
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cursor-api-proxy/pkg/infrastructure/process"
|
||||
)
|
||||
|
||||
type CursorCliModel struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type ModelAlias struct {
|
||||
CursorID string
|
||||
AnthropicID string
|
||||
Name string
|
||||
}
|
||||
|
||||
var anthropicToCursor = map[string]string{
|
||||
"claude-opus-4-6": "opus-4.6",
|
||||
"claude-opus-4.6": "opus-4.6",
|
||||
|
|
@ -24,12 +39,12 @@ var anthropicToCursor = map[string]string{
|
|||
"claude-sonnet-4-6-thinking": "sonnet-4.6-thinking",
|
||||
"claude-opus-4-5-thinking": "opus-4.5-thinking",
|
||||
"claude-sonnet-4-5-thinking": "sonnet-4.5-thinking",
|
||||
}
|
||||
|
||||
type ModelAlias struct {
|
||||
CursorID string
|
||||
AnthropicID string
|
||||
Name string
|
||||
"claude-3-5-sonnet": "claude-3.5-sonnet",
|
||||
"claude-3-5-sonnet-20241022": "claude-3.5-sonnet",
|
||||
"claude-3-5-haiku": "claude-3.5-haiku",
|
||||
"claude-3-opus": "claude-3-opus",
|
||||
"claude-3-sonnet": "claude-3-sonnet",
|
||||
"claude-3-haiku": "claude-3-haiku",
|
||||
}
|
||||
|
||||
var cursorToAnthropicAlias = []ModelAlias{
|
||||
|
|
@ -43,13 +58,61 @@ var cursorToAnthropicAlias = []ModelAlias{
|
|||
{"sonnet-4.5-thinking", "claude-sonnet-4-5-thinking", "Claude 4.5 Sonnet (Thinking)"},
|
||||
}
|
||||
|
||||
// cursorModelPattern matches cursor model IDs like "opus-4.6", "sonnet-4.7-thinking".
|
||||
var modelLineRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._:/-]*)\s+-\s+(.*)$`)
|
||||
var trailingParenRe = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
|
||||
var cursorModelPattern = regexp.MustCompile(`^([a-zA-Z]+)-(\d+)\.(\d+)(-thinking)?$`)
|
||||
|
||||
// reverseDynamicPattern matches dynamically generated anthropic aliases
|
||||
// like "claude-opus-4-7", "claude-sonnet-4-7-thinking".
|
||||
var reverseDynamicPattern = regexp.MustCompile(`^claude-([a-zA-Z]+)-(\d+)-(\d+)(-thinking)?$`)
|
||||
|
||||
type AnthropicAlias struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func ParseCursorCliModels(output string) []CursorCliModel {
|
||||
lines := strings.Split(output, "\n")
|
||||
seen := make(map[string]CursorCliModel)
|
||||
var order []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
m := modelLineRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id := m[1]
|
||||
rawName := m[2]
|
||||
name := strings.TrimSpace(trailingParenRe.ReplaceAllString(rawName, ""))
|
||||
if name == "" {
|
||||
name = id
|
||||
}
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = CursorCliModel{ID: id, Name: name}
|
||||
order = append(order, id)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]CursorCliModel, 0, len(order))
|
||||
for _, id := range order {
|
||||
result = append(result, seen[id])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ListCursorCliModels(agentBin string, timeoutMs int) ([]CursorCliModel, error) {
|
||||
tmpDir := os.TempDir()
|
||||
result, err := process.Run(agentBin, []string{"--print-models_oneline"}, process.RunOptions{
|
||||
Cwd: tmpDir,
|
||||
TimeoutMs: timeoutMs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("cursor cli failed: %s", result.Stderr)
|
||||
}
|
||||
return ParseCursorCliModels(result.Stdout), nil
|
||||
}
|
||||
|
||||
func generateDynamicAlias(cursorID string) (AnthropicAlias, bool) {
|
||||
m := cursorModelPattern.FindStringSubmatch(cursorID)
|
||||
if m == nil {
|
||||
|
|
@ -78,41 +141,29 @@ func reverseDynamicAlias(anthropicID string) (string, bool) {
|
|||
}
|
||||
|
||||
func ResolveToCursorModel(requested string) string {
|
||||
if strings.TrimSpace(requested) == "" {
|
||||
return ""
|
||||
if mapped, ok := anthropicToCursor[requested]; ok {
|
||||
return mapped
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(requested))
|
||||
if v, ok := anthropicToCursor[key]; ok {
|
||||
return v
|
||||
if cursorID, ok := reverseDynamicAlias(requested); ok {
|
||||
return cursorID
|
||||
}
|
||||
if v, ok := reverseDynamicAlias(key); ok {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(requested)
|
||||
return requested
|
||||
}
|
||||
|
||||
type AnthropicAlias struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
func GetAnthropicModelAliases(cursorIDs []string) []AnthropicAlias {
|
||||
result := make([]AnthropicAlias, 0, len(cursorToAnthropicAlias)+len(cursorIDs))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
func GetAnthropicModelAliases(availableCursorIDs []string) []AnthropicAlias {
|
||||
set := make(map[string]bool, len(availableCursorIDs))
|
||||
for _, id := range availableCursorIDs {
|
||||
set[id] = true
|
||||
}
|
||||
|
||||
staticSet := make(map[string]bool, len(cursorToAnthropicAlias))
|
||||
var result []AnthropicAlias
|
||||
for _, a := range cursorToAnthropicAlias {
|
||||
if set[a.CursorID] {
|
||||
staticSet[a.CursorID] = true
|
||||
result = append(result, AnthropicAlias{ID: a.AnthropicID, Name: a.Name})
|
||||
}
|
||||
result = append(result, AnthropicAlias{
|
||||
ID: a.AnthropicID,
|
||||
Name: a.Name,
|
||||
})
|
||||
seen[a.CursorID] = true
|
||||
}
|
||||
|
||||
for _, id := range availableCursorIDs {
|
||||
if staticSet[id] {
|
||||
for _, id := range cursorIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
if alias, ok := generateDynamicAlias(id); ok {
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
)
|
||||
|
||||
// ChatUsecase defines the interface for chat operations
|
||||
type ChatUsecase interface {
|
||||
Execute(ctx context.Context, input ChatInput) (ChatOutput, error)
|
||||
Stream(ctx context.Context, input ChatInput, callback func(entity.StreamChunk)) error
|
||||
}
|
||||
|
||||
// ChatInput represents the input for chat operations
|
||||
type ChatInput struct {
|
||||
Model string
|
||||
Messages []entity.Message
|
||||
Tools []entity.Tool
|
||||
Stream bool
|
||||
}
|
||||
|
||||
// ChatOutput represents the output from chat operations
|
||||
type ChatOutput struct {
|
||||
Content string
|
||||
Thinking string
|
||||
ToolCalls []entity.ToolCall
|
||||
}
|
||||
|
||||
// AgentRunner defines the interface for running AI agents
|
||||
type AgentRunner interface {
|
||||
RunSync(ctx context.Context, config interface{}, args []string) (RunResult, error)
|
||||
RunStream(ctx context.Context, config interface{}, args []string, onLine func(string)) (StreamResult, error)
|
||||
}
|
||||
|
||||
// RunResult represents the result of a synchronous agent run
|
||||
type RunResult struct {
|
||||
Code int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
// StreamResult represents the result of a streaming agent run
|
||||
type StreamResult struct {
|
||||
Code int
|
||||
Stderr string
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/pool"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -192,7 +193,7 @@ func LogAccountAssigned(configDir string) {
|
|||
fmt.Printf("%s %s→%s account %s%s%s\n", ts(), cBCyan, cReset, cBold, name, cReset)
|
||||
}
|
||||
|
||||
func LogAccountStats(verbose bool, stats []pool.AccountStat) {
|
||||
func LogAccountStats(verbose bool, stats []entity.AccountStat) {
|
||||
if !verbose || len(stats) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package process_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/process"
|
||||
"cursor-api-proxy/pkg/infrastructure/process"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -3,7 +3,7 @@ package process
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"cursor-api-proxy/internal/env"
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package winlimit
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/env"
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ package cursor
|
|||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/apitypes"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
"cursor-api-proxy/internal/config"
|
||||
)
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ func (p *Provider) Close() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) error {
|
||||
func (p *Provider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ package geminiweb
|
|||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/apitypes"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -114,7 +114,7 @@ func (p *PlaywrightProvider) launchIfNeeded() error {
|
|||
}
|
||||
|
||||
// Generate 生成回應
|
||||
func (p *PlaywrightProvider) Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) (err error) {
|
||||
func (p *PlaywrightProvider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) (err error) {
|
||||
// 確保在返回錯誤時保存診斷
|
||||
defer func() {
|
||||
if err != nil {
|
||||
|
|
@ -182,7 +182,7 @@ func (p *PlaywrightProvider) Generate(ctx context.Context, model string, message
|
|||
fmt.Println("Browser is open. You can:")
|
||||
fmt.Println("1. Log in to Gemini now")
|
||||
fmt.Println("2. Continue without login")
|
||||
fmt.Println("========================================\n")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("[GeminiWeb] ✓ Logged in")
|
||||
|
|
@ -216,8 +216,8 @@ func (p *PlaywrightProvider) Generate(ctx context.Context, model string, message
|
|||
}
|
||||
|
||||
// 9. 回調
|
||||
cb(apitypes.StreamChunk{Type: apitypes.ChunkText, Text: response})
|
||||
cb(apitypes.StreamChunk{Type: apitypes.ChunkDone, Done: true})
|
||||
cb(entity.StreamChunk{Type: entity.ChunkText, Text: response})
|
||||
cb(entity.StreamChunk{Type: entity.ChunkDone, Done: true})
|
||||
|
||||
fmt.Printf("[GeminiWeb] Response complete (%d chars)\n", len(response))
|
||||
return nil
|
||||
|
|
@ -624,18 +624,39 @@ func (p *PlaywrightProvider) selectModel(model string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// buildPromptFromMessages 從訊息列表建構提示詞
|
||||
func buildPromptFromMessagesPlaywright(messages []apitypes.Message) string {
|
||||
// buildPromptFromMessagesPlaywright 從訊息列表建構提示詞
|
||||
func buildPromptFromMessagesPlaywright(messages []entity.Message) string {
|
||||
var prompt string
|
||||
for _, m := range messages {
|
||||
content := messageContentToStringPlaywright(m.Content)
|
||||
switch m.Role {
|
||||
case "system":
|
||||
prompt += "System: " + m.Content + "\n\n"
|
||||
prompt += "System: " + content + "\n\n"
|
||||
case "user":
|
||||
prompt += m.Content + "\n\n"
|
||||
prompt += content + "\n\n"
|
||||
case "assistant":
|
||||
prompt += "Assistant: " + m.Content + "\n\n"
|
||||
prompt += "Assistant: " + content + "\n\n"
|
||||
}
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
// messageContentToStringPlaywright converts Message.Content to string
|
||||
func messageContentToStringPlaywright(content interface{}) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var result string
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
if text, ok := m["text"].(string); ok {
|
||||
result += text
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ package geminiweb
|
|||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/apitypes"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -54,7 +54,7 @@ func (p *Provider) getSessionDir() string {
|
|||
}
|
||||
|
||||
// Generate 生成回應
|
||||
func (p *Provider) Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) error {
|
||||
func (p *Provider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) error {
|
||||
fmt.Printf("[GeminiWeb] Starting generation with model: %s\n", model)
|
||||
|
||||
// 1. 獲取瀏覽器管理器
|
||||
|
|
@ -97,7 +97,7 @@ func (p *Provider) Generate(ctx context.Context, model string, messages []apityp
|
|||
fmt.Println("Browser is open. You can:")
|
||||
fmt.Println("1. Log in to Gemini now")
|
||||
fmt.Println("2. Continue without login")
|
||||
fmt.Println("========================================\n")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[GeminiWeb] Logged in\n")
|
||||
|
|
@ -131,29 +131,51 @@ func (p *Provider) Generate(ctx context.Context, model string, messages []apityp
|
|||
}
|
||||
|
||||
// 11. 串流回調
|
||||
cb(apitypes.StreamChunk{Type: apitypes.ChunkText, Text: response})
|
||||
cb(apitypes.StreamChunk{Type: apitypes.ChunkDone, Done: true})
|
||||
cb(entity.StreamChunk{Type: entity.ChunkText, Text: response})
|
||||
cb(entity.StreamChunk{Type: entity.ChunkDone, Done: true})
|
||||
|
||||
fmt.Printf("[GeminiWeb] Response complete (%d chars)\n", len(response))
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPromptFromMessages 從訊息列表建構提示詞
|
||||
func buildPromptFromMessages(messages []apitypes.Message) string {
|
||||
func buildPromptFromMessages(messages []entity.Message) string {
|
||||
var prompt string
|
||||
for _, m := range messages {
|
||||
content := messageContentToString(m.Content)
|
||||
switch m.Role {
|
||||
case "system":
|
||||
prompt += "System: " + m.Content + "\n\n"
|
||||
prompt += "System: " + content + "\n\n"
|
||||
case "user":
|
||||
prompt += m.Content + "\n\n"
|
||||
prompt += content + "\n\n"
|
||||
case "assistant":
|
||||
prompt += "Assistant: " + m.Content + "\n\n"
|
||||
prompt += "Assistant: " + content + "\n\n"
|
||||
}
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
// messageContentToString converts Message.Content to string
|
||||
func messageContentToString(content interface{}) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
// Handle array content (multimodal)
|
||||
var result string
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
if text, ok := m["text"].(string); ok {
|
||||
result += text
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// RunLogin 執行登入流程(供 gemini-login 命令使用)
|
||||
func RunLogin(cfg config.BridgeConfig, sessionName string) error {
|
||||
if sessionName == "" {
|
||||
|
|
@ -1,32 +1,22 @@
|
|||
package pool
|
||||
package repository
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
)
|
||||
|
||||
type accountStatus struct {
|
||||
configDir string
|
||||
activeRequests int
|
||||
lastUsed int64
|
||||
rateLimitUntil int64
|
||||
totalRequests int
|
||||
totalSuccess int
|
||||
totalErrors int
|
||||
totalRateLimits int
|
||||
totalLatencyMs int64
|
||||
}
|
||||
|
||||
type AccountStat struct {
|
||||
ConfigDir string
|
||||
ActiveRequests int
|
||||
TotalRequests int
|
||||
TotalSuccess int
|
||||
TotalErrors int
|
||||
TotalRateLimits int
|
||||
TotalLatencyMs int64
|
||||
IsRateLimited bool
|
||||
RateLimitUntil int64
|
||||
configDir string
|
||||
activeRequests int
|
||||
lastUsed int64
|
||||
rateLimitUntil int64
|
||||
totalRequests int
|
||||
totalSuccess int
|
||||
totalErrors int
|
||||
totalRateLimits int
|
||||
totalLatencyMs int64
|
||||
}
|
||||
|
||||
type AccountPool struct {
|
||||
|
|
@ -155,13 +145,13 @@ func (p *AccountPool) ReportRateLimit(configDir string, penaltyMs int64) {
|
|||
}
|
||||
}
|
||||
|
||||
func (p *AccountPool) GetStats() []AccountStat {
|
||||
func (p *AccountPool) GetStats() []entity.AccountStat {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now().UnixMilli()
|
||||
stats := make([]AccountStat, len(p.accounts))
|
||||
stats := make([]entity.AccountStat, len(p.accounts))
|
||||
for i, a := range p.accounts {
|
||||
stats[i] = AccountStat{
|
||||
stats[i] = entity.AccountStat{
|
||||
ConfigDir: a.configDir,
|
||||
ActiveRequests: a.activeRequests,
|
||||
TotalRequests: a.totalRequests,
|
||||
|
|
@ -180,7 +170,6 @@ func (p *AccountPool) Count() int {
|
|||
return len(p.accounts)
|
||||
}
|
||||
|
||||
|
||||
// ─── PoolHandle interface ──────────────────────────────────────────────────
|
||||
// PoolHandle 讓 handler 可以注入獨立的 pool 實例,避免多 port 模式共用全域 pool。
|
||||
|
||||
|
|
@ -191,19 +180,19 @@ type PoolHandle interface {
|
|||
ReportRequestSuccess(configDir string, latencyMs int64)
|
||||
ReportRequestError(configDir string, latencyMs int64)
|
||||
ReportRateLimit(configDir string, penaltyMs int64)
|
||||
GetStats() []AccountStat
|
||||
GetStats() []entity.AccountStat
|
||||
}
|
||||
|
||||
// GlobalPoolHandle 包裝全域函式以實作 PoolHandle 介面(單 port 模式使用)
|
||||
type GlobalPoolHandle struct{}
|
||||
|
||||
func (GlobalPoolHandle) GetNextConfigDir() string { return GetNextAccountConfigDir() }
|
||||
func (GlobalPoolHandle) ReportRequestStart(d string) { ReportRequestStart(d) }
|
||||
func (GlobalPoolHandle) ReportRequestEnd(d string) { ReportRequestEnd(d) }
|
||||
func (GlobalPoolHandle) GetNextConfigDir() string { return GetNextAccountConfigDir() }
|
||||
func (GlobalPoolHandle) ReportRequestStart(d string) { ReportRequestStart(d) }
|
||||
func (GlobalPoolHandle) ReportRequestEnd(d string) { ReportRequestEnd(d) }
|
||||
func (GlobalPoolHandle) ReportRequestSuccess(d string, l int64) { ReportRequestSuccess(d, l) }
|
||||
func (GlobalPoolHandle) ReportRequestError(d string, l int64) { ReportRequestError(d, l) }
|
||||
func (GlobalPoolHandle) ReportRateLimit(d string, p int64) { ReportRateLimit(d, p) }
|
||||
func (GlobalPoolHandle) GetStats() []AccountStat { return GetAccountStats() }
|
||||
func (GlobalPoolHandle) GetStats() []entity.AccountStat { return GetAccountStats() }
|
||||
|
||||
// ─── Global pool ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -273,7 +262,7 @@ func ReportRateLimit(configDir string, penaltyMs int64) {
|
|||
}
|
||||
}
|
||||
|
||||
func GetAccountStats() []AccountStat {
|
||||
func GetAccountStats() []entity.AccountStat {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package pool
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package agent
|
||||
package usecase
|
||||
|
||||
import "cursor-api-proxy/internal/config"
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package agent
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package agent
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/internal/process"
|
||||
"cursor-api-proxy/pkg/infrastructure/process"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package sanitize
|
||||
package usecase
|
||||
|
||||
import "regexp"
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package sanitize
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package agent
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package toolcall
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/providers/geminiweb"
|
||||
"cursor-api-proxy/pkg/provider/geminiweb"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ func analyzeDOM(page *rod.Page) {
|
|||
ariaLabel, _ := el.Attribute("aria-label")
|
||||
placeholder, _ := el.Attribute("placeholder")
|
||||
fmt.Printf(" [%d] tag=%s class=%s aria-label=%s placeholder=%s\n",
|
||||
i, tag, class, ariaLabel, placeholder)
|
||||
i, tag, ptrToStr(class), ptrToStr(ariaLabel), ptrToStr(placeholder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +120,7 @@ func analyzeDOM(page *rod.Page) {
|
|||
text, _ := el.Text()
|
||||
text = truncate(text, 30)
|
||||
fmt.Printf(" [%d] tag=%s class=%s aria-label=%s text=%s\n",
|
||||
i, tag, class, ariaLabel, text)
|
||||
i, tag, ptrToStr(class), ptrToStr(ariaLabel), text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ func analyzeDOM(page *rod.Page) {
|
|||
ariaLabel, _ := el.Attribute("aria-label")
|
||||
text, _ := el.Text()
|
||||
fmt.Printf(" [%d] tag=%s class=%s aria-label=%s text=%s\n",
|
||||
i, tag, class, ariaLabel, truncate(text, 30))
|
||||
i, tag, ptrToStr(class), ptrToStr(ariaLabel), truncate(text, 30))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,3 +157,10 @@ func truncate(s string, max int) string {
|
|||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
|
||||
func ptrToStr(s *string) string {
|
||||
if s == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue