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 |
|
|
@ -1,5 +1,4 @@
|
|||
.idea/
|
||||
bin/
|
||||
cursor-api-proxy*
|
||||
.env
|
||||
cursor-adapter
|
||||
cursor-mcp-server
|
||||
.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
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
# ──────────────────────────────────────────────
|
||||
# cursor-api-proxy — 設定與建置
|
||||
# 編輯下方變數,然後執行 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
|
||||
API_KEY ?=
|
||||
TIMEOUT_MS ?= 3600000
|
||||
MULTI_PORT ?= false
|
||||
VERBOSE ?= false
|
||||
|
||||
# ── Agent / 模型設定 ──────────────────────────
|
||||
AGENT_BIN ?= agent
|
||||
AGENT_NODE ?=
|
||||
AGENT_SCRIPT ?=
|
||||
DEFAULT_MODEL ?= auto
|
||||
STRICT_MODEL ?= true
|
||||
MAX_MODE ?= false
|
||||
FORCE ?= false
|
||||
APPROVE_MCPS ?= false
|
||||
|
||||
# ── 工作區與帳號 ──────────────────────────────
|
||||
WORKSPACE ?=
|
||||
CHAT_ONLY_WORKSPACE ?= true
|
||||
CONFIG_DIRS ?=
|
||||
|
||||
# ── OpenCode 模型設定 ────────────────────
|
||||
OPENCODE_MODEL ?= cursor/claude-4.6-sonnet-medium
|
||||
OPENCODE_SMALL_MODEL ?= cursor/gpt-5.4-nano-medium
|
||||
|
||||
# ── Cursor / Claude Code(~/.claude)────────────────
|
||||
CLAUDE_SETTINGS ?= $(HOME)/.claude/settings.json
|
||||
CLAUDE_JSON ?= $(HOME)/.claude.json
|
||||
ANTHROPIC_AUTH_TOKEN ?=
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL ?= claude-4.6-sonnet-medium
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL ?= claude-4.6-opus-max
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL ?= gemini-3-flash
|
||||
ANTHROPIC_BASE_HOST ?= $(HOST)
|
||||
|
||||
# ── TLS / HTTPS ───────────────────────────────
|
||||
TLS_CERT ?=
|
||||
TLS_KEY ?=
|
||||
|
||||
# ── Gemini Web Provider ───────────────────────
|
||||
PROVIDER ?= cursor
|
||||
GEMINI_ACCOUNT_DIR ?=
|
||||
GEMINI_BROWSER_VISIBLE ?= false
|
||||
GEMINI_MAX_SESSIONS ?= 3
|
||||
|
||||
# ── 記錄 ──────────────────────────────────────
|
||||
SESSIONS_LOG ?=
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
ENV_FILE ?= .env
|
||||
|
||||
OPENCODE_CONFIG ?= $(HOME)/.config/opencode/opencode.json
|
||||
|
||||
# ── Docker 設定 ───────────────────────────────
|
||||
DOCKER_IMAGE ?= cursor-api-proxy
|
||||
DOCKER_TAG ?= latest
|
||||
DOCKER_COMPOSE ?= docker compose
|
||||
|
||||
.PHONY: env run build clean help opencode opencode-models pm2 pm2-stop pm2-logs claude-code pm2-claude-code \
|
||||
claude-settings claude-onboarding claude-cursor-setup \
|
||||
docker-build docker-up docker-down docker-logs docker-restart docker-shell docker-env docker-setup
|
||||
|
||||
## 產生 .env 檔(預設輸出至 .env,可用 ENV_FILE=xxx 覆寫)
|
||||
env:
|
||||
@printf '# 由 make env 自動產生,請勿手動編輯\n' > $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_HOST=%s\n' "$(HOST)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_PORT=%s\n' "$(PORT)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_API_KEY=%s\n' "$(API_KEY)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_TIMEOUT_MS=%s\n' "$(TIMEOUT_MS)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_MULTI_PORT=%s\n' "$(MULTI_PORT)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_VERBOSE=%s\n' "$(VERBOSE)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_AGENT_BIN=%s\n' "$(AGENT_BIN)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_AGENT_NODE=%s\n' "$(AGENT_NODE)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_AGENT_SCRIPT=%s\n' "$(AGENT_SCRIPT)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_DEFAULT_MODEL=%s\n' "$(DEFAULT_MODEL)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_STRICT_MODEL=%s\n' "$(STRICT_MODEL)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_MAX_MODE=%s\n' "$(MAX_MODE)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_FORCE=%s\n' "$(FORCE)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_APPROVE_MCPS=%s\n' "$(APPROVE_MCPS)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_WORKSPACE=%s\n' "$(WORKSPACE)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE=%s\n' "$(CHAT_ONLY_WORKSPACE)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_CONFIG_DIRS=%s\n' "$(CONFIG_DIRS)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_TLS_CERT=%s\n' "$(TLS_CERT)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_TLS_KEY=%s\n' "$(TLS_KEY)" >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_SESSIONS_LOG=%s\n' "$(SESSIONS_LOG)" >> $(ENV_FILE)
|
||||
@printf '# ── Provider 設定 ───────────────────────────\n' >> $(ENV_FILE)
|
||||
@printf 'CURSOR_BRIDGE_PROVIDER=%s\n' "$(PROVIDER)" >> $(ENV_FILE)
|
||||
@printf '# Gemini Web Provider 設定(當 PROVIDER=gemini-web 時使用)\n' >> $(ENV_FILE)
|
||||
@printf 'GEMINI_ACCOUNT_DIR=%s\n' "$(GEMINI_ACCOUNT_DIR)" >> $(ENV_FILE)
|
||||
@printf 'GEMINI_BROWSER_VISIBLE=%s\n' "$(GEMINI_BROWSER_VISIBLE)" >> $(ENV_FILE)
|
||||
@printf 'GEMINI_MAX_SESSIONS=%s\n' "$(GEMINI_MAX_SESSIONS)" >> $(ENV_FILE)
|
||||
@echo "已產生 $(ENV_FILE)"
|
||||
|
||||
## 編譯二進位檔
|
||||
build:
|
||||
go build -o cursor-api-proxy .
|
||||
|
||||
## 載入 .env 後直接執行(需先執行 make env 或已有 .env)
|
||||
run: build
|
||||
@if [ -f $(ENV_FILE) ]; then \
|
||||
set -a && . ./$(ENV_FILE) && set +a && ./cursor-api-proxy; \
|
||||
else \
|
||||
echo "找不到 $(ENV_FILE),請先執行 make env"; exit 1; \
|
||||
fi
|
||||
|
||||
## 清除產出物
|
||||
clean:
|
||||
rm -f cursor-api-proxy $(ENV_FILE)
|
||||
|
||||
## 設定 OpenCode 使用此代理(更新 opencode.json 的 cursor 與 gemini-web provider)
|
||||
opencode: build
|
||||
@if [ ! -f "$(OPENCODE_CONFIG)" ]; then \
|
||||
echo "找不到 $(OPENCODE_CONFIG),建立新設定檔"; \
|
||||
mkdir -p $$(dirname "$(OPENCODE_CONFIG)"); \
|
||||
printf '{\n "model": "$(OPENCODE_MODEL)",\n "small_model": "$(OPENCODE_SMALL_MODEL)",\n "provider": {\n "cursor": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Cursor Agent",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": { "auto": { "name": "Cursor Auto" } }\n },\n "gemini-web": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Gemini Web",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": {\n "gemini-2.0-flash": { "name": "Gemini 2.0 Flash" },\n "gemini-2.5-pro": { "name": "Gemini 2.5 Pro" },\n "gemini-2.5-pro-thinking": { "name": "Gemini 2.5 Pro Thinking" }\n }\n }\n }\n}\n' > "$(OPENCODE_CONFIG)"; \
|
||||
echo "已建立 $(OPENCODE_CONFIG)(包含 cursor 與 gemini-web provider)"; \
|
||||
elif [ -n "$(API_KEY)" ]; then \
|
||||
jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" --arg key "$(API_KEY)" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider.cursor.options.apiKey = $$key | .provider["gemini-web"].options.baseURL = $$base | .provider["gemini-web"].options.apiKey = $$key' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \
|
||||
echo "已更新 $(OPENCODE_CONFIG)(model=$(OPENCODE_MODEL), small_model=$(OPENCODE_SMALL_MODEL), baseURL → http://$(HOST):$(PORT)/v1,apiKey 已設定)"; \
|
||||
else \
|
||||
jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider["gemini-web"].options.baseURL = $$base' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \
|
||||
echo "已更新 $(OPENCODE_CONFIG)(model=$(OPENCODE_MODEL), small_model=$(OPENCODE_SMALL_MODEL), baseURL → http://$(HOST):$(PORT)/v1)"; \
|
||||
fi
|
||||
|
||||
## 啟動代理並用 curl 同步模型列表到 opencode.json
|
||||
opencode-models: opencode
|
||||
@echo "啟動代理以取得模型列表..."
|
||||
@set -a && . ./$(ENV_FILE) 2>/dev/null; set +a; \
|
||||
./cursor-api-proxy & PID=$$!; \
|
||||
sleep 2; \
|
||||
MODELS=$$(curl -s http://$(HOST):$(PORT)/v1/models | jq '[.data[].id]'); \
|
||||
kill $$PID 2>/dev/null; wait $$PID 2>/dev/null; \
|
||||
if [ -n "$$MODELS" ] && [ "$$MODELS" != "null" ]; then \
|
||||
jq --argjson ids "$$MODELS" 'reduce $ids[] as $id (.; .provider.cursor.models[$id] = { name: $id } | .provider["gemini-web"].models[$id] = { name: $id })' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \
|
||||
echo "已同步模型列表到 $(OPENCODE_CONFIG)(cursor 與 gemini-web)"; \
|
||||
else \
|
||||
echo "無法取得模型列表,請確認代理已啟動"; \
|
||||
fi
|
||||
|
||||
## 編譯並用 pm2 啟動
|
||||
pm2: build
|
||||
@if [ -f "$(ENV_FILE)" ]; then \
|
||||
env $$(cat $(ENV_FILE) | grep -v '^#' | xargs) CURSOR_BRIDGE_HOST=$(HOST) CURSOR_BRIDGE_PORT=$(PORT) pm2 start ./cursor-api-proxy --name cursor-api-proxy --update-env; \
|
||||
else \
|
||||
CURSOR_BRIDGE_HOST=$(HOST) CURSOR_BRIDGE_PORT=$(PORT) pm2 start ./cursor-api-proxy --name cursor-api-proxy; \
|
||||
fi
|
||||
@pm2 save
|
||||
@echo "pm2 已啟動 cursor-api-proxy(http://$(HOST):$(PORT))"
|
||||
|
||||
## 用 pm2 啟動 OpenCode 代理(設定 + 啟動一步完成)
|
||||
pm2-opencode: opencode pm2
|
||||
@echo "OpenCode 設定已更新並用 pm2 啟動代理"
|
||||
|
||||
## 寫入 ~/.claude/settings.json(ANTHROPIC_BASE_URL、三個 DEFAULT_* 模型;需 jq)
|
||||
claude-settings:
|
||||
@command -v jq >/dev/null 2>&1 || { echo "需要 jq"; exit 1; }
|
||||
@mkdir -p $$(dirname "$(CLAUDE_SETTINGS)")
|
||||
@jq -n \
|
||||
--arg base "http://$(ANTHROPIC_BASE_HOST):$(PORT)" \
|
||||
--arg token "$(ANTHROPIC_AUTH_TOKEN)" \
|
||||
--arg sonnet "$(ANTHROPIC_DEFAULT_SONNET_MODEL)" \
|
||||
--arg opus "$(ANTHROPIC_DEFAULT_OPUS_MODEL)" \
|
||||
--arg haiku "$(ANTHROPIC_DEFAULT_HAIKU_MODEL)" \
|
||||
'{ env: { ANTHROPIC_BASE_URL: $$base, ANTHROPIC_AUTH_TOKEN: $$token, ANTHROPIC_DEFAULT_SONNET_MODEL: $$sonnet, ANTHROPIC_DEFAULT_OPUS_MODEL: $$opus, ANTHROPIC_DEFAULT_HAIKU_MODEL: $$haiku } }' \
|
||||
> "$(CLAUDE_SETTINGS).tmp" && mv "$(CLAUDE_SETTINGS).tmp" "$(CLAUDE_SETTINGS)"
|
||||
@echo "已寫入 $(CLAUDE_SETTINGS)(BASE_URL=http://$(ANTHROPIC_BASE_HOST):$(PORT))"
|
||||
|
||||
## 將 ~/.claude.json 的 hasCompletedOnboarding 設為 true(繞過初次引導;需 jq)
|
||||
claude-onboarding:
|
||||
@command -v jq >/dev/null 2>&1 || { echo "需要 jq"; exit 1; }
|
||||
@test -f "$(CLAUDE_JSON)" || { echo "找不到 $(CLAUDE_JSON)"; exit 1; }
|
||||
@jq '.hasCompletedOnboarding = true' "$(CLAUDE_JSON)" > "$(CLAUDE_JSON).tmp" && mv "$(CLAUDE_JSON).tmp" "$(CLAUDE_JSON)"
|
||||
@echo "已設定 $(CLAUDE_JSON) hasCompletedOnboarding=true"
|
||||
|
||||
## 一次執行 claude-settings + claude-onboarding
|
||||
claude-cursor-setup: claude-settings claude-onboarding
|
||||
@echo "Cursor/Claude Code 本機設定已套用"
|
||||
|
||||
## 編譯並用 pm2 啟動 + 設定 Claude Code 環境變數
|
||||
pm2-claude-code: pm2
|
||||
@echo ""
|
||||
@echo "Claude Code 設定:將以下指令加入你的 shell 啟動檔(~/.bashrc 或 ~/.zshrc):"
|
||||
@echo ""
|
||||
@echo " export ANTHROPIC_BASE_URL=http://$(HOST):$(PORT)"
|
||||
@echo " export ANTHROPIC_API_KEY=$(if $(API_KEY),$(API_KEY),dummy-key)"
|
||||
@echo ""
|
||||
@echo "或在當前 shell 執行:"
|
||||
@echo ""
|
||||
@echo " export ANTHROPIC_BASE_URL=http://$(HOST):$(PORT)"
|
||||
@echo " export ANTHROPIC_API_KEY=$(if $(API_KEY),$(API_KEY),dummy-key)"
|
||||
@echo " claude"
|
||||
@echo ""
|
||||
|
||||
## 停止 pm2 中的代理
|
||||
pm2-stop:
|
||||
pm2 stop cursor-api-proxy 2>/dev/null || echo "cursor-api-proxy 未在執行"
|
||||
|
||||
## 查看 pm2 日誌
|
||||
pm2-logs:
|
||||
pm2 logs cursor-api-proxy
|
||||
|
||||
|
||||
## ──────────────────────────────────────────────────
|
||||
## Docker Compose 指令
|
||||
## ──────────────────────────────────────────────────
|
||||
|
||||
## 複製 .env.example 為 .env 並自動偵測本機 agent 路徑(首次設定)
|
||||
docker-env:
|
||||
@if [ -f .env ]; then \
|
||||
echo ".env 已存在,若要重置請手動刪除後再執行"; \
|
||||
else \
|
||||
cp .env.example .env; \
|
||||
DETECTED_AGENT=$$(which agent 2>/dev/null || echo ""); \
|
||||
if [ -n "$$DETECTED_AGENT" ]; then \
|
||||
REAL_AGENT=$$(readlink -f "$$DETECTED_AGENT"); \
|
||||
sed -i "s|^CURSOR_AGENT_HOST_BIN=.*|CURSOR_AGENT_HOST_BIN=$$REAL_AGENT|" .env; \
|
||||
echo " 已偵測 agent: $$REAL_AGENT"; \
|
||||
else \
|
||||
echo " 警告:找不到 agent,請手動設定 CURSOR_AGENT_HOST_BIN"; \
|
||||
fi; \
|
||||
echo "已建立 .env,請確認設定後執行 make docker-up"; \
|
||||
fi
|
||||
|
||||
## 建置 Docker 映像檔
|
||||
docker-build:
|
||||
$(DOCKER_COMPOSE) build
|
||||
|
||||
## 啟動 Docker Compose(背景執行)
|
||||
docker-up:
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "找不到 .env,請先執行:make docker-env"; exit 1; \
|
||||
fi
|
||||
$(DOCKER_COMPOSE) up -d
|
||||
@echo "cursor-api-proxy 已啟動(http://0.0.0.0:$(PORT))"
|
||||
|
||||
## 首次設定並啟動(複製 .env + build + up,一步完成)
|
||||
docker-setup:
|
||||
@if [ ! -f .env ]; then \
|
||||
cp .env.example .env; \
|
||||
echo "已建立 .env,請先編輯填入必要設定(CURSOR_AGENT_HOST_BIN、CURSOR_ACCOUNTS_DIR),然後重新執行 make docker-setup"; \
|
||||
exit 1; \
|
||||
fi
|
||||
$(DOCKER_COMPOSE) build
|
||||
$(DOCKER_COMPOSE) up -d
|
||||
@echo "cursor-api-proxy 已啟動(http://0.0.0.0:$(PORT))"
|
||||
@echo "查看日誌:make docker-logs"
|
||||
|
||||
## 停止並移除容器
|
||||
docker-down:
|
||||
$(DOCKER_COMPOSE) down
|
||||
|
||||
## 查看容器日誌(即時跟蹤)
|
||||
docker-logs:
|
||||
$(DOCKER_COMPOSE) logs -f cursor-api-proxy
|
||||
|
||||
## 重新建置並啟動容器
|
||||
docker-restart:
|
||||
$(DOCKER_COMPOSE) down
|
||||
$(DOCKER_COMPOSE) build
|
||||
$(DOCKER_COMPOSE) up -d
|
||||
@echo "cursor-api-proxy 已重新啟動"
|
||||
|
||||
## 進入容器 shell(除錯用)
|
||||
docker-shell:
|
||||
$(DOCKER_COMPOSE) exec cursor-api-proxy sh
|
||||
|
||||
## 顯示說明
|
||||
help:
|
||||
@echo "可用目標:"
|
||||
@echo " make env 產生 .env(先在 Makefile 頂端填好變數)"
|
||||
@echo " make build 編譯 cursor-api-proxy 二進位檔"
|
||||
@echo " make run 編譯並載入 .env 執行"
|
||||
@echo " make pm2 編譯並用 pm2 啟動代理"
|
||||
@echo " make pm2-stop 停止 pm2 中的代理"
|
||||
@echo " make pm2-logs 查看 pm2 日誌"
|
||||
@echo " make pm2-claude-code 啟動代理 + 輸出 Claude Code 設定指令"
|
||||
@echo " make opencode 編譯並設定 OpenCode(更新 opencode.json)"
|
||||
@echo " make pm2-opencode 設定 OpenCode + 啟動代理"
|
||||
@echo " make opencode-models 編譯、設定 OpenCode 並同步模型列表"
|
||||
@echo " make claude-settings 寫入 ~/.claude/settings.json(模型與 BASE_URL)"
|
||||
@echo " make claude-onboarding 設定 ~/.claude.json hasCompletedOnboarding=true"
|
||||
@echo " make claude-cursor-setup 同上兩步一次完成"
|
||||
@echo " make clean 刪除二進位檔與 .env"
|
||||
@echo ""
|
||||
@echo "Docker Compose 指令:"
|
||||
@echo " make docker-env 複製 .env.example 為 .env(首次設定)"
|
||||
@echo " make docker-setup 首次設定並啟動(自動複製 .env + build + up)"
|
||||
@echo " make docker-build 建置 Docker 映像檔"
|
||||
@echo " make docker-up 啟動容器(背景執行,需已有 .env)"
|
||||
@echo " make docker-down 停止並移除容器"
|
||||
@echo " make docker-logs 查看容器即時日誌"
|
||||
@echo " make docker-restart 重新建置並啟動容器"
|
||||
@echo " make docker-shell 進入容器 shell(除錯用)"
|
||||
@echo ""
|
||||
@echo "Provider 設定範例:"
|
||||
@echo " make env PROVIDER=cursor # 使用 Cursor(預設)"
|
||||
@echo " make env PROVIDER=gemini-web # 使用 Gemini Web"
|
||||
@echo " make env PROVIDER=gemini-web GEMINI_ACCOUNT_DIR=/path/to/sessions"
|
||||
@echo " make env PROVIDER=gemini-web GEMINI_BROWSER_VISIBLE=true"
|
||||
@echo " make opencode # 設定 OpenCode(含 cursor 與 gemini-web provider)"
|
||||
@echo ""
|
||||
@echo "覆寫範例:"
|
||||
@echo " make env PORT=9000 API_KEY=mysecret TIMEOUT_MS=60000"
|
||||
@echo " make pm2-claude-code PORT=8765 API_KEY=mykey"
|
||||
@echo " make pm2-opencode PORT=8765"
|
||||
@echo " make claude-settings PORT=8766 ANTHROPIC_BASE_HOST=localhost ANTHROPIC_DEFAULT_OPUS_MODEL=claude-4.6-opus-high"
|
||||
@echo ""
|
||||
@echo "使用 Gemini Web Provider:"
|
||||
@echo " 1. make env PROVIDER=gemini-web"
|
||||
@echo " 2. gemini-login my-session # 登入並儲存 session"
|
||||
@echo " 3. make run # 啟動代理"
|
||||
@echo " 4. 在 OpenCode 設定 model: gemini/gemini-2.5-pro"
|
||||
200
README.md
200
README.md
|
|
@ -1,100 +1,152 @@
|
|||
# cursor-adapter
|
||||
# Cursor API Proxy
|
||||
|
||||
以 **OpenAI/相容 Chat Completions API** 對外提供服務,後端透過 **Cursor CLI**(預設 `agent`)或選用的 **ACP** 傳輸與 Cursor 互動的本地代理程式。另提供 **Anthropic Messages** 相容端點。
|
||||
一個代理伺服器,讓你用標準 OpenAI / Anthropic API 存取 Cursor CLI 模型。
|
||||
|
||||
## 需求
|
||||
可接入:
|
||||
- Claude Code
|
||||
- OpenCode
|
||||
- 任何支援 OpenAI / Anthropic API 的工具
|
||||
|
||||
- Go **1.26.1**(見 `go.mod`)
|
||||
- 已安裝並可在 `PATH` 中執行的 **Cursor CLI**(名稱或路徑見設定檔 `cursor_cli_path`)
|
||||
---
|
||||
|
||||
## 建置與執行
|
||||
## 功能
|
||||
|
||||
- API 相容(OpenAI / Anthropic)
|
||||
- 多帳號管理
|
||||
- 模型自動對映(轉成 claude-*)
|
||||
- 支援區網存取(0.0.0.0)
|
||||
- 連線池優化
|
||||
|
||||
---
|
||||
|
||||
## 快速開始(本機)
|
||||
### 看幫助
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
### 安裝依賴
|
||||
|
||||
```bash
|
||||
go build -o cursor-adapter .
|
||||
./cursor-adapter
|
||||
curl https://cursor.com/install -fsS | bash
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
```
|
||||
|
||||
未指定 `-c`/`--config` 時,會讀取 `~/.cursor-adapter/config.yaml`;若檔案不存在則使用內建預設值。
|
||||
|
||||
首次可複製範例後再編輯:
|
||||
### 下載與建置
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.cursor-adapter
|
||||
cp config.example.yaml ~/.cursor-adapter/config.yaml
|
||||
git clone https://code.30cm.net/daniel.w/opencode-cursor-agent.git
|
||||
cd cursor-api-proxy-go
|
||||
go build -o cursor-api-proxy .
|
||||
```
|
||||
|
||||
## 命令列參數
|
||||
### 登入
|
||||
|
||||
| 參數 | 說明 |
|
||||
```bash
|
||||
./cursor-api-proxy login myaccount
|
||||
```
|
||||
|
||||
### 啟動
|
||||
|
||||
```bash
|
||||
make env PORT=8766 API_KEY=mysecret
|
||||
make pm2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Claude Code 設定
|
||||
|
||||
```bash
|
||||
make claude-settings PORT=8766
|
||||
make claude-onboarding
|
||||
claude
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenCode 設定
|
||||
|
||||
編輯:
|
||||
|
||||
~/.config/opencode/opencode.json
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"cursor": {
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:8766/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker(簡化版)
|
||||
|
||||
```bash
|
||||
make docker-setup
|
||||
vim .env
|
||||
make docker-setup
|
||||
```
|
||||
|
||||
檢查:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8766/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用指令
|
||||
|
||||
```bash
|
||||
make docker-up
|
||||
make docker-down
|
||||
make docker-logs
|
||||
make docker-restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| 路徑 | 方法 |
|
||||
|------|------|
|
||||
| `-c`, `--config` | 設定檔路徑 |
|
||||
| `-p`, `--port` | 監聽埠(覆寫設定檔) |
|
||||
| `--debug` | 除錯層級日誌 |
|
||||
| `--use-acp` | 改用 Cursor ACP 傳輸(預設為 CLI stream-json) |
|
||||
| `--chat-only-workspace` | 預設 `true`:在暫存工作區並覆寫 `HOME`/`CURSOR_CONFIG_DIR` 等,避免子行程讀取啟動目錄或 `~/.cursor` 規則;設為 `false` 時代理的工作目錄會對 Cursor agent 可見 |
|
||||
| /v1/chat/completions | POST |
|
||||
| /v1/messages | POST |
|
||||
| /v1/models | GET |
|
||||
| /health | GET |
|
||||
|
||||
啟動前會檢查 Cursor CLI 是否可用;失敗時程式會退出並顯示錯誤。
|
||||
---
|
||||
|
||||
## 設定檔(YAML)
|
||||
## 環境變數(核心)
|
||||
|
||||
欄位與 `config.example.yaml` 對齊,例如:
|
||||
| 變數 | 預設 |
|
||||
|------|------|
|
||||
| CURSOR_BRIDGE_HOST | 127.0.0.1 |
|
||||
| CURSOR_BRIDGE_PORT | 8766 |
|
||||
| CURSOR_BRIDGE_TIMEOUT_MS | 3600000 |
|
||||
|
||||
- `port`:HTTP 服務埠(預設 `8976`)
|
||||
- `cursor_cli_path`:CLI 可執行檔名或路徑
|
||||
- `default_model`、`available_models`、`timeout`(秒)、`max_concurrent`
|
||||
- `use_acp`、`chat_only_workspace`、`log_level`
|
||||
- `cursor_mode`:`plan`(預設,純大腦+ `<tool_call>` 翻譯成 caller 端 tool_use)或 `agent`(讓 Cursor CLI 自己拿 host 的檔案/shell 工具直接執行)
|
||||
- `workspace_root`:絕對路徑;設了之後子行程就跑在這個資料夾,不再用 chat-only temp dir。`agent` 模式下幾乎都要設。Per-request 用 `X-Cursor-Workspace` header 動態覆蓋。
|
||||
---
|
||||
|
||||
### 兩種典型擺法
|
||||
|
||||
1. **大腦模式(預設)**
|
||||
```yaml
|
||||
cursor_mode: plan
|
||||
chat_only_workspace: true
|
||||
```
|
||||
Cursor CLI 不執行任何東西。proxy 把 system_prompt 注入腦袋,要它輸出 `<tool_call>{...}</tool_call>`,proxy 再翻成 Anthropic `tool_use` 給 caller(Claude Desktop / Claude Code / opencode)跑。caller 必須有自己的 host MCP(例如 desktop-commander)。
|
||||
|
||||
2. **執行者模式**
|
||||
```yaml
|
||||
cursor_mode: agent
|
||||
chat_only_workspace: false
|
||||
workspace_root: /Users/<you>/Desktop
|
||||
system_prompt: "" # 移掉「你只是大腦」的口令,讓它正常使用工具
|
||||
```
|
||||
Cursor CLI 自己用內建 filesystem/shell 工具直接動 `workspace_root`。caller 不需要任何 MCP,整段在 host 上完成;回到 caller 那邊只有最後的文字結論。
|
||||
|
||||
## HTTP 端點
|
||||
|
||||
服務綁定 **127.0.0.1**(僅本機)。路由啟用 CORS(`Access-Control-Allow-Origin: *`),並允許標頭如 `Authorization`、`X-Cursor-Session-ID`、`X-Cursor-Workspace`。
|
||||
|
||||
| 方法 | 路徑 | 說明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 健康檢查 |
|
||||
| GET | `/v1/models` | 模型列表 |
|
||||
| POST | `/v1/chat/completions` | OpenAI 相容聊天完成(含串流 SSE) |
|
||||
| POST | `/v1/messages` | Anthropic Messages 相容 |
|
||||
|
||||
## 開發與測試
|
||||
## 帳號操作
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
./cursor-api-proxy login myaccount
|
||||
./cursor-api-proxy accounts
|
||||
./cursor-api-proxy logout myaccount
|
||||
./cursor-api-proxy reset-hwid
|
||||
```
|
||||
|
||||
輔助腳本:`scripts/test_cursor_cli.sh`(驗證本機 Cursor CLI 是否可呼叫)。
|
||||
---
|
||||
|
||||
## 專案結構(概要)
|
||||
## 備註
|
||||
|
||||
- `main.go`:CLI 入口、設定與橋接組裝、服務啟動
|
||||
- `internal/config`:設定載入與驗證
|
||||
- `internal/server`:HTTP 路由、CORS、OpenAI/Anthropic 處理、SSE、工作階段
|
||||
- `internal/bridge`:與 Cursor CLI/ACP 的橋接與併發控制
|
||||
- `internal/converter`:請求/回應與模型對應
|
||||
- `internal/types`:共用型別與 API 結構
|
||||
- `internal/workspace`:暫存工作區與隔離行為
|
||||
- `internal/sanitize`:輸入清理
|
||||
- `docs/`:計畫、架構、PRD 與 Cursor CLI 格式等文件
|
||||
- Docker 預設開放區網(0.0.0.0)
|
||||
- 模型自動同步
|
||||
- 支援多模型切換
|
||||
|
||||
## 安全與隱私
|
||||
|
||||
建議維持 `chat_only_workspace: true`,除非你有意讓 Cursor agent 存取代理行程的工作目錄與本機 Cursor 設定。詳見 `config.example.yaml` 註解與 `internal/config` 實作。
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"cursor-api-proxy/pkg/usecase"
|
||||
)
|
||||
|
||||
type AccountInfo struct {
|
||||
Name string
|
||||
ConfigDir string
|
||||
Authenticated bool
|
||||
Email string
|
||||
DisplayName string
|
||||
AuthID string
|
||||
Plan string
|
||||
SubscriptionStatus string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
func ReadAccountInfo(name, configDir string) AccountInfo {
|
||||
info := AccountInfo{Name: name, ConfigDir: configDir}
|
||||
|
||||
configFile := filepath.Join(configDir, "cli-config.json")
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
AuthInfo *struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AuthID string `json:"authId"`
|
||||
} `json:"authInfo"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err == nil && raw.AuthInfo != nil {
|
||||
info.Authenticated = true
|
||||
info.Email = raw.AuthInfo.Email
|
||||
info.DisplayName = raw.AuthInfo.DisplayName
|
||||
info.AuthID = raw.AuthInfo.AuthID
|
||||
}
|
||||
|
||||
statsigFile := filepath.Join(configDir, "statsig-cache.json")
|
||||
statsigData, err := os.ReadFile(statsigFile)
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
var statsigWrapper struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(statsigData, &statsigWrapper); err != nil || statsigWrapper.Data == "" {
|
||||
return info
|
||||
}
|
||||
|
||||
var statsig struct {
|
||||
User *struct {
|
||||
Custom *struct {
|
||||
IsEnterpriseUser bool `json:"isEnterpriseUser"`
|
||||
StripeSubscriptionStatus string `json:"stripeSubscriptionStatus"`
|
||||
StripeMembershipExpiration string `json:"stripeMembershipExpiration"`
|
||||
} `json:"custom"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(statsigWrapper.Data), &statsig); err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
if statsig.User != nil && statsig.User.Custom != nil {
|
||||
c := statsig.User.Custom
|
||||
if c.IsEnterpriseUser {
|
||||
info.Plan = "Enterprise"
|
||||
} else if c.StripeSubscriptionStatus == "active" {
|
||||
info.Plan = "Pro"
|
||||
} else {
|
||||
info.Plan = "Free"
|
||||
}
|
||||
info.SubscriptionStatus = c.StripeSubscriptionStatus
|
||||
info.ExpiresAt = c.StripeMembershipExpiration
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func HandleAccountsList() error {
|
||||
accountsDir := usecase.AccountsDir()
|
||||
|
||||
entries, err := os.ReadDir(accountsDir)
|
||||
if err != nil {
|
||||
fmt.Println("No accounts found. Use 'cursor-api-proxy login' to add one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
fmt.Println("No accounts found. Use 'cursor-api-proxy login' to add one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print("Cursor Accounts:\n\n")
|
||||
|
||||
keychainToken := usecase.ReadKeychainToken()
|
||||
|
||||
for i, name := range names {
|
||||
configDir := filepath.Join(accountsDir, name)
|
||||
info := ReadAccountInfo(name, configDir)
|
||||
|
||||
fmt.Printf(" %d. %s\n", i+1, name)
|
||||
|
||||
if info.Authenticated {
|
||||
cachedToken := usecase.ReadCachedToken(configDir)
|
||||
keychainMatchesAccount := keychainToken != "" && info.AuthID != "" && TokenSub(keychainToken) == info.AuthID
|
||||
token := cachedToken
|
||||
if token == "" && keychainMatchesAccount {
|
||||
token = keychainToken
|
||||
}
|
||||
|
||||
var liveProfile *StripeProfile
|
||||
var liveUsage *UsageData
|
||||
if token != "" {
|
||||
liveUsage, _ = FetchAccountUsage(token)
|
||||
liveProfile, _ = FetchStripeProfile(token)
|
||||
}
|
||||
|
||||
if info.Email != "" {
|
||||
display := ""
|
||||
if info.DisplayName != "" {
|
||||
display = " (" + info.DisplayName + ")"
|
||||
}
|
||||
fmt.Printf(" %s%s\n", info.Email, display)
|
||||
}
|
||||
|
||||
if info.Plan != "" && liveProfile == nil {
|
||||
canceled := ""
|
||||
if info.SubscriptionStatus == "canceled" {
|
||||
canceled = " · canceled"
|
||||
}
|
||||
expiry := ""
|
||||
if info.ExpiresAt != "" {
|
||||
expiry = " · expires " + info.ExpiresAt
|
||||
}
|
||||
fmt.Printf(" %s%s%s\n", info.Plan, canceled, expiry)
|
||||
}
|
||||
fmt.Println(" Authenticated")
|
||||
|
||||
if liveProfile != nil {
|
||||
fmt.Printf(" %s\n", DescribePlan(liveProfile))
|
||||
}
|
||||
if liveUsage != nil {
|
||||
for _, line := range FormatUsageSummary(liveUsage) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Not authenticated")
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
}
|
||||
|
||||
fmt.Println("Tip: run 'cursor-api-proxy logout <name>' to remove an account.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleLogout(accountName string) error {
|
||||
if accountName == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: Please specify the account name to remove.")
|
||||
fmt.Fprintln(os.Stderr, "Usage: cursor-api-proxy logout <account-name>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
accountsDir := usecase.AccountsDir()
|
||||
configDir := filepath.Join(accountsDir, accountName)
|
||||
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Account '%s' not found.\n", accountName)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(configDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Account '%s' removed.\n", accountName)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package cmd
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ParsedArgs struct {
|
||||
Tailscale bool
|
||||
Help bool
|
||||
Login bool
|
||||
AccountsList bool
|
||||
Logout bool
|
||||
AccountName string
|
||||
Proxies []string
|
||||
ResetHwid bool
|
||||
DeepClean bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func ParseArgs(argv []string) (ParsedArgs, error) {
|
||||
var args ParsedArgs
|
||||
|
||||
for i := 0; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
|
||||
switch arg {
|
||||
case "login", "add-account":
|
||||
args.Login = true
|
||||
if i+1 < len(argv) && len(argv[i+1]) > 0 && argv[i+1][0] != '-' {
|
||||
i++
|
||||
args.AccountName = argv[i]
|
||||
}
|
||||
|
||||
case "logout", "remove-account":
|
||||
args.Logout = true
|
||||
if i+1 < len(argv) && len(argv[i+1]) > 0 && argv[i+1][0] != '-' {
|
||||
i++
|
||||
args.AccountName = argv[i]
|
||||
}
|
||||
|
||||
case "accounts", "list-accounts":
|
||||
args.AccountsList = true
|
||||
|
||||
case "reset-hwid", "reset":
|
||||
args.ResetHwid = true
|
||||
|
||||
case "--deep-clean":
|
||||
args.DeepClean = true
|
||||
|
||||
case "--dry-run":
|
||||
args.DryRun = true
|
||||
|
||||
case "--tailscale":
|
||||
args.Tailscale = true
|
||||
|
||||
case "--help", "-h":
|
||||
args.Help = true
|
||||
|
||||
default:
|
||||
if len(arg) > len("--proxy=") && arg[:len("--proxy=")] == "--proxy=" {
|
||||
raw := arg[len("--proxy="):]
|
||||
parts := splitComma(raw)
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
args.Proxies = append(args.Proxies, p)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return args, fmt.Errorf("Unknown argument: %s", arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func splitComma(s string) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == ',' {
|
||||
part := trim(s[start:i])
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func trim(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func PrintHelp(version string) {
|
||||
fmt.Printf("cursor-api-proxy v%s\n\n", version)
|
||||
fmt.Println("Usage:")
|
||||
fmt.Println(" cursor-api-proxy [options]")
|
||||
fmt.Println("")
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" login [name] Log into a Cursor account (saved to ~/.cursor-api-proxy/accounts/)")
|
||||
fmt.Println(" login [name] --proxy=... Same, but with a proxy from a comma-separated list")
|
||||
fmt.Println(" logout <name> Remove a saved Cursor account")
|
||||
fmt.Println(" accounts List saved accounts with plan info")
|
||||
fmt.Println(" reset-hwid Reset Cursor machine/telemetry IDs (anti-ban)")
|
||||
fmt.Println(" reset-hwid --deep-clean Also wipe session storage and cookies")
|
||||
fmt.Println("")
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" --tailscale Bind to 0.0.0.0 for tailnet/LAN access")
|
||||
fmt.Println(" -h, --help Show this help message")
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"cursor-api-proxy/pkg/usecase"
|
||||
)
|
||||
|
||||
var loginURLRe = regexp.MustCompile(`https://cursor\.com/loginDeepControl.*?redirectTarget=cli`)
|
||||
|
||||
func HandleLogin(accountName string, proxies []string) error {
|
||||
e := env.OsEnvToMap()
|
||||
loaded := env.LoadEnvConfig(e, "")
|
||||
agentBin := loaded.AgentBin
|
||||
|
||||
if accountName == "" {
|
||||
accountName = fmt.Sprintf("account-%d", time.Now().UnixMilli()%10000)
|
||||
}
|
||||
|
||||
accountsDir := usecase.AccountsDir()
|
||||
configDir := filepath.Join(accountsDir, accountName)
|
||||
dirWasNew := !fileExists(configDir)
|
||||
|
||||
if err := os.MkdirAll(accountsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create accounts dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Logging into Cursor account: %s\n", accountName)
|
||||
fmt.Printf("Config: %s\n\n", configDir)
|
||||
fmt.Println("Run the login command — complete the login in your browser.")
|
||||
fmt.Println("")
|
||||
|
||||
cleanupDir := func() {
|
||||
if dirWasNew {
|
||||
_ = os.RemoveAll(configDir)
|
||||
}
|
||||
}
|
||||
|
||||
cmdEnv := make([]string, 0, len(e)+2)
|
||||
for k, v := range e {
|
||||
cmdEnv = append(cmdEnv, k+"="+v)
|
||||
}
|
||||
cmdEnv = append(cmdEnv, "CURSOR_CONFIG_DIR="+configDir)
|
||||
cmdEnv = append(cmdEnv, "NO_OPEN_BROWSER=1")
|
||||
|
||||
child := exec.Command(agentBin, "login")
|
||||
child.Env = cmdEnv
|
||||
child.Stdin = os.Stdin
|
||||
child.Stderr = os.Stderr
|
||||
|
||||
stdoutPipe, err := child.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := child.Start(); err != nil {
|
||||
cleanupDir()
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("could not find '%s'. Make sure the Cursor CLI is installed", agentBin)
|
||||
}
|
||||
return fmt.Errorf("error launching agent login: %w", err)
|
||||
}
|
||||
|
||||
// Handle cancellation signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
_ = child.Process.Kill()
|
||||
cleanupDir()
|
||||
if sig == syscall.SIGINT {
|
||||
fmt.Println("\n\nLogin cancelled.")
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
var stdoutBuf string
|
||||
scanner := bufio.NewScanner(stdoutPipe)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fmt.Println(line)
|
||||
stdoutBuf += line + "\n"
|
||||
|
||||
if loginURLRe.MatchString(stdoutBuf) {
|
||||
match := loginURLRe.FindString(stdoutBuf)
|
||||
if match != "" {
|
||||
fmt.Printf("\nOpen this URL in your browser (incognito recommended):\n%s\n\n", match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := child.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
cleanupDir()
|
||||
return fmt.Errorf("login failed with code %d", exitErr.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache keychain token for this account
|
||||
token := usecase.ReadKeychainToken()
|
||||
if token != "" {
|
||||
usecase.WriteCachedToken(configDir, token)
|
||||
}
|
||||
|
||||
fmt.Printf("\nAccount '%s' saved — it will be auto-discovered when you start the proxy.\n", accountName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func sha256hex() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func sha512hex() string {
|
||||
b := make([]byte, 64)
|
||||
_, _ = rand.Read(b)
|
||||
h := sha512.Sum512(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func log(icon, msg string) {
|
||||
fmt.Printf(" %s %s\n", icon, msg)
|
||||
}
|
||||
|
||||
func getCursorGlobalStorage() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage")
|
||||
case "windows":
|
||||
appdata := os.Getenv("APPDATA")
|
||||
return filepath.Join(appdata, "Cursor", "User", "globalStorage")
|
||||
default:
|
||||
xdg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdg == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
xdg = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(xdg, "Cursor", "User", "globalStorage")
|
||||
}
|
||||
}
|
||||
|
||||
func getCursorRoot() string {
|
||||
gs := getCursorGlobalStorage()
|
||||
return filepath.Dir(filepath.Dir(gs))
|
||||
}
|
||||
|
||||
func generateNewIDs() map[string]string {
|
||||
return map[string]string{
|
||||
"telemetry.machineId": sha256hex(),
|
||||
"telemetry.macMachineId": sha512hex(),
|
||||
"telemetry.devDeviceId": newUUID(),
|
||||
"telemetry.sqmId": "{" + fmt.Sprintf("%s", newUUID()+"") + "}",
|
||||
"storage.serviceMachineId": newUUID(),
|
||||
}
|
||||
}
|
||||
|
||||
func killCursor() {
|
||||
log("", "Stopping Cursor processes...")
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
exec.Command("taskkill", "/F", "/IM", "Cursor.exe").Run()
|
||||
default:
|
||||
exec.Command("pkill", "-x", "Cursor").Run()
|
||||
exec.Command("pkill", "-f", "Cursor.app").Run()
|
||||
}
|
||||
log("", "Cursor stopped (or was not running)")
|
||||
}
|
||||
|
||||
func updateStorageJSON(storagePath string, ids map[string]string) {
|
||||
if _, err := os.Stat(storagePath); os.IsNotExist(err) {
|
||||
log("", fmt.Sprintf("storage.json not found: %s", storagePath))
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
exec.Command("chflags", "nouchg", storagePath).Run()
|
||||
exec.Command("chmod", "644", storagePath).Run()
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
log("", fmt.Sprintf("storage.json read error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
log("", fmt.Sprintf("storage.json parse error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range ids {
|
||||
obj[k] = v
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
log("", fmt.Sprintf("storage.json marshal error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(storagePath, out, 0644); err != nil {
|
||||
log("", fmt.Sprintf("storage.json write error: %v", err))
|
||||
return
|
||||
}
|
||||
log("", "storage.json updated")
|
||||
}
|
||||
|
||||
func updateStateVscdb(dbPath string, ids map[string]string) {
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
log("", fmt.Sprintf("state.vscdb not found: %s", dbPath))
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
exec.Command("chflags", "nouchg", dbPath).Run()
|
||||
exec.Command("chmod", "644", dbPath).Run()
|
||||
}
|
||||
|
||||
if err := updateVscdbPureGo(dbPath, ids); err != nil {
|
||||
log("", fmt.Sprintf("state.vscdb error: %v", err))
|
||||
} else {
|
||||
log("", "state.vscdb updated")
|
||||
}
|
||||
}
|
||||
|
||||
func updateMachineIDFile(machineID, cursorRoot string) {
|
||||
var candidates []string
|
||||
if runtime.GOOS == "linux" {
|
||||
candidates = []string{
|
||||
filepath.Join(cursorRoot, "machineid"),
|
||||
filepath.Join(cursorRoot, "machineId"),
|
||||
}
|
||||
} else {
|
||||
candidates = []string{filepath.Join(cursorRoot, "machineId")}
|
||||
}
|
||||
|
||||
filePath := candidates[0]
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
filePath = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
log("", fmt.Sprintf("machineId dir error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
exec.Command("chflags", "nouchg", filePath).Run()
|
||||
exec.Command("chmod", "644", filePath).Run()
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, []byte(machineID+"\n"), 0644); err != nil {
|
||||
log("", fmt.Sprintf("machineId write error: %v", err))
|
||||
return
|
||||
}
|
||||
log("", fmt.Sprintf("machineId file updated (%s)", filepath.Base(filePath)))
|
||||
}
|
||||
|
||||
var dirsToWipe = []string{
|
||||
"Session Storage", "Local Storage", "IndexedDB", "Cache", "Code Cache",
|
||||
"GPUCache", "Service Worker", "Network", "Cookies", "Cookies-journal",
|
||||
}
|
||||
|
||||
func deepClean(cursorRoot string) {
|
||||
log("", "Deep-cleaning session data...")
|
||||
wiped := 0
|
||||
for _, name := range dirsToWipe {
|
||||
target := filepath.Join(cursorRoot, name)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := os.RemoveAll(target); err == nil {
|
||||
wiped++
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(target); err == nil {
|
||||
wiped++
|
||||
}
|
||||
}
|
||||
}
|
||||
log("", fmt.Sprintf("Wiped %d cache/session items", wiped))
|
||||
}
|
||||
|
||||
func HandleResetHwid(doDeepClean, dryRun bool) error {
|
||||
fmt.Print("\nCursor HWID Reset\n\n")
|
||||
fmt.Println(" Resets all machine / telemetry IDs so Cursor sees a fresh install.")
|
||||
fmt.Print(" Cursor must be closed — it will be killed automatically.\n\n")
|
||||
|
||||
globalStorage := getCursorGlobalStorage()
|
||||
cursorRoot := getCursorRoot()
|
||||
|
||||
if _, err := os.Stat(globalStorage); os.IsNotExist(err) {
|
||||
fmt.Printf("Cursor config not found at:\n %s\n", globalStorage)
|
||||
fmt.Println(" Make sure Cursor is installed and has been run at least once.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println(" [DRY RUN] Would reset IDs in:")
|
||||
fmt.Printf(" %s\n", filepath.Join(globalStorage, "storage.json"))
|
||||
fmt.Printf(" %s\n", filepath.Join(globalStorage, "state.vscdb"))
|
||||
fmt.Printf(" %s\n", filepath.Join(cursorRoot, "machineId"))
|
||||
return nil
|
||||
}
|
||||
|
||||
killCursor()
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
newIDs := generateNewIDs()
|
||||
log("", "Generated new IDs:")
|
||||
for k, v := range newIDs {
|
||||
fmt.Printf(" %s: %s\n", k, v)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
log("", "Updating storage.json...")
|
||||
updateStorageJSON(filepath.Join(globalStorage, "storage.json"), newIDs)
|
||||
|
||||
log("", "Updating state.vscdb...")
|
||||
updateStateVscdb(filepath.Join(globalStorage, "state.vscdb"), newIDs)
|
||||
|
||||
log("", "Updating machineId file...")
|
||||
updateMachineIDFile(newIDs["telemetry.machineId"], cursorRoot)
|
||||
|
||||
if doDeepClean {
|
||||
fmt.Println()
|
||||
deepClean(cursorRoot)
|
||||
}
|
||||
|
||||
fmt.Print("\nHWID reset complete. You can now restart Cursor.\n\n")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func updateVscdbPureGo(dbPath string, ids map[string]string) error {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create table: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range ids {
|
||||
_, err = db.Exec(`INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)`, k, v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ModelUsage struct {
|
||||
NumRequests int `json:"numRequests"`
|
||||
NumRequestsTotal int `json:"numRequestsTotal"`
|
||||
NumTokens int `json:"numTokens"`
|
||||
MaxTokenUsage *int `json:"maxTokenUsage"`
|
||||
MaxRequestUsage *int `json:"maxRequestUsage"`
|
||||
}
|
||||
|
||||
type UsageData struct {
|
||||
StartOfMonth string `json:"startOfMonth"`
|
||||
Models map[string]ModelUsage `json:"-"`
|
||||
}
|
||||
|
||||
type StripeProfile struct {
|
||||
MembershipType string `json:"membershipType"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
DaysRemainingOnTrial *int `json:"daysRemainingOnTrial"`
|
||||
IsTeamMember bool `json:"isTeamMember"`
|
||||
IsYearlyPlan bool `json:"isYearlyPlan"`
|
||||
}
|
||||
|
||||
func DecodeJWTPayload(token string) map[string]interface{} {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
padded := strings.ReplaceAll(parts[1], "-", "+")
|
||||
padded = strings.ReplaceAll(padded, "_", "/")
|
||||
data, err := base64.StdEncoding.DecodeString(padded + strings.Repeat("=", (4-len(padded)%4)%4))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TokenSub(token string) string {
|
||||
payload := DecodeJWTPayload(token)
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
if sub, ok := payload["sub"].(string); ok {
|
||||
return sub
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func apiGet(path, token string) (map[string]interface{}, error) {
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
req, err := http.NewRequest("GET", "https://api2.cursor.sh"+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FetchAccountUsage(token string) (*UsageData, error) {
|
||||
raw, err := apiGet("/auth/usage", token)
|
||||
if err != nil || raw == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startOfMonth, _ := raw["startOfMonth"].(string)
|
||||
usage := &UsageData{
|
||||
StartOfMonth: startOfMonth,
|
||||
Models: make(map[string]ModelUsage),
|
||||
}
|
||||
|
||||
for k, v := range raw {
|
||||
if k == "startOfMonth" {
|
||||
continue
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var mu ModelUsage
|
||||
if err := json.Unmarshal(data, &mu); err == nil {
|
||||
usage.Models[k] = mu
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func FetchStripeProfile(token string) (*StripeProfile, error) {
|
||||
raw, err := apiGet("/auth/full_stripe_profile", token)
|
||||
if err != nil || raw == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := &StripeProfile{
|
||||
MembershipType: fmt.Sprintf("%v", raw["membershipType"]),
|
||||
SubscriptionStatus: fmt.Sprintf("%v", raw["subscriptionStatus"]),
|
||||
IsTeamMember: raw["isTeamMember"] == true,
|
||||
IsYearlyPlan: raw["isYearlyPlan"] == true,
|
||||
}
|
||||
if d, ok := raw["daysRemainingOnTrial"].(float64); ok {
|
||||
di := int(d)
|
||||
profile.DaysRemainingOnTrial = &di
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func DescribePlan(profile *StripeProfile) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
switch profile.MembershipType {
|
||||
case "free_trial":
|
||||
days := 0
|
||||
if profile.DaysRemainingOnTrial != nil {
|
||||
days = *profile.DaysRemainingOnTrial
|
||||
}
|
||||
return fmt.Sprintf("Pro Trial (%dd left) — unlimited fast requests", days)
|
||||
case "pro":
|
||||
return "Pro — extended limits"
|
||||
case "pro_plus":
|
||||
return "Pro+ — extended limits"
|
||||
case "ultra":
|
||||
return "Ultra — extended limits"
|
||||
case "free", "hobby":
|
||||
return "Hobby (free) — limited agent requests"
|
||||
default:
|
||||
return fmt.Sprintf("%s · %s", profile.MembershipType, profile.SubscriptionStatus)
|
||||
}
|
||||
}
|
||||
|
||||
var modelLabels = map[string]string{
|
||||
"gpt-4": "Fast Premium Requests",
|
||||
"claude-sonnet-4-6": "Claude Sonnet 4.6",
|
||||
"claude-sonnet-4-5-20250929-v1": "Claude Sonnet 4.5",
|
||||
"claude-sonnet-4-20250514-v1": "Claude Sonnet 4",
|
||||
"claude-opus-4-6-v1": "Claude Opus 4.6",
|
||||
"claude-opus-4-5-20251101-v1": "Claude Opus 4.5",
|
||||
"claude-opus-4-1-20250805-v1": "Claude Opus 4.1",
|
||||
"claude-opus-4-20250514-v1": "Claude Opus 4",
|
||||
"claude-haiku-4-5-20251001-v1": "Claude Haiku 4.5",
|
||||
"claude-3-5-haiku-20241022-v1": "Claude 3.5 Haiku",
|
||||
"gpt-5": "GPT-5",
|
||||
"gpt-4o": "GPT-4o",
|
||||
"o1": "o1",
|
||||
"o3-mini": "o3-mini",
|
||||
"cursor-small": "Cursor Small (free)",
|
||||
}
|
||||
|
||||
func modelLabel(key string) string {
|
||||
if label, ok := modelLabels[key]; ok {
|
||||
return label
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func FormatUsageSummary(usage *UsageData) []string {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
|
||||
start := "?"
|
||||
if usage.StartOfMonth != "" {
|
||||
if t, err := time.Parse(time.RFC3339, usage.StartOfMonth); err == nil {
|
||||
start = t.Format("2006-01-02")
|
||||
} else {
|
||||
start = usage.StartOfMonth
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" Billing period from %s", start))
|
||||
|
||||
if len(usage.Models) == 0 {
|
||||
lines = append(lines, " No requests this billing period")
|
||||
return lines
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
key string
|
||||
usage ModelUsage
|
||||
}
|
||||
var entries []entry
|
||||
for k, v := range usage.Models {
|
||||
entries = append(entries, entry{k, v})
|
||||
}
|
||||
|
||||
// Sort: entries with limits first, then by usage descending
|
||||
for i := 1; i < len(entries); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a, b := entries[j-1], entries[j]
|
||||
aHasLimit := a.usage.MaxRequestUsage != nil
|
||||
bHasLimit := b.usage.MaxRequestUsage != nil
|
||||
if !aHasLimit && bHasLimit {
|
||||
entries[j-1], entries[j] = entries[j], entries[j-1]
|
||||
} else if aHasLimit == bHasLimit && a.usage.NumRequests < b.usage.NumRequests {
|
||||
entries[j-1], entries[j] = entries[j], entries[j-1]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
used := e.usage.NumRequests
|
||||
max := e.usage.MaxRequestUsage
|
||||
label := modelLabel(e.key)
|
||||
if max != nil && *max > 0 {
|
||||
pct := int(float64(used) / float64(*max) * 100)
|
||||
bar := makeBar(used, *max, 12)
|
||||
lines = append(lines, fmt.Sprintf(" %s: %d/%d (%d%%) [%s]", label, used, *max, pct, bar))
|
||||
} else if used > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" %s: %d requests", label, used))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(" %s: 0 requests (unlimited)", label))
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func makeBar(used, max, width int) string {
|
||||
fill := int(float64(used) / float64(max) * float64(width))
|
||||
if fill > width {
|
||||
fill = width
|
||||
}
|
||||
return strings.Repeat("█", fill) + strings.Repeat("░", width-fill)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"cursor-api-proxy/pkg/provider/geminiweb"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
accountName := ""
|
||||
visible := false
|
||||
|
||||
// 解析命令列參數
|
||||
for i := 1; i < len(os.Args); i++ {
|
||||
arg := os.Args[i]
|
||||
if arg == "--visible" || arg == "-v" {
|
||||
visible = true
|
||||
} else if arg == "--help" || arg == "-h" {
|
||||
printHelp()
|
||||
os.Exit(0)
|
||||
} else if !strings.HasPrefix(arg, "-") {
|
||||
accountName = arg
|
||||
}
|
||||
}
|
||||
|
||||
e := env.OsEnvToMap()
|
||||
loaded := env.LoadEnvConfig(e, "")
|
||||
cfg := config.LoadBridgeConfig(e, "")
|
||||
|
||||
cfg.GeminiAccountDir = loaded.GeminiAccountDir
|
||||
// 命令列參數優先於環境變數
|
||||
cfg.GeminiBrowserVisible = visible || loaded.GeminiBrowserVisible
|
||||
|
||||
fmt.Printf("Session 儲存位置: %s\n", cfg.GeminiAccountDir)
|
||||
fmt.Printf("瀏覽器可見: %v\n", cfg.GeminiBrowserVisible)
|
||||
|
||||
if err := geminiweb.RunLogin(cfg, accountName); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Println("使用方法: gemini-login [options] [session-name]")
|
||||
fmt.Println("")
|
||||
fmt.Println("選項:")
|
||||
fmt.Println(" --visible, -v 顯示瀏覽器視窗(預設隱藏)")
|
||||
fmt.Println(" --help, -h 顯示此說明")
|
||||
fmt.Println("")
|
||||
fmt.Println("環境變數:")
|
||||
fmt.Println(" GEMINI_ACCOUNT_DIR Session 儲存目錄(預設: ~/.cursor-api-proxy/gemini-accounts)")
|
||||
fmt.Println(" GEMINI_BROWSER_VISIBLE 是否顯示瀏覽器(true/false,預設: false)")
|
||||
fmt.Println("")
|
||||
fmt.Println("範例:")
|
||||
fmt.Println(" gemini-login my-session")
|
||||
fmt.Println(" gemini-login --visible my-session")
|
||||
fmt.Println(" GEMINI_BROWSER_VISIBLE=true gemini-login")
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
// Command cursor-mcp-server is a Model Context Protocol (MCP) server that
|
||||
// exposes the cursor-adapter HTTP API as MCP tools for Claude Desktop.
|
||||
//
|
||||
// It communicates with Claude Desktop over stdio (JSON-RPC) and forwards
|
||||
// requests to a running cursor-adapter instance via HTTP.
|
||||
//
|
||||
// Usage (standalone):
|
||||
//
|
||||
// go run ./cmd/mcp-server
|
||||
// go run ./cmd/mcp-server --adapter-url http://127.0.0.1:8765
|
||||
//
|
||||
// Usage (Claude Desktop config):
|
||||
//
|
||||
// {
|
||||
// "mcpServers": {
|
||||
// "cursor-bridge": {
|
||||
// "command": "/path/to/cursor-mcp-server",
|
||||
// "args": ["--adapter-url", "http://127.0.0.1:8765"]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
var adapterURL string
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&adapterURL, "adapter-url", "http://127.0.0.1:8765", "cursor-adapter HTTP base URL")
|
||||
}
|
||||
|
||||
// --- Tool input/output types ---
|
||||
|
||||
type AskCursorInput struct {
|
||||
Prompt string `json:"prompt" mcp:"required"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type EmptyInput struct{}
|
||||
|
||||
type TextOutput struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// --- Tool handlers ---
|
||||
|
||||
func askCursor(ctx context.Context, _ *mcp.CallToolRequest, input AskCursorInput) (*mcp.CallToolResult, TextOutput, error) {
|
||||
model := input.Model
|
||||
if model == "" {
|
||||
model = "claude-opus-4-7-high"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"max_tokens": 16384,
|
||||
"messages": []map[string]string{{"role": "user", "content": input.Prompt}},
|
||||
"stream": false,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", adapterURL+"/v1/messages", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, TextOutput{}, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", "mcp-bridge")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cannot connect to cursor-adapter at " + adapterURL + ". Make sure it is running."}},
|
||||
IsError: true,
|
||||
}, TextOutput{}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("❌ cursor-adapter HTTP %d: %s", resp.StatusCode, string(respBody))}},
|
||||
IsError: true,
|
||||
}, TextOutput{}, nil
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &data); err != nil {
|
||||
return nil, TextOutput{Text: string(respBody)}, nil
|
||||
}
|
||||
|
||||
if data.Error != nil {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cursor error: " + data.Error.Message}},
|
||||
IsError: true,
|
||||
}, TextOutput{}, nil
|
||||
}
|
||||
|
||||
var texts []string
|
||||
for _, block := range data.Content {
|
||||
if block.Type == "text" {
|
||||
texts = append(texts, block.Text)
|
||||
}
|
||||
}
|
||||
result := strings.Join(texts, "\n")
|
||||
if result == "" {
|
||||
result = string(respBody)
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("[Model: %s]\n\n%s", model, result)}},
|
||||
}, TextOutput{Text: result}, nil
|
||||
}
|
||||
|
||||
func listModels(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, TextOutput, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", adapterURL+"/v1/models", nil)
|
||||
if err != nil {
|
||||
return nil, TextOutput{}, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cannot connect to cursor-adapter"}},
|
||||
IsError: true,
|
||||
}, TextOutput{}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
var data struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &data); err != nil {
|
||||
return nil, TextOutput{Text: string(respBody)}, nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Available models (%d total):\n", len(data.Data)))
|
||||
for _, m := range data.Data {
|
||||
lines = append(lines, " "+m.ID)
|
||||
}
|
||||
text := strings.Join(lines, "\n")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: text}},
|
||||
}, TextOutput{Text: text}, nil
|
||||
}
|
||||
|
||||
func checkHealth(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, TextOutput, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", adapterURL+"/health", nil)
|
||||
if err != nil {
|
||||
return nil, TextOutput{}, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ cursor-adapter is not running"}},
|
||||
IsError: true,
|
||||
}, TextOutput{}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
var pretty bytes.Buffer
|
||||
text := string(respBody)
|
||||
if err := json.Indent(&pretty, respBody, "", " "); err == nil {
|
||||
text = pretty.String()
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: text}},
|
||||
}, TextOutput{Text: text}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if envURL := os.Getenv("CURSOR_ADAPTER_URL"); envURL != "" {
|
||||
adapterURL = envURL
|
||||
}
|
||||
|
||||
server := mcp.NewServer(
|
||||
&mcp.Implementation{
|
||||
Name: "cursor-bridge",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
&mcp.ServerOptions{
|
||||
Instructions: "This server provides access to the Cursor AI coding agent via cursor-adapter. " +
|
||||
"Use ask_cursor to delegate coding tasks, code generation, debugging, or technical questions to Cursor.",
|
||||
},
|
||||
)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "ask_cursor",
|
||||
Description: "Ask the Cursor AI agent a question or delegate a coding task. " +
|
||||
"Use this when you need code generation, review, debugging, or a second opinion. " +
|
||||
"The Cursor agent acts as a pure reasoning engine. " +
|
||||
"Available models: claude-opus-4-7-high (default), claude-opus-4-7-thinking-high, " +
|
||||
"claude-4.6-opus-high, claude-4.6-sonnet-medium, gpt-5.4-medium, gemini-3.1-pro. " +
|
||||
"Pass model name in the 'model' field.",
|
||||
}, askCursor)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "list_cursor_models",
|
||||
Description: "List all available models from the Cursor adapter.",
|
||||
}, listModels)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "cursor_health",
|
||||
Description: "Check the health status of the cursor-adapter service.",
|
||||
}, checkHealth)
|
||||
|
||||
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
port: 8976
|
||||
cursor_cli_path: agent
|
||||
default_model: claude-sonnet-4-20250514
|
||||
timeout: 300
|
||||
max_concurrent: 5
|
||||
use_acp: false
|
||||
|
||||
# Isolate Cursor CLI / ACP child in an empty temp workspace with
|
||||
# HOME / CURSOR_CONFIG_DIR / XDG_CONFIG_HOME overridden so the agent can
|
||||
# neither read the adapter's cwd nor load global rules from ~/.cursor.
|
||||
# Recommended: true. Set to false only if you intentionally want the
|
||||
# Cursor agent to see the adapter's working directory.
|
||||
chat_only_workspace: true
|
||||
|
||||
# How to launch the Cursor CLI subprocess.
|
||||
# plan (default): pass --mode plan; the CLI never executes tools, it
|
||||
# just proposes plans. The proxy translates brain-side
|
||||
# <tool_call>...</tool_call> sentinels into real Anthropic
|
||||
# tool_use blocks for the calling client to execute.
|
||||
# agent: omit --mode and add --trust; the CLI runs in its native agent
|
||||
# mode with full filesystem/shell tools and acts inside
|
||||
# workspace_root. Use this when you want the CLI itself to be
|
||||
# the executor (e.g. let it reorganise ~/Desktop directly).
|
||||
cursor_mode: plan
|
||||
|
||||
# Absolute directory the Cursor CLI subprocess runs in. Setting this
|
||||
# disables the chat-only temp workspace isolation. Required when
|
||||
# cursor_mode: agent if you want the CLI to act on a real folder.
|
||||
# Per-request override: clients can send `X-Cursor-Workspace: /abs/path`.
|
||||
# Example: workspace_root: /Users/daniel/Desktop
|
||||
workspace_root: ""
|
||||
|
||||
log_level: INFO
|
||||
available_models:
|
||||
- claude-sonnet-4-20250514
|
||||
- claude-opus-4-20250514
|
||||
- gpt-5.2
|
||||
- gemini-3.1-pro
|
||||
110
config.yaml
110
config.yaml
|
|
@ -1,110 +0,0 @@
|
|||
port: 8765
|
||||
cursor_cli_path: agent
|
||||
default_model: claude-opus-4-7-high
|
||||
timeout: 300
|
||||
max_concurrent: 5
|
||||
use_acp: false
|
||||
chat_only_workspace: true
|
||||
log_level: ERROR
|
||||
|
||||
system_prompt: |
|
||||
You are the reasoning brain of a two-process AI system. The caller
|
||||
(Claude Desktop / Claude Code / opencode) has the user's real machine,
|
||||
filesystem, terminal, and any MCP-provided tools. You do not. You drive
|
||||
it remotely by emitting tool invocations the proxy translates into real
|
||||
Anthropic tool_use blocks for the caller to execute.
|
||||
|
||||
Each turn you receive (a) the user's request, (b) the list of executors
|
||||
the caller currently has under "Available executors", and (c) a
|
||||
transcript of past tool_use / tool_result blocks. Decide what to do
|
||||
next.
|
||||
|
||||
CONTRACT — output exactly ONE of the following per turn:
|
||||
|
||||
1. A normal answer in plain markdown (when no tool is needed, or when
|
||||
you are presenting the final result to the user).
|
||||
|
||||
2. EXACTLY one tool invocation, wrapped in sentinels with NOTHING else
|
||||
before or after:
|
||||
|
||||
<tool_call>
|
||||
{"name":"<tool_name>","input": { ... }}
|
||||
</tool_call>
|
||||
|
||||
- "name" must match one of the executors listed under "Available
|
||||
executors". If no executors are listed, fall back to a plain-text
|
||||
answer.
|
||||
- "input" must conform to that tool's input_schema.
|
||||
- Do not add commentary around the block. The proxy will translate
|
||||
it into a real tool_use call; the caller will run it and send you
|
||||
a tool_result on the next turn.
|
||||
|
||||
HARD RULES:
|
||||
- NEVER claim you "cannot access the filesystem / terminal / browser",
|
||||
"are in Ask/Plan mode", "are sandboxed", or "need the user to do it
|
||||
manually". Those statements are false here — the caller is your
|
||||
hands.
|
||||
- NEVER fabricate tool names. If the tool you need is not listed,
|
||||
explain in plain text what command/operation should be run and stop.
|
||||
- Prefer a single tool_call per turn; iterate via the tool_result
|
||||
feedback. Multi-step plans should be expressed as a sequence of
|
||||
turns, not crammed into one block.
|
||||
- Default target shell when emitting raw shell commands inside text:
|
||||
zsh on macOS, unless the user says otherwise.
|
||||
|
||||
VOCABULARY — when the user says any of:
|
||||
"工作目錄" / "working directory" / "cwd" / "pwd" / "目前資料夾" /
|
||||
"這個資料夾" / "the folder" / "the project"
|
||||
they ALWAYS mean the folder the caller (Claude Desktop / Claude
|
||||
Code / opencode) attached or opened for this session — i.e. the
|
||||
host-mounted folder visible to the caller's executor (typically
|
||||
under `/sessions/<id>/mnt/<X>` or an absolute host path). They
|
||||
NEVER mean the directory your own subprocess happens to be running
|
||||
in, and they NEVER mean a sandbox path like `/sessions/.../mnt/`
|
||||
with no folder under it. If you are tempted to call `pwd` and
|
||||
answer with that, stop — the answer the user wants is the mount
|
||||
root, found by listing `/sessions/*/mnt/*/` (see ORIENTATION
|
||||
below) or by reading the "Known host-mount paths" section.
|
||||
|
||||
ORIENTATION (first turn of a fresh session):
|
||||
The caller's executor often runs inside a sandbox (e.g. Claude
|
||||
Desktop's Cowork) that bind-mounts ONE folder the user attached for
|
||||
this session. The folder's name is unknown to you in advance — it
|
||||
could be Desktop, a project root, Documents, anything. From the
|
||||
sandbox it shows up under `/sessions/<id>/mnt/<whatever>`, and that
|
||||
path IS the user's working folder for this conversation regardless of
|
||||
its name.
|
||||
|
||||
If the user refers to "my folder" / "the mounted folder" / "this
|
||||
project" / "the desktop" / etc. and you have a shell-like executor
|
||||
available but no path has been established yet (no `Working
|
||||
directory:` line, no "Known host-mount paths" section, no prior
|
||||
tool_result revealing one), your FIRST tool_call must be a single
|
||||
discovery probe that enumerates every mount under `/sessions/*/mnt/`,
|
||||
e.g.:
|
||||
|
||||
<tool_call>
|
||||
{"name":"<shell_tool>","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}}
|
||||
</tool_call>
|
||||
|
||||
Treat whatever directory comes back under `/sessions/*/mnt/<X>` as
|
||||
THE working folder for this session, no matter what `<X>` is. Then
|
||||
use that path (or subpaths under it) for every subsequent tool_call.
|
||||
Do NOT ask the user to name or re-state the folder — they already
|
||||
attached it. The proxy also re-surfaces previously discovered mount
|
||||
roots under "Known host-mount paths" on later turns; prefer those
|
||||
over re-probing.
|
||||
|
||||
available_models:
|
||||
- claude-opus-4-7-high
|
||||
- claude-opus-4-7-thinking-high
|
||||
- claude-4.6-opus-high
|
||||
- claude-4.6-opus-high-thinking
|
||||
- claude-4.6-sonnet-medium
|
||||
- claude-4.6-sonnet-medium-thinking
|
||||
- claude-4.5-opus-high
|
||||
- claude-4.5-sonnet
|
||||
- claude-4-sonnet
|
||||
- gpt-5.4-medium
|
||||
- gpt-5.2
|
||||
- gemini-3.1-pro
|
||||
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}
|
||||
78
go.mod
78
go.mod
|
|
@ -1,21 +1,71 @@
|
|||
module github.com/daniel/cursor-adapter
|
||||
module cursor-api-proxy
|
||||
|
||||
go 1.26.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
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/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
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-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/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
|
||||
)
|
||||
|
|
|
|||
269
go.sum
269
go.sum
|
|
@ -1,35 +1,248 @@
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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=
|
||||
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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -1,332 +0,0 @@
|
|||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func cliOpts(path string, chatOnly bool, max int, timeout time.Duration) Options {
|
||||
return Options{CursorPath: path, ChatOnly: chatOnly, MaxConcurrent: max, Timeout: timeout}
|
||||
}
|
||||
|
||||
func TestNewBridge(t *testing.T) {
|
||||
b := NewCLIBridge(cliOpts("/usr/bin/agent", false, 4, 30*time.Second))
|
||||
if b == nil {
|
||||
t.Fatal("NewCLIBridge returned nil")
|
||||
}
|
||||
if b.cursorPath != "/usr/bin/agent" {
|
||||
t.Errorf("cursorPath = %q, want %q", b.cursorPath, "/usr/bin/agent")
|
||||
}
|
||||
if cap(b.semaphore) != 4 {
|
||||
t.Errorf("semaphore capacity = %d, want 4", cap(b.semaphore))
|
||||
}
|
||||
if b.timeout != 30*time.Second {
|
||||
t.Errorf("timeout = %v, want 30s", b.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBridge_DefaultConcurrency(t *testing.T) {
|
||||
b := NewCLIBridge(cliOpts("agent", false, 0, 10*time.Second))
|
||||
if cap(b.semaphore) != 1 {
|
||||
t.Errorf("semaphore capacity = %d, want 1 (default)", cap(b.semaphore))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBridge_NegativeConcurrency(t *testing.T) {
|
||||
b := NewCLIBridge(cliOpts("agent", false, -5, 10*time.Second))
|
||||
if cap(b.semaphore) != 1 {
|
||||
t.Errorf("semaphore capacity = %d, want 1 (default for negative)", cap(b.semaphore))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBridge_UsesACPWhenRequested(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
b := NewBridge(Options{CursorPath: "agent", Logger: logger, UseACP: true, MaxConcurrent: 2, Timeout: 10 * time.Second})
|
||||
if _, ok := b.(*ACPBridge); !ok {
|
||||
t.Fatalf("expected ACPBridge, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPCommandArgs_NoModel(t *testing.T) {
|
||||
got := buildACPCommandArgs("/tmp/workspace", "auto")
|
||||
want := []string{"--workspace", "/tmp/workspace", "acp"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPCommandArgs_WithModel(t *testing.T) {
|
||||
got := buildACPCommandArgs("/tmp/workspace", "sonnet-4.6")
|
||||
want := []string{"--workspace", "/tmp/workspace", "--model", "sonnet-4.6", "acp"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCLICommandArgs_PlanMode(t *testing.T) {
|
||||
got := buildCLICommandArgs("hello", "auto", "/tmp/workspace", "plan", true, false)
|
||||
wantPrefix := []string{
|
||||
"--print",
|
||||
"--mode", "plan",
|
||||
"--workspace", "/tmp/workspace",
|
||||
"--model", "auto",
|
||||
"--stream-partial-output", "--output-format", "stream-json",
|
||||
}
|
||||
if len(got) != len(wantPrefix)+1 {
|
||||
t.Fatalf("unexpected arg length: %v", got)
|
||||
}
|
||||
for i := range wantPrefix {
|
||||
if got[i] != wantPrefix[i] {
|
||||
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], wantPrefix[i], got)
|
||||
}
|
||||
}
|
||||
if got[len(got)-1] != "hello" {
|
||||
t.Fatalf("last arg = %q, want prompt", got[len(got)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) {
|
||||
got := buildCLICommandArgs("hi", "", "/tmp/ws", "plan", false, true)
|
||||
found := false
|
||||
for _, a := range got {
|
||||
if a == "--trust" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected --trust when chatOnly=true, got args: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCLICommandArgs_AgentModeOmitsModeFlagAndAddsTrust(t *testing.T) {
|
||||
got := buildCLICommandArgs("hi", "", "/Users/me/Desktop", "agent", false, false)
|
||||
for _, a := range got {
|
||||
if a == "--mode" {
|
||||
t.Fatalf("agent mode should not emit --mode flag, args: %v", got)
|
||||
}
|
||||
}
|
||||
hasTrust := false
|
||||
for _, a := range got {
|
||||
if a == "--trust" {
|
||||
hasTrust = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTrust {
|
||||
t.Fatalf("agent mode should imply --trust, args: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// mockCmdBridge builds a bridge that executes a fake command for channel logic testing.
|
||||
//
|
||||
//nolint:unused
|
||||
func mockCmdBridge(t *testing.T) *CLIBridge {
|
||||
t.Helper()
|
||||
return NewCLIBridge(cliOpts("echo", false, 2, 5*time.Second))
|
||||
}
|
||||
|
||||
func TestExecute_ContextCancelled(t *testing.T) {
|
||||
b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
outputChan, errChan := b.Execute(ctx, "test prompt", "gpt-4", "")
|
||||
|
||||
// Should receive error due to cancelled context
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err == nil {
|
||||
t.Error("expected error from cancelled context, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for error from cancelled context")
|
||||
}
|
||||
|
||||
// outputChan should be closed
|
||||
select {
|
||||
case _, ok := <-outputChan:
|
||||
if ok {
|
||||
t.Error("expected outputChan to be closed")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for outputChan to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_SemaphoreBlocking(t *testing.T) {
|
||||
b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second))
|
||||
|
||||
// Fill the semaphore
|
||||
b.semaphore <- struct{}{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, errChan := b.Execute(ctx, "test", "model", "")
|
||||
|
||||
// Should get error because semaphore is full and context times out
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for semaphore blocking error")
|
||||
}
|
||||
|
||||
// Release the semaphore
|
||||
<-b.semaphore
|
||||
}
|
||||
|
||||
func TestExecute_InvalidCommand(t *testing.T) {
|
||||
b := NewCLIBridge(cliOpts("/nonexistent/command", false, 1, 5*time.Second))
|
||||
ctx := context.Background()
|
||||
|
||||
outputChan, errChan := b.Execute(ctx, "test", "model", "")
|
||||
|
||||
var outputs []string
|
||||
for line := range outputChan {
|
||||
outputs = append(outputs, line)
|
||||
}
|
||||
|
||||
// Should have error from starting invalid command
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid command, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ValidJSONOutput(t *testing.T) {
|
||||
// Use "printf" to simulate JSON line output
|
||||
b := NewCLIBridge(cliOpts("printf", false, 2, 5*time.Second))
|
||||
ctx := context.Background()
|
||||
|
||||
// printf with JSON lines
|
||||
outputChan, errChan := b.Execute(ctx, `{"type":"assistant","message":{"content":[{"text":"hello"}]}}\n{"type":"result"}`, "model", "")
|
||||
|
||||
var outputs []string
|
||||
for line := range outputChan {
|
||||
outputs = append(outputs, line)
|
||||
}
|
||||
|
||||
// Check errChan for any errors
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
t.Logf("error (may be expected): %v", err)
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
if len(outputs) == 0 {
|
||||
t.Log("no outputs received (printf may not handle newlines as expected)")
|
||||
} else {
|
||||
t.Logf("received %d output lines", len(outputs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleACPNotification_ForwardsAgentMessageChunk(t *testing.T) {
|
||||
w := &acpWorker{}
|
||||
var got strings.Builder
|
||||
w.setActiveSinkLocked(func(text string) {
|
||||
got.WriteString(text)
|
||||
})
|
||||
|
||||
params, err := json.Marshal(map[string]interface{}{
|
||||
"sessionId": "s1",
|
||||
"update": map[string]interface{}{
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": "嗨",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal params: %v", err)
|
||||
}
|
||||
|
||||
handled := w.handleACPNotification(acpMessage{
|
||||
Method: "session/update",
|
||||
Params: params,
|
||||
})
|
||||
|
||||
if !handled {
|
||||
t.Fatal("expected session/update to be handled")
|
||||
}
|
||||
if got.String() != "嗨" {
|
||||
t.Fatalf("sink text = %q, want %q", got.String(), "嗨")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleACPNotification_IgnoresNonAssistantContentUpdate(t *testing.T) {
|
||||
w := &acpWorker{}
|
||||
var got strings.Builder
|
||||
w.setActiveSinkLocked(func(text string) {
|
||||
got.WriteString(text)
|
||||
})
|
||||
|
||||
params, err := json.Marshal(map[string]interface{}{
|
||||
"sessionId": "s1",
|
||||
"update": map[string]interface{}{
|
||||
"sessionUpdate": "planner_thought_chunk",
|
||||
"content": map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": "Handling user greetings",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal params: %v", err)
|
||||
}
|
||||
|
||||
handled := w.handleACPNotification(acpMessage{
|
||||
Method: "session/update",
|
||||
Params: params,
|
||||
})
|
||||
|
||||
if !handled {
|
||||
t.Fatal("expected session/update to be handled")
|
||||
}
|
||||
if got.String() != "" {
|
||||
t.Fatalf("sink text = %q, want empty", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLoop_DoesNotPanicWhenReaderDoneIsNil(t *testing.T) {
|
||||
w := &acpWorker{
|
||||
pending: make(map[int]chan acpResponse),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("readLoop should not panic when readerDone is nil, got %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
w.readLoop(io.NopCloser(strings.NewReader("")))
|
||||
}
|
||||
|
||||
// Ensure exec is used (imported but may appear unused without integration tests)
|
||||
var _ = exec.Command
|
||||
|
|
@ -1,202 +1,162 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
// Config for go-zero (generated by goctl)
|
||||
type Config struct {
|
||||
Port int `yaml:"port"`
|
||||
CursorCLIPath string `yaml:"cursor_cli_path"`
|
||||
DefaultModel string `yaml:"default_model"`
|
||||
Timeout int `yaml:"timeout"`
|
||||
MaxConcurrent int `yaml:"max_concurrent"`
|
||||
UseACP bool `yaml:"use_acp"`
|
||||
ChatOnlyWorkspace bool `yaml:"chat_only_workspace"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
AvailableModels []string `yaml:"available_models,omitempty"`
|
||||
SystemPrompt string `yaml:"system_prompt"`
|
||||
rest.RestConf
|
||||
|
||||
// CursorMode controls how the Cursor CLI subprocess is launched.
|
||||
// "plan" (default): pass `--mode plan`. The CLI never executes
|
||||
// tools; it only proposes plans. Combined with brain
|
||||
// SystemPrompt + <tool_call> sentinel translation, the
|
||||
// caller (Claude Desktop) is the executor.
|
||||
// "agent": omit `--mode`, letting Cursor CLI use its native agent
|
||||
// mode with full filesystem/shell tools. The CLI itself
|
||||
// becomes the executor and acts inside WorkspaceRoot.
|
||||
CursorMode string `yaml:"cursor_mode"`
|
||||
// Cursor 配置
|
||||
AgentBin string
|
||||
DefaultModel string
|
||||
Provider string
|
||||
TimeoutMs int
|
||||
|
||||
// WorkspaceRoot, when non-empty, is the absolute directory the Cursor
|
||||
// CLI subprocess runs in (and treats as its project root). Setting
|
||||
// this disables the chat-only temp workspace isolation. Useful when
|
||||
// you want the CLI to actually edit files on the host (e.g. set to
|
||||
// /Users/<you>/Desktop and use cursor_mode: agent to let it
|
||||
// reorganise that folder directly).
|
||||
//
|
||||
// Per-request override: clients may send `X-Cursor-Workspace: /abs/path`
|
||||
// to switch the working directory just for that call.
|
||||
WorkspaceRoot string `yaml:"workspace_root"`
|
||||
// 多帳號池
|
||||
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
|
||||
}
|
||||
|
||||
// Defaults returns a Config populated with default values.
|
||||
//
|
||||
// ChatOnlyWorkspace defaults to true. This is the cursor-api-proxy posture:
|
||||
// every Cursor CLI / ACP child is spawned in an empty temp directory with
|
||||
// HOME / CURSOR_CONFIG_DIR overridden so it cannot see the host user's real
|
||||
// project files or global ~/.cursor rules. Set to false only if you really
|
||||
// want the Cursor agent to have access to the cwd where cursor-adapter
|
||||
// started.
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
Port: 8976,
|
||||
CursorCLIPath: "agent",
|
||||
DefaultModel: "auto",
|
||||
Timeout: 300,
|
||||
MaxConcurrent: 5,
|
||||
UseACP: false,
|
||||
ChatOnlyWorkspace: true,
|
||||
LogLevel: "INFO",
|
||||
SystemPrompt: DefaultSystemPrompt,
|
||||
CursorMode: "plan",
|
||||
WorkspaceRoot: "",
|
||||
// BridgeConfig for backward compatibility with existing code
|
||||
type BridgeConfig struct {
|
||||
AgentBin string
|
||||
Host string
|
||||
Port int
|
||||
RequiredKey string
|
||||
DefaultModel string
|
||||
Mode string
|
||||
Provider string
|
||||
Force bool
|
||||
ApproveMcps bool
|
||||
StrictModel bool
|
||||
Workspace string
|
||||
TimeoutMs int
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
SessionsLogPath string
|
||||
ChatOnlyWorkspace bool
|
||||
Verbose bool
|
||||
MaxMode bool
|
||||
ConfigDirs []string
|
||||
MultiPort bool
|
||||
WinCmdlineMax int
|
||||
GeminiAccountDir string
|
||||
GeminiBrowserVisible bool
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSystemPrompt is prepended to every prompt sent to the Cursor CLI.
|
||||
// It puts the model in "remote brain" mode: it never executes anything
|
||||
// directly; instead it either answers in plain text or emits a single
|
||||
// <tool_call>{...}</tool_call> sentinel that the proxy translates into a
|
||||
// real Anthropic tool_use block for the caller (Claude Desktop / Claude
|
||||
// Code / opencode) to execute. The caller's tool_result comes back as
|
||||
// transcript on the next turn.
|
||||
const DefaultSystemPrompt = `You are the reasoning brain of a two-process AI system. ` +
|
||||
`The caller (Claude Desktop / Claude Code / opencode) has the user's real machine, ` +
|
||||
`filesystem, terminal, and any MCP-provided tools. You do not. You drive it remotely ` +
|
||||
`by emitting tool invocations the proxy translates into real Anthropic tool_use blocks ` +
|
||||
`for the caller to execute.
|
||||
|
||||
Each turn you receive (a) the user's request, (b) the list of executors the caller ` +
|
||||
`currently has under "Available executors", and (c) a transcript of past tool_use / ` +
|
||||
`tool_result blocks.
|
||||
|
||||
CONTRACT — output exactly ONE of:
|
||||
|
||||
1. A normal answer in plain markdown (when no tool is needed, or when you are ` +
|
||||
`presenting the final result to the user).
|
||||
|
||||
2. EXACTLY one tool invocation, wrapped in sentinels with NOTHING else around it:
|
||||
|
||||
<tool_call>
|
||||
{"name":"<tool_name>","input": { ... }}
|
||||
</tool_call>
|
||||
|
||||
"name" must match an executor listed under "Available executors"; "input" must ` +
|
||||
`conform to that tool's input_schema.
|
||||
|
||||
HARD RULES:
|
||||
- NEVER claim you "cannot access the filesystem / terminal / browser", "are in Ask ` +
|
||||
`or Plan mode", "are sandboxed", or "need the user to do it manually". Those ` +
|
||||
`statements are false — the caller is your hands.
|
||||
- NEVER fabricate tool names. If the tool you need is not in the list, explain in ` +
|
||||
`plain text what should be run and stop.
|
||||
- Prefer a single tool_call per turn; iterate via tool_result feedback.
|
||||
- Default shell when emitting raw commands as text: zsh on macOS.
|
||||
|
||||
VOCABULARY — when the user says any of:
|
||||
"工作目錄" / "working directory" / "cwd" / "pwd" / "目前資料夾" /
|
||||
"這個資料夾" / "the folder" / "the project"
|
||||
they ALWAYS mean the folder the caller (Claude Desktop / Claude Code / opencode) ` +
|
||||
`attached or opened for this session — i.e. the host-mounted folder visible ` +
|
||||
`to the caller's executor (typically under ` + "`/sessions/<id>/mnt/<X>`" + ` or ` +
|
||||
`an absolute host path). They NEVER mean the directory your own subprocess ` +
|
||||
`happens to be running in, and they NEVER mean a sandbox path like ` +
|
||||
"`/sessions/.../mnt/`" + ` with no folder under it. If you are tempted to call ` +
|
||||
"`pwd`" + ` and answer with that, stop — the answer the user wants is the ` +
|
||||
`mount root, which is found by listing ` + "`/sessions/*/mnt/*/`" + ` (see ` +
|
||||
`ORIENTATION below) or by reading the "Known host-mount paths" section.
|
||||
|
||||
ORIENTATION (first turn of a fresh session):
|
||||
The caller's executor often runs inside a sandbox (e.g. Claude Desktop's ` +
|
||||
`Cowork) that bind-mounts ONE folder the user attached for this session. ` +
|
||||
`The folder's name is unknown to you in advance — it could be Desktop, a ` +
|
||||
`project root, Documents, anything. From the sandbox it shows up under ` +
|
||||
"`/sessions/<id>/mnt/<whatever>`" + `, and that path IS the user's working ` +
|
||||
`folder for this conversation regardless of its name.
|
||||
|
||||
If the user refers to "my folder" / "the mounted folder" / "this project" / ` +
|
||||
`"the desktop" / etc. and you have a shell-like executor available but no ` +
|
||||
`path has been established yet (no ` + "`Working directory:`" + ` line, no ` +
|
||||
`"Known host-mount paths" section, no prior tool_result revealing one), ` +
|
||||
`your FIRST tool_call must be a single discovery probe that enumerates ` +
|
||||
`every mount under ` + "`/sessions/*/mnt/`" + `, e.g.:
|
||||
|
||||
<tool_call>
|
||||
{"name":"<shell_tool>","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}}
|
||||
</tool_call>
|
||||
|
||||
Treat whatever directory comes back under ` + "`/sessions/*/mnt/<X>`" + ` as ` +
|
||||
`THE working folder for this session, no matter what ` + "`<X>`" + ` is. ` +
|
||||
`Then use that path (or subpaths under it) for every subsequent tool_call. ` +
|
||||
`Do NOT ask the user to name or re-state the folder — they already attached ` +
|
||||
`it. The proxy also re-surfaces previously discovered mount roots under ` +
|
||||
`"Known host-mount paths" on later turns; prefer those over re-probing.`
|
||||
|
||||
// Load reads a YAML config file from path. If path is empty it defaults to
|
||||
// ~/.cursor-adapter/config.yaml. When the file does not exist, a config with
|
||||
// default values is returned without an error.
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving home directory: %w", err)
|
||||
// LoadBridgeConfig loads config from environment (for backward compatibility)
|
||||
func LoadBridgeConfig(e env.EnvSource, cwd string) BridgeConfig {
|
||||
loaded := env.LoadEnvConfig(e, cwd)
|
||||
return BridgeConfig{
|
||||
AgentBin: loaded.AgentBin,
|
||||
Host: loaded.Host,
|
||||
Port: loaded.Port,
|
||||
RequiredKey: loaded.RequiredKey,
|
||||
DefaultModel: loaded.DefaultModel,
|
||||
Mode: "ask",
|
||||
Provider: loaded.Provider,
|
||||
Force: loaded.Force,
|
||||
ApproveMcps: loaded.ApproveMcps,
|
||||
StrictModel: loaded.StrictModel,
|
||||
Workspace: loaded.Workspace,
|
||||
TimeoutMs: loaded.TimeoutMs,
|
||||
TLSCertPath: loaded.TLSCertPath,
|
||||
TLSKeyPath: loaded.TLSKeyPath,
|
||||
SessionsLogPath: loaded.SessionsLogPath,
|
||||
ChatOnlyWorkspace: loaded.ChatOnlyWorkspace,
|
||||
Verbose: loaded.Verbose,
|
||||
MaxMode: loaded.MaxMode,
|
||||
ConfigDirs: loaded.ConfigDirs,
|
||||
MultiPort: loaded.MultiPort,
|
||||
WinCmdlineMax: loaded.WinCmdlineMax,
|
||||
GeminiAccountDir: loaded.GeminiAccountDir,
|
||||
GeminiBrowserVisible: loaded.GeminiBrowserVisible,
|
||||
GeminiMaxSessions: loaded.GeminiMaxSessions,
|
||||
}
|
||||
path = filepath.Join(home, ".cursor-adapter", "config.yaml")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c := Defaults()
|
||||
return &c, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := Defaults()
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("validating config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Port <= 0 {
|
||||
return fmt.Errorf("port must be > 0, got %d", c.Port)
|
||||
}
|
||||
if c.CursorCLIPath == "" {
|
||||
return fmt.Errorf("cursor_cli_path must not be empty")
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
return fmt.Errorf("timeout must be > 0, got %d", c.Timeout)
|
||||
}
|
||||
switch c.CursorMode {
|
||||
case "", "plan", "agent":
|
||||
default:
|
||||
return fmt.Errorf("cursor_mode must be \"plan\" or \"agent\", got %q", c.CursorMode)
|
||||
}
|
||||
if c.WorkspaceRoot != "" {
|
||||
if !filepath.IsAbs(c.WorkspaceRoot) {
|
||||
return fmt.Errorf("workspace_root must be an absolute path, got %q", c.WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +1,60 @@
|
|||
package config
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
d := Defaults()
|
||||
func TestConfigToBridgeConfig(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
|
||||
if d.Port != 8976 {
|
||||
t.Errorf("expected port 8976, got %d", d.Port)
|
||||
bc := cfg.ToBridgeConfig()
|
||||
|
||||
if bc.Host != "" {
|
||||
t.Errorf("Host = %q, want empty", bc.Host)
|
||||
}
|
||||
if d.CursorCLIPath != "agent" {
|
||||
t.Errorf("expected cursor_cli_path 'agent', got %q", d.CursorCLIPath)
|
||||
}
|
||||
if d.DefaultModel != "auto" {
|
||||
t.Errorf("expected default_model 'auto', got %q", d.DefaultModel)
|
||||
}
|
||||
if d.Timeout != 300 {
|
||||
t.Errorf("expected timeout 300, got %d", d.Timeout)
|
||||
}
|
||||
if d.MaxConcurrent != 5 {
|
||||
t.Errorf("expected max_concurrent 5, got %d", d.MaxConcurrent)
|
||||
}
|
||||
if d.LogLevel != "INFO" {
|
||||
t.Errorf("expected log_level 'INFO', got %q", d.LogLevel)
|
||||
}
|
||||
if d.UseACP {
|
||||
t.Errorf("expected use_acp false by default, got true")
|
||||
if bc.Mode != "ask" {
|
||||
t.Errorf("Mode = %q, want ask", bc.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidYAML(t *testing.T) {
|
||||
content := `port: 9000
|
||||
cursor_cli_path: mycli
|
||||
default_model: gpt-5.2
|
||||
timeout: 60
|
||||
max_concurrent: 10
|
||||
use_acp: true
|
||||
log_level: DEBUG
|
||||
available_models:
|
||||
- gpt-5.2
|
||||
- claude-sonnet-4-20250514
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := Config{
|
||||
Port: 9000,
|
||||
CursorCLIPath: "mycli",
|
||||
DefaultModel: "gpt-5.2",
|
||||
Timeout: 60,
|
||||
MaxConcurrent: 10,
|
||||
UseACP: true,
|
||||
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,
|
||||
LogLevel: "DEBUG",
|
||||
AvailableModels: []string{"gpt-5.2", "claude-sonnet-4-20250514"},
|
||||
SystemPrompt: DefaultSystemPrompt,
|
||||
CursorMode: "plan",
|
||||
WorkspaceRoot: "",
|
||||
GeminiAccountDir: "/tmp/gemini",
|
||||
GeminiMaxSessions: 5,
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(*cfg, want) {
|
||||
t.Errorf("mismatch\n got: %+v\nwant: %+v", *cfg, want)
|
||||
bc := cfg.ToBridgeConfig()
|
||||
|
||||
if bc.AgentBin != "cursor" {
|
||||
t.Errorf("AgentBin = %q, want cursor", bc.AgentBin)
|
||||
}
|
||||
if bc.DefaultModel != "claude-3.5-sonnet" {
|
||||
t.Errorf("DefaultModel = %q, want claude-3.5-sonnet", bc.DefaultModel)
|
||||
}
|
||||
if bc.TimeoutMs != 300000 {
|
||||
t.Errorf("TimeoutMs = %d, want 300000", bc.TimeoutMs)
|
||||
}
|
||||
if !bc.Force {
|
||||
t.Error("Force should be true")
|
||||
}
|
||||
if !bc.ApproveMcps {
|
||||
t.Error("ApproveMcps should be true")
|
||||
}
|
||||
if !bc.StrictModel {
|
||||
t.Error("StrictModel should be true")
|
||||
}
|
||||
if bc.Mode != "ask" {
|
||||
t.Errorf("Mode = %q, want ask", bc.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for missing file, got: %v", err)
|
||||
}
|
||||
|
||||
d := Defaults()
|
||||
if !reflect.DeepEqual(*cfg, d) {
|
||||
t.Errorf("expected defaults\n got: %+v\nwant: %+v", *cfg, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidYAML(t *testing.T) {
|
||||
content := `{{not valid yaml`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "zero port",
|
||||
yaml: "port: 0\ncursor_cli_path: agent\ntimeout: 10\n",
|
||||
wantErr: "port must be > 0",
|
||||
},
|
||||
{
|
||||
name: "empty cursor_cli_path",
|
||||
yaml: "port: 80\ncursor_cli_path: \"\"\ntimeout: 10\n",
|
||||
wantErr: "cursor_cli_path must not be empty",
|
||||
},
|
||||
{
|
||||
name: "zero timeout",
|
||||
yaml: "port: 80\ncursor_cli_path: agent\ntimeout: 0\n",
|
||||
wantErr: "timeout must be > 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(tt.yaml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); !contains(got, tt.wantErr) {
|
||||
t.Errorf("error %q should contain %q", got, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchSubstring(s, substr)
|
||||
}
|
||||
|
||||
func searchSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
package converter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
// CursorLine 代表 Cursor CLI stream-json 的一行。
|
||||
type CursorLine struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Message *CursorMessage `json:"message,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Usage *CursorUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// FlexibleContent 可以是 string 或 []CursorContent。
|
||||
type FlexibleContent []CursorContent
|
||||
|
||||
func (fc *FlexibleContent) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
*fc = []CursorContent{{Type: "text", Text: s}}
|
||||
return nil
|
||||
}
|
||||
var items []CursorContent
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
*fc = items
|
||||
return nil
|
||||
}
|
||||
|
||||
type CursorMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content FlexibleContent `json:"content"`
|
||||
}
|
||||
|
||||
type CursorContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type CursorUsage struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
OutputTokens int `json:"outputTokens"`
|
||||
}
|
||||
|
||||
// ConvertResult 是轉換一行的結果。
|
||||
type ConvertResult struct {
|
||||
Chunk *types.ChatCompletionChunk
|
||||
Done bool
|
||||
Skip bool
|
||||
Error error
|
||||
Usage *CursorUsage
|
||||
}
|
||||
|
||||
// StreamParser tracks accumulated assistant output so the OpenAI stream only
|
||||
// emits newly appended text, not the full Cursor message each time.
|
||||
type StreamParser struct {
|
||||
chatID string
|
||||
accumulated string
|
||||
lastRawText string
|
||||
}
|
||||
|
||||
func NewStreamParser(chatID string) *StreamParser {
|
||||
return &StreamParser{chatID: chatID}
|
||||
}
|
||||
|
||||
func (p *StreamParser) Parse(line string) ConvertResult {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
|
||||
var cl CursorLine
|
||||
if err := json.Unmarshal([]byte(trimmed), &cl); err != nil {
|
||||
return ConvertResult{Error: fmt.Errorf("unmarshal error: %w", err)}
|
||||
}
|
||||
|
||||
switch cl.Type {
|
||||
case "system", "user":
|
||||
return ConvertResult{Skip: true}
|
||||
|
||||
case "assistant":
|
||||
content := ExtractContent(cl.Message)
|
||||
return p.emitAssistantDelta(content)
|
||||
|
||||
case "result":
|
||||
if cl.IsError {
|
||||
errMsg := cl.Result
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown cursor error"
|
||||
}
|
||||
return ConvertResult{Error: fmt.Errorf("cursor error: %s", errMsg)}
|
||||
}
|
||||
return ConvertResult{
|
||||
Done: true,
|
||||
Usage: cl.Usage,
|
||||
}
|
||||
|
||||
default:
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreamParser) ParseRawText(content string) ConvertResult {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
if content == p.lastRawText {
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
p.lastRawText = content
|
||||
|
||||
chunk := types.NewChatCompletionChunk(p.chatID, 0, "", types.Delta{
|
||||
Content: &content,
|
||||
})
|
||||
return ConvertResult{Chunk: &chunk}
|
||||
}
|
||||
|
||||
// emitAssistantDelta handles both output modes the Cursor CLI can use:
|
||||
//
|
||||
// - CUMULATIVE: each assistant message contains the full text so far
|
||||
// (text.startsWith(accumulated)). We emit only the new suffix and
|
||||
// replace accumulated with the full text.
|
||||
// - INCREMENTAL: each assistant message contains just the new fragment.
|
||||
// We emit the fragment verbatim and append it to accumulated.
|
||||
//
|
||||
// Either way, the duplicate final "assistant" message that Cursor CLI emits
|
||||
// at the end of a session is caught by the content == accumulated check and
|
||||
// skipped.
|
||||
func (p *StreamParser) emitAssistantDelta(content string) ConvertResult {
|
||||
if content == "" {
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
if content == p.accumulated {
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
|
||||
var delta string
|
||||
if p.accumulated != "" && strings.HasPrefix(content, p.accumulated) {
|
||||
delta = content[len(p.accumulated):]
|
||||
p.accumulated = content
|
||||
} else {
|
||||
delta = content
|
||||
p.accumulated += content
|
||||
}
|
||||
|
||||
if delta == "" {
|
||||
return ConvertResult{Skip: true}
|
||||
}
|
||||
|
||||
chunk := types.NewChatCompletionChunk(p.chatID, 0, "", types.Delta{
|
||||
Content: &delta,
|
||||
})
|
||||
return ConvertResult{Chunk: &chunk}
|
||||
}
|
||||
|
||||
// ConvertLine 將一行 Cursor stream-json 轉換為 OpenAI SSE chunk。
|
||||
func ConvertLine(line string, chatID string) ConvertResult {
|
||||
return NewStreamParser(chatID).Parse(line)
|
||||
}
|
||||
|
||||
// ExtractContent 從 CursorMessage 提取所有文字內容。
|
||||
func ExtractContent(msg *CursorMessage) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, c := range msg.Content {
|
||||
if c.Text != "" {
|
||||
parts = append(parts, c.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertLineAssistant(t *testing.T) {
|
||||
line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}}`
|
||||
result := ConvertLine(line, "chat-123")
|
||||
|
||||
if result.Skip {
|
||||
t.Error("expected not Skip")
|
||||
}
|
||||
if result.Done {
|
||||
t.Error("expected not Done")
|
||||
}
|
||||
if result.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Error)
|
||||
}
|
||||
if result.Chunk == nil {
|
||||
t.Fatal("expected Chunk, got nil")
|
||||
}
|
||||
if result.Chunk.ID != "chat-123" {
|
||||
t.Errorf("Chunk.ID = %q, want %q", result.Chunk.ID, "chat-123")
|
||||
}
|
||||
if result.Chunk.Object != "chat.completion.chunk" {
|
||||
t.Errorf("Chunk.Object = %q, want %q", result.Chunk.Object, "chat.completion.chunk")
|
||||
}
|
||||
if len(result.Chunk.Choices) != 1 {
|
||||
t.Fatalf("len(Choices) = %d, want 1", len(result.Chunk.Choices))
|
||||
}
|
||||
if *result.Chunk.Choices[0].Delta.Content != "Hello, world!" {
|
||||
t.Errorf("Delta.Content = %q, want %q", *result.Chunk.Choices[0].Delta.Content, "Hello, world!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLineSystem(t *testing.T) {
|
||||
line := `{"type":"system","message":{"role":"system","content":"init"}}`
|
||||
result := ConvertLine(line, "chat-123")
|
||||
|
||||
if !result.Skip {
|
||||
t.Error("expected Skip for system line")
|
||||
}
|
||||
if result.Chunk != nil {
|
||||
t.Error("expected nil Chunk for system line")
|
||||
}
|
||||
if result.Error != nil {
|
||||
t.Errorf("unexpected error: %v", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLineUser(t *testing.T) {
|
||||
line := `{"type":"user","message":{"role":"user","content":"hello"}}`
|
||||
result := ConvertLine(line, "chat-123")
|
||||
|
||||
if !result.Skip {
|
||||
t.Error("expected Skip for user line")
|
||||
}
|
||||
if result.Chunk != nil {
|
||||
t.Error("expected nil Chunk for user line")
|
||||
}
|
||||
if result.Error != nil {
|
||||
t.Errorf("unexpected error: %v", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLineResultSuccess(t *testing.T) {
|
||||
line := `{"type":"result","subtype":"success","result":"done","usage":{"inputTokens":100,"outputTokens":50}}`
|
||||
result := ConvertLine(line, "chat-123")
|
||||
|
||||
if !result.Done {
|
||||
t.Error("expected Done")
|
||||
}
|
||||
if result.Skip {
|
||||
t.Error("expected not Skip")
|
||||
}
|
||||
if result.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Error)
|
||||
}
|
||||
if result.Usage == nil {
|
||||
t.Fatal("expected Usage, got nil")
|
||||
}
|
||||
if result.Usage.InputTokens != 100 {
|
||||
t.Errorf("Usage.InputTokens = %d, want 100", result.Usage.InputTokens)
|
||||
}
|
||||
if result.Usage.OutputTokens != 50 {
|
||||
t.Errorf("Usage.OutputTokens = %d, want 50", result.Usage.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLineResultError(t *testing.T) {
|
||||
line := `{"type":"result","is_error":true,"result":"something went wrong"}`
|
||||
result := ConvertLine(line, "chat-123")
|
||||
|
||||
if result.Error == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(result.Error.Error(), "something went wrong") {
|
||||
t.Errorf("error = %q, want to contain %q", result.Error.Error(), "something went wrong")
|
||||
}
|
||||
if result.Done {
|
||||
t.Error("expected not Done for error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLineEmpty(t *testing.T) {
|
||||
tests := []string{"", " ", "\n", " \n "}
|
||||
for _, line := range tests {
|
||||
result := ConvertLine(line, "chat-123")
|
||||
if !result.Skip {
|
||||
t.Errorf("expected Skip for empty/whitespace line %q", line)
|
||||
}
|
||||
if result.Error != nil {
|
||||
t.Errorf("unexpected error for empty line: %v", result.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLineInvalidJSON(t *testing.T) {
|
||||
line := `{"type":"assistant", invalid json}`
|
||||
result := ConvertLine(line, "chat-123")
|
||||
|
||||
if result.Error == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(result.Error.Error(), "unmarshal error") {
|
||||
t.Errorf("error = %q, want to contain %q", result.Error.Error(), "unmarshal error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContent(t *testing.T) {
|
||||
t.Run("nil message", func(t *testing.T) {
|
||||
result := ExtractContent(nil)
|
||||
if result != "" {
|
||||
t.Errorf("ExtractContent(nil) = %q, want empty", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single content", func(t *testing.T) {
|
||||
msg := &CursorMessage{
|
||||
Role: "assistant",
|
||||
Content: []CursorContent{
|
||||
{Type: "text", Text: "Hello"},
|
||||
},
|
||||
}
|
||||
result := ExtractContent(msg)
|
||||
if result != "Hello" {
|
||||
t.Errorf("ExtractContent() = %q, want %q", result, "Hello")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple content", func(t *testing.T) {
|
||||
msg := &CursorMessage{
|
||||
Role: "assistant",
|
||||
Content: []CursorContent{
|
||||
{Type: "text", Text: "Hello"},
|
||||
{Type: "text", Text: ", "},
|
||||
{Type: "text", Text: "world!"},
|
||||
},
|
||||
}
|
||||
result := ExtractContent(msg)
|
||||
if result != "Hello, world!" {
|
||||
t.Errorf("ExtractContent() = %q, want %q", result, "Hello, world!")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty content", func(t *testing.T) {
|
||||
msg := &CursorMessage{
|
||||
Role: "assistant",
|
||||
Content: []CursorContent{},
|
||||
}
|
||||
result := ExtractContent(msg)
|
||||
if result != "" {
|
||||
t.Errorf("ExtractContent() = %q, want empty", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamParser_OnlyEmitsNewDeltaFromAccumulatedAssistantMessages(t *testing.T) {
|
||||
parser := NewStreamParser("chat-123")
|
||||
|
||||
first := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hel"}]}}`)
|
||||
if first.Error != nil {
|
||||
t.Fatalf("unexpected error on first chunk: %v", first.Error)
|
||||
}
|
||||
if first.Chunk == nil || first.Chunk.Choices[0].Delta.Content == nil {
|
||||
t.Fatal("expected first chunk content")
|
||||
}
|
||||
if got := *first.Chunk.Choices[0].Delta.Content; got != "Hel" {
|
||||
t.Fatalf("first delta = %q, want %q", got, "Hel")
|
||||
}
|
||||
|
||||
second := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
|
||||
if second.Error != nil {
|
||||
t.Fatalf("unexpected error on second chunk: %v", second.Error)
|
||||
}
|
||||
if second.Chunk == nil || second.Chunk.Choices[0].Delta.Content == nil {
|
||||
t.Fatal("expected second chunk content")
|
||||
}
|
||||
if got := *second.Chunk.Choices[0].Delta.Content; got != "lo" {
|
||||
t.Fatalf("second delta = %q, want %q", got, "lo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParser_SkipsFinalDuplicateAssistantMessage(t *testing.T) {
|
||||
parser := NewStreamParser("chat-123")
|
||||
|
||||
first := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
|
||||
if first.Skip || first.Error != nil || first.Chunk == nil {
|
||||
t.Fatalf("expected first assistant chunk, got %+v", first)
|
||||
}
|
||||
|
||||
duplicate := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
|
||||
if !duplicate.Skip {
|
||||
t.Fatalf("expected duplicate assistant message to be skipped, got %+v", duplicate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParser_ResultIncludesUsage(t *testing.T) {
|
||||
parser := NewStreamParser("chat-123")
|
||||
|
||||
result := parser.Parse(`{"type":"result","subtype":"success","usage":{"inputTokens":10,"outputTokens":4}}`)
|
||||
if !result.Done {
|
||||
t.Fatal("expected result.Done")
|
||||
}
|
||||
if result.Usage == nil {
|
||||
t.Fatal("expected usage")
|
||||
}
|
||||
if result.Usage.InputTokens != 10 || result.Usage.OutputTokens != 4 {
|
||||
t.Fatalf("unexpected usage: %+v", result.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParser_CanReconstructFinalContentFromIncrementalAssistantMessages(t *testing.T) {
|
||||
parser := NewStreamParser("chat-123")
|
||||
|
||||
lines := []string{
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好,世界"}]}}`,
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
for i, line := range lines {
|
||||
result := parser.Parse(line)
|
||||
if result.Error != nil {
|
||||
t.Fatalf("line %d unexpected error: %v", i, result.Error)
|
||||
}
|
||||
if result.Skip {
|
||||
continue
|
||||
}
|
||||
if result.Chunk == nil || result.Chunk.Choices[0].Delta.Content == nil {
|
||||
t.Fatalf("line %d expected chunk content, got %+v", i, result)
|
||||
}
|
||||
content.WriteString(*result.Chunk.Choices[0].Delta.Content)
|
||||
}
|
||||
|
||||
if got := content.String(); got != "你好,世界" {
|
||||
t.Fatalf("reconstructed content = %q, want %q", got, "你好,世界")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParser_RawTextFallbackSkipsExactDuplicates(t *testing.T) {
|
||||
parser := NewStreamParser("chat-123")
|
||||
|
||||
first := parser.ParseRawText("plain chunk")
|
||||
if first.Skip || first.Chunk == nil || first.Chunk.Choices[0].Delta.Content == nil {
|
||||
t.Fatalf("expected raw text chunk, got %+v", first)
|
||||
}
|
||||
|
||||
duplicate := parser.ParseRawText("plain chunk")
|
||||
if !duplicate.Skip {
|
||||
t.Fatalf("expected duplicate raw text to be skipped, got %+v", duplicate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParser_IncrementalFragmentsAccumulateAndSkipFinalDuplicate(t *testing.T) {
|
||||
parser := NewStreamParser("chat-123")
|
||||
|
||||
fragments := []string{"你", "好,", "世", "界!"}
|
||||
var got strings.Builder
|
||||
for i, fr := range fragments {
|
||||
line := fmt.Sprintf(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":%q}]}}`, fr)
|
||||
res := parser.Parse(line)
|
||||
if res.Skip || res.Error != nil || res.Chunk == nil {
|
||||
t.Fatalf("fragment %d: expected delta chunk, got %+v", i, res)
|
||||
}
|
||||
got.WriteString(*res.Chunk.Choices[0].Delta.Content)
|
||||
}
|
||||
|
||||
if got.String() != "你好,世界!" {
|
||||
t.Fatalf("reconstructed = %q, want 你好,世界!", got.String())
|
||||
}
|
||||
|
||||
final := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好,世界!"}]}}`)
|
||||
if !final.Skip {
|
||||
t.Fatalf("expected final duplicate cumulative message to be skipped, got %+v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStreamParser_DoesNotExistYet(t *testing.T) {
|
||||
_ = fmt.Sprintf
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
package converter
|
||||
|
||||
import "strings"
|
||||
|
||||
// Short-name aliases → actual Cursor model IDs.
|
||||
// Allows users to configure friendly names in OpenCode instead of memorising
|
||||
// exact Cursor IDs like "claude-4.6-sonnet-medium".
|
||||
var shortAlias = map[string]string{
|
||||
// Claude 4.7
|
||||
"opus-4.7": "claude-opus-4-7-high",
|
||||
"opus-4.7-thinking": "claude-opus-4-7-thinking-high",
|
||||
"opus-4.7-low": "claude-opus-4-7-low",
|
||||
"opus-4.7-medium": "claude-opus-4-7-medium",
|
||||
"opus-4.7-high": "claude-opus-4-7-high",
|
||||
"opus-4.7-xhigh": "claude-opus-4-7-xhigh",
|
||||
"opus-4.7-max": "claude-opus-4-7-max",
|
||||
|
||||
// Claude 4.6
|
||||
"sonnet-4.6": "claude-4.6-sonnet-medium",
|
||||
"sonnet-4.6-thinking": "claude-4.6-sonnet-medium-thinking",
|
||||
"opus-4.6": "claude-4.6-opus-high",
|
||||
"opus-4.6-thinking": "claude-4.6-opus-high-thinking",
|
||||
"opus-4.6-max": "claude-4.6-opus-max",
|
||||
|
||||
// Claude 4.5
|
||||
"sonnet-4.5": "claude-4.5-sonnet",
|
||||
"sonnet-4.5-thinking": "claude-4.5-sonnet-thinking",
|
||||
"opus-4.5": "claude-4.5-opus-high",
|
||||
"opus-4.5-thinking": "claude-4.5-opus-high-thinking",
|
||||
|
||||
// Claude 4
|
||||
"sonnet-4": "claude-4-sonnet",
|
||||
"sonnet-4-thinking": "claude-4-sonnet-thinking",
|
||||
|
||||
// Anthropic API-style names → Cursor IDs
|
||||
// Claude 4.7
|
||||
"claude-opus-4-7": "claude-opus-4-7-high",
|
||||
"claude-opus-4.7": "claude-opus-4-7-high",
|
||||
"claude-opus-4-7-thinking": "claude-opus-4-7-thinking-high",
|
||||
"claude-opus-4.7-thinking": "claude-opus-4-7-thinking-high",
|
||||
// Claude 4.6
|
||||
"claude-opus-4-6": "claude-4.6-opus-high",
|
||||
"claude-opus-4.6": "claude-4.6-opus-high",
|
||||
"claude-sonnet-4-6": "claude-4.6-sonnet-medium",
|
||||
"claude-sonnet-4.6": "claude-4.6-sonnet-medium",
|
||||
"claude-opus-4-6-thinking": "claude-4.6-opus-high-thinking",
|
||||
"claude-sonnet-4-6-thinking": "claude-4.6-sonnet-medium-thinking",
|
||||
// Claude 4.5
|
||||
"claude-opus-4-5": "claude-4.5-opus-high",
|
||||
"claude-opus-4.5": "claude-4.5-opus-high",
|
||||
"claude-sonnet-4-5": "claude-4.5-sonnet",
|
||||
"claude-sonnet-4.5": "claude-4.5-sonnet",
|
||||
"claude-opus-4-5-thinking": "claude-4.5-opus-high-thinking",
|
||||
"claude-sonnet-4-5-thinking": "claude-4.5-sonnet-thinking",
|
||||
// Claude 4
|
||||
"claude-sonnet-4": "claude-4-sonnet",
|
||||
"claude-sonnet-4-thinking": "claude-4-sonnet-thinking",
|
||||
|
||||
// Old Anthropic date-based names
|
||||
"claude-sonnet-4-20250514": "claude-4-sonnet",
|
||||
"claude-opus-4-20250514": "claude-4.5-opus-high",
|
||||
|
||||
// GPT shortcuts
|
||||
"gpt-5.4": "gpt-5.4-medium",
|
||||
"gpt-5.4-fast": "gpt-5.4-medium-fast",
|
||||
|
||||
// Gemini
|
||||
"gemini-3.1": "gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// ResolveToCursorModel maps a user-supplied model name to its Cursor model ID.
|
||||
// If the name is already a valid Cursor ID, it passes through unchanged.
|
||||
func ResolveToCursorModel(requested string) string {
|
||||
if requested == "" {
|
||||
return ""
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(requested))
|
||||
if mapped, ok := shortAlias[key]; ok {
|
||||
return mapped
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
type aliasEntry struct {
|
||||
CursorID string
|
||||
AliasID string
|
||||
Name string
|
||||
}
|
||||
|
||||
var reverseAliases = []aliasEntry{
|
||||
// Claude 4.7 — Cursor uses "claude-opus-4-7-*" natively, add friendly aliases
|
||||
{"claude-opus-4-7-low", "claude-opus-4.7-low", "Claude Opus 4.7 (Low)"},
|
||||
{"claude-opus-4-7-medium", "claude-opus-4.7-medium", "Claude Opus 4.7 (Medium)"},
|
||||
{"claude-opus-4-7-high", "claude-opus-4.7-high", "Claude Opus 4.7"},
|
||||
{"claude-opus-4-7-xhigh", "claude-opus-4.7-xhigh", "Claude Opus 4.7 (XHigh)"},
|
||||
{"claude-opus-4-7-max", "claude-opus-4.7-max", "Claude Opus 4.7 (Max)"},
|
||||
{"claude-opus-4-7-thinking-low", "claude-opus-4.7-thinking-low", "Claude Opus 4.7 Thinking (Low)"},
|
||||
{"claude-opus-4-7-thinking-medium", "claude-opus-4.7-thinking-medium", "Claude Opus 4.7 Thinking (Medium)"},
|
||||
{"claude-opus-4-7-thinking-high", "claude-opus-4.7-thinking-high", "Claude Opus 4.7 Thinking"},
|
||||
{"claude-opus-4-7-thinking-xhigh", "claude-opus-4.7-thinking-xhigh", "Claude Opus 4.7 Thinking (XHigh)"},
|
||||
{"claude-opus-4-7-thinking-max", "claude-opus-4.7-thinking-max", "Claude Opus 4.7 Thinking (Max)"},
|
||||
// Claude 4.6
|
||||
{"claude-4.6-opus-high", "claude-opus-4-6", "Claude Opus 4.6"},
|
||||
{"claude-4.6-opus-high-thinking", "claude-opus-4-6-thinking", "Claude Opus 4.6 (Thinking)"},
|
||||
{"claude-4.6-opus-max", "claude-opus-4-6-max", "Claude Opus 4.6 (Max)"},
|
||||
{"claude-4.6-opus-max-thinking", "claude-opus-4-6-max-thinking", "Claude Opus 4.6 Max (Thinking)"},
|
||||
{"claude-4.6-sonnet-medium", "claude-sonnet-4-6", "Claude Sonnet 4.6"},
|
||||
{"claude-4.6-sonnet-medium-thinking", "claude-sonnet-4-6-thinking", "Claude Sonnet 4.6 (Thinking)"},
|
||||
// Claude 4.5
|
||||
{"claude-4.5-opus-high", "claude-opus-4-5", "Claude Opus 4.5"},
|
||||
{"claude-4.5-opus-high-thinking", "claude-opus-4-5-thinking", "Claude Opus 4.5 (Thinking)"},
|
||||
{"claude-4.5-sonnet", "claude-sonnet-4-5", "Claude Sonnet 4.5"},
|
||||
{"claude-4.5-sonnet-thinking", "claude-sonnet-4-5-thinking", "Claude Sonnet 4.5 (Thinking)"},
|
||||
// Claude 4
|
||||
{"claude-4-sonnet", "claude-sonnet-4", "Claude Sonnet 4"},
|
||||
{"claude-4-sonnet-thinking", "claude-sonnet-4-thinking", "Claude Sonnet 4 (Thinking)"},
|
||||
}
|
||||
|
||||
// GetAnthropicModelAliases returns alias entries for models available in Cursor,
|
||||
// so that /v1/models shows both Cursor IDs and friendly Anthropic-style names.
|
||||
func GetAnthropicModelAliases(availableCursorIDs []string) []struct {
|
||||
ID string
|
||||
Name string
|
||||
} {
|
||||
set := make(map[string]bool, len(availableCursorIDs))
|
||||
for _, id := range availableCursorIDs {
|
||||
set[id] = true
|
||||
}
|
||||
var result []struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
for _, a := range reverseAliases {
|
||||
if set[a.CursorID] {
|
||||
result = append(result, struct {
|
||||
ID string
|
||||
Name string
|
||||
}{ID: a.AliasID, Name: a.Name})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,67 +0,0 @@
|
|||
// Package sanitize strips third-party AI branding, telemetry headers, and
|
||||
// identifying metadata from prompts before they are forwarded to the Cursor
|
||||
// CLI. Without this, the Cursor agent sees "You are Claude Code..." style
|
||||
// system prompts and behaves confusingly (trying to use tools it doesn't
|
||||
// own, reasoning about being "Anthropic's CLI", etc.).
|
||||
//
|
||||
// Ported from cursor-api-proxy/src/lib/sanitize.ts.
|
||||
package sanitize
|
||||
|
||||
import "regexp"
|
||||
|
||||
type rule struct {
|
||||
pattern *regexp.Regexp
|
||||
replace string
|
||||
}
|
||||
|
||||
// Note: Go regexp is RE2, no lookbehind/lookahead, but these rules don't need any.
|
||||
// (?i) enables case-insensitive for that single rule.
|
||||
var rules = []rule{
|
||||
// Strip x-anthropic-billing-header line (injected by Claude Code CLI).
|
||||
{regexp.MustCompile(`(?i)x-anthropic-billing-header:[^\n]*\n?`), ""},
|
||||
// Strip individual telemetry tokens that may appear in headers or text.
|
||||
{regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""},
|
||||
{regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""},
|
||||
{regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""},
|
||||
|
||||
// --- Sandbox / capability limitation stripping ---
|
||||
// Claude Desktop's system prompt tells the model it's in a sandbox,
|
||||
// cannot access the filesystem, is in "Ask mode" / "Cowork mode", etc.
|
||||
// These phrases cause the model to refuse helpful responses. We strip
|
||||
// them so the model still sees tool definitions but not the restrictions.
|
||||
|
||||
// "you cannot access ...", "you do not have access to ...", etc.
|
||||
{regexp.MustCompile(`(?i)[^\n]*(?:you (?:cannot|can ?not|do not|don[''\x{2019}]t|are unable to) (?:access|read|write|modify|execute|run|create|delete|move|open))[^\n]*\n?`), ""},
|
||||
// "you are in a sandboxed environment", "running in a sandbox", etc.
|
||||
{regexp.MustCompile(`(?i)[^\n]*(?:sandbox(?:ed)?|isolated) (?:environment|mode|context)[^\n]*\n?`), ""},
|
||||
// "you are in Ask mode" / "Cowork mode" / "read-only mode"
|
||||
{regexp.MustCompile(`(?i)[^\n]*(?:Ask mode|Cowork(?:er)? mode|read[- ]only mode)[^\n]*\n?`), ""},
|
||||
// "you don't have filesystem access" / "no filesystem access"
|
||||
{regexp.MustCompile(`(?i)[^\n]*(?:no|without|lack(?:s|ing)?|limited) (?:file ?system|file|terminal|shell|command[- ]line) access[^\n]*\n?`), ""},
|
||||
// "you cannot run commands on the user's machine"
|
||||
{regexp.MustCompile(`(?i)[^\n]*cannot (?:run|execute) (?:commands?|scripts?|code) (?:on|in)[^\n]*\n?`), ""},
|
||||
|
||||
// --- Branding replacement ---
|
||||
// Replace "Claude Code" product name with "Cursor" (case-sensitive on purpose).
|
||||
{regexp.MustCompile(`\bClaude Code\b`), "Cursor"},
|
||||
// Replace full Anthropic CLI description. Handle both straight and curly apostrophes.
|
||||
{regexp.MustCompile(`(?i)Anthropic['\x{2019}]s official CLI for Claude`), "Cursor AI assistant"},
|
||||
// Replace remaining Anthropic brand references.
|
||||
{regexp.MustCompile(`\bAnthropic\b`), "Cursor"},
|
||||
// Known Anthropic domains.
|
||||
{regexp.MustCompile(`(?i)anthropic\.com`), "cursor.com"},
|
||||
{regexp.MustCompile(`(?i)claude\.ai`), "cursor.sh"},
|
||||
// Normalise leftover leading semicolons/whitespace at start of content.
|
||||
{regexp.MustCompile(`^[;,\s]+`), ""},
|
||||
}
|
||||
|
||||
// Text applies all sanitization rules to s.
|
||||
func Text(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
for _, r := range rules {
|
||||
s = r.pattern.ReplaceAllString(s, r.replace)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package sanitize
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestText_StripsBillingHeader(t *testing.T) {
|
||||
in := "x-anthropic-billing-header: cc_version=1.0.8; cch=abc123\nhello world"
|
||||
out := Text(in)
|
||||
if out != "hello world" {
|
||||
t.Errorf("got %q, want %q", out, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_StripsTelemetryTokens(t *testing.T) {
|
||||
in := "request: cc_version=2.3; cc_entrypoint=cli; cch=deadbeef the rest"
|
||||
out := Text(in)
|
||||
if got, want := out, "request: the rest"; got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_ReplacesClaudeCodeBranding(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"You are Claude Code, Anthropic's official CLI for Claude.": "You are Cursor, Cursor AI assistant.",
|
||||
"Powered by Anthropic.": "Powered by Cursor.",
|
||||
"Visit https://claude.ai/docs or https://anthropic.com for more": "Visit https://cursor.sh/docs or https://cursor.com for more",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Text(in); got != want {
|
||||
t.Errorf("Text(%q)\n got: %q\n want: %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_EmptyStringPassesThrough(t *testing.T) {
|
||||
if got := Text(""); got != "" {
|
||||
t.Errorf("Text(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_IsIdempotent(t *testing.T) {
|
||||
in := "Claude Code says hi at anthropic.com"
|
||||
first := Text(in)
|
||||
second := Text(first)
|
||||
if first != second {
|
||||
t.Errorf("sanitize is not idempotent:\n first: %q\n second: %q", first, second)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/sanitize"
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
// systemReminderRe matches <system-reminder>...</system-reminder> blocks
|
||||
// that Claude Desktop embeds inside user messages.
|
||||
var systemReminderRe = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>\s*`)
|
||||
|
||||
// buildPromptFromAnthropicMessages flattens an Anthropic Messages request
|
||||
// into a single prompt string suitable for `agent --print`.
|
||||
//
|
||||
// "Pure brain + remote executors" design:
|
||||
// - DROP all client system messages (mode descriptions / sandbox warnings
|
||||
// that make the model refuse).
|
||||
// - USE ONLY the adapter's injected system prompt.
|
||||
// - RENDER req.Tools as a plain-text inventory of executors that the
|
||||
// caller (Claude Desktop / Claude Code / opencode) owns. The brain must
|
||||
// know it has remote hands.
|
||||
// - RENDER assistant tool_use and user tool_result blocks as readable
|
||||
// transcript, so multi-turn ReAct loops keep working.
|
||||
// - STRIP <system-reminder> blocks embedded in user messages.
|
||||
func buildPromptFromAnthropicMessages(req types.AnthropicMessagesRequest, injectedSystemPrompt string) string {
|
||||
var prompt strings.Builder
|
||||
|
||||
if injectedSystemPrompt != "" {
|
||||
prompt.WriteString("System:\n")
|
||||
prompt.WriteString(injectedSystemPrompt)
|
||||
prompt.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if hints := renderMountHints(extractMountHints(req)); hints != "" {
|
||||
prompt.WriteString(hints)
|
||||
prompt.WriteString("\n")
|
||||
}
|
||||
|
||||
if toolsBlock := renderToolsForBrain(req.Tools); toolsBlock != "" {
|
||||
prompt.WriteString(toolsBlock)
|
||||
prompt.WriteString("\n")
|
||||
}
|
||||
|
||||
for _, msg := range req.Messages {
|
||||
text := renderMessageBlocks(msg.Role, msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case "assistant":
|
||||
prompt.WriteString("Assistant: ")
|
||||
default:
|
||||
prompt.WriteString("User: ")
|
||||
}
|
||||
prompt.WriteString(text)
|
||||
prompt.WriteString("\n\n")
|
||||
}
|
||||
|
||||
prompt.WriteString("Assistant:")
|
||||
return prompt.String()
|
||||
}
|
||||
|
||||
// renderToolsForBrain converts the Anthropic tools[] array into a readable
|
||||
// inventory the brain can reason about. The brain is told it MUST emit
|
||||
// <tool_call>{...}</tool_call> sentinels when it wants to invoke one; the
|
||||
// proxy translates that into real Anthropic tool_use blocks for the caller.
|
||||
func renderToolsForBrain(tools []types.AnthropicTool) string {
|
||||
if len(tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Available executors (the caller will run these for you):\n")
|
||||
for _, t := range tools {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(t.Name)
|
||||
if desc := strings.TrimSpace(t.Description); desc != "" {
|
||||
b.WriteString(": ")
|
||||
b.WriteString(singleLine(desc))
|
||||
}
|
||||
if len(t.InputSchema) > 0 {
|
||||
b.WriteString("\n input_schema: ")
|
||||
b.WriteString(compactJSON(t.InputSchema))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\nTo invoke a tool, output EXACTLY one fenced block (and nothing else for that turn):\n")
|
||||
b.WriteString("<tool_call>\n")
|
||||
b.WriteString(`{"name":"<tool_name>","input":{...}}` + "\n")
|
||||
b.WriteString("</tool_call>\n")
|
||||
b.WriteString("If you do NOT need a tool, just answer in plain text.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderMessageBlocks renders a single message's content blocks into a
|
||||
// transcript snippet. Text blocks are sanitised; tool_use blocks render as
|
||||
// `[tool_call name=... input=...]`; tool_result blocks render as
|
||||
// `[tool_result for=... ok|error] ...`.
|
||||
func renderMessageBlocks(role string, content types.AnthropicContent) string {
|
||||
var parts []string
|
||||
for _, block := range content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
if block.Text == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := systemReminderRe.ReplaceAllString(block.Text, "")
|
||||
cleaned = sanitize.Text(cleaned)
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned != "" {
|
||||
parts = append(parts, cleaned)
|
||||
}
|
||||
case "tool_use":
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"[tool_call name=%q input=%s]",
|
||||
block.Name, compactJSON(block.Input),
|
||||
))
|
||||
case "tool_result":
|
||||
status := "ok"
|
||||
if block.IsError {
|
||||
status = "error"
|
||||
}
|
||||
body := renderToolResultContent(block.Content)
|
||||
if body == "" {
|
||||
body = "(empty)"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"[tool_result for=%s status=%s]\n%s",
|
||||
block.ToolUseID, status, body,
|
||||
))
|
||||
case "image", "document":
|
||||
parts = append(parts, fmt.Sprintf("[%s attached]", block.Type))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// renderToolResultContent flattens a tool_result.content payload (which can
|
||||
// be a string or an array of {type:"text",text:...} blocks) to plain text.
|
||||
func renderToolResultContent(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
var blocks []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &blocks); err == nil {
|
||||
var out []string
|
||||
for _, b := range blocks {
|
||||
if b.Type == "text" && b.Text != "" {
|
||||
out = append(out, b.Text)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
func compactJSON(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func singleLine(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
for strings.Contains(s, " ") {
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/converter"
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) {
|
||||
bodyBytes, readErr := io.ReadAll(r.Body)
|
||||
if readErr != nil {
|
||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("read body: "+readErr.Error(), "invalid_request_error", ""))
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
var req types.AnthropicMessagesRequest
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("invalid request body: "+err.Error(), "invalid_request_error", ""))
|
||||
return
|
||||
}
|
||||
|
||||
if req.MaxTokens <= 0 {
|
||||
req.MaxTokens = 16384
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("messages must not be empty", "invalid_request_error", ""))
|
||||
return
|
||||
}
|
||||
|
||||
model := req.Model
|
||||
if model == "" || model == "auto" {
|
||||
model = s.cfg.DefaultModel
|
||||
}
|
||||
cursorModel := converter.ResolveToCursorModel(model)
|
||||
sessionKey := ensureSessionHeader(w, r)
|
||||
|
||||
// Surface caller-side knobs in the log: which tool names the brain is
|
||||
// about to see, and (if no explicit X-Cursor-Workspace header was set)
|
||||
// any host directory the caller's prompt happens to mention. The
|
||||
// detected directory is promoted onto the request header so the
|
||||
// downstream bridge picks it up via the standard ctx override path.
|
||||
if len(req.Tools) > 0 {
|
||||
toolNames := make([]string, 0, len(req.Tools))
|
||||
for _, t := range req.Tools {
|
||||
toolNames = append(toolNames, t.Name)
|
||||
}
|
||||
log.Printf("[tools] caller has %d executors: %v", len(toolNames), toolNames)
|
||||
}
|
||||
if r.Header.Get(workspaceHeaderName) == "" {
|
||||
if detected := detectAnthropicCwd(req); detected != "" {
|
||||
log.Printf("[workspace] detected caller cwd from prompt: %s", detected)
|
||||
r.Header.Set(workspaceHeaderName, detected)
|
||||
}
|
||||
}
|
||||
|
||||
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
||||
prompt := buildPromptFromAnthropicMessages(req, s.cfg.SystemPrompt)
|
||||
|
||||
if req.Stream {
|
||||
s.streamAnthropicMessages(w, r, prompt, cursorModel, model, msgID, sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
s.nonStreamAnthropicMessages(w, r, prompt, cursorModel, model, msgID, sessionKey)
|
||||
}
|
||||
|
||||
func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
|
||||
sse := NewSSEWriter(w)
|
||||
parser := converter.NewStreamParser(msgID)
|
||||
tcParser := NewToolCallStreamParser()
|
||||
|
||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-r.Context().Done()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
outputChan, errChan := s.br.Execute(ctx, prompt, cursorModel, sessionKey)
|
||||
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "message_start",
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": displayModel,
|
||||
"content": []interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
st := &anthropicStreamState{
|
||||
sse: sse,
|
||||
blockIndex: 0,
|
||||
}
|
||||
|
||||
emitText := func(text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
st.ensureTextBlockOpen()
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": st.blockIndex,
|
||||
"delta": map[string]interface{}{"type": "text_delta", "text": text},
|
||||
})
|
||||
st.outChars += len(text)
|
||||
}
|
||||
|
||||
emitToolCall := func(call ParsedToolCall) {
|
||||
st.closeTextBlockIfOpen()
|
||||
st.blockIndex++
|
||||
toolID := newToolUseID()
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": st.blockIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolID,
|
||||
"name": call.Name,
|
||||
"input": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": st.blockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta",
|
||||
"partial_json": string(call.Input),
|
||||
},
|
||||
})
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": st.blockIndex,
|
||||
})
|
||||
st.toolCallsEmitted++
|
||||
}
|
||||
|
||||
feedDelta := func(content string) bool {
|
||||
emit, calls, err := tcParser.Feed(content)
|
||||
emitText(emit)
|
||||
for _, c := range calls {
|
||||
emitToolCall(c)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[tool_call] parse error: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for line := range outputChan {
|
||||
result := parser.Parse(line)
|
||||
if result.Skip {
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
if strings.Contains(result.Error.Error(), "unmarshal error") {
|
||||
result = parser.ParseRawText(line)
|
||||
if result.Skip {
|
||||
continue
|
||||
}
|
||||
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
|
||||
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
||||
feedDelta(*c)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": map[string]interface{}{"type": "api_error", "message": result.Error.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
|
||||
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
||||
feedDelta(*c)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if leftover, err := tcParser.Flush(); leftover != "" {
|
||||
emitText(leftover)
|
||||
if err != nil {
|
||||
log.Printf("[tool_call] flush warning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
st.closeTextBlockIfOpen()
|
||||
|
||||
stopReason := "end_turn"
|
||||
if st.toolCallsEmitted > 0 {
|
||||
stopReason = "tool_use"
|
||||
}
|
||||
|
||||
outTokens := maxInt(1, st.outChars/4)
|
||||
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{"stop_reason": stopReason, "stop_sequence": nil},
|
||||
"usage": map[string]interface{}{"output_tokens": outTokens},
|
||||
})
|
||||
writeAnthropicSSE(sse, map[string]interface{}{
|
||||
"type": "message_stop",
|
||||
})
|
||||
|
||||
select {
|
||||
case <-errChan:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) nonStreamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
|
||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-r.Context().Done()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
rawContent, err := s.br.ExecuteSync(ctx, prompt, cursorModel, sessionKey)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "api_error", ""))
|
||||
return
|
||||
}
|
||||
|
||||
cleanText, calls := ExtractAllToolCalls(rawContent)
|
||||
usage := estimateUsage(prompt, rawContent)
|
||||
|
||||
var content []types.AnthropicResponseBlock
|
||||
if cleanText != "" {
|
||||
content = append(content, types.AnthropicResponseBlock{Type: "text", Text: cleanText})
|
||||
}
|
||||
for _, c := range calls {
|
||||
content = append(content, types.AnthropicResponseBlock{
|
||||
Type: "tool_use",
|
||||
ID: newToolUseID(),
|
||||
Name: c.Name,
|
||||
Input: c.Input,
|
||||
})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
content = append(content, types.AnthropicResponseBlock{Type: "text", Text: ""})
|
||||
}
|
||||
|
||||
stopReason := "end_turn"
|
||||
if len(calls) > 0 {
|
||||
stopReason = "tool_use"
|
||||
}
|
||||
|
||||
resp := types.AnthropicMessagesResponse{
|
||||
ID: msgID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Model: displayModel,
|
||||
StopReason: stopReason,
|
||||
Usage: types.AnthropicUsage{
|
||||
InputTokens: usage.PromptTokens,
|
||||
OutputTokens: usage.CompletionTokens,
|
||||
},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// anthropicStreamState tracks per-request streaming state: which content
|
||||
// block index we are on, whether the current text block is open, output
|
||||
// character count for usage estimation, and how many tool_use blocks were
|
||||
// emitted so we can pick stop_reason.
|
||||
type anthropicStreamState struct {
|
||||
sse *SSEWriter
|
||||
blockIndex int
|
||||
textOpen bool
|
||||
outChars int
|
||||
toolCallsEmitted int
|
||||
}
|
||||
|
||||
func (st *anthropicStreamState) ensureTextBlockOpen() {
|
||||
if st.textOpen {
|
||||
return
|
||||
}
|
||||
writeAnthropicSSE(st.sse, map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": st.blockIndex,
|
||||
"content_block": map[string]interface{}{"type": "text", "text": ""},
|
||||
})
|
||||
st.textOpen = true
|
||||
}
|
||||
|
||||
func (st *anthropicStreamState) closeTextBlockIfOpen() {
|
||||
if !st.textOpen {
|
||||
return
|
||||
}
|
||||
writeAnthropicSSE(st.sse, map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": st.blockIndex,
|
||||
})
|
||||
st.textOpen = false
|
||||
}
|
||||
|
||||
func newToolUseID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("toolu_%d", time.Now().UnixNano())
|
||||
}
|
||||
return "toolu_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func writeAnthropicSSE(sse *SSEWriter, event interface{}) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sse.w, "data: %s\n\n", data)
|
||||
if sse.flush != nil {
|
||||
sse.flush.Flush()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
// cwdPatterns matches the most common ways callers (Claude Code, opencode,
|
||||
// Cursor CLI itself, custom clients) advertise their host working
|
||||
// directory inside the prompt.
|
||||
//
|
||||
// Patterns must capture an absolute path in group 1.
|
||||
var cwdPatterns = []*regexp.Regexp{
|
||||
// Claude Code style:
|
||||
// <env>
|
||||
// Working directory: /Users/x/proj
|
||||
// Is directory a git repo: Yes
|
||||
// ...
|
||||
// </env>
|
||||
regexp.MustCompile(`(?si)<env>.*?working directory:\s*(\S+)`),
|
||||
|
||||
// Generic <cwd>...</cwd> wrapper.
|
||||
regexp.MustCompile(`(?i)<cwd>\s*([^<\s][^<]*?)\s*</cwd>`),
|
||||
|
||||
// "Working directory: /abs/path" on its own line.
|
||||
regexp.MustCompile(`(?im)^\s*working directory:\s*(/[^\s<>]+)\s*$`),
|
||||
|
||||
// "Current working directory is /abs/path" / "current working directory: /abs/path"
|
||||
regexp.MustCompile(`(?i)current working directory(?: is)?[:\s]+(/[^\s<>]+)`),
|
||||
|
||||
// Loose "cwd: /abs/path" / "cwd=/abs/path".
|
||||
regexp.MustCompile(`(?i)\bcwd\s*[:=]\s*(/[^\s<>]+)`),
|
||||
}
|
||||
|
||||
// detectCallerWorkspace returns the first absolute, host-resident directory
|
||||
// it can extract from corpus. It rejects:
|
||||
// - non-absolute paths (e.g. "src/")
|
||||
// - paths that don't exist on the host (e.g. "/sessions/..." sandbox
|
||||
// paths sent by Claude Desktop's Cowork VM)
|
||||
// - paths that point to a file rather than a directory
|
||||
//
|
||||
// Returning "" simply means "no usable workspace hint found", and callers
|
||||
// should fall back to config defaults.
|
||||
func detectCallerWorkspace(corpus string) string {
|
||||
for _, p := range cwdPatterns {
|
||||
m := p.FindStringSubmatch(corpus)
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
cand := strings.TrimSpace(m[1])
|
||||
// Strip trailing punctuation that often follows a path in prose.
|
||||
cand = strings.TrimRight(cand, `.,;:"'`+"`)>")
|
||||
if cand == "" || !filepath.IsAbs(cand) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(cand)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
return cand
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectAnthropicCwd scans an Anthropic Messages request for a workspace
|
||||
// hint. It walks system blocks first (Claude Code / opencode usually put
|
||||
// the <env> block there), then user/assistant text blocks (some clients
|
||||
// embed it as <system-reminder> inside the first user message).
|
||||
func detectAnthropicCwd(req types.AnthropicMessagesRequest) string {
|
||||
var sb strings.Builder
|
||||
for _, b := range req.System {
|
||||
if b.Type == "text" && b.Text != "" {
|
||||
sb.WriteString(b.Text)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
for _, b := range m.Content {
|
||||
if b.Type == "text" && b.Text != "" {
|
||||
sb.WriteString(b.Text)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return detectCallerWorkspace(sb.String())
|
||||
}
|
||||
|
||||
// detectOpenAICwd scans an OpenAI-style chat completion request for a
|
||||
// workspace hint, including system messages (which the brain prompt
|
||||
// builder otherwise drops).
|
||||
func detectOpenAICwd(req types.ChatCompletionRequest) string {
|
||||
var sb strings.Builder
|
||||
for _, m := range req.Messages {
|
||||
sb.WriteString(string(m.Content))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return detectCallerWorkspace(sb.String())
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
func TestDetectCallerWorkspace_ClaudeCodeEnvBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
corpus := "<env>\nWorking directory: " + dir + "\nIs directory a git repo: Yes\n</env>"
|
||||
got := detectCallerWorkspace(corpus)
|
||||
if got != dir {
|
||||
t.Fatalf("got %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCallerWorkspace_RejectsNonExistentSandboxPath(t *testing.T) {
|
||||
corpus := "Working directory: /sessions/gracious-magical-franklin/proj"
|
||||
got := detectCallerWorkspace(corpus)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty (path doesn't exist on host), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCallerWorkspace_RejectsRelativePath(t *testing.T) {
|
||||
corpus := "cwd: src/"
|
||||
got := detectCallerWorkspace(corpus)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty for relative path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCallerWorkspace_RejectsFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corpus := "Working directory: " + f
|
||||
got := detectCallerWorkspace(corpus)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty for file path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAnthropicCwd_FromSystemBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
req := types.AnthropicMessagesRequest{
|
||||
System: []types.AnthropicBlock{
|
||||
{Type: "text", Text: "<env>\nWorking directory: " + dir + "\n</env>"},
|
||||
},
|
||||
Messages: []types.AnthropicMessage{
|
||||
{Role: "user", Content: []types.AnthropicBlock{{Type: "text", Text: "hi"}}},
|
||||
},
|
||||
}
|
||||
if got := detectAnthropicCwd(req); got != dir {
|
||||
t.Fatalf("got %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAnthropicCwd_FromUserMessage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
req := types.AnthropicMessagesRequest{
|
||||
Messages: []types.AnthropicMessage{
|
||||
{Role: "user", Content: []types.AnthropicBlock{
|
||||
{Type: "text", Text: "<system-reminder>Current working directory: " + dir + "</system-reminder>\nHelp me"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := detectAnthropicCwd(req); got != dir {
|
||||
t.Fatalf("got %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAnthropicCwd_TrimsTrailingPunctuation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
corpus := "Working directory: " + dir + "."
|
||||
if got := detectCallerWorkspace(corpus); got != dir {
|
||||
t.Fatalf("got %q, want %q (trailing dot should be stripped)", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAnthropicCwd_NoneFound(t *testing.T) {
|
||||
req := types.AnthropicMessagesRequest{
|
||||
Messages: []types.AnthropicMessage{
|
||||
{Role: "user", Content: []types.AnthropicBlock{{Type: "text", Text: "just a question"}}},
|
||||
},
|
||||
}
|
||||
if got := detectAnthropicCwd(req); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check that none of our regexes mis-eat absolute paths inside
|
||||
// regular sentences without a cwd marker.
|
||||
func TestDetectCallerWorkspace_IgnoresUnmarkedAbsolutePaths(t *testing.T) {
|
||||
corpus := "I edited /tmp/foo earlier."
|
||||
if !strings.HasPrefix(corpus, "I edited") { // keep the import used
|
||||
t.Fatal("test fixture changed")
|
||||
}
|
||||
if got := detectCallerWorkspace(corpus); got != "" {
|
||||
t.Fatalf("got %q, want empty (no cwd marker)", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/converter"
|
||||
"github.com/daniel/cursor-adapter/internal/sanitize"
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
modelCacheMu sync.Mutex
|
||||
modelCacheData []string
|
||||
modelCacheAt time.Time
|
||||
modelCacheTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
models, err := s.cachedListModels(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "internal_error", ""))
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().Unix()
|
||||
data := make([]types.ModelInfo, 0, len(models)*2)
|
||||
for _, m := range models {
|
||||
data = append(data, types.ModelInfo{ID: m, Object: "model", Created: ts, OwnedBy: "cursor"})
|
||||
}
|
||||
aliases := converter.GetAnthropicModelAliases(models)
|
||||
for _, a := range aliases {
|
||||
data = append(data, types.ModelInfo{ID: a.ID, Object: "model", Created: ts, OwnedBy: "cursor"})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, types.ModelList{Object: "list", Data: data})
|
||||
}
|
||||
|
||||
func (s *Server) cachedListModels(ctx context.Context) ([]string, error) {
|
||||
modelCacheMu.Lock()
|
||||
defer modelCacheMu.Unlock()
|
||||
|
||||
if modelCacheData != nil && time.Since(modelCacheAt) < modelCacheTTL {
|
||||
return modelCacheData, nil
|
||||
}
|
||||
|
||||
models, err := s.br.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelCacheData = models
|
||||
modelCacheAt = time.Now()
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ChatCompletionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("invalid request body: "+err.Error(), "invalid_request_error", ""))
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if len(req.Messages) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("messages must not be empty", "invalid_request_error", ""))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Pure brain: only our system prompt, drop the client's ---
|
||||
var parts []string
|
||||
if s.cfg.SystemPrompt != "" {
|
||||
parts = append(parts, "system: "+s.cfg.SystemPrompt)
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
// Drop client system messages (mode descriptions, tool schemas).
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
text := sanitize.Text(string(m.Content))
|
||||
// Strip <system-reminder> blocks embedded in messages.
|
||||
text = systemReminderRe.ReplaceAllString(text, "")
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", m.Role, text))
|
||||
}
|
||||
prompt := strings.Join(parts, "\n")
|
||||
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = s.cfg.DefaultModel
|
||||
}
|
||||
cursorModel := converter.ResolveToCursorModel(model)
|
||||
sessionKey := ensureSessionHeader(w, r)
|
||||
|
||||
if r.Header.Get(workspaceHeaderName) == "" {
|
||||
if detected := detectOpenAICwd(req); detected != "" {
|
||||
slog.Debug("workspace detected from prompt", "path", detected)
|
||||
r.Header.Set(workspaceHeaderName, detected)
|
||||
}
|
||||
}
|
||||
|
||||
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
|
||||
created := time.Now().Unix()
|
||||
|
||||
if req.Stream {
|
||||
s.streamChat(w, r, prompt, cursorModel, model, chatID, created, sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
s.nonStreamChat(w, r, prompt, cursorModel, model, chatID, created, sessionKey)
|
||||
}
|
||||
|
||||
func (s *Server) streamChat(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, chatID string, created int64, sessionKey string) {
|
||||
sse := NewSSEWriter(w)
|
||||
parser := converter.NewStreamParser(chatID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-r.Context().Done()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
outputChan, errChan := s.br.Execute(ctx, prompt, cursorModel, sessionKey)
|
||||
|
||||
roleAssistant := "assistant"
|
||||
initChunk := types.NewChatCompletionChunk(chatID, created, displayModel, types.Delta{
|
||||
Role: &roleAssistant,
|
||||
})
|
||||
if err := sse.WriteChunk(initChunk); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var accumulated strings.Builder
|
||||
|
||||
for line := range outputChan {
|
||||
result := parser.Parse(line)
|
||||
|
||||
if result.Skip {
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
if strings.Contains(result.Error.Error(), "unmarshal error") {
|
||||
result = parser.ParseRawText(line)
|
||||
if result.Skip {
|
||||
continue
|
||||
}
|
||||
if result.Chunk != nil {
|
||||
result.Chunk.Created = created
|
||||
result.Chunk.Model = displayModel
|
||||
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
||||
accumulated.WriteString(*c)
|
||||
}
|
||||
if err := sse.WriteChunk(*result.Chunk); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
sse.WriteError(result.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if result.Chunk != nil {
|
||||
result.Chunk.Created = created
|
||||
result.Chunk.Model = displayModel
|
||||
if len(result.Chunk.Choices) > 0 {
|
||||
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
||||
accumulated.WriteString(*c)
|
||||
}
|
||||
}
|
||||
if err := sse.WriteChunk(*result.Chunk); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if result.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
promptTokens := maxInt(1, int(math.Round(float64(len(prompt))/4.0)))
|
||||
completionTokens := maxInt(1, int(math.Round(float64(accumulated.Len())/4.0)))
|
||||
usage := &types.Usage{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
TotalTokens: promptTokens + completionTokens,
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
slog.Error("stream bridge error", "err", err)
|
||||
sse.WriteError(err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
stopReason := "stop"
|
||||
finalChunk := types.NewChatCompletionChunk(chatID, created, displayModel, types.Delta{})
|
||||
finalChunk.Choices[0].FinishReason = &stopReason
|
||||
finalChunk.Usage = usage
|
||||
sse.WriteChunk(finalChunk)
|
||||
sse.WriteDone()
|
||||
}
|
||||
|
||||
func (s *Server) nonStreamChat(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, chatID string, created int64, sessionKey string) {
|
||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-r.Context().Done()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
content, err := s.br.ExecuteSync(ctx, prompt, cursorModel, sessionKey)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "internal_error", ""))
|
||||
return
|
||||
}
|
||||
|
||||
usage := estimateUsage(prompt, content)
|
||||
stopReason := "stop"
|
||||
|
||||
resp := types.ChatCompletionResponse{
|
||||
ID: chatID,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: displayModel,
|
||||
Choices: []types.Choice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: types.ChatMessage{Role: "assistant", Content: types.ChatMessageContent(content)},
|
||||
FinishReason: &stopReason,
|
||||
},
|
||||
},
|
||||
Usage: usage,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func estimateUsage(prompt, content string) types.Usage {
|
||||
promptTokens := maxInt(1, int(math.Round(float64(len(prompt))/4.0)))
|
||||
completionTokens := maxInt(1, int(math.Round(float64(len(content))/4.0)))
|
||||
return types.Usage{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
TotalTokens: promptTokens + completionTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "ok"
|
||||
cliStatus := "available"
|
||||
if err := s.br.CheckHealth(ctx); err != nil {
|
||||
cliStatus = fmt.Sprintf("unavailable: %v", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": status,
|
||||
"cursor_cli": cliStatus,
|
||||
"version": "0.2.0",
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
|
@ -1,490 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/config"
|
||||
)
|
||||
|
||||
type mockBridge struct {
|
||||
executeLines []string
|
||||
executeErr error
|
||||
executeSync string
|
||||
executeSyncErr error
|
||||
lastPrompt string
|
||||
lastSessionKey string
|
||||
models []string
|
||||
healthErr error
|
||||
}
|
||||
|
||||
func (m *mockBridge) Execute(ctx context.Context, prompt string, model string, sessionKey string) (<-chan string, <-chan error) {
|
||||
m.lastPrompt = prompt
|
||||
m.lastSessionKey = sessionKey
|
||||
outputChan := make(chan string, len(m.executeLines))
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(outputChan)
|
||||
defer close(errChan)
|
||||
for _, line := range m.executeLines {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
return
|
||||
case outputChan <- line:
|
||||
}
|
||||
}
|
||||
if m.executeErr != nil {
|
||||
errChan <- m.executeErr
|
||||
}
|
||||
}()
|
||||
|
||||
return outputChan, errChan
|
||||
}
|
||||
|
||||
func (m *mockBridge) ListModels(ctx context.Context) ([]string, error) {
|
||||
return m.models, nil
|
||||
}
|
||||
|
||||
func (m *mockBridge) ExecuteSync(ctx context.Context, prompt string, model string, sessionKey string) (string, error) {
|
||||
m.lastPrompt = prompt
|
||||
m.lastSessionKey = sessionKey
|
||||
if m.executeSyncErr != nil {
|
||||
return "", m.executeSyncErr
|
||||
}
|
||||
if m.executeSync != "" {
|
||||
return m.executeSync, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockBridge) CheckHealth(ctx context.Context) error {
|
||||
return m.healthErr
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_NonStreamingResponse(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
srv := New(&cfg, &mockBridge{
|
||||
executeSync: "Hello",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"messages":[{"role":"user","content":"Say hello"}],
|
||||
"stream":false
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Type != "message" {
|
||||
t.Fatalf("type = %q, want %q", resp.Type, "message")
|
||||
}
|
||||
if resp.Role != "assistant" {
|
||||
t.Fatalf("role = %q, want %q", resp.Role, "assistant")
|
||||
}
|
||||
if len(resp.Content) != 1 || resp.Content[0].Text != "Hello" {
|
||||
t.Fatalf("content = %+v, want single text block 'Hello'", resp.Content)
|
||||
}
|
||||
if resp.StopReason != "end_turn" {
|
||||
t.Fatalf("stop_reason = %q, want %q", resp.StopReason, "end_turn")
|
||||
}
|
||||
if resp.Usage.InputTokens <= 0 || resp.Usage.OutputTokens <= 0 {
|
||||
t.Fatalf("usage should be estimated and > 0, got %+v", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_StreamingResponse(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
srv := New(&cfg, &mockBridge{
|
||||
executeLines: []string{
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}}`,
|
||||
`{"type":"result","subtype":"success","usage":{"inputTokens":9,"outputTokens":1}}`,
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"messages":[{"role":"user","content":"Say hi"}],
|
||||
"stream":true
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`"type":"message_start"`,
|
||||
`"type":"content_block_start"`,
|
||||
`"type":"content_block_delta"`,
|
||||
`"text":"Hi"`,
|
||||
`"type":"content_block_stop"`,
|
||||
`"type":"message_delta"`,
|
||||
`"stop_reason":"end_turn"`,
|
||||
`"type":"message_stop"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("stream body missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletions_ForwardsProvidedSessionHeader(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
br := &mockBridge{executeSync: "Hello"}
|
||||
srv := New(&cfg, br)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"messages":[{"role":"user","content":"hello"}],
|
||||
"stream":false
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(sessionHeaderName, "sess_frontend_123")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if br.lastSessionKey != "sess_frontend_123" {
|
||||
t.Fatalf("bridge session key = %q, want %q", br.lastSessionKey, "sess_frontend_123")
|
||||
}
|
||||
if got := rec.Header().Get(sessionHeaderName); got != "sess_frontend_123" {
|
||||
t.Fatalf("response session header = %q, want %q", got, "sess_frontend_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletions_AcceptsArrayContentBlocks(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
br := &mockBridge{executeSync: "Hello"}
|
||||
srv := New(&cfg, br)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"messages":[
|
||||
{"role":"system","content":[{"type":"text","text":"You are terse."}]},
|
||||
{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":" world"}]}
|
||||
],
|
||||
"stream":false
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
// Client system messages should be DROPPED (pure brain mode).
|
||||
if strings.Contains(br.lastPrompt, "You are terse.") {
|
||||
t.Fatalf("prompt should NOT contain client system message, got: %q", br.lastPrompt)
|
||||
}
|
||||
// User text should still be present and concatenated.
|
||||
if !strings.Contains(br.lastPrompt, "user: hello world") {
|
||||
t.Fatalf("prompt = %q, want concatenated user text content", br.lastPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletions_StreamingEmitsRoleFinishReasonAndUsage(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
srv := New(&cfg, &mockBridge{
|
||||
executeLines: []string{
|
||||
// system + user chunks should be skipped entirely, never echoed as content
|
||||
`{"type":"system","subtype":"init","session_id":"abc","cwd":"/tmp"}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"text","text":"user: hello"}]}}`,
|
||||
// incremental assistant fragments
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}`,
|
||||
// cumulative duplicate (Cursor CLI sometimes finalises with the full text)
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
|
||||
`{"type":"result","subtype":"success","result":"你好","usage":{"inputTokens":3,"outputTokens":2}}`,
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"stream":true
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
|
||||
// Must never leak system/user JSON as "content"
|
||||
if strings.Contains(body, `"subtype":"init"`) || strings.Contains(body, `"type":"user"`) {
|
||||
t.Fatalf("stream body leaked system/user JSON into SSE content: %s", body)
|
||||
}
|
||||
|
||||
// First delta chunk must carry role:assistant (not content)
|
||||
if !strings.Contains(body, `"delta":{"role":"assistant"}`) {
|
||||
t.Fatalf("first chunk missing role=assistant delta: %s", body)
|
||||
}
|
||||
|
||||
// Content deltas must be plain text — not JSON-stringified Cursor lines
|
||||
if !strings.Contains(body, `"delta":{"content":"你"}`) {
|
||||
t.Fatalf("first content delta not plain text: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"delta":{"content":"好"}`) {
|
||||
t.Fatalf("second content delta missing: %s", body)
|
||||
}
|
||||
|
||||
// Final cumulative message that equals accumulated text must be suppressed
|
||||
// (accumulated = "你好" after the two fragments; final "你好" should be Skip'd)
|
||||
count := strings.Count(body, `"你好"`)
|
||||
if count > 0 {
|
||||
t.Fatalf("duplicate final cumulative message should have been skipped (found %d occurrences of full text as delta): %s", count, body)
|
||||
}
|
||||
|
||||
// Final chunk must have finish_reason=stop and usage at top level
|
||||
if !strings.Contains(body, `"finish_reason":"stop"`) {
|
||||
t.Fatalf("final chunk missing finish_reason=stop: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"usage":{`) {
|
||||
t.Fatalf("final chunk missing usage: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `data: [DONE]`) {
|
||||
t.Fatalf("stream missing [DONE] terminator: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_StreamingEmitsNoDuplicateFinalText(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
srv := New(&cfg, &mockBridge{
|
||||
executeLines: []string{
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
|
||||
`{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`,
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"stream":true
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if strings.Count(body, `"text":"你好"`) > 0 {
|
||||
t.Fatalf("final cumulative duplicate should be suppressed: %s", body)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"text":"你"`,
|
||||
`"text":"好"`,
|
||||
`"type":"message_stop"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q in stream: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_PromptIncludesToolsAndToolHistory(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
br := &mockBridge{executeSync: "ok"}
|
||||
srv := New(&cfg, br)
|
||||
|
||||
body := `{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"tools":[{"name":"bash","description":"Run a shell command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}],
|
||||
"messages":[
|
||||
{"role":"user","content":[{"type":"text","text":"clean up my desktop"}]},
|
||||
{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"bash","input":{"command":"ls ~/Desktop"}}]},
|
||||
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.png\nb.txt"}]}
|
||||
],
|
||||
"stream":false
|
||||
}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
prompt := br.lastPrompt
|
||||
for _, want := range []string{
|
||||
"Available executors",
|
||||
"- bash",
|
||||
"Run a shell command",
|
||||
"<tool_call>",
|
||||
"clean up my desktop",
|
||||
`[tool_call name="bash" input=`,
|
||||
"[tool_result for=toolu_1 status=ok]",
|
||||
"a.png",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q\nprompt:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_NonStreamTranslatesToolCallToToolUse(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
br := &mockBridge{
|
||||
executeSync: "I'll run it now.\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"mkdir -p ~/Desktop/screenshots\"}}\n</tool_call>",
|
||||
}
|
||||
srv := New(&cfg, br)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"tools":[{"name":"bash"}],
|
||||
"messages":[{"role":"user","content":"organize desktop"}],
|
||||
"stream":false
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`"stop_reason":"tool_use"`,
|
||||
`"type":"tool_use"`,
|
||||
`"name":"bash"`,
|
||||
`"command":"mkdir -p ~/Desktop/screenshots"`,
|
||||
`"type":"text"`,
|
||||
`I'll run it now.`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response missing %q\nbody=%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_StreamTranslatesToolCallToToolUseSSE(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
srv := New(&cfg, &mockBridge{
|
||||
executeLines: []string{
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n<tool_call>\n"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n</tool_call>"}]}}`,
|
||||
`{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`,
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"tools":[{"name":"bash"}],
|
||||
"messages":[{"role":"user","content":"go"}],
|
||||
"stream":true
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`"type":"message_start"`,
|
||||
`"type":"content_block_start"`,
|
||||
`"type":"text"`,
|
||||
`"text":"running`,
|
||||
`"type":"tool_use"`,
|
||||
`"name":"bash"`,
|
||||
`"type":"input_json_delta"`,
|
||||
`\"command\":\"ls\"`,
|
||||
`"stop_reason":"tool_use"`,
|
||||
`"type":"message_stop"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("stream missing %q\nbody=%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<tool_call>") {
|
||||
t.Fatalf("stream leaked raw <tool_call> sentinel: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicMessages_GeneratesSessionHeaderWhenMissing(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
br := &mockBridge{executeSync: "Hello"}
|
||||
srv := New(&cfg, br)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
||||
"model":"auto",
|
||||
"max_tokens":128,
|
||||
"messages":[{"role":"user","content":"hello"}],
|
||||
"stream":false
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if br.lastSessionKey == "" {
|
||||
t.Fatal("expected generated session key to be forwarded to bridge")
|
||||
}
|
||||
if got := rec.Header().Get(sessionHeaderName); got == "" {
|
||||
t.Fatal("expected generated session header in response")
|
||||
}
|
||||
if got := rec.Header().Get(exposeHeadersName); !strings.Contains(got, sessionHeaderName) {
|
||||
t.Fatalf("expose headers = %q, want to contain %q", got, sessionHeaderName)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
// Cowork-style mount path: /sessions/<adjective>-<adjective>-<color>/mnt/<folder>
|
||||
// (and any deeper subpath; we capture only the mount root).
|
||||
var mountPathRe = regexp.MustCompile(`/sessions/[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+/mnt/[^\s/'"]+`)
|
||||
|
||||
// extractMountHints walks all prior tool_result blocks in the conversation
|
||||
// and returns any Cowork-style /sessions/<id>/mnt/<folder> mount roots
|
||||
// they reveal, deduped & sorted.
|
||||
//
|
||||
// This is purely stateless — we re-derive the set from the request body
|
||||
// every turn. No server-side cache to invalidate, and it survives proxy
|
||||
// restarts because the caller (Claude Desktop) replays the full history
|
||||
// on each request anyway.
|
||||
func extractMountHints(req types.AnthropicMessagesRequest) []string {
|
||||
seen := map[string]struct{}{}
|
||||
for _, m := range req.Messages {
|
||||
for _, b := range m.Content {
|
||||
if b.Type != "tool_result" {
|
||||
continue
|
||||
}
|
||||
for _, p := range mountPathRe.FindAllString(renderToolResultContent(b.Content), -1) {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// renderMountHints turns a list of mount roots into a prompt section the
|
||||
// brain can refer to. Returns "" when there are no hints.
|
||||
func renderMountHints(hints []string) string {
|
||||
if len(hints) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Known host-mount paths (discovered earlier in this conversation, prefer these for any host file work):\n")
|
||||
for _, h := range hints {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(h)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/daniel/cursor-adapter/internal/bridge"
|
||||
"github.com/daniel/cursor-adapter/internal/config"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
br bridge.Bridge
|
||||
mux *chi.Mux
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, br bridge.Bridge) *Server {
|
||||
s := &Server{cfg: cfg, br: br}
|
||||
s.mux = s.buildRouter()
|
||||
return s
|
||||
}
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Cursor-Session-ID, X-Cursor-Workspace")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-Cursor-Session-ID")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) buildRouter() *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(corsMiddleware)
|
||||
|
||||
r.Get("/v1/models", s.handleListModels)
|
||||
r.Post("/v1/chat/completions", s.handleChatCompletions)
|
||||
r.Post("/v1/messages", s.handleAnthropicMessages)
|
||||
r.Get("/health", s.handleHealth)
|
||||
|
||||
// Claude Desktop sends HEAD / as a health check before making API calls.
|
||||
// Return 200 so it doesn't error with "K.text.trim" before sending the real request.
|
||||
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
r.Head("/", rootHandler)
|
||||
r.Get("/", rootHandler)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
|
||||
return http.ListenAndServe(addr, s.mux)
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/bridge"
|
||||
)
|
||||
|
||||
const sessionHeaderName = "X-Cursor-Session-ID"
|
||||
const workspaceHeaderName = "X-Cursor-Workspace"
|
||||
const exposeHeadersName = "Access-Control-Expose-Headers"
|
||||
|
||||
// requestContext attaches per-request bridge knobs (currently: workspace
|
||||
// override) read from headers onto ctx.
|
||||
func requestContext(r *http.Request) context.Context {
|
||||
ctx := r.Context()
|
||||
ws := strings.TrimSpace(r.Header.Get(workspaceHeaderName))
|
||||
if ws != "" && filepath.IsAbs(ws) {
|
||||
ctx = bridge.WithWorkspaceOverride(ctx, ws)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func ensureSessionHeader(w http.ResponseWriter, r *http.Request) string {
|
||||
sessionKey := strings.TrimSpace(r.Header.Get(sessionHeaderName))
|
||||
if sessionKey == "" {
|
||||
sessionKey = fmt.Sprintf("csess_%d", time.Now().UnixNano())
|
||||
}
|
||||
w.Header().Set(sessionHeaderName, sessionKey)
|
||||
|
||||
existing := w.Header().Get(exposeHeadersName)
|
||||
if existing == "" {
|
||||
w.Header().Set(exposeHeadersName, sessionHeaderName)
|
||||
} else if !strings.Contains(strings.ToLower(existing), strings.ToLower(sessionHeaderName)) {
|
||||
w.Header().Set(exposeHeadersName, existing+", "+sessionHeaderName)
|
||||
}
|
||||
|
||||
return sessionKey
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
// SSEWriter 封裝 http.ResponseWriter 用於 SSE streaming。
|
||||
type SSEWriter struct {
|
||||
w http.ResponseWriter
|
||||
flush http.Flusher
|
||||
}
|
||||
|
||||
// NewSSEWriter 建立 SSEWriter,設定必要的 headers。
|
||||
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
|
||||
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")
|
||||
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
return &SSEWriter{w: w, flush: flusher}
|
||||
}
|
||||
|
||||
// WriteChunk 寫入一個 SSE chunk。
|
||||
func (s *SSEWriter) WriteChunk(chunk types.ChatCompletionChunk) error {
|
||||
data, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal chunk: %w", err)
|
||||
}
|
||||
fmt.Fprintf(s.w, "data: %s\n\n", data)
|
||||
if s.flush != nil {
|
||||
s.flush.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteDone 寫入 SSE 結束標記。
|
||||
func (s *SSEWriter) WriteDone() {
|
||||
fmt.Fprint(s.w, "data: [DONE]\n\n")
|
||||
if s.flush != nil {
|
||||
s.flush.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// WriteError 寫入 SSE 格式的錯誤。
|
||||
func (s *SSEWriter) WriteError(errMsg string) {
|
||||
stopReason := "stop"
|
||||
chunk := types.NewChatCompletionChunk("error", 0, "", types.Delta{Content: &errMsg})
|
||||
chunk.Choices[0].FinishReason = &stopReason
|
||||
s.WriteChunk(chunk)
|
||||
s.WriteDone()
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/daniel/cursor-adapter/internal/types"
|
||||
)
|
||||
|
||||
func TestNewSSEWriter(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
sse := NewSSEWriter(rec)
|
||||
if sse == nil {
|
||||
t.Fatal("NewSSEWriter returned nil")
|
||||
}
|
||||
headers := rec.Header()
|
||||
if got := headers.Get("Content-Type"); got != "text/event-stream" {
|
||||
t.Errorf("Content-Type = %q, want %q", got, "text/event-stream")
|
||||
}
|
||||
if got := headers.Get("Cache-Control"); got != "no-cache" {
|
||||
t.Errorf("Cache-Control = %q, want %q", got, "no-cache")
|
||||
}
|
||||
if got := headers.Get("Connection"); got != "keep-alive" {
|
||||
t.Errorf("Connection = %q, want %q", got, "keep-alive")
|
||||
}
|
||||
if got := headers.Get("X-Accel-Buffering"); got != "no" {
|
||||
t.Errorf("X-Accel-Buffering = %q, want %q", got, "no")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChunk(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
sse := NewSSEWriter(rec)
|
||||
|
||||
content := "hello"
|
||||
chunk := types.NewChatCompletionChunk("test-id", 0, "", types.Delta{Content: &content})
|
||||
|
||||
if err := sse.WriteChunk(chunk); err != nil {
|
||||
t.Fatalf("WriteChunk returned error: %v", err)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.HasPrefix(body, "data: ") {
|
||||
t.Errorf("WriteChunk output missing 'data: ' prefix, got %q", body)
|
||||
}
|
||||
if !strings.HasSuffix(body, "\n\n") {
|
||||
t.Errorf("WriteChunk output missing trailing newlines, got %q", body)
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimPrefix(body, "data: ")
|
||||
jsonStr = strings.TrimSuffix(jsonStr, "\n\n")
|
||||
var parsed types.ChatCompletionChunk
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal chunk JSON: %v", err)
|
||||
}
|
||||
if parsed.ID != "test-id" {
|
||||
t.Errorf("parsed chunk ID = %q, want %q", parsed.ID, "test-id")
|
||||
}
|
||||
if len(parsed.Choices) != 1 || *parsed.Choices[0].Delta.Content != "hello" {
|
||||
t.Errorf("parsed chunk content mismatch, got %v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDone(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
sse := NewSSEWriter(rec)
|
||||
|
||||
sse.WriteDone()
|
||||
|
||||
body := rec.Body.String()
|
||||
want := "data: [DONE]\n\n"
|
||||
if body != want {
|
||||
t.Errorf("WriteDone output = %q, want %q", body, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sentinels the brain is instructed to wrap tool calls with. We use XML-ish
|
||||
// tags rather than markdown fences because they are unambiguous and easy to
|
||||
// detect mid-stream without confusing them with normal code blocks.
|
||||
const (
|
||||
toolCallOpen = "<tool_call>"
|
||||
toolCallClose = "</tool_call>"
|
||||
)
|
||||
|
||||
// ParsedToolCall is a successfully extracted tool invocation request from
|
||||
// the brain's text stream.
|
||||
type ParsedToolCall struct {
|
||||
Name string
|
||||
Input json.RawMessage
|
||||
}
|
||||
|
||||
// ToolCallStreamParser is a small streaming state machine that splits an
|
||||
// incoming text stream into:
|
||||
// - safe-to-emit plain text (everything outside <tool_call>...</tool_call>)
|
||||
// - one or more ParsedToolCall (everything between sentinels)
|
||||
//
|
||||
// It buffers just enough trailing bytes to avoid emitting half of an opening
|
||||
// sentinel as text.
|
||||
type ToolCallStreamParser struct {
|
||||
buf strings.Builder
|
||||
inToolCall bool
|
||||
}
|
||||
|
||||
// NewToolCallStreamParser returns a fresh parser.
|
||||
func NewToolCallStreamParser() *ToolCallStreamParser {
|
||||
return &ToolCallStreamParser{}
|
||||
}
|
||||
|
||||
// Feed appends s to the parser's buffer and returns:
|
||||
// - emitText: text safe to forward as text_delta to the caller now
|
||||
// - calls: tool calls fully extracted in this Feed
|
||||
// - err: a malformed tool_call block (invalid JSON inside sentinels)
|
||||
//
|
||||
// Feed never returns text that could be the prefix of an opening sentinel —
|
||||
// such bytes stay buffered until the next Feed/Flush.
|
||||
func (p *ToolCallStreamParser) Feed(s string) (emitText string, calls []ParsedToolCall, err error) {
|
||||
p.buf.WriteString(s)
|
||||
var emitted strings.Builder
|
||||
|
||||
for {
|
||||
current := p.buf.String()
|
||||
if p.inToolCall {
|
||||
closeIdx := strings.Index(current, toolCallClose)
|
||||
if closeIdx < 0 {
|
||||
return emitted.String(), calls, nil
|
||||
}
|
||||
payload := current[:closeIdx]
|
||||
call, perr := parseToolCallPayload(payload)
|
||||
rest := current[closeIdx+len(toolCallClose):]
|
||||
rest = strings.TrimPrefix(rest, "\r")
|
||||
rest = strings.TrimPrefix(rest, "\n")
|
||||
p.buf.Reset()
|
||||
p.buf.WriteString(rest)
|
||||
p.inToolCall = false
|
||||
if perr != nil {
|
||||
return emitted.String(), calls, perr
|
||||
}
|
||||
calls = append(calls, call)
|
||||
continue
|
||||
}
|
||||
|
||||
openIdx := strings.Index(current, toolCallOpen)
|
||||
if openIdx >= 0 {
|
||||
emitted.WriteString(current[:openIdx])
|
||||
rest := current[openIdx+len(toolCallOpen):]
|
||||
rest = strings.TrimPrefix(rest, "\r")
|
||||
rest = strings.TrimPrefix(rest, "\n")
|
||||
p.buf.Reset()
|
||||
p.buf.WriteString(rest)
|
||||
p.inToolCall = true
|
||||
continue
|
||||
}
|
||||
|
||||
// No open sentinel yet. Emit everything except a potential prefix
|
||||
// of `<tool_call>` lurking at the tail of the buffer.
|
||||
hold := potentialSentinelSuffix(current, toolCallOpen)
|
||||
if hold == 0 {
|
||||
emitted.WriteString(current)
|
||||
p.buf.Reset()
|
||||
return emitted.String(), calls, nil
|
||||
}
|
||||
emitted.WriteString(current[:len(current)-hold])
|
||||
tail := current[len(current)-hold:]
|
||||
p.buf.Reset()
|
||||
p.buf.WriteString(tail)
|
||||
return emitted.String(), calls, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Flush returns any remaining buffered text and resets the parser. If we
|
||||
// ended mid-`<tool_call>` block (no closing sentinel), the partial content
|
||||
// is returned as plain text — better the caller sees something than data
|
||||
// loss.
|
||||
func (p *ToolCallStreamParser) Flush() (string, error) {
|
||||
leftover := p.buf.String()
|
||||
p.buf.Reset()
|
||||
if p.inToolCall {
|
||||
p.inToolCall = false
|
||||
return toolCallOpen + leftover, fmt.Errorf("unterminated %s block", toolCallOpen)
|
||||
}
|
||||
return leftover, nil
|
||||
}
|
||||
|
||||
// ExtractAllToolCalls is the non-streaming counterpart: scan the full text
|
||||
// once, return cleaned text (with tool_call blocks removed) plus extracted
|
||||
// calls. Any malformed block is preserved verbatim in the returned text.
|
||||
func ExtractAllToolCalls(text string) (cleanText string, calls []ParsedToolCall) {
|
||||
var out strings.Builder
|
||||
rest := text
|
||||
for {
|
||||
i := strings.Index(rest, toolCallOpen)
|
||||
if i < 0 {
|
||||
out.WriteString(rest)
|
||||
break
|
||||
}
|
||||
out.WriteString(rest[:i])
|
||||
after := rest[i+len(toolCallOpen):]
|
||||
j := strings.Index(after, toolCallClose)
|
||||
if j < 0 {
|
||||
// Unterminated; keep the rest verbatim.
|
||||
out.WriteString(toolCallOpen)
|
||||
out.WriteString(after)
|
||||
break
|
||||
}
|
||||
payload := after[:j]
|
||||
if call, err := parseToolCallPayload(payload); err == nil {
|
||||
calls = append(calls, call)
|
||||
} else {
|
||||
// Keep malformed block as-is so the user can see it.
|
||||
out.WriteString(toolCallOpen)
|
||||
out.WriteString(payload)
|
||||
out.WriteString(toolCallClose)
|
||||
}
|
||||
rest = strings.TrimPrefix(after[j+len(toolCallClose):], "\n")
|
||||
}
|
||||
return strings.TrimSpace(out.String()), calls
|
||||
}
|
||||
|
||||
func parseToolCallPayload(payload string) (ParsedToolCall, error) {
|
||||
trimmed := strings.TrimSpace(payload)
|
||||
// Allow the brain to wrap the JSON in ```json fences too.
|
||||
trimmed = strings.TrimPrefix(trimmed, "```json")
|
||||
trimmed = strings.TrimPrefix(trimmed, "```")
|
||||
trimmed = strings.TrimSuffix(trimmed, "```")
|
||||
trimmed = strings.TrimSpace(trimmed)
|
||||
if trimmed == "" {
|
||||
return ParsedToolCall{}, fmt.Errorf("empty tool_call body")
|
||||
}
|
||||
var raw struct {
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Args json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(trimmed), &raw); err != nil {
|
||||
return ParsedToolCall{}, fmt.Errorf("invalid tool_call json: %w", err)
|
||||
}
|
||||
name := raw.Name
|
||||
if name == "" {
|
||||
name = raw.Tool
|
||||
}
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, fmt.Errorf("tool_call missing name")
|
||||
}
|
||||
input := raw.Input
|
||||
if len(input) == 0 {
|
||||
input = raw.Args
|
||||
}
|
||||
if len(input) == 0 {
|
||||
input = json.RawMessage(`{}`)
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, nil
|
||||
}
|
||||
|
||||
// potentialSentinelSuffix returns the length of the longest suffix of s
|
||||
// that is a strict prefix of sentinel.
|
||||
func potentialSentinelSuffix(s, sentinel string) int {
|
||||
maxLen := len(sentinel) - 1
|
||||
if maxLen > len(s) {
|
||||
maxLen = len(s)
|
||||
}
|
||||
for i := maxLen; i > 0; i-- {
|
||||
if strings.HasPrefix(sentinel, s[len(s)-i:]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolCallStreamParser_PlainTextPassThrough(t *testing.T) {
|
||||
p := NewToolCallStreamParser()
|
||||
emit, calls, err := p.Feed("hello world\n")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no calls, got %+v", calls)
|
||||
}
|
||||
if emit != "hello world\n" {
|
||||
t.Fatalf("emit = %q, want passthrough", emit)
|
||||
}
|
||||
rest, err := p.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush error: %v", err)
|
||||
}
|
||||
if rest != "" {
|
||||
t.Fatalf("flush leftover = %q, want empty", rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStreamParser_ExtractsCompleteCall(t *testing.T) {
|
||||
p := NewToolCallStreamParser()
|
||||
in := "before\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n</tool_call>\nafter"
|
||||
emit, calls, err := p.Feed(in)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("name = %q", calls[0].Name)
|
||||
}
|
||||
if !strings.Contains(string(calls[0].Input), `"command":"ls"`) {
|
||||
t.Fatalf("input = %s", calls[0].Input)
|
||||
}
|
||||
if !strings.Contains(emit, "before") || !strings.Contains(emit, "after") {
|
||||
t.Fatalf("emit lost surrounding text: %q", emit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStreamParser_HoldsPartialOpenSentinel(t *testing.T) {
|
||||
p := NewToolCallStreamParser()
|
||||
// Feed a chunk ending with a partial "<tool_ca". Parser must not emit it.
|
||||
emit, calls, err := p.Feed("text<tool_ca")
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("calls = %+v", calls)
|
||||
}
|
||||
if emit != "text" {
|
||||
t.Fatalf("emit = %q, want %q", emit, "text")
|
||||
}
|
||||
emit2, calls2, err := p.Feed("ll>{\"name\":\"x\"}</tool_call>")
|
||||
if err != nil {
|
||||
t.Fatalf("error 2: %v", err)
|
||||
}
|
||||
if emit2 != "" {
|
||||
t.Fatalf("emit2 = %q, want empty (only call extracted)", emit2)
|
||||
}
|
||||
if len(calls2) != 1 || calls2[0].Name != "x" {
|
||||
t.Fatalf("calls2 = %+v", calls2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStreamParser_RejectsInvalidJSON(t *testing.T) {
|
||||
p := NewToolCallStreamParser()
|
||||
_, _, err := p.Feed("<tool_call>not json</tool_call>")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error for invalid JSON inside sentinels")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAllToolCalls_MultipleAndCleanText(t *testing.T) {
|
||||
in := "preamble\n<tool_call>{\"name\":\"a\",\"input\":{}}</tool_call>\nmiddle\n<tool_call>{\"tool\":\"b\",\"arguments\":{\"x\":1}}</tool_call>\nend"
|
||||
clean, calls := ExtractAllToolCalls(in)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls = %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "a" || calls[1].Name != "b" {
|
||||
t.Fatalf("names = %q, %q", calls[0].Name, calls[1].Name)
|
||||
}
|
||||
if !strings.Contains(clean, "preamble") || !strings.Contains(clean, "middle") || !strings.Contains(clean, "end") {
|
||||
t.Fatalf("clean text wrong: %q", clean)
|
||||
}
|
||||
if strings.Contains(clean, "<tool_call>") {
|
||||
t.Fatalf("clean text still contains sentinels: %q", clean)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// AnthropicBlock is a single content block in Anthropic Messages API. It can
|
||||
// be text, image, document, tool_use, or tool_result. We decode the raw JSON
|
||||
// once and keep the rest as RawData so downstream code (prompt building) can
|
||||
// render every type faithfully.
|
||||
type AnthropicBlock struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// type=text
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// type=tool_use
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
|
||||
// type=tool_result
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
|
||||
// type=image / document
|
||||
Source json.RawMessage `json:"source,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicTextBlock kept for response serialisation of plain text content.
|
||||
// Deprecated: use AnthropicResponseBlock for outputs that may also carry
|
||||
// tool_use blocks.
|
||||
type AnthropicTextBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicResponseBlock is a polymorphic content block emitted by the
|
||||
// proxy. It can be a "text" block or a synthetic "tool_use" block produced
|
||||
// by translating a brain-side <tool_call>...</tool_call> sentinel.
|
||||
type AnthropicResponseBlock struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// type=text
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// type=tool_use
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicContent is a flexible field: it can be a plain string OR an array
|
||||
// of blocks. Claude Code always sends the array form.
|
||||
type AnthropicContent []AnthropicBlock
|
||||
|
||||
func (c *AnthropicContent) UnmarshalJSON(data []byte) error {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err == nil {
|
||||
*c = []AnthropicBlock{{Type: "text", Text: text}}
|
||||
return nil
|
||||
}
|
||||
|
||||
var blocks []AnthropicBlock
|
||||
if err := json.Unmarshal(data, &blocks); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = blocks
|
||||
return nil
|
||||
}
|
||||
|
||||
type AnthropicSystem AnthropicContent
|
||||
|
||||
func (s *AnthropicSystem) UnmarshalJSON(data []byte) error {
|
||||
return (*AnthropicContent)(s).UnmarshalJSON(data)
|
||||
}
|
||||
|
||||
type AnthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content AnthropicContent `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicTool mirrors Anthropic's `tools` entry shape. InputSchema is left
|
||||
// as RawMessage so we can render it verbatim in a system prompt without
|
||||
// caring about the exact JSON Schema structure.
|
||||
type AnthropicTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema json.RawMessage `json:"input_schema,omitempty"`
|
||||
}
|
||||
|
||||
type AnthropicMessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []AnthropicMessage `json:"messages"`
|
||||
System AnthropicSystem `json:"system,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []AnthropicTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type AnthropicMessagesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []AnthropicResponseBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage AnthropicUsage `json:"usage"`
|
||||
}
|
||||
|
||||
type AnthropicUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
|
@ -1,57 +1,43 @@
|
|||
// Code generated by goctl. DO NOT EDIT.
|
||||
// goctl 1.10.1
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
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"`
|
||||
}
|
||||
|
||||
func StringPtr(s string) *string { return &s }
|
||||
|
||||
// Request
|
||||
type ChatMessageContent string
|
||||
|
||||
type ChatMessageContentPart struct {
|
||||
type AnthropicResponse struct {
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ChatMessageContent) UnmarshalJSON(data []byte) error {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err == nil {
|
||||
*c = ChatMessageContent(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
var parts []ChatMessageContentPart
|
||||
if err := json.Unmarshal(data, &parts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Type == "text" {
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
*c = ChatMessageContent(content.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content ChatMessageContent `json:"content"`
|
||||
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 []ChatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Response (non-streaming)
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
|
|
@ -61,8 +47,83 @@ type ChatCompletionResponse struct {
|
|||
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message ChatMessage `json:"message"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
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 {
|
||||
|
|
@ -70,73 +131,3 @@ type Usage struct {
|
|||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// Streaming chunk
|
||||
type ChatCompletionChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Choices []ChunkChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta Delta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type Delta struct {
|
||||
Role *string `json:"role,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// Models list
|
||||
type ModelList struct {
|
||||
Object string `json:"object"`
|
||||
Data []ModelInfo `json:"data"`
|
||||
}
|
||||
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
|
||||
// Error response
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
type ErrorBody struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
func NewErrorResponse(message, errType, code string) ErrorResponse {
|
||||
return ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Message: message,
|
||||
Type: errType,
|
||||
Code: code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewChatCompletionChunk(id string, created int64, model string, delta Delta) ChatCompletionChunk {
|
||||
return ChatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []ChunkChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: delta,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
// Package workspace sets up an isolated temp directory for each Cursor CLI /
|
||||
// ACP child. It pre-populates a minimal .cursor config so the agent does not
|
||||
// load the real user's global rules from ~/.cursor, and returns a set of
|
||||
// environment overrides (HOME, CURSOR_CONFIG_DIR, XDG_CONFIG_HOME, APPDATA…)
|
||||
// so the child cannot escape back to the real profile.
|
||||
//
|
||||
// Ported from cursor-api-proxy/src/lib/workspace.ts.
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// ChatOnly prepares an isolated temp workspace and returns:
|
||||
// - dir: the absolute path of the new temp directory (caller is responsible
|
||||
// for removing it when the child process exits).
|
||||
// - env: a map of environment variables to override on the child.
|
||||
//
|
||||
// Auth is the tricky part. The Cursor CLI resolves login tokens from the
|
||||
// real user profile (macOS keychain on darwin, ~/.cursor/agent-cli-state.json
|
||||
// elsewhere); if we override HOME to the temp dir, `agent --print` dies with
|
||||
// "Authentication required. Please run 'agent login' first…". So we keep
|
||||
// HOME untouched unless either:
|
||||
// - CURSOR_API_KEY is set (the CLI uses the env var and doesn't need HOME), or
|
||||
// - authConfigDir is non-empty (account-pool mode, not used here yet).
|
||||
//
|
||||
// We *do* always override CURSOR_CONFIG_DIR → tempDir/.cursor. That's the
|
||||
// setting Cursor uses to locate rules/, cli-config.json, and per-project
|
||||
// state, so this single override is enough to stop the agent from loading
|
||||
// the user's real ~/.cursor/rules/* into the prompt.
|
||||
func ChatOnly(authConfigDir string) (dir string, env map[string]string, err error) {
|
||||
tempDir, err := os.MkdirTemp("", "cursor-adapter-ws-*")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("mkdtemp: %w", err)
|
||||
}
|
||||
|
||||
cursorDir := filepath.Join(tempDir, ".cursor")
|
||||
if err := os.MkdirAll(filepath.Join(cursorDir, "rules"), 0o755); err != nil {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return "", nil, fmt.Errorf("mkdir .cursor/rules: %w", err)
|
||||
}
|
||||
|
||||
minimalConfig := map[string]any{
|
||||
"version": 1,
|
||||
"editor": map[string]any{"vimMode": false},
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{},
|
||||
"deny": []any{},
|
||||
},
|
||||
}
|
||||
cfgBytes, _ := json.Marshal(minimalConfig)
|
||||
if err := os.WriteFile(filepath.Join(cursorDir, "cli-config.json"), cfgBytes, 0o644); err != nil {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return "", nil, fmt.Errorf("write cli-config.json: %w", err)
|
||||
}
|
||||
|
||||
env = map[string]string{}
|
||||
if authConfigDir != "" {
|
||||
env["CURSOR_CONFIG_DIR"] = authConfigDir
|
||||
return tempDir, env, nil
|
||||
}
|
||||
|
||||
env["CURSOR_CONFIG_DIR"] = cursorDir
|
||||
|
||||
// Only fully isolate HOME if the child will auth via CURSOR_API_KEY.
|
||||
// With keychain/home-based auth, replacing HOME makes agent exit 1.
|
||||
if os.Getenv("CURSOR_API_KEY") != "" {
|
||||
env["HOME"] = tempDir
|
||||
env["USERPROFILE"] = tempDir
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
appDataRoaming := filepath.Join(tempDir, "AppData", "Roaming")
|
||||
appDataLocal := filepath.Join(tempDir, "AppData", "Local")
|
||||
_ = os.MkdirAll(appDataRoaming, 0o755)
|
||||
_ = os.MkdirAll(appDataLocal, 0o755)
|
||||
env["APPDATA"] = appDataRoaming
|
||||
env["LOCALAPPDATA"] = appDataLocal
|
||||
} else {
|
||||
xdg := filepath.Join(tempDir, ".config")
|
||||
_ = os.MkdirAll(xdg, 0o755)
|
||||
env["XDG_CONFIG_HOME"] = xdg
|
||||
}
|
||||
}
|
||||
|
||||
return tempDir, env, nil
|
||||
}
|
||||
|
||||
// MergeEnv takes the current process env (as "KEY=VALUE" strings) and
|
||||
// overlays overrides on top, returning a new slice suitable for exec.Cmd.Env.
|
||||
// Keys from overrides replace any existing entries with the same key.
|
||||
func MergeEnv(base []string, overrides map[string]string) []string {
|
||||
if len(overrides) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(base)+len(overrides))
|
||||
seen := make(map[string]bool, len(overrides))
|
||||
|
||||
for _, kv := range base {
|
||||
eq := indexOf(kv, '=')
|
||||
if eq < 0 {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
key := kv[:eq]
|
||||
if v, ok := overrides[key]; ok {
|
||||
out = append(out, key+"="+v)
|
||||
seen[key] = true
|
||||
} else {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
for k, v := range overrides {
|
||||
if !seen[k] {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(s string, c byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
package workspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatOnly_NoApiKey_KeepsHome(t *testing.T) {
|
||||
t.Setenv("CURSOR_API_KEY", "")
|
||||
|
||||
dir, env, err := ChatOnly("")
|
||||
if err != nil {
|
||||
t.Fatalf("ChatOnly: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
|
||||
if !filepath.IsAbs(dir) {
|
||||
t.Errorf("expected absolute path, got %q", dir)
|
||||
}
|
||||
|
||||
cfgPath := filepath.Join(dir, ".cursor", "cli-config.json")
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected cli-config.json to exist: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("cli-config.json is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if env["CURSOR_CONFIG_DIR"] != filepath.Join(dir, ".cursor") {
|
||||
t.Errorf("CURSOR_CONFIG_DIR override wrong: %q", env["CURSOR_CONFIG_DIR"])
|
||||
}
|
||||
if _, ok := env["HOME"]; ok {
|
||||
t.Errorf("HOME should NOT be overridden without CURSOR_API_KEY, got %q (would break keychain auth)", env["HOME"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatOnly_WithApiKey_IsolatesHome(t *testing.T) {
|
||||
t.Setenv("CURSOR_API_KEY", "sk-fake-for-test")
|
||||
|
||||
dir, env, err := ChatOnly("")
|
||||
if err != nil {
|
||||
t.Fatalf("ChatOnly: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
|
||||
if env["HOME"] != dir {
|
||||
t.Errorf("HOME override = %q, want %q", env["HOME"], dir)
|
||||
}
|
||||
if runtime.GOOS != "windows" && env["XDG_CONFIG_HOME"] != filepath.Join(dir, ".config") {
|
||||
t.Errorf("XDG_CONFIG_HOME override wrong: %q", env["XDG_CONFIG_HOME"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatOnly_WithAuthConfigDir_OnlySetsCursorConfigDir(t *testing.T) {
|
||||
dir, env, err := ChatOnly("/tmp/fake-auth")
|
||||
if err != nil {
|
||||
t.Fatalf("ChatOnly: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
|
||||
if env["CURSOR_CONFIG_DIR"] != "/tmp/fake-auth" {
|
||||
t.Errorf("expected CURSOR_CONFIG_DIR to be the auth dir, got %q", env["CURSOR_CONFIG_DIR"])
|
||||
}
|
||||
if _, ok := env["HOME"]; ok {
|
||||
t.Errorf("HOME should not be overridden when authConfigDir is set, got %q", env["HOME"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEnv_OverridesExistingKeys(t *testing.T) {
|
||||
base := []string{"FOO=1", "HOME=/old", "BAR=2"}
|
||||
out := MergeEnv(base, map[string]string{
|
||||
"HOME": "/new",
|
||||
"BAZ": "3",
|
||||
})
|
||||
|
||||
joined := strings.Join(out, "\n")
|
||||
if !strings.Contains(joined, "HOME=/new") {
|
||||
t.Errorf("expected HOME=/new in merged env, got: %v", out)
|
||||
}
|
||||
if strings.Contains(joined, "HOME=/old") {
|
||||
t.Errorf("old HOME should have been replaced, got: %v", out)
|
||||
}
|
||||
if !strings.Contains(joined, "FOO=1") || !strings.Contains(joined, "BAR=2") {
|
||||
t.Errorf("unchanged keys should pass through, got: %v", out)
|
||||
}
|
||||
if !strings.Contains(joined, "BAZ=3") {
|
||||
t.Errorf("new key should be appended, got: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEnv_EmptyOverridesReturnsSameSlice(t *testing.T) {
|
||||
base := []string{"FOO=1"}
|
||||
out := MergeEnv(base, nil)
|
||||
if len(out) != 1 || out[0] != "FOO=1" {
|
||||
t.Errorf("expected base unchanged, got %v", out)
|
||||
}
|
||||
}
|
||||
158
main.go
158
main.go
|
|
@ -1,102 +1,90 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/daniel/cursor-adapter/internal/bridge"
|
||||
"github.com/daniel/cursor-adapter/internal/config"
|
||||
"github.com/daniel/cursor-adapter/internal/server"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
configPath string
|
||||
port int
|
||||
debug bool
|
||||
useACP bool
|
||||
chatOnlySet bool
|
||||
chatOnlyFlag bool
|
||||
)
|
||||
const version = "1.0.0"
|
||||
|
||||
var configFile = flag.String("f", "etc/chat-api.yaml", "the config file")
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cursor-adapter",
|
||||
Short: "OpenAI-compatible proxy for Cursor CLI",
|
||||
RunE: run,
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "config file path (default: ~/.cursor-adapter/config.yaml)")
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 0, "server port (overrides config)")
|
||||
rootCmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
rootCmd.Flags().BoolVar(&useACP, "use-acp", false, "use Cursor ACP transport instead of CLI stream-json")
|
||||
rootCmd.Flags().BoolVar(&chatOnlyFlag, "chat-only-workspace", true, "isolate Cursor CLI in an empty temp workspace with overridden HOME/CURSOR_CONFIG_DIR (set to false to let Cursor agent see the adapter's cwd)")
|
||||
rootCmd.PreRun = func(cmd *cobra.Command, args []string) {
|
||||
chatOnlySet = cmd.Flags().Changed("chat-only-workspace")
|
||||
// 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 true
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
if args.Login {
|
||||
if err := cmd.HandleLogin(args.AccountName, args.Proxies); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
var logLevel slog.Level
|
||||
if debug {
|
||||
logLevel = slog.LevelDebug
|
||||
} else {
|
||||
logLevel = slog.LevelInfo
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
if port > 0 {
|
||||
cfg.Port = port
|
||||
}
|
||||
if useACP {
|
||||
cfg.UseACP = true
|
||||
}
|
||||
if chatOnlySet {
|
||||
cfg.ChatOnlyWorkspace = chatOnlyFlag
|
||||
}
|
||||
|
||||
br := bridge.NewBridge(bridge.Options{
|
||||
CursorPath: cfg.CursorCLIPath,
|
||||
Logger: logger,
|
||||
UseACP: cfg.UseACP,
|
||||
ChatOnly: cfg.ChatOnlyWorkspace,
|
||||
MaxConcurrent: cfg.MaxConcurrent,
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
Mode: cfg.CursorMode,
|
||||
WorkspaceRoot: cfg.WorkspaceRoot,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := br.CheckHealth(ctx); err != nil {
|
||||
return fmt.Errorf("cursor cli not available: %w", err)
|
||||
}
|
||||
logger.Info("Cursor CLI OK")
|
||||
|
||||
srv := server.New(cfg, br)
|
||||
mode := "CLI"
|
||||
if cfg.UseACP {
|
||||
mode = "ACP"
|
||||
}
|
||||
logger.Info("Starting cursor-adapter",
|
||||
"port", cfg.Port,
|
||||
"transport", mode,
|
||||
"cursor_mode", cfg.CursorMode,
|
||||
"workspace_root", cfg.WorkspaceRoot,
|
||||
"chat_only_workspace", cfg.ChatOnlyWorkspace,
|
||||
)
|
||||
return srv.Run()
|
||||
return true
|
||||
}
|
||||
|
||||
if args.Logout {
|
||||
if err := cmd.HandleLogout(args.AccountName); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if args.AccountsList {
|
||||
if err := cmd.HandleAccountsList(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if args.ResetHwid {
|
||||
if err := cmd.HandleResetHwid(args.DeepClean, args.DryRun); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Not a CLI command
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
package anthropic
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/pkg/adapter/openai"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MessageParam struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
type MessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []MessageParam `json:"messages"`
|
||||
System interface{} `json:"system"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []interface{} `json:"tools"`
|
||||
}
|
||||
|
||||
func systemToText(system interface{}) string {
|
||||
if system == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := system.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case []interface{}:
|
||||
var parts []string
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
if m["type"] == "text" {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func anthropicBlockToText(p interface{}) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := p.(type) {
|
||||
case string:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
typ, _ := v["type"].(string)
|
||||
switch typ {
|
||||
case "text":
|
||||
if t, ok := v["text"].(string); ok {
|
||||
return t
|
||||
}
|
||||
case "image":
|
||||
if src, ok := v["source"].(map[string]interface{}); ok {
|
||||
srcType, _ := src["type"].(string)
|
||||
switch srcType {
|
||||
case "base64":
|
||||
mt, _ := src["media_type"].(string)
|
||||
if mt == "" {
|
||||
mt = "image"
|
||||
}
|
||||
return "[Image: base64 " + mt + "]"
|
||||
case "url":
|
||||
url, _ := src["url"].(string)
|
||||
return "[Image: " + url + "]"
|
||||
}
|
||||
}
|
||||
return "[Image]"
|
||||
case "document":
|
||||
title, _ := v["title"].(string)
|
||||
if title == "" {
|
||||
if src, ok := v["source"].(map[string]interface{}); ok {
|
||||
title, _ = src["url"].(string)
|
||||
}
|
||||
}
|
||||
if title != "" {
|
||||
return "[Document: " + title + "]"
|
||||
}
|
||||
return "[Document]"
|
||||
case "tool_use":
|
||||
name, _ := v["name"].(string)
|
||||
id, _ := v["id"].(string)
|
||||
input := v["input"]
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
if inputJSON == nil {
|
||||
inputJSON = []byte("{}")
|
||||
}
|
||||
tag := fmt.Sprintf("<tool_call>\n{\"name\": \"%s\", \"arguments\": %s}\n</tool_call>", name, string(inputJSON))
|
||||
if id != "" {
|
||||
tag = fmt.Sprintf("[tool_use_id=%s] ", id) + tag
|
||||
}
|
||||
return tag
|
||||
case "tool_result":
|
||||
toolUseID, _ := v["tool_use_id"].(string)
|
||||
content := v["content"]
|
||||
var contentText string
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
contentText = c
|
||||
case []interface{}:
|
||||
var parts []string
|
||||
for _, block := range c {
|
||||
if bm, ok := block.(map[string]interface{}); ok {
|
||||
if bm["type"] == "text" {
|
||||
if t, ok := bm["text"].(string); ok {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contentText = strings.Join(parts, "\n")
|
||||
}
|
||||
label := "Tool result"
|
||||
if toolUseID != "" {
|
||||
label += " [id=" + toolUseID + "]"
|
||||
}
|
||||
return label + ": " + contentText
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func anthropicContentToText(content interface{}) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var parts []string
|
||||
for _, p := range v {
|
||||
if t := anthropicBlockToText(p); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildPromptFromAnthropicMessages(messages []MessageParam, system interface{}) string {
|
||||
var oaiMessages []interface{}
|
||||
|
||||
systemText := systemToText(system)
|
||||
if systemText != "" {
|
||||
oaiMessages = append(oaiMessages, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": systemText,
|
||||
})
|
||||
}
|
||||
|
||||
for _, m := range messages {
|
||||
text := anthropicContentToText(m.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
role := m.Role
|
||||
if role != "user" && role != "assistant" {
|
||||
role = "user"
|
||||
}
|
||||
oaiMessages = append(oaiMessages, map[string]interface{}{
|
||||
"role": role,
|
||||
"content": text,
|
||||
})
|
||||
}
|
||||
|
||||
return openai.BuildPromptFromMessages(oaiMessages)
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package anthropic_test
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/pkg/adapter/anthropic"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_Simple(t *testing.T) {
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "assistant", Content: "Hi there"},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
|
||||
if !strings.Contains(prompt, "Hello") {
|
||||
t.Errorf("prompt missing user message: %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Hi there") {
|
||||
t.Errorf("prompt missing assistant message: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_WithSystem(t *testing.T) {
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "user", Content: "ping"},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, "You are a helpful bot.")
|
||||
if !strings.Contains(prompt, "You are a helpful bot.") {
|
||||
t.Errorf("prompt missing system: %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "ping") {
|
||||
t.Errorf("prompt missing user: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_SystemArray(t *testing.T) {
|
||||
system := []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "Part A"},
|
||||
map[string]interface{}{"type": "text", "text": "Part B"},
|
||||
}
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "user", Content: "test"},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, system)
|
||||
if !strings.Contains(prompt, "Part A") {
|
||||
t.Errorf("prompt missing Part A: %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Part B") {
|
||||
t.Errorf("prompt missing Part B: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_ContentBlocks(t *testing.T) {
|
||||
content := []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "block one"},
|
||||
map[string]interface{}{"type": "text", "text": "block two"},
|
||||
}
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "user", Content: content},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
|
||||
if !strings.Contains(prompt, "block one") {
|
||||
t.Errorf("prompt missing 'block one': %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "block two") {
|
||||
t.Errorf("prompt missing 'block two': %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_ImageBlock(t *testing.T) {
|
||||
content := []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "image",
|
||||
"source": map[string]interface{}{
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "abc123",
|
||||
},
|
||||
},
|
||||
}
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "user", Content: content},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
|
||||
if !strings.Contains(prompt, "[Image") {
|
||||
t.Errorf("prompt missing [Image]: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_EmptyContentSkipped(t *testing.T) {
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "user", Content: ""},
|
||||
{Role: "assistant", Content: "response"},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
|
||||
if !strings.Contains(prompt, "response") {
|
||||
t.Errorf("prompt missing 'response': %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromAnthropicMessages_UnknownRoleBecomesUser(t *testing.T) {
|
||||
messages := []anthropic.MessageParam{
|
||||
{Role: "system", Content: "system-as-user"},
|
||||
}
|
||||
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
|
||||
if !strings.Contains(prompt, "system-as-user") {
|
||||
t.Errorf("prompt missing 'system-as-user': %q", prompt)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []interface{} `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []interface{} `json:"tools"`
|
||||
ToolChoice interface{} `json:"tool_choice"`
|
||||
Functions []interface{} `json:"functions"`
|
||||
FunctionCall interface{} `json:"function_call"`
|
||||
}
|
||||
|
||||
func NormalizeModelID(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(trimmed, "/")
|
||||
last := parts[len(parts)-1]
|
||||
if last == "" {
|
||||
return ""
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func imageURLToText(imageURL interface{}) string {
|
||||
if imageURL == nil {
|
||||
return "[Image]"
|
||||
}
|
||||
var url string
|
||||
switch v := imageURL.(type) {
|
||||
case string:
|
||||
url = v
|
||||
case map[string]interface{}:
|
||||
if u, ok := v["url"].(string); ok {
|
||||
url = u
|
||||
}
|
||||
}
|
||||
if url == "" {
|
||||
return "[Image]"
|
||||
}
|
||||
if strings.HasPrefix(url, "data:") {
|
||||
end := strings.Index(url, ";")
|
||||
mime := "image"
|
||||
if end > 5 {
|
||||
mime = url[5:end]
|
||||
}
|
||||
return "[Image: base64 " + mime + "]"
|
||||
}
|
||||
return "[Image: " + url + "]"
|
||||
}
|
||||
|
||||
func MessageContentToText(content interface{}) string {
|
||||
if content == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var parts []string
|
||||
for _, p := range v {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
switch part := p.(type) {
|
||||
case string:
|
||||
parts = append(parts, part)
|
||||
case map[string]interface{}:
|
||||
typ, _ := part["type"].(string)
|
||||
switch typ {
|
||||
case "text":
|
||||
if t, ok := part["text"].(string); ok {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
case "image_url":
|
||||
parts = append(parts, imageURLToText(part["image_url"]))
|
||||
case "image":
|
||||
src := part["source"]
|
||||
if src == nil {
|
||||
src = part["url"]
|
||||
}
|
||||
parts = append(parts, imageURLToText(src))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ToolsToSystemText(tools []interface{}, functions []interface{}) string {
|
||||
var defs []interface{}
|
||||
|
||||
for _, t := range tools {
|
||||
if m, ok := t.(map[string]interface{}); ok {
|
||||
if m["type"] == "function" {
|
||||
if fn := m["function"]; fn != nil {
|
||||
defs = append(defs, fn)
|
||||
}
|
||||
} else {
|
||||
defs = append(defs, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
defs = append(defs, functions...)
|
||||
|
||||
if len(defs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, "Available tools (respond with a JSON object to call one):", "")
|
||||
|
||||
for _, raw := range defs {
|
||||
fn, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
desc, _ := fn["description"].(string)
|
||||
params := "{}"
|
||||
if p := fn["parameters"]; p != nil {
|
||||
if b, err := json.MarshalIndent(p, "", " "); err == nil {
|
||||
params = string(b)
|
||||
}
|
||||
} else if p := fn["input_schema"]; p != nil {
|
||||
if b, err := json.MarshalIndent(p, "", " "); err == nil {
|
||||
params = string(b)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "Function: "+name+"\nDescription: "+desc+"\nParameters: "+params)
|
||||
}
|
||||
|
||||
lines = append(lines, "",
|
||||
"When you want to call a tool, use this EXACT format:",
|
||||
"",
|
||||
"<tool_call>",
|
||||
`{"name": "function_name", "arguments": {"param1": "value1"}}`,
|
||||
"</tool_call>",
|
||||
"",
|
||||
"Rules:",
|
||||
"- Write your reasoning BEFORE the tool call",
|
||||
"- You may make multiple tool calls by using multiple <tool_call> blocks",
|
||||
"- STOP writing after the last </tool_call> tag",
|
||||
"- If no tool is needed, respond normally without <tool_call> tags",
|
||||
)
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
type SimpleMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func BuildPromptFromMessages(messages []interface{}) string {
|
||||
var systemParts []string
|
||||
var convo []string
|
||||
|
||||
for _, raw := range messages {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
role, _ := m["role"].(string)
|
||||
text := MessageContentToText(m["content"])
|
||||
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
if text != "" {
|
||||
systemParts = append(systemParts, text)
|
||||
}
|
||||
case "user":
|
||||
if text != "" {
|
||||
convo = append(convo, "User: "+text)
|
||||
}
|
||||
case "assistant":
|
||||
toolCalls, _ := m["tool_calls"].([]interface{})
|
||||
if len(toolCalls) > 0 {
|
||||
var parts []string
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
tcMap, ok := tc.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := tcMap["function"].(map[string]interface{})
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
args, _ := fn["arguments"].(string)
|
||||
if args == "" {
|
||||
args = "{}"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("<tool_call>\n{\"name\": \"%s\", \"arguments\": %s}\n</tool_call>", name, args))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
convo = append(convo, "Assistant: "+strings.Join(parts, "\n"))
|
||||
}
|
||||
} else if text != "" {
|
||||
convo = append(convo, "Assistant: "+text)
|
||||
}
|
||||
case "tool", "function":
|
||||
name, _ := m["name"].(string)
|
||||
toolCallID, _ := m["tool_call_id"].(string)
|
||||
label := "Tool result"
|
||||
if name != "" {
|
||||
label = "Tool result (" + name + ")"
|
||||
}
|
||||
if toolCallID != "" {
|
||||
label += " [id=" + toolCallID + "]"
|
||||
}
|
||||
if text != "" {
|
||||
convo = append(convo, label+": "+text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
system := ""
|
||||
if len(systemParts) > 0 {
|
||||
system = "System:\n" + strings.Join(systemParts, "\n\n") + "\n\n"
|
||||
}
|
||||
transcript := strings.Join(convo, "\n\n")
|
||||
return system + transcript + "\n\nAssistant:"
|
||||
}
|
||||
|
||||
func BuildPromptFromSimpleMessages(messages []SimpleMessage) string {
|
||||
ifaces := make([]interface{}, len(messages))
|
||||
for i, m := range messages {
|
||||
ifaces[i] = map[string]interface{}{"role": m.Role, "content": m.Content}
|
||||
}
|
||||
return BuildPromptFromMessages(ifaces)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package openai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeModelID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"gpt-4", "gpt-4"},
|
||||
{"openai/gpt-4", "gpt-4"},
|
||||
{"anthropic/claude-3", "claude-3"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"a/b/c", "c"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := NormalizeModelID(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("NormalizeModelID(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptFromMessages(t *testing.T) {
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{"role": "system", "content": "You are helpful."},
|
||||
map[string]interface{}{"role": "user", "content": "Hello"},
|
||||
map[string]interface{}{"role": "assistant", "content": "Hi there"},
|
||||
}
|
||||
got := BuildPromptFromMessages(messages)
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty prompt")
|
||||
}
|
||||
containsSystem := false
|
||||
containsUser := false
|
||||
containsAssistant := false
|
||||
for i := 0; i < len(got)-10; i++ {
|
||||
if got[i:i+6] == "System" {
|
||||
containsSystem = true
|
||||
}
|
||||
if got[i:i+4] == "User" {
|
||||
containsUser = true
|
||||
}
|
||||
if got[i:i+9] == "Assistant" {
|
||||
containsAssistant = true
|
||||
}
|
||||
}
|
||||
if !containsSystem || !containsUser || !containsAssistant {
|
||||
t.Errorf("prompt missing sections: system=%v user=%v assistant=%v\n%s",
|
||||
containsSystem, containsUser, containsAssistant, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsToSystemText(t *testing.T) {
|
||||
tools := []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": map[string]interface{}{"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := ToolsToSystemText(tools, nil)
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty tools text")
|
||||
}
|
||||
if len(got) < 10 {
|
||||
t.Errorf("tools text too short: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsToSystemTextEmpty(t *testing.T) {
|
||||
got := ToolsToSystemText(nil, nil)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string for no tools, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
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",
|
||||
"claude-sonnet-4-6": "sonnet-4.6",
|
||||
"claude-sonnet-4.6": "sonnet-4.6",
|
||||
"claude-opus-4-5": "opus-4.5",
|
||||
"claude-opus-4.5": "opus-4.5",
|
||||
"claude-sonnet-4-5": "sonnet-4.5",
|
||||
"claude-sonnet-4.5": "sonnet-4.5",
|
||||
"claude-opus-4": "opus-4.6",
|
||||
"claude-sonnet-4": "sonnet-4.6",
|
||||
"claude-haiku-4-5-20251001": "sonnet-4.5",
|
||||
"claude-haiku-4-5": "sonnet-4.5",
|
||||
"claude-haiku-4-6": "sonnet-4.6",
|
||||
"claude-haiku-4": "sonnet-4.5",
|
||||
"claude-opus-4-6-thinking": "opus-4.6-thinking",
|
||||
"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",
|
||||
"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{
|
||||
{"opus-4.6", "claude-opus-4-6", "Claude 4.6 Opus"},
|
||||
{"opus-4.6-thinking", "claude-opus-4-6-thinking", "Claude 4.6 Opus (Thinking)"},
|
||||
{"sonnet-4.6", "claude-sonnet-4-6", "Claude 4.6 Sonnet"},
|
||||
{"sonnet-4.6-thinking", "claude-sonnet-4-6-thinking", "Claude 4.6 Sonnet (Thinking)"},
|
||||
{"opus-4.5", "claude-opus-4-5", "Claude 4.5 Opus"},
|
||||
{"opus-4.5-thinking", "claude-opus-4-5-thinking", "Claude 4.5 Opus (Thinking)"},
|
||||
{"sonnet-4.5", "claude-sonnet-4-5", "Claude 4.5 Sonnet"},
|
||||
{"sonnet-4.5-thinking", "claude-sonnet-4-5-thinking", "Claude 4.5 Sonnet (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)?$`)
|
||||
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 {
|
||||
return AnthropicAlias{}, false
|
||||
}
|
||||
family := m[1]
|
||||
major := m[2]
|
||||
minor := m[3]
|
||||
thinking := m[4]
|
||||
|
||||
anthropicID := "claude-" + family + "-" + major + "-" + minor + thinking
|
||||
capFamily := strings.ToUpper(family[:1]) + family[1:]
|
||||
name := capFamily + " " + major + "." + minor
|
||||
if thinking == "-thinking" {
|
||||
name += " (Thinking)"
|
||||
}
|
||||
return AnthropicAlias{ID: anthropicID, Name: name}, true
|
||||
}
|
||||
|
||||
func reverseDynamicAlias(anthropicID string) (string, bool) {
|
||||
m := reverseDynamicPattern.FindStringSubmatch(anthropicID)
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
return m[1] + "-" + m[2] + "." + m[3] + m[4], true
|
||||
}
|
||||
|
||||
func ResolveToCursorModel(requested string) string {
|
||||
if mapped, ok := anthropicToCursor[requested]; ok {
|
||||
return mapped
|
||||
}
|
||||
if cursorID, ok := reverseDynamicAlias(requested); ok {
|
||||
return cursorID
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func GetAnthropicModelAliases(cursorIDs []string) []AnthropicAlias {
|
||||
result := make([]AnthropicAlias, 0, len(cursorToAnthropicAlias)+len(cursorIDs))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, a := range cursorToAnthropicAlias {
|
||||
result = append(result, AnthropicAlias{
|
||||
ID: a.AnthropicID,
|
||||
Name: a.Name,
|
||||
})
|
||||
seen[a.CursorID] = true
|
||||
}
|
||||
|
||||
for _, id := range cursorIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
if alias, ok := generateDynamicAlias(id); ok {
|
||||
result = append(result, alias)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
package env
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EnvSource map[string]string
|
||||
|
||||
type LoadedEnv struct {
|
||||
AgentBin string
|
||||
AgentNode string
|
||||
AgentScript string
|
||||
CommandShell string
|
||||
Host string
|
||||
Port int
|
||||
RequiredKey string
|
||||
DefaultModel string
|
||||
Provider string
|
||||
Force bool
|
||||
ApproveMcps bool
|
||||
StrictModel bool
|
||||
Workspace string
|
||||
TimeoutMs int
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
SessionsLogPath string
|
||||
ChatOnlyWorkspace bool
|
||||
Verbose bool
|
||||
MaxMode bool
|
||||
ConfigDirs []string
|
||||
MultiPort bool
|
||||
WinCmdlineMax int
|
||||
GeminiAccountDir string
|
||||
GeminiBrowserVisible bool
|
||||
GeminiMaxSessions int
|
||||
}
|
||||
|
||||
type AgentCommand struct {
|
||||
Command string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
WindowsVerbatimArguments bool
|
||||
AgentScriptPath string
|
||||
ConfigDir string
|
||||
}
|
||||
|
||||
func getEnvVal(e EnvSource, names []string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := e[name]; ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envBool(e EnvSource, names []string, def bool) bool {
|
||||
raw := getEnvVal(e, names)
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
switch strings.ToLower(raw) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(e EnvSource, names []string, def int) int {
|
||||
raw := getEnvVal(e, names)
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeModelId(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "auto"
|
||||
}
|
||||
parts := strings.Split(raw, "/")
|
||||
last := parts[len(parts)-1]
|
||||
if last == "" {
|
||||
return "auto"
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func resolveAbs(raw, cwd string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(raw) {
|
||||
return raw
|
||||
}
|
||||
return filepath.Join(cwd, raw)
|
||||
}
|
||||
|
||||
func isAuthenticatedAccountDir(dir string) bool {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "cli-config.json"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var cfg struct {
|
||||
AuthInfo *struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"authInfo"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
return cfg.AuthInfo != nil && cfg.AuthInfo.Email != ""
|
||||
}
|
||||
|
||||
func discoverAccountDirs(homeDir string) []string {
|
||||
if homeDir == "" {
|
||||
return nil
|
||||
}
|
||||
accountsDir := filepath.Join(homeDir, ".cursor-api-proxy", "accounts")
|
||||
entries, err := os.ReadDir(accountsDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var dirs []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(accountsDir, e.Name())
|
||||
if isAuthenticatedAccountDir(dir) {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func parseDotEnv(path string) EnvSource {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(EnvSource)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func OsEnvToMap(cwdHint ...string) EnvSource {
|
||||
m := make(EnvSource)
|
||||
for _, kv := range os.Environ() {
|
||||
parts := strings.SplitN(kv, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
m[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
cwd := ""
|
||||
if len(cwdHint) > 0 && cwdHint[0] != "" {
|
||||
cwd = cwdHint[0]
|
||||
} else {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
if dotenv := parseDotEnv(filepath.Join(cwd, ".env")); dotenv != nil {
|
||||
for k, v := range dotenv {
|
||||
if _, exists := m[k]; !exists {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func LoadEnvConfig(e EnvSource, cwd string) LoadedEnv {
|
||||
if e == nil {
|
||||
e = OsEnvToMap()
|
||||
}
|
||||
if cwd == "" {
|
||||
var err error
|
||||
cwd, err = os.Getwd()
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
}
|
||||
|
||||
host := getEnvVal(e, []string{"CURSOR_BRIDGE_HOST"})
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := envInt(e, []string{"CURSOR_BRIDGE_PORT"}, 8765)
|
||||
if port <= 0 {
|
||||
port = 8765
|
||||
}
|
||||
|
||||
home := getEnvVal(e, []string{"HOME", "USERPROFILE"})
|
||||
|
||||
sessionsLogPath := func() string {
|
||||
if p := resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_SESSIONS_LOG"}), cwd); p != "" {
|
||||
return p
|
||||
}
|
||||
if home != "" {
|
||||
return filepath.Join(home, ".cursor-api-proxy", "sessions.log")
|
||||
}
|
||||
return filepath.Join(cwd, "sessions.log")
|
||||
}()
|
||||
|
||||
var configDirs []string
|
||||
if raw := getEnvVal(e, []string{"CURSOR_CONFIG_DIRS", "CURSOR_ACCOUNT_DIRS"}); raw != "" {
|
||||
for _, d := range strings.Split(raw, ",") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
if p := resolveAbs(d, cwd); p != "" {
|
||||
configDirs = append(configDirs, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(configDirs) == 0 {
|
||||
configDirs = discoverAccountDirs(home)
|
||||
}
|
||||
|
||||
winMax := envInt(e, []string{"CURSOR_BRIDGE_WIN_CMDLINE_MAX"}, 30000)
|
||||
if winMax < 4096 {
|
||||
winMax = 4096
|
||||
}
|
||||
if winMax > 32700 {
|
||||
winMax = 32700
|
||||
}
|
||||
|
||||
agentBin := getEnvVal(e, []string{"CURSOR_AGENT_BIN", "CURSOR_CLI_BIN", "CURSOR_CLI_PATH"})
|
||||
if agentBin == "" {
|
||||
agentBin = "agent"
|
||||
}
|
||||
commandShell := getEnvVal(e, []string{"COMSPEC"})
|
||||
if commandShell == "" {
|
||||
commandShell = "cmd.exe"
|
||||
}
|
||||
workspace := resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_WORKSPACE"}), cwd)
|
||||
if workspace == "" {
|
||||
workspace = cwd
|
||||
}
|
||||
|
||||
geminiAccountDir := getEnvVal(e, []string{"GEMINI_ACCOUNT_DIR"})
|
||||
if geminiAccountDir == "" {
|
||||
geminiAccountDir = filepath.Join(home, ".cursor-api-proxy", "gemini-accounts")
|
||||
} else {
|
||||
geminiAccountDir = resolveAbs(geminiAccountDir, cwd)
|
||||
}
|
||||
|
||||
return LoadedEnv{
|
||||
AgentBin: agentBin,
|
||||
AgentNode: getEnvVal(e, []string{"CURSOR_AGENT_NODE"}),
|
||||
AgentScript: getEnvVal(e, []string{"CURSOR_AGENT_SCRIPT"}),
|
||||
CommandShell: commandShell,
|
||||
Host: host,
|
||||
Port: port,
|
||||
RequiredKey: getEnvVal(e, []string{"CURSOR_BRIDGE_API_KEY"}),
|
||||
DefaultModel: normalizeModelId(getEnvVal(e, []string{"CURSOR_BRIDGE_DEFAULT_MODEL"})),
|
||||
Provider: getEnvVal(e, []string{"CURSOR_BRIDGE_PROVIDER"}),
|
||||
Force: envBool(e, []string{"CURSOR_BRIDGE_FORCE"}, false),
|
||||
ApproveMcps: envBool(e, []string{"CURSOR_BRIDGE_APPROVE_MCPS"}, false),
|
||||
StrictModel: envBool(e, []string{"CURSOR_BRIDGE_STRICT_MODEL"}, true),
|
||||
Workspace: workspace,
|
||||
TimeoutMs: envInt(e, []string{"CURSOR_BRIDGE_TIMEOUT_MS"}, 300000),
|
||||
TLSCertPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_CERT"}), cwd),
|
||||
TLSKeyPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_KEY"}), cwd),
|
||||
SessionsLogPath: sessionsLogPath,
|
||||
ChatOnlyWorkspace: envBool(e, []string{"CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE"}, true),
|
||||
Verbose: envBool(e, []string{"CURSOR_BRIDGE_VERBOSE"}, false),
|
||||
MaxMode: envBool(e, []string{"CURSOR_BRIDGE_MAX_MODE"}, false),
|
||||
ConfigDirs: configDirs,
|
||||
MultiPort: envBool(e, []string{"CURSOR_BRIDGE_MULTI_PORT"}, false),
|
||||
WinCmdlineMax: winMax,
|
||||
GeminiAccountDir: geminiAccountDir,
|
||||
GeminiBrowserVisible: envBool(e, []string{"GEMINI_BROWSER_VISIBLE"}, false),
|
||||
GeminiMaxSessions: envInt(e, []string{"GEMINI_MAX_SESSIONS"}, 3),
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveAgentCommand(cmd string, args []string, e EnvSource, cwd string) AgentCommand {
|
||||
if e == nil {
|
||||
e = OsEnvToMap()
|
||||
}
|
||||
loaded := LoadEnvConfig(e, cwd)
|
||||
|
||||
cloneEnv := func() map[string]string {
|
||||
m := make(map[string]string, len(e))
|
||||
for k, v := range e {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if loaded.AgentNode != "" && loaded.AgentScript != "" {
|
||||
agentScriptPath := loaded.AgentScript
|
||||
if !filepath.IsAbs(agentScriptPath) {
|
||||
agentScriptPath = filepath.Join(cwd, agentScriptPath)
|
||||
}
|
||||
agentDir := filepath.Dir(agentScriptPath)
|
||||
configDir := filepath.Join(agentDir, "..", "data", "config")
|
||||
env2 := cloneEnv()
|
||||
env2["CURSOR_INVOKED_AS"] = "agent.cmd"
|
||||
ac := AgentCommand{
|
||||
Command: loaded.AgentNode,
|
||||
Args: append([]string{loaded.AgentScript}, args...),
|
||||
Env: env2,
|
||||
AgentScriptPath: agentScriptPath,
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "cli-config.json")); err == nil {
|
||||
ac.ConfigDir = configDir
|
||||
}
|
||||
return ac
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(cmd), ".cmd") {
|
||||
cmdResolved := cmd
|
||||
if !filepath.IsAbs(cmd) {
|
||||
cmdResolved = filepath.Join(cwd, cmd)
|
||||
}
|
||||
dir := filepath.Dir(cmdResolved)
|
||||
nodeBin := filepath.Join(dir, "node.exe")
|
||||
script := filepath.Join(dir, "index.js")
|
||||
if _, err1 := os.Stat(nodeBin); err1 == nil {
|
||||
if _, err2 := os.Stat(script); err2 == nil {
|
||||
configDir := filepath.Join(dir, "..", "data", "config")
|
||||
env2 := cloneEnv()
|
||||
env2["CURSOR_INVOKED_AS"] = "agent.cmd"
|
||||
ac := AgentCommand{
|
||||
Command: nodeBin,
|
||||
Args: append([]string{script}, args...),
|
||||
Env: env2,
|
||||
AgentScriptPath: script,
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "cli-config.json")); err == nil {
|
||||
ac.ConfigDir = configDir
|
||||
}
|
||||
return ac
|
||||
}
|
||||
}
|
||||
|
||||
quotedArgs := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
if strings.Contains(a, " ") {
|
||||
quotedArgs[i] = `"` + a + `"`
|
||||
} else {
|
||||
quotedArgs[i] = a
|
||||
}
|
||||
}
|
||||
cmdLine := `""` + cmd + `" ` + strings.Join(quotedArgs, " ") + `"`
|
||||
return AgentCommand{
|
||||
Command: loaded.CommandShell,
|
||||
Args: []string{"/d", "/s", "/c", cmdLine},
|
||||
Env: cloneEnv(),
|
||||
WindowsVerbatimArguments: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AgentCommand{Command: cmd, Args: args, Env: cloneEnv()}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package env
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadEnvConfigDefaults(t *testing.T) {
|
||||
e := EnvSource{}
|
||||
loaded := LoadEnvConfig(e, "/tmp")
|
||||
|
||||
if loaded.Host != "127.0.0.1" {
|
||||
t.Errorf("expected 127.0.0.1, got %s", loaded.Host)
|
||||
}
|
||||
if loaded.Port != 8765 {
|
||||
t.Errorf("expected 8765, got %d", loaded.Port)
|
||||
}
|
||||
if loaded.DefaultModel != "auto" {
|
||||
t.Errorf("expected auto, got %s", loaded.DefaultModel)
|
||||
}
|
||||
if loaded.AgentBin != "agent" {
|
||||
t.Errorf("expected agent, got %s", loaded.AgentBin)
|
||||
}
|
||||
if !loaded.StrictModel {
|
||||
t.Error("expected strictModel=true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvConfigOverride(t *testing.T) {
|
||||
e := EnvSource{
|
||||
"CURSOR_BRIDGE_HOST": "0.0.0.0",
|
||||
"CURSOR_BRIDGE_PORT": "9000",
|
||||
"CURSOR_BRIDGE_DEFAULT_MODEL": "gpt-4",
|
||||
"CURSOR_AGENT_BIN": "/usr/local/bin/agent",
|
||||
}
|
||||
loaded := LoadEnvConfig(e, "/tmp")
|
||||
|
||||
if loaded.Host != "0.0.0.0" {
|
||||
t.Errorf("expected 0.0.0.0, got %s", loaded.Host)
|
||||
}
|
||||
if loaded.Port != 9000 {
|
||||
t.Errorf("expected 9000, got %d", loaded.Port)
|
||||
}
|
||||
if loaded.DefaultModel != "gpt-4" {
|
||||
t.Errorf("expected gpt-4, got %s", loaded.DefaultModel)
|
||||
}
|
||||
if loaded.AgentBin != "/usr/local/bin/agent" {
|
||||
t.Errorf("expected /usr/local/bin/agent, got %s", loaded.AgentBin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"gpt-4", "gpt-4"},
|
||||
{"openai/gpt-4", "gpt-4"},
|
||||
{"", "auto"},
|
||||
{" ", "auto"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := normalizeModelId(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("normalizeModelId(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var bearerRe = regexp.MustCompile(`(?i)^Bearer\s+(.+)$`)
|
||||
|
||||
func ExtractBearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
m := bearerRe.FindStringSubmatch(h)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, body interface{}, extraHeaders map[string]string) {
|
||||
for k, v := range extraHeaders {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func WriteSSEHeaders(w http.ResponseWriter, extraHeaders map[string]string) {
|
||||
for k, v := range extraHeaders {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
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")
|
||||
w.WriteHeader(200)
|
||||
}
|
||||
|
||||
func ReadBody(r *http.Request) (string, error) {
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package httputil
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"Bearer mytoken123", "mytoken123"},
|
||||
{"bearer MYTOKEN", "MYTOKEN"},
|
||||
{"", ""},
|
||||
{"Basic abc", ""},
|
||||
{"Bearer ", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
got := ExtractBearerToken(req)
|
||||
if got != tc.want {
|
||||
t.Errorf("ExtractBearerToken(%q) = %q, want %q", tc.header, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
WriteJSON(w, 200, map[string]string{"ok": "true"}, nil)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if w.Header().Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONWithExtraHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
WriteJSON(w, 201, nil, map[string]string{"X-Custom": "value"})
|
||||
|
||||
if w.Header().Get("X-Custom") != "value" {
|
||||
t.Errorf("expected X-Custom=value, got %s", w.Header().Get("X-Custom"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
cReset = "\x1b[0m"
|
||||
cBold = "\x1b[1m"
|
||||
cDim = "\x1b[2m"
|
||||
cCyan = "\x1b[36m"
|
||||
cBCyan = "\x1b[1;96m"
|
||||
cGreen = "\x1b[32m"
|
||||
cBGreen = "\x1b[1;92m"
|
||||
cYellow = "\x1b[33m"
|
||||
cMagenta = "\x1b[35m"
|
||||
cBMagenta = "\x1b[1;95m"
|
||||
cRed = "\x1b[31m"
|
||||
cGray = "\x1b[90m"
|
||||
cWhite = "\x1b[97m"
|
||||
)
|
||||
|
||||
var roleStyle = map[string]string{
|
||||
"system": cYellow,
|
||||
"user": cCyan,
|
||||
"assistant": cGreen,
|
||||
}
|
||||
|
||||
var roleEmoji = map[string]string{
|
||||
"system": "🔧",
|
||||
"user": "👤",
|
||||
"assistant": "🤖",
|
||||
}
|
||||
|
||||
func ts() string {
|
||||
return cGray + time.Now().UTC().Format("15:04:05") + cReset
|
||||
}
|
||||
|
||||
func tsDate() string {
|
||||
return cGray + time.Now().UTC().Format("2006-01-02 15:04:05") + cReset
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
head := int(float64(max) * 0.6)
|
||||
tail := max - head
|
||||
omitted := len(s) - head - tail
|
||||
return s[:head] + fmt.Sprintf("%s … (%d chars omitted) … ", cDim, omitted) + s[len(s)-tail:] + cReset
|
||||
}
|
||||
|
||||
func hr(ch string, length int) string {
|
||||
return cGray + strings.Repeat(ch, length) + cReset
|
||||
}
|
||||
|
||||
type TrafficMessage struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
func LogDebug(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("%s %s[DEBUG]%s %s\n", ts(), cGray, cReset, msg)
|
||||
}
|
||||
|
||||
func LogServerStart(version, scheme, host string, port int, cfg config.BridgeConfig) {
|
||||
provider := cfg.Provider
|
||||
if provider == "" {
|
||||
provider = "cursor"
|
||||
}
|
||||
fmt.Printf("\n%s%s╔══════════════════════════════════════════╗%s\n", cBold, cBCyan, cReset)
|
||||
fmt.Printf("%s%s cursor-api-proxy %sv%s%s%s%s ready%s\n",
|
||||
cBold, cBCyan, cReset, cBold, cWhite, version, cBCyan, cReset)
|
||||
fmt.Printf("%s%s╚══════════════════════════════════════════╝%s\n\n", cBold, cBCyan, cReset)
|
||||
url := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
fmt.Printf(" %s●%s listening %s%s%s\n", cBGreen, cReset, cBold, url, cReset)
|
||||
fmt.Printf(" %s▸%s provider %s%s%s\n", cCyan, cReset, cBold, provider, cReset)
|
||||
fmt.Printf(" %s▸%s agent %s%s%s\n", cCyan, cReset, cDim, cfg.AgentBin, cReset)
|
||||
fmt.Printf(" %s▸%s workspace %s%s%s\n", cCyan, cReset, cDim, cfg.Workspace, cReset)
|
||||
fmt.Printf(" %s▸%s model %s%s%s\n", cCyan, cReset, cDim, cfg.DefaultModel, cReset)
|
||||
fmt.Printf(" %s▸%s mode %s%s%s\n", cCyan, cReset, cDim, cfg.Mode, cReset)
|
||||
fmt.Printf(" %s▸%s timeout %s%d ms%s\n", cCyan, cReset, cDim, cfg.TimeoutMs, cReset)
|
||||
|
||||
// 顯示 Gemini Web Provider 相關設定
|
||||
if provider == "gemini-web" {
|
||||
fmt.Printf(" %s▸%s gemini-dir %s%s%s\n", cCyan, cReset, cDim, cfg.GeminiAccountDir, cReset)
|
||||
fmt.Printf(" %s▸%s max-sess %s%d%s\n", cCyan, cReset, cDim, cfg.GeminiMaxSessions, cReset)
|
||||
}
|
||||
|
||||
flags := []string{}
|
||||
if cfg.Force {
|
||||
flags = append(flags, "force")
|
||||
}
|
||||
if cfg.ApproveMcps {
|
||||
flags = append(flags, "approve-mcps")
|
||||
}
|
||||
if cfg.MaxMode {
|
||||
flags = append(flags, "max-mode")
|
||||
}
|
||||
if cfg.Verbose {
|
||||
flags = append(flags, "verbose")
|
||||
}
|
||||
if cfg.ChatOnlyWorkspace {
|
||||
flags = append(flags, "chat-only")
|
||||
}
|
||||
if cfg.RequiredKey != "" {
|
||||
flags = append(flags, "api-key-required")
|
||||
}
|
||||
if len(flags) > 0 {
|
||||
fmt.Printf(" %s▸%s flags %s%s%s\n", cCyan, cReset, cYellow, strings.Join(flags, " · "), cReset)
|
||||
}
|
||||
if len(cfg.ConfigDirs) > 0 {
|
||||
fmt.Printf(" %s▸%s pool %s%d accounts%s\n", cCyan, cReset, cBGreen, len(cfg.ConfigDirs), cReset)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func LogShutdown(sig string) {
|
||||
fmt.Printf("\n%s %s⊘ %s received — shutting down gracefully…%s\n", tsDate(), cYellow, sig, cReset)
|
||||
}
|
||||
|
||||
func LogRequestStart(method, pathname, model string, timeoutMs int, isStream bool) {
|
||||
modeTag := fmt.Sprintf("%ssync%s", cDim, cReset)
|
||||
if isStream {
|
||||
modeTag = fmt.Sprintf("%s⚡ stream%s", cBCyan, cReset)
|
||||
}
|
||||
fmt.Printf("%s %s▶%s %s %s %s timeout:%dms %s\n",
|
||||
ts(), cBCyan, cReset, method, pathname, model, timeoutMs, modeTag)
|
||||
}
|
||||
|
||||
func LogRequestDone(method, pathname, model string, latencyMs int64, code int) {
|
||||
statusColor := cBGreen
|
||||
if code != 0 {
|
||||
statusColor = cRed
|
||||
}
|
||||
fmt.Printf("%s %s■%s %s %s %s %s%dms exit:%d%s\n",
|
||||
ts(), statusColor, cReset, method, pathname, model, cDim, latencyMs, code, cReset)
|
||||
}
|
||||
|
||||
func LogRequestTimeout(method, pathname, model string, timeoutMs int) {
|
||||
fmt.Printf("%s %s⏱%s %s %s %s %stimed-out after %dms%s\n",
|
||||
ts(), cRed, cReset, method, pathname, model, cRed, timeoutMs, cReset)
|
||||
}
|
||||
|
||||
func LogClientDisconnect(method, pathname, model string, latencyMs int64) {
|
||||
fmt.Printf("%s %s⚡%s %s %s %s %sclient disconnected after %dms%s\n",
|
||||
ts(), cYellow, cReset, method, pathname, model, cYellow, latencyMs, cReset)
|
||||
}
|
||||
|
||||
func LogStreamChunk(model string, text string, chunkNum int) {
|
||||
preview := truncate(strings.ReplaceAll(text, "\n", "↵ "), 120)
|
||||
fmt.Printf("%s %s▸%s #%d %s%s%s\n",
|
||||
ts(), cDim, cReset, chunkNum, cWhite, preview, cReset)
|
||||
}
|
||||
|
||||
func LogRawLine(line string) {
|
||||
preview := truncate(strings.ReplaceAll(line, "\n", "↵ "), 200)
|
||||
fmt.Printf("%s %s│%s %sraw%s %s\n",
|
||||
ts(), cGray, cReset, cDim, cReset, preview)
|
||||
}
|
||||
|
||||
func LogIncoming(method, pathname, remoteAddress string) {
|
||||
methodColor := cBCyan
|
||||
switch method {
|
||||
case "POST":
|
||||
methodColor = cBMagenta
|
||||
case "GET":
|
||||
methodColor = cBCyan
|
||||
case "DELETE":
|
||||
methodColor = cRed
|
||||
}
|
||||
fmt.Printf("%s %s%s%s%s %s%s%s %s(%s)%s\n",
|
||||
ts(),
|
||||
methodColor, cBold, method, cReset,
|
||||
cWhite, pathname, cReset,
|
||||
cDim, remoteAddress, cReset,
|
||||
)
|
||||
}
|
||||
|
||||
func LogAccountAssigned(configDir string) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
name := filepath.Base(configDir)
|
||||
fmt.Printf("%s %s→%s account %s%s%s\n", ts(), cBCyan, cReset, cBold, name, cReset)
|
||||
}
|
||||
|
||||
func LogAccountStats(verbose bool, stats []entity.AccountStat) {
|
||||
if !verbose || len(stats) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
fmt.Printf("%s┌─ Account Stats %s┐%s\n", cGray, strings.Repeat("─", 44), cReset)
|
||||
for _, s := range stats {
|
||||
name := fmt.Sprintf("%-20s", filepath.Base(s.ConfigDir))
|
||||
active := fmt.Sprintf("%sactive:0%s", cDim, cReset)
|
||||
if s.ActiveRequests > 0 {
|
||||
active = fmt.Sprintf("%sactive:%d%s", cBCyan, s.ActiveRequests, cReset)
|
||||
}
|
||||
total := fmt.Sprintf("total:%s%d%s", cBold, s.TotalRequests, cReset)
|
||||
ok := fmt.Sprintf("%sok:%d%s", cGreen, s.TotalSuccess, cReset)
|
||||
errStr := fmt.Sprintf("%serr:0%s", cDim, cReset)
|
||||
if s.TotalErrors > 0 {
|
||||
errStr = fmt.Sprintf("%serr:%d%s", cRed, s.TotalErrors, cReset)
|
||||
}
|
||||
rl := fmt.Sprintf("%srl:0%s", cDim, cReset)
|
||||
if s.TotalRateLimits > 0 {
|
||||
rl = fmt.Sprintf("%srl:%d%s", cYellow, s.TotalRateLimits, cReset)
|
||||
}
|
||||
avg := "avg:-"
|
||||
if s.TotalRequests > 0 {
|
||||
avg = fmt.Sprintf("avg:%dms", s.TotalLatencyMs/int64(s.TotalRequests))
|
||||
}
|
||||
status := fmt.Sprintf("%s✓%s", cGreen, cReset)
|
||||
if s.IsRateLimited {
|
||||
recovers := time.UnixMilli(s.RateLimitUntil).UTC().Format(time.RFC3339)
|
||||
_ = now
|
||||
status = fmt.Sprintf("%s⛔ rate-limited (recovers %s)%s", cRed, recovers, cReset)
|
||||
}
|
||||
fmt.Printf(" %s%s%s %s %s %s %s %s %s%s%s %s\n",
|
||||
cBold, name, cReset, active, total, ok, errStr, rl, cDim, avg, cReset, status)
|
||||
}
|
||||
fmt.Printf("%s└%s┘%s\n", cGray, strings.Repeat("─", 60), cReset)
|
||||
}
|
||||
|
||||
func LogTrafficRequest(verbose bool, model string, messages []TrafficMessage, isStream bool) {
|
||||
if !verbose {
|
||||
return
|
||||
}
|
||||
modeTag := fmt.Sprintf("%ssync%s", cDim, cReset)
|
||||
if isStream {
|
||||
modeTag = fmt.Sprintf("%s⚡ stream%s", cBCyan, cReset)
|
||||
}
|
||||
modelStr := fmt.Sprintf("%s✦ %s%s", cBMagenta, model, cReset)
|
||||
fmt.Println(hr("─", 60))
|
||||
fmt.Printf("%s 📤 %s%sREQUEST%s %s %s\n", ts(), cBCyan, cBold, cReset, modelStr, modeTag)
|
||||
for _, m := range messages {
|
||||
roleColor := cWhite
|
||||
if c, ok := roleStyle[m.Role]; ok {
|
||||
roleColor = c
|
||||
}
|
||||
emoji := "💬"
|
||||
if e, ok := roleEmoji[m.Role]; ok {
|
||||
emoji = e
|
||||
}
|
||||
label := fmt.Sprintf("%s%s[%s]%s", roleColor, cBold, m.Role, cReset)
|
||||
charCount := fmt.Sprintf("%s(%d chars)%s", cDim, len(m.Content), cReset)
|
||||
preview := truncate(strings.ReplaceAll(m.Content, "\n", "↵ "), 280)
|
||||
fmt.Printf(" %s %s %s\n", emoji, label, charCount)
|
||||
fmt.Printf(" %s%s%s\n", cDim, preview, cReset)
|
||||
}
|
||||
}
|
||||
|
||||
func LogTrafficResponse(verbose bool, model, text string, isStream bool) {
|
||||
if !verbose {
|
||||
return
|
||||
}
|
||||
modeTag := fmt.Sprintf("%ssync%s", cDim, cReset)
|
||||
if isStream {
|
||||
modeTag = fmt.Sprintf("%s⚡ stream%s", cBGreen, cReset)
|
||||
}
|
||||
modelStr := fmt.Sprintf("%s✦ %s%s", cBMagenta, model, cReset)
|
||||
charCount := fmt.Sprintf("%s%d%s%s chars%s", cBold, len(text), cReset, cDim, cReset)
|
||||
preview := truncate(strings.ReplaceAll(text, "\n", "↵ "), 480)
|
||||
fmt.Printf("%s 📥 %s%sRESPONSE%s %s %s %s\n", ts(), cBGreen, cBold, cReset, modelStr, modeTag, charCount)
|
||||
fmt.Printf(" 🤖 %s%s%s\n", cGreen, preview, cReset)
|
||||
fmt.Println(hr("─", 60))
|
||||
}
|
||||
|
||||
func AppendSessionLine(logPath, method, pathname, remoteAddress string, statusCode int) {
|
||||
line := fmt.Sprintf("%s %s %s %s %d\n", time.Now().UTC().Format(time.RFC3339), method, pathname, remoteAddress, statusCode)
|
||||
dir := filepath.Dir(logPath)
|
||||
if err := os.MkdirAll(dir, 0755); err == nil {
|
||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(line)
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func LogTruncation(originalLen, finalLen int) {
|
||||
fmt.Printf("%s %s⚠ prompt truncated%s %s(%d → %d chars, tail preserved)%s\n",
|
||||
ts(), cYellow, cReset, cDim, originalLen, finalLen, cReset)
|
||||
}
|
||||
|
||||
func LogAgentError(logPath, method, pathname, remoteAddress string, exitCode int, stderr string) string {
|
||||
errMsg := fmt.Sprintf("Cursor CLI failed (exit %d): %s", exitCode, strings.TrimSpace(stderr))
|
||||
fmt.Fprintf(os.Stderr, "%s %s✗ agent error%s %s%s%s\n", ts(), cRed, cReset, cDim, errMsg, cReset)
|
||||
truncated := strings.TrimSpace(stderr)
|
||||
if len(truncated) > 200 {
|
||||
truncated = truncated[:200]
|
||||
}
|
||||
truncated = strings.ReplaceAll(truncated, "\n", " ")
|
||||
line := fmt.Sprintf("%s ERROR %s %s %s agent_exit_%d %s\n",
|
||||
time.Now().UTC().Format(time.RFC3339), method, pathname, remoteAddress, exitCode, truncated)
|
||||
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
|
||||
_, _ = f.WriteString(line)
|
||||
f.Close()
|
||||
}
|
||||
return errMsg
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package parser
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type StreamParser func(line string)
|
||||
|
||||
type Parser struct {
|
||||
Parse StreamParser
|
||||
Flush func()
|
||||
}
|
||||
|
||||
// CreateStreamParser 建立串流解析器(向後相容,不傳遞 thinking)
|
||||
func CreateStreamParser(onText func(string), onDone func()) Parser {
|
||||
return CreateStreamParserWithThinking(onText, nil, onDone)
|
||||
}
|
||||
|
||||
// CreateStreamParserWithThinking 建立串流解析器,支援思考過程輸出。
|
||||
// onThinking 可為 nil,表示忽略思考過程。
|
||||
func CreateStreamParserWithThinking(onText func(string), onThinking func(string), onDone func()) Parser {
|
||||
// accumulated 是所有已輸出內容的串接
|
||||
accumulatedText := ""
|
||||
accumulatedThinking := ""
|
||||
done := false
|
||||
|
||||
parse := func(line string) {
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
var obj struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
Message *struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Type == "assistant" && obj.Message != nil {
|
||||
fullText := ""
|
||||
fullThinking := ""
|
||||
for _, p := range obj.Message.Content {
|
||||
switch p.Type {
|
||||
case "text":
|
||||
if p.Text != "" {
|
||||
fullText += p.Text
|
||||
}
|
||||
case "thinking":
|
||||
if p.Thinking != "" {
|
||||
fullThinking += p.Thinking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 處理思考過程(不因去重而 return,避免跳過同行的文字內容)
|
||||
if onThinking != nil && fullThinking != "" && fullThinking != accumulatedThinking {
|
||||
// 增量模式:新內容以 accumulated 為前綴
|
||||
if len(fullThinking) >= len(accumulatedThinking) && fullThinking[:len(accumulatedThinking)] == accumulatedThinking {
|
||||
delta := fullThinking[len(accumulatedThinking):]
|
||||
if delta != "" {
|
||||
onThinking(delta)
|
||||
}
|
||||
accumulatedThinking = fullThinking
|
||||
} else {
|
||||
// 獨立片段:直接輸出,但 accumulated 要串接
|
||||
onThinking(fullThinking)
|
||||
accumulatedThinking = accumulatedThinking + fullThinking
|
||||
}
|
||||
}
|
||||
|
||||
// 處理一般文字
|
||||
if fullText == "" || fullText == accumulatedText {
|
||||
return
|
||||
}
|
||||
// 增量模式:新內容以 accumulated 為前綴
|
||||
if len(fullText) >= len(accumulatedText) && fullText[:len(accumulatedText)] == accumulatedText {
|
||||
delta := fullText[len(accumulatedText):]
|
||||
if delta != "" {
|
||||
onText(delta)
|
||||
}
|
||||
accumulatedText = fullText
|
||||
} else {
|
||||
// 獨立片段:直接輸出,但 accumulated 要串接
|
||||
onText(fullText)
|
||||
accumulatedText = accumulatedText + fullText
|
||||
}
|
||||
}
|
||||
|
||||
if obj.Type == "result" && obj.Subtype == "success" {
|
||||
done = true
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
flush := func() {
|
||||
if !done {
|
||||
done = true
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
return Parser{Parse: parse, Flush: flush}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeAssistantLine(text string) string {
|
||||
obj := map[string]interface{}{
|
||||
"type": "assistant",
|
||||
"message": map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "text", "text": text},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func makeResultLine() string {
|
||||
b, _ := json.Marshal(map[string]string{"type": "result", "subtype": "success"})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestStreamParserFragmentMode(t *testing.T) {
|
||||
// cursor --stream-partial-output 模式:每個訊息是獨立 token fragment
|
||||
var texts []string
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
p.Parse(makeAssistantLine("你"))
|
||||
p.Parse(makeAssistantLine("好!有"))
|
||||
p.Parse(makeAssistantLine("什"))
|
||||
p.Parse(makeAssistantLine("麼"))
|
||||
|
||||
if len(texts) != 4 {
|
||||
t.Fatalf("expected 4 fragments, got %d: %v", len(texts), texts)
|
||||
}
|
||||
if texts[0] != "你" || texts[1] != "好!有" || texts[2] != "什" || texts[3] != "麼" {
|
||||
t.Fatalf("unexpected texts: %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserDeduplicatesFinalFullText(t *testing.T) {
|
||||
// 最後一個訊息是完整的累積文字,應被跳過(去重)
|
||||
var texts []string
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
p.Parse(makeAssistantLine("Hello"))
|
||||
p.Parse(makeAssistantLine(" world"))
|
||||
// 最後一個是完整累積文字,應被去重
|
||||
p.Parse(makeAssistantLine("Hello world"))
|
||||
|
||||
if len(texts) != 2 {
|
||||
t.Fatalf("expected 2 fragments (final full text deduplicated), got %d: %v", len(texts), texts)
|
||||
}
|
||||
if texts[0] != "Hello" || texts[1] != " world" {
|
||||
t.Fatalf("unexpected texts: %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserCallsOnDone(t *testing.T) {
|
||||
var texts []string
|
||||
doneCount := 0
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() { doneCount++ },
|
||||
)
|
||||
|
||||
p.Parse(makeResultLine())
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected onDone called once, got %d", doneCount)
|
||||
}
|
||||
if len(texts) != 0 {
|
||||
t.Fatalf("expected no text, got %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserIgnoresLinesAfterDone(t *testing.T) {
|
||||
var texts []string
|
||||
doneCount := 0
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() { doneCount++ },
|
||||
)
|
||||
|
||||
p.Parse(makeResultLine())
|
||||
p.Parse(makeAssistantLine("late"))
|
||||
if len(texts) != 0 {
|
||||
t.Fatalf("expected no text after done, got %v", texts)
|
||||
}
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected onDone called once, got %d", doneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserIgnoresNonAssistantLines(t *testing.T) {
|
||||
var texts []string
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
b1, _ := json.Marshal(map[string]interface{}{"type": "user", "message": map[string]interface{}{}})
|
||||
p.Parse(string(b1))
|
||||
b2, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "assistant",
|
||||
"message": map[string]interface{}{"content": []interface{}{}},
|
||||
})
|
||||
p.Parse(string(b2))
|
||||
p.Parse(`{"type":"assistant","message":{"content":[{"type":"code","text":"x"}]}}`)
|
||||
|
||||
if len(texts) != 0 {
|
||||
t.Fatalf("expected no texts, got %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserIgnoresParseErrors(t *testing.T) {
|
||||
var texts []string
|
||||
doneCount := 0
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() { doneCount++ },
|
||||
)
|
||||
|
||||
p.Parse("not json")
|
||||
p.Parse("{")
|
||||
p.Parse("")
|
||||
|
||||
if len(texts) != 0 || doneCount != 0 {
|
||||
t.Fatalf("expected nothing, got texts=%v done=%d", texts, doneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserJoinsMultipleTextParts(t *testing.T) {
|
||||
var texts []string
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
obj := map[string]interface{}{
|
||||
"type": "assistant",
|
||||
"message": map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "text", "text": " "},
|
||||
{"type": "text", "text": "world"},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
p.Parse(string(b))
|
||||
|
||||
if len(texts) != 1 || texts[0] != "Hello world" {
|
||||
t.Fatalf("expected ['Hello world'], got %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserFlushTriggersDone(t *testing.T) {
|
||||
var texts []string
|
||||
doneCount := 0
|
||||
p := CreateStreamParser(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func() { doneCount++ },
|
||||
)
|
||||
|
||||
p.Parse(makeAssistantLine("Hello"))
|
||||
// agent 結束但沒有 result/success,手動 flush
|
||||
p.Flush()
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected onDone called once after Flush, got %d", doneCount)
|
||||
}
|
||||
// 再 flush 不應重複觸發
|
||||
p.Flush()
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected onDone called only once, got %d", doneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserFlushAfterDoneIsNoop(t *testing.T) {
|
||||
doneCount := 0
|
||||
p := CreateStreamParser(
|
||||
func(text string) {},
|
||||
func() { doneCount++ },
|
||||
)
|
||||
|
||||
p.Parse(makeResultLine())
|
||||
p.Flush()
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected onDone called once, got %d", doneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func makeThinkingLine(thinking string) string {
|
||||
obj := map[string]interface{}{
|
||||
"type": "assistant",
|
||||
"message": map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "thinking", "thinking": thinking},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func makeThinkingAndTextLine(thinking, text string) string {
|
||||
obj := map[string]interface{}{
|
||||
"type": "assistant",
|
||||
"message": map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "thinking", "thinking": thinking},
|
||||
{"type": "text", "text": text},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestStreamParserWithThinkingCallsOnThinking(t *testing.T) {
|
||||
var texts []string
|
||||
var thinkings []string
|
||||
p := CreateStreamParserWithThinking(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func(thinking string) { thinkings = append(thinkings, thinking) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
p.Parse(makeThinkingLine("思考中..."))
|
||||
p.Parse(makeAssistantLine("回答"))
|
||||
|
||||
if len(thinkings) != 1 || thinkings[0] != "思考中..." {
|
||||
t.Fatalf("expected thinkings=['思考中...'], got %v", thinkings)
|
||||
}
|
||||
if len(texts) != 1 || texts[0] != "回答" {
|
||||
t.Fatalf("expected texts=['回答'], got %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserWithThinkingNilOnThinkingIgnoresThinking(t *testing.T) {
|
||||
var texts []string
|
||||
p := CreateStreamParserWithThinking(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
nil,
|
||||
func() {},
|
||||
)
|
||||
|
||||
p.Parse(makeThinkingLine("忽略的思考"))
|
||||
p.Parse(makeAssistantLine("文字"))
|
||||
|
||||
if len(texts) != 1 || texts[0] != "文字" {
|
||||
t.Fatalf("expected texts=['文字'], got %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParserWithThinkingDeduplication(t *testing.T) {
|
||||
var thinkings []string
|
||||
p := CreateStreamParserWithThinking(
|
||||
func(text string) {},
|
||||
func(thinking string) { thinkings = append(thinkings, thinking) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
p.Parse(makeThinkingLine("A"))
|
||||
p.Parse(makeThinkingLine("B"))
|
||||
// 重複的完整思考,應被跳過
|
||||
p.Parse(makeThinkingLine("AB"))
|
||||
|
||||
if len(thinkings) != 2 || thinkings[0] != "A" || thinkings[1] != "B" {
|
||||
t.Fatalf("expected thinkings=['A','B'], got %v", thinkings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamParserThinkingDuplicateButTextStillEmitted 驗證 bug 修復:
|
||||
// 當 thinking 重複(去重跳過)但同一行有 text 時,text 仍必須輸出。
|
||||
func TestStreamParserThinkingDuplicateButTextStillEmitted(t *testing.T) {
|
||||
var texts []string
|
||||
var thinkings []string
|
||||
p := CreateStreamParserWithThinking(
|
||||
func(text string) { texts = append(texts, text) },
|
||||
func(thinking string) { thinkings = append(thinkings, thinking) },
|
||||
func() {},
|
||||
)
|
||||
|
||||
// 第一行:thinking="思考中" + text(thinking 為新增,兩者都應輸出)
|
||||
p.Parse(makeThinkingAndTextLine("思考中", "第一段"))
|
||||
// 第二行:thinking 與上一行相同(去重),但 text 是新的,text 仍應輸出
|
||||
p.Parse(makeThinkingAndTextLine("思考中", "第二段"))
|
||||
|
||||
if len(thinkings) != 1 || thinkings[0] != "思考中" {
|
||||
t.Fatalf("expected thinkings=['思考中'], got %v", thinkings)
|
||||
}
|
||||
if len(texts) != 2 || texts[0] != "第一段" || texts[1] != "第二段" {
|
||||
t.Fatalf("expected texts=['第一段','第二段'], got %v", texts)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
//go:build !windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func killProcessGroup(c *exec.Cmd) error {
|
||||
if c.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// 殺死整個 process group(負號表示 group)
|
||||
pgid, err := syscall.Getpgid(c.Process.Pid)
|
||||
if err == nil {
|
||||
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
||||
}
|
||||
// 同時也 kill 主程序,以防萬一
|
||||
return c.Process.Kill()
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
//go:build windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func killProcessGroup(c *exec.Cmd) error {
|
||||
if c.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Process.Kill()
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
package process_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/pkg/infrastructure/process"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sh 是跨平台 shell 執行小 script 的輔助函式
|
||||
func sh(t *testing.T, script string, opts process.RunOptions) (process.RunResult, error) {
|
||||
t.Helper()
|
||||
return process.Run("sh", []string{"-c", script}, opts)
|
||||
}
|
||||
|
||||
func TestRun_StdoutAndStderr(t *testing.T) {
|
||||
result, err := sh(t, "echo hello; echo world >&2", process.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
t.Errorf("Code = %d, want 0", result.Code)
|
||||
}
|
||||
if result.Stdout != "hello\n" {
|
||||
t.Errorf("Stdout = %q, want %q", result.Stdout, "hello\n")
|
||||
}
|
||||
if result.Stderr != "world\n" {
|
||||
t.Errorf("Stderr = %q, want %q", result.Stderr, "world\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_BasicSpawn(t *testing.T) {
|
||||
result, err := sh(t, "printf ok", process.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
t.Errorf("Code = %d, want 0", result.Code)
|
||||
}
|
||||
if result.Stdout != "ok" {
|
||||
t.Errorf("Stdout = %q, want ok", result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ConfigDir_Propagated(t *testing.T) {
|
||||
result, err := process.Run("sh", []string{"-c", `printf "$CURSOR_CONFIG_DIR"`},
|
||||
process.RunOptions{ConfigDir: "/test/account/dir"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Stdout != "/test/account/dir" {
|
||||
t.Errorf("Stdout = %q, want /test/account/dir", result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ConfigDir_Absent(t *testing.T) {
|
||||
// 確保沒有殘留的環境變數
|
||||
_ = os.Unsetenv("CURSOR_CONFIG_DIR")
|
||||
result, err := process.Run("sh", []string{"-c", `printf "${CURSOR_CONFIG_DIR:-unset}"`},
|
||||
process.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Stdout != "unset" {
|
||||
t.Errorf("Stdout = %q, want unset", result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NonZeroExit(t *testing.T) {
|
||||
result, err := sh(t, "exit 42", process.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != 42 {
|
||||
t.Errorf("Code = %d, want 42", result.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_Timeout(t *testing.T) {
|
||||
start := time.Now()
|
||||
result, err := sh(t, "sleep 30", process.RunOptions{TimeoutMs: 300})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code == 0 {
|
||||
t.Error("expected non-zero exit code after timeout")
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("elapsed %v, want < 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_OnLine(t *testing.T) {
|
||||
var lines []string
|
||||
result, err := process.RunStreaming("sh", []string{"-c", "printf 'a\nb\nc\n'"},
|
||||
process.RunStreamingOptions{
|
||||
OnLine: func(line string) { lines = append(lines, line) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
t.Errorf("Code = %d, want 0", result.Code)
|
||||
}
|
||||
if len(lines) != 3 {
|
||||
t.Errorf("got %d lines, want 3: %v", len(lines), lines)
|
||||
}
|
||||
if lines[0] != "a" || lines[1] != "b" || lines[2] != "c" {
|
||||
t.Errorf("lines = %v, want [a b c]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_FlushFinalLine(t *testing.T) {
|
||||
var lines []string
|
||||
result, err := process.RunStreaming("sh", []string{"-c", "printf tail"},
|
||||
process.RunStreamingOptions{
|
||||
OnLine: func(line string) { lines = append(lines, line) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
t.Errorf("Code = %d, want 0", result.Code)
|
||||
}
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("got %d lines, want 1: %v", len(lines), lines)
|
||||
}
|
||||
if lines[0] != "tail" {
|
||||
t.Errorf("lines[0] = %q, want tail", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_ConfigDir(t *testing.T) {
|
||||
var lines []string
|
||||
_, err := process.RunStreaming("sh", []string{"-c", `printf "$CURSOR_CONFIG_DIR"`},
|
||||
process.RunStreamingOptions{
|
||||
RunOptions: process.RunOptions{ConfigDir: "/my/config/dir"},
|
||||
OnLine: func(line string) { lines = append(lines, line) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 1 || lines[0] != "/my/config/dir" {
|
||||
t.Errorf("lines = %v, want [/my/config/dir]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_Stderr(t *testing.T) {
|
||||
result, err := process.RunStreaming("sh", []string{"-c", "echo err-output >&2"},
|
||||
process.RunStreamingOptions{OnLine: func(string) {}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Stderr == "" {
|
||||
t.Error("expected stderr to contain output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_Timeout(t *testing.T) {
|
||||
start := time.Now()
|
||||
result, err := process.RunStreaming("sh", []string{"-c", "sleep 30"},
|
||||
process.RunStreamingOptions{
|
||||
RunOptions: process.RunOptions{TimeoutMs: 300},
|
||||
OnLine: func(string) {},
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code == 0 {
|
||||
t.Error("expected non-zero exit code after timeout")
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("elapsed %v, want < 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_Concurrent(t *testing.T) {
|
||||
var lines1, lines2 []string
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
run := func(label string, target *[]string) {
|
||||
process.RunStreaming("sh", []string{"-c", "printf '" + label + "'"},
|
||||
process.RunStreamingOptions{
|
||||
OnLine: func(line string) { *target = append(*target, line) },
|
||||
})
|
||||
done <- struct{}{}
|
||||
}
|
||||
|
||||
go run("stream1", &lines1)
|
||||
go run("stream2", &lines2)
|
||||
|
||||
<-done
|
||||
<-done
|
||||
|
||||
if len(lines1) != 1 || lines1[0] != "stream1" {
|
||||
t.Errorf("lines1 = %v, want [stream1]", lines1)
|
||||
}
|
||||
if len(lines2) != 1 || lines2[0] != "stream2" {
|
||||
t.Errorf("lines2 = %v, want [stream2]", lines2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreaming_ContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
start := time.Now()
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
process.RunStreaming("sh", []string{"-c", "sleep 30"},
|
||||
process.RunStreamingOptions{
|
||||
RunOptions: process.RunOptions{Ctx: ctx},
|
||||
OnLine: func(string) {},
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.AfterFunc(100*time.Millisecond, cancel)
|
||||
<-done
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("elapsed %v, want < 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
start := time.Now()
|
||||
done := make(chan process.RunResult, 1)
|
||||
|
||||
go func() {
|
||||
r, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{Ctx: ctx})
|
||||
done <- r
|
||||
}()
|
||||
|
||||
time.AfterFunc(100*time.Millisecond, cancel)
|
||||
result := <-done
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if result.Code == 0 {
|
||||
t.Error("expected non-zero exit code after cancel")
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("elapsed %v, want < 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_AlreadyCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 已取消
|
||||
|
||||
start := time.Now()
|
||||
result, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{Ctx: ctx})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if result.Code == 0 {
|
||||
t.Error("expected non-zero exit code")
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("elapsed %v, want < 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillAllChildProcesses(t *testing.T) {
|
||||
done := make(chan process.RunResult, 1)
|
||||
go func() {
|
||||
r, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{})
|
||||
done <- r
|
||||
}()
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
process.KillAllChildProcesses()
|
||||
result := <-done
|
||||
|
||||
if result.Code == 0 {
|
||||
t.Error("expected non-zero exit code after kill")
|
||||
}
|
||||
// 再次呼叫不應 panic
|
||||
process.KillAllChildProcesses()
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunResult struct {
|
||||
Code int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
type RunOptions struct {
|
||||
Cwd string
|
||||
TimeoutMs int
|
||||
MaxMode bool
|
||||
ConfigDir string
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
type RunStreamingOptions struct {
|
||||
RunOptions
|
||||
OnLine func(line string)
|
||||
}
|
||||
|
||||
// ─── Global child process registry ──────────────────────────────────────────
|
||||
|
||||
var (
|
||||
activeMu sync.Mutex
|
||||
activeChildren []*exec.Cmd
|
||||
)
|
||||
|
||||
func registerChild(c *exec.Cmd) {
|
||||
activeMu.Lock()
|
||||
activeChildren = append(activeChildren, c)
|
||||
activeMu.Unlock()
|
||||
}
|
||||
|
||||
func unregisterChild(c *exec.Cmd) {
|
||||
activeMu.Lock()
|
||||
for i, ch := range activeChildren {
|
||||
if ch == c {
|
||||
activeChildren = append(activeChildren[:i], activeChildren[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
activeMu.Unlock()
|
||||
}
|
||||
|
||||
func KillAllChildProcesses() {
|
||||
activeMu.Lock()
|
||||
all := make([]*exec.Cmd, len(activeChildren))
|
||||
copy(all, activeChildren)
|
||||
activeChildren = nil
|
||||
activeMu.Unlock()
|
||||
for _, c := range all {
|
||||
killProcessGroup(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spawn ────────────────────────────────────────────────────────────────
|
||||
|
||||
func spawnChild(cmdStr string, args []string, opts *RunOptions, maxModeFn func(scriptPath, configDir string)) *exec.Cmd {
|
||||
envSrc := env.OsEnvToMap()
|
||||
resolved := env.ResolveAgentCommand(cmdStr, args, envSrc, opts.Cwd)
|
||||
|
||||
if opts.MaxMode && maxModeFn != nil {
|
||||
maxModeFn(resolved.AgentScriptPath, opts.ConfigDir)
|
||||
}
|
||||
|
||||
envMap := make(map[string]string, len(resolved.Env))
|
||||
for k, v := range resolved.Env {
|
||||
envMap[k] = v
|
||||
}
|
||||
if opts.ConfigDir != "" {
|
||||
envMap["CURSOR_CONFIG_DIR"] = opts.ConfigDir
|
||||
} else if resolved.ConfigDir != "" {
|
||||
if _, exists := envMap["CURSOR_CONFIG_DIR"]; !exists {
|
||||
envMap["CURSOR_CONFIG_DIR"] = resolved.ConfigDir
|
||||
}
|
||||
}
|
||||
|
||||
envSlice := make([]string, 0, len(envMap))
|
||||
for k, v := range envMap {
|
||||
envSlice = append(envSlice, k+"="+v)
|
||||
}
|
||||
|
||||
ctx := opts.Ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// 使用 WaitDelay 確保 context cancel 後子程序 goroutine 能及時退出
|
||||
c := exec.CommandContext(ctx, resolved.Command, resolved.Args...)
|
||||
c.Dir = opts.Cwd
|
||||
c.Env = envSlice
|
||||
// 設定新的 process group,使 kill 能傳遞給所有子孫程序
|
||||
c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// WaitDelay:context cancel 後額外等待這麼久再強制關閉 pipes
|
||||
c.WaitDelay = 5 * time.Second
|
||||
// Cancel 函式:殺死整個 process group
|
||||
c.Cancel = func() error {
|
||||
return killProcessGroup(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// MaxModeFn is set by the agent package to avoid import cycle.
|
||||
var MaxModeFn func(agentScriptPath, configDir string)
|
||||
|
||||
func Run(cmdStr string, args []string, opts RunOptions) (RunResult, error) {
|
||||
ctx := opts.Ctx
|
||||
var cancel context.CancelFunc
|
||||
if opts.TimeoutMs > 0 {
|
||||
if ctx == nil {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond)
|
||||
} else {
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond)
|
||||
}
|
||||
defer cancel()
|
||||
opts.Ctx = ctx
|
||||
} else if ctx == nil {
|
||||
opts.Ctx = context.Background()
|
||||
}
|
||||
|
||||
c := spawnChild(cmdStr, args, &opts, MaxModeFn)
|
||||
var stdoutBuf, stderrBuf strings.Builder
|
||||
c.Stdout = &stdoutBuf
|
||||
c.Stderr = &stderrBuf
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
// context 已取消或命令找不到時
|
||||
if opts.Ctx != nil && opts.Ctx.Err() != nil {
|
||||
return RunResult{Code: -1}, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "exec: ") || strings.Contains(err.Error(), "no such file") {
|
||||
return RunResult{}, fmt.Errorf("command not found: %s. Install Cursor CLI (agent) or set CURSOR_AGENT_BIN to its path", cmdStr)
|
||||
}
|
||||
return RunResult{}, err
|
||||
}
|
||||
registerChild(c)
|
||||
defer unregisterChild(c)
|
||||
|
||||
err := c.Wait()
|
||||
code := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
if code == 0 {
|
||||
code = -1
|
||||
}
|
||||
} else {
|
||||
// context cancelled or killed — return -1 but no error
|
||||
return RunResult{Code: -1, Stdout: stdoutBuf.String(), Stderr: stderrBuf.String()}, nil
|
||||
}
|
||||
}
|
||||
return RunResult{
|
||||
Code: code,
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type StreamResult struct {
|
||||
Code int
|
||||
Stderr string
|
||||
}
|
||||
|
||||
func RunStreaming(cmdStr string, args []string, opts RunStreamingOptions) (StreamResult, error) {
|
||||
ctx := opts.Ctx
|
||||
var cancel context.CancelFunc
|
||||
if opts.TimeoutMs > 0 {
|
||||
if ctx == nil {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond)
|
||||
} else {
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond)
|
||||
}
|
||||
defer cancel()
|
||||
opts.RunOptions.Ctx = ctx
|
||||
} else if opts.RunOptions.Ctx == nil {
|
||||
opts.RunOptions.Ctx = context.Background()
|
||||
}
|
||||
|
||||
c := spawnChild(cmdStr, args, &opts.RunOptions, MaxModeFn)
|
||||
stdoutPipe, err := c.StdoutPipe()
|
||||
if err != nil {
|
||||
return StreamResult{}, err
|
||||
}
|
||||
stderrPipe, err := c.StderrPipe()
|
||||
if err != nil {
|
||||
return StreamResult{}, err
|
||||
}
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
if strings.Contains(err.Error(), "exec: ") || strings.Contains(err.Error(), "no such file") {
|
||||
return StreamResult{}, fmt.Errorf("command not found: %s. Install Cursor CLI (agent) or set CURSOR_AGENT_BIN to its path", cmdStr)
|
||||
}
|
||||
return StreamResult{}, err
|
||||
}
|
||||
registerChild(c)
|
||||
defer unregisterChild(c)
|
||||
|
||||
var stderrBuf strings.Builder
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(stdoutPipe)
|
||||
scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) != "" {
|
||||
opts.OnLine(line)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(stderrPipe)
|
||||
scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024)
|
||||
for scanner.Scan() {
|
||||
stderrBuf.WriteString(scanner.Text())
|
||||
stderrBuf.WriteString("\n")
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
err = c.Wait()
|
||||
code := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
if code == 0 {
|
||||
code = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return StreamResult{Code: code, Stderr: stderrBuf.String()}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package winlimit
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/pkg/infrastructure/env"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const WinPromptOmissionPrefix = "[Earlier messages omitted: Windows command-line length limit.]\n\n"
|
||||
const LinuxPromptOmissionPrefix = "[Earlier messages omitted: Linux ARG_MAX command-line length limit.]\n\n"
|
||||
|
||||
// safeLinuxArgMax returns a conservative estimate of ARG_MAX on Linux.
|
||||
// The actual limit is typically 2MB; we use 1.5MB to leave room for env vars.
|
||||
func safeLinuxArgMax() int {
|
||||
return 1536 * 1024
|
||||
}
|
||||
|
||||
type FitPromptResult struct {
|
||||
OK bool
|
||||
Args []string
|
||||
Truncated bool
|
||||
OriginalLength int
|
||||
FinalPromptLength int
|
||||
Error string
|
||||
}
|
||||
|
||||
func estimateCmdlineLength(resolved env.AgentCommand) int {
|
||||
argv := append([]string{resolved.Command}, resolved.Args...)
|
||||
if resolved.WindowsVerbatimArguments {
|
||||
n := 0
|
||||
for _, a := range argv {
|
||||
n += len(a)
|
||||
}
|
||||
if len(argv) > 1 {
|
||||
n += len(argv) - 1
|
||||
}
|
||||
return n + 512
|
||||
}
|
||||
dstLen := 0
|
||||
for _, a := range argv {
|
||||
dstLen += len(a)
|
||||
}
|
||||
dstLen = dstLen*2 + len(argv)*2
|
||||
if len(argv) > 1 {
|
||||
dstLen += len(argv) - 1
|
||||
}
|
||||
return dstLen + 512
|
||||
}
|
||||
|
||||
func FitPromptToWinCmdline(agentBin string, fixedArgs []string, prompt string, maxCmdline int, cwd string) FitPromptResult {
|
||||
if runtime.GOOS != "windows" {
|
||||
return fitPromptLinux(fixedArgs, prompt)
|
||||
}
|
||||
|
||||
e := env.OsEnvToMap()
|
||||
measured := func(p string) int {
|
||||
args := make([]string, len(fixedArgs)+1)
|
||||
copy(args, fixedArgs)
|
||||
args[len(fixedArgs)] = p
|
||||
resolved := env.ResolveAgentCommand(agentBin, args, e, cwd)
|
||||
return estimateCmdlineLength(resolved)
|
||||
}
|
||||
|
||||
if measured("") > maxCmdline {
|
||||
return FitPromptResult{
|
||||
OK: false,
|
||||
Error: "Windows command line exceeds the configured limit even without a prompt; shorten workspace path, model id, or CURSOR_BRIDGE_WIN_CMDLINE_MAX.",
|
||||
}
|
||||
}
|
||||
|
||||
if measured(prompt) <= maxCmdline {
|
||||
args := make([]string, len(fixedArgs)+1)
|
||||
copy(args, fixedArgs)
|
||||
args[len(fixedArgs)] = prompt
|
||||
return FitPromptResult{
|
||||
OK: true,
|
||||
Args: args,
|
||||
Truncated: false,
|
||||
OriginalLength: len(prompt),
|
||||
FinalPromptLength: len(prompt),
|
||||
}
|
||||
}
|
||||
|
||||
prefix := WinPromptOmissionPrefix
|
||||
if measured(prefix) > maxCmdline {
|
||||
return FitPromptResult{
|
||||
OK: false,
|
||||
Error: "Windows command line too long to fit even the truncation notice; shorten workspace path or flags.",
|
||||
}
|
||||
}
|
||||
|
||||
lo, hi, best := 0, len(prompt), 0
|
||||
for lo <= hi {
|
||||
mid := (lo + hi) / 2
|
||||
var tail string
|
||||
if mid > 0 {
|
||||
tail = prompt[len(prompt)-mid:]
|
||||
}
|
||||
candidate := prefix + tail
|
||||
if measured(candidate) <= maxCmdline {
|
||||
best = mid
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
var finalPrompt string
|
||||
if best == 0 {
|
||||
finalPrompt = prefix
|
||||
} else {
|
||||
finalPrompt = prefix + prompt[len(prompt)-best:]
|
||||
}
|
||||
|
||||
args := make([]string, len(fixedArgs)+1)
|
||||
copy(args, fixedArgs)
|
||||
args[len(fixedArgs)] = finalPrompt
|
||||
return FitPromptResult{
|
||||
OK: true,
|
||||
Args: args,
|
||||
Truncated: true,
|
||||
OriginalLength: len(prompt),
|
||||
FinalPromptLength: len(finalPrompt),
|
||||
}
|
||||
}
|
||||
|
||||
// fitPromptLinux handles Linux ARG_MAX truncation.
|
||||
func fitPromptLinux(fixedArgs []string, prompt string) FitPromptResult {
|
||||
argMax := safeLinuxArgMax()
|
||||
|
||||
// Estimate total cmdline size: sum of all fixed args + prompt + null terminators
|
||||
fixedLen := 0
|
||||
for _, a := range fixedArgs {
|
||||
fixedLen += len(a) + 1
|
||||
}
|
||||
totalLen := fixedLen + len(prompt) + 1
|
||||
|
||||
if totalLen <= argMax {
|
||||
args := make([]string, len(fixedArgs)+1)
|
||||
copy(args, fixedArgs)
|
||||
args[len(fixedArgs)] = prompt
|
||||
return FitPromptResult{
|
||||
OK: true,
|
||||
Args: args,
|
||||
Truncated: false,
|
||||
OriginalLength: len(prompt),
|
||||
FinalPromptLength: len(prompt),
|
||||
}
|
||||
}
|
||||
|
||||
// Need to truncate: keep the tail of the prompt (most recent messages)
|
||||
prefix := LinuxPromptOmissionPrefix
|
||||
available := argMax - fixedLen - len(prefix) - 1
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
|
||||
var finalPrompt string
|
||||
if available <= 0 {
|
||||
finalPrompt = prefix
|
||||
} else if available >= len(prompt) {
|
||||
finalPrompt = prefix + prompt
|
||||
} else {
|
||||
finalPrompt = prefix + prompt[len(prompt)-available:]
|
||||
}
|
||||
|
||||
args := make([]string, len(fixedArgs)+1)
|
||||
copy(args, fixedArgs)
|
||||
args[len(fixedArgs)] = finalPrompt
|
||||
return FitPromptResult{
|
||||
OK: true,
|
||||
Args: args,
|
||||
Truncated: true,
|
||||
OriginalLength: len(prompt),
|
||||
FinalPromptLength: len(finalPrompt),
|
||||
}
|
||||
}
|
||||
|
||||
func WarnPromptTruncated(originalLength, finalLength int) {
|
||||
_ = originalLength
|
||||
_ = finalLength
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package winlimit
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNonWindowsPassThrough(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping non-Windows test on Windows")
|
||||
}
|
||||
|
||||
fixedArgs := []string{"--print", "--model", "gpt-4"}
|
||||
prompt := "Hello world"
|
||||
result := FitPromptToWinCmdline("agent", fixedArgs, prompt, 30000, "/tmp")
|
||||
|
||||
if !result.OK {
|
||||
t.Fatalf("expected OK=true on non-Windows, got error: %s", result.Error)
|
||||
}
|
||||
if result.Truncated {
|
||||
t.Error("expected no truncation on non-Windows")
|
||||
}
|
||||
if result.OriginalLength != len(prompt) {
|
||||
t.Errorf("expected original length %d, got %d", len(prompt), result.OriginalLength)
|
||||
}
|
||||
// Last arg should be the prompt
|
||||
if len(result.Args) == 0 || result.Args[len(result.Args)-1] != prompt {
|
||||
t.Errorf("expected last arg to be prompt, got %v", result.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOmissionPrefix(t *testing.T) {
|
||||
if !strings.Contains(WinPromptOmissionPrefix, "Earlier messages omitted") {
|
||||
t.Errorf("omission prefix should mention earlier messages, got: %q", WinPromptOmissionPrefix)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package workspace
|
||||
|
||||
import (
|
||||
"cursor-api-proxy/internal/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WorkspaceResult struct {
|
||||
WorkspaceDir string
|
||||
TempDir string
|
||||
}
|
||||
|
||||
func ResolveWorkspace(cfg config.BridgeConfig, workspaceHeader string) WorkspaceResult {
|
||||
if cfg.ChatOnlyWorkspace {
|
||||
tempDir, err := os.MkdirTemp("", "cursor-proxy-")
|
||||
if err != nil {
|
||||
tempDir = filepath.Join(os.TempDir(), "cursor-proxy-fallback")
|
||||
_ = os.MkdirAll(tempDir, 0700)
|
||||
}
|
||||
return WorkspaceResult{WorkspaceDir: tempDir, TempDir: tempDir}
|
||||
}
|
||||
|
||||
headerWs := strings.TrimSpace(workspaceHeader)
|
||||
if headerWs != "" {
|
||||
return WorkspaceResult{WorkspaceDir: headerWs}
|
||||
}
|
||||
return WorkspaceResult{WorkspaceDir: cfg.Workspace}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
"cursor-api-proxy/internal/config"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
cfg config.BridgeConfig
|
||||
}
|
||||
|
||||
func NewProvider(cfg config.BridgeConfig) *Provider {
|
||||
return &Provider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string {
|
||||
return "cursor"
|
||||
}
|
||||
|
||||
func (p *Provider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package geminiweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
type Browser struct {
|
||||
browser *rod.Browser
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewBrowser(visible bool) (*Browser, error) {
|
||||
l := launcher.New()
|
||||
if visible {
|
||||
l = l.Headless(false)
|
||||
} else {
|
||||
l = l.Headless(true)
|
||||
}
|
||||
|
||||
url, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
|
||||
b := rod.New().ControlURL(url)
|
||||
if err := b.Connect(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect browser: %w", err)
|
||||
}
|
||||
|
||||
return &Browser{browser: b, visible: visible}, nil
|
||||
}
|
||||
|
||||
func (b *Browser) Close() error {
|
||||
if b.browser != nil {
|
||||
return b.browser.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Browser) NewPage() (*rod.Page, error) {
|
||||
return b.browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
|
||||
}
|
||||
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
Expires float64 `json:"expires"`
|
||||
HTTPOnly bool `json:"httpOnly"`
|
||||
Secure bool `json:"secure"`
|
||||
}
|
||||
|
||||
func LoadCookiesFromFile(cookieFile string) ([]Cookie, error) {
|
||||
data, err := os.ReadFile(cookieFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read cookies: %w", err)
|
||||
}
|
||||
|
||||
var cookies []Cookie
|
||||
if err := json.Unmarshal(data, &cookies); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse cookies: %w", err)
|
||||
}
|
||||
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
func SaveCookiesToFile(cookies []Cookie, cookieFile string) error {
|
||||
data, err := json.MarshalIndent(cookies, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal cookies: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(cookieFile)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cookie dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cookieFile, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write cookies: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetCookiesOnPage(page *rod.Page, cookies []Cookie) error {
|
||||
var protoCookies []*proto.NetworkCookieParam
|
||||
for _, c := range cookies {
|
||||
p := &proto.NetworkCookieParam{
|
||||
Name: c.Name,
|
||||
Value: c.Value,
|
||||
Domain: c.Domain,
|
||||
Path: c.Path,
|
||||
HTTPOnly: c.HTTPOnly,
|
||||
Secure: c.Secure,
|
||||
}
|
||||
if c.Expires > 0 {
|
||||
exp := proto.TimeSinceEpoch(c.Expires)
|
||||
p.Expires = exp
|
||||
}
|
||||
protoCookies = append(protoCookies, p)
|
||||
}
|
||||
return page.SetCookies(protoCookies)
|
||||
}
|
||||
|
||||
func WaitForElement(page *rod.Page, selector string, timeout time.Duration) (*rod.Element, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return page.Context(ctx).Element(selector)
|
||||
}
|
||||
|
||||
func WaitForElements(page *rod.Page, selector string, timeout time.Duration) (rod.Elements, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return page.Context(ctx).Elements(selector)
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
package geminiweb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// BrowserManager 管理瀏覽器實例的生命週期
|
||||
type BrowserManager struct {
|
||||
mu sync.Mutex
|
||||
browser *rod.Browser
|
||||
userDataDir string
|
||||
page *rod.Page
|
||||
visible bool
|
||||
isRunning bool
|
||||
currentModel string
|
||||
}
|
||||
|
||||
var (
|
||||
globalManager *BrowserManager
|
||||
globalMu sync.Mutex
|
||||
)
|
||||
|
||||
// GetBrowserManager 獲取全域瀏覽器管理器(單例)
|
||||
func GetBrowserManager(userDataDir string, visible bool) (*BrowserManager, error) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalManager != nil {
|
||||
return globalManager, nil
|
||||
}
|
||||
|
||||
manager, err := NewBrowserManager(userDataDir, visible)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
globalManager = manager
|
||||
return globalManager, nil
|
||||
}
|
||||
|
||||
// NewBrowserManager 建立新的瀏覽器管理器
|
||||
func NewBrowserManager(userDataDir string, visible bool) (*BrowserManager, error) {
|
||||
cleanLockFiles(userDataDir)
|
||||
|
||||
if err := os.MkdirAll(userDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user data dir: %w", err)
|
||||
}
|
||||
|
||||
return &BrowserManager{
|
||||
userDataDir: userDataDir,
|
||||
visible: visible,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cleanLockFiles 清理 Chrome 的殘留鎖檔案
|
||||
func cleanLockFiles(userDataDir string) {
|
||||
lockFiles := []string{
|
||||
"SingletonLock",
|
||||
"SingletonCookie",
|
||||
"SingletonSocket",
|
||||
"Default/SingletonLock",
|
||||
"Default/SingletonCookie",
|
||||
"Default/SingletonSocket",
|
||||
}
|
||||
|
||||
for _, file := range lockFiles {
|
||||
path := filepath.Join(userDataDir, file)
|
||||
os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Launch 啟動瀏覽器(如果尚未啟動)
|
||||
func (m *BrowserManager) Launch() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.isRunning && m.browser != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
l := launcher.New()
|
||||
|
||||
if m.visible {
|
||||
l = l.Headless(false)
|
||||
} else {
|
||||
l = l.Headless(true)
|
||||
}
|
||||
|
||||
l = l.UserDataDir(m.userDataDir)
|
||||
|
||||
url, err := l.Launch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
|
||||
b := rod.New().ControlURL(url)
|
||||
if err := b.Connect(); err != nil {
|
||||
return fmt.Errorf("failed to connect browser: %w", err)
|
||||
}
|
||||
|
||||
m.browser = b
|
||||
|
||||
page, err := b.Page(proto.TargetCreateTarget{URL: "about:blank"})
|
||||
if err != nil {
|
||||
_ = b.Close()
|
||||
return fmt.Errorf("failed to create page: %w", err)
|
||||
}
|
||||
|
||||
m.page = page
|
||||
m.isRunning = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPage 獲取頁面
|
||||
func (m *BrowserManager) GetPage() (*rod.Page, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.isRunning || m.browser == nil {
|
||||
return nil, fmt.Errorf("browser not running")
|
||||
}
|
||||
|
||||
return m.page, nil
|
||||
}
|
||||
|
||||
// Close 關閉瀏覽器
|
||||
func (m *BrowserManager) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.isRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if m.browser != nil {
|
||||
err = m.browser.Close()
|
||||
m.browser = nil
|
||||
}
|
||||
|
||||
m.page = nil
|
||||
m.isRunning = false
|
||||
return err
|
||||
}
|
||||
|
||||
// IsRunning 檢查瀏覽器是否正在運行
|
||||
func (m *BrowserManager) IsRunning() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.isRunning
|
||||
}
|
||||
|
||||
// SetCurrentModel 設定當前模型
|
||||
func (m *BrowserManager) SetCurrentModel(model string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.currentModel = model
|
||||
}
|
||||
|
||||
// GetCurrentModel 獲取當前模型
|
||||
func (m *BrowserManager) GetCurrentModel() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.currentModel
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
package geminiweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
)
|
||||
|
||||
const geminiURL = "https://gemini.google.com/app"
|
||||
|
||||
// 輸入框選擇器(依優先順序)
|
||||
var inputSelectors = []string{
|
||||
".ProseMirror",
|
||||
"rich-textarea",
|
||||
"div[role='textbox'][contenteditable='true']",
|
||||
"div[contenteditable='true']",
|
||||
"textarea",
|
||||
}
|
||||
|
||||
// NavigateToGemini 導航到 Gemini
|
||||
func NavigateToGemini(page *rod.Page) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := page.Context(ctx).Navigate(geminiURL); err != nil {
|
||||
return fmt.Errorf("failed to navigate: %w", err)
|
||||
}
|
||||
return page.Context(ctx).WaitLoad()
|
||||
}
|
||||
|
||||
// IsLoggedIn 檢查是否已登入
|
||||
func IsLoggedIn(page *rod.Page) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, sel := range inputSelectors {
|
||||
if _, err := page.Context(ctx).Element(sel); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SelectModel 選擇模型(可選)
|
||||
func SelectModel(page *rod.Page, model string) error {
|
||||
fmt.Printf("[GeminiWeb] Model selection skipped (using current model)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TypeInput 在輸入框中輸入文字
|
||||
func TypeInput(page *rod.Page, text string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fmt.Println("[GeminiWeb] Looking for input field...")
|
||||
|
||||
// 1. 嘗試所有選擇器
|
||||
var inputEl *rod.Element
|
||||
var err error
|
||||
|
||||
for _, sel := range inputSelectors {
|
||||
fmt.Printf(" Trying: %s\n", sel)
|
||||
inputEl, err = page.Context(ctx).Element(sel)
|
||||
if err == nil {
|
||||
fmt.Printf(" ✓ Found with: %s\n", sel)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 2. Fallback: 嘗試等待頁面載入完成後重試
|
||||
fmt.Println("[GeminiWeb] Waiting for page to fully load...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
for _, sel := range inputSelectors {
|
||||
fmt.Printf(" Retrying: %s\n", sel)
|
||||
inputEl, err = page.Context(ctx).Element(sel)
|
||||
if err == nil {
|
||||
fmt.Printf(" ✓ Found with: %s\n", sel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 3. Debug: 印出頁面標題和 URL
|
||||
info, _ := page.Info()
|
||||
fmt.Printf("[GeminiWeb] DEBUG: URL=%s Title=%s\n", info.URL, info.Title)
|
||||
|
||||
// 4. Fallback: 嘗試更通用的選擇器
|
||||
fmt.Println("[GeminiWeb] Trying generic selectors...")
|
||||
genericSelectors := []string{
|
||||
"div[contenteditable]",
|
||||
"[contenteditable]",
|
||||
"textarea",
|
||||
"input[type='text']",
|
||||
}
|
||||
|
||||
for _, sel := range genericSelectors {
|
||||
fmt.Printf(" Trying generic: %s\n", sel)
|
||||
inputEl, err = page.Context(ctx).Element(sel)
|
||||
if err == nil {
|
||||
fmt.Printf(" ✓ Found with: %s\n", sel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
info, _ := page.Info()
|
||||
return fmt.Errorf("input field not found after trying all selectors (URL=%s)", info.URL)
|
||||
}
|
||||
|
||||
// 2. Focus 輸入框
|
||||
fmt.Printf("[GeminiWeb] Focusing input field...\n")
|
||||
if err := inputEl.Focus(); err != nil {
|
||||
return fmt.Errorf("failed to focus input: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 3. 使用 Input 方法
|
||||
fmt.Printf("[GeminiWeb] Typing %d chars...\n", len(text))
|
||||
if err := inputEl.Input(text); err != nil {
|
||||
return fmt.Errorf("failed to input text: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
fmt.Println("[GeminiWeb] Input complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClickSend 發送訊息
|
||||
func ClickSend(page *rod.Page) error {
|
||||
// 方法 1: 按 Enter
|
||||
if err := page.Keyboard.Press('\r'); err != nil {
|
||||
return fmt.Errorf("failed to press Enter: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForReady 等待頁面空閒
|
||||
func WaitForReady(page *rod.Page) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fmt.Println("[GeminiWeb] Checking if page is ready...")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Println("[GeminiWeb] Page ready check timeout, proceeding anyway")
|
||||
return nil
|
||||
default:
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 檢查是否有停止按鈕
|
||||
hasStopBtn := false
|
||||
stopBtns, _ := page.Elements("button[aria-label*='Stop'], button[aria-label*='停止']")
|
||||
for _, btn := range stopBtns {
|
||||
visible, _ := btn.Visible()
|
||||
if visible {
|
||||
hasStopBtn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasStopBtn {
|
||||
fmt.Println("[GeminiWeb] Page is ready")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractResponse 提取回應文字
|
||||
func ExtractResponse(page *rod.Page) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var lastText string
|
||||
lastUpdate := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if lastText != "" {
|
||||
return lastText, nil
|
||||
}
|
||||
return "", fmt.Errorf("response timeout")
|
||||
default:
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 尋找回應文字
|
||||
for _, sel := range responseSelectors {
|
||||
elements, err := page.Elements(sel)
|
||||
if err != nil || len(elements) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 取得最後一個元素的文字
|
||||
lastEl := elements[len(elements)-1]
|
||||
text, err := lastEl.Text()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
if text != "" && text != lastText && len(text) > len(lastText) {
|
||||
lastText = text
|
||||
lastUpdate = time.Now()
|
||||
fmt.Printf("[GeminiWeb] Response length: %d\n", len(text))
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查是否已完成(2 秒內沒有新內容)
|
||||
if time.Since(lastUpdate) > 2*time.Second && lastText != "" {
|
||||
// 最後檢查一次是否還有停止按鈕
|
||||
hasStopBtn := false
|
||||
stopBtns, _ := page.Elements("button[aria-label*='Stop'], button[aria-label*='停止']")
|
||||
for _, btn := range stopBtns {
|
||||
visible, _ := btn.Visible()
|
||||
if visible {
|
||||
hasStopBtn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasStopBtn {
|
||||
return lastText, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默認的回應選擇器
|
||||
var responseSelectors = []string{
|
||||
".model-response-text",
|
||||
".message-content",
|
||||
".markdown",
|
||||
".prose",
|
||||
"model-response",
|
||||
}
|
||||
|
|
@ -0,0 +1,662 @@
|
|||
package geminiweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
// PlaywrightProvider 使用 Playwright 的 Gemini Provider
|
||||
type PlaywrightProvider struct {
|
||||
cfg config.BridgeConfig
|
||||
pw *playwright.Playwright
|
||||
browser playwright.Browser
|
||||
context playwright.BrowserContext
|
||||
page playwright.Page
|
||||
mu sync.Mutex
|
||||
userDataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
playwrightInstance *playwright.Playwright
|
||||
playwrightOnce sync.Once
|
||||
playwrightErr error
|
||||
)
|
||||
|
||||
// NewPlaywrightProvider 建立新的 Playwright Provider
|
||||
func NewPlaywrightProvider(cfg config.BridgeConfig) (*PlaywrightProvider, error) {
|
||||
// 確保 Playwright 已初始化(單例)
|
||||
playwrightOnce.Do(func() {
|
||||
playwrightInstance, playwrightErr = playwright.Run()
|
||||
if playwrightErr != nil {
|
||||
playwrightErr = fmt.Errorf("failed to run playwright: %w", playwrightErr)
|
||||
}
|
||||
})
|
||||
|
||||
if playwrightErr != nil {
|
||||
return nil, playwrightErr
|
||||
}
|
||||
|
||||
// 清理 Chrome 鎖檔案
|
||||
userDataDir := filepath.Join(cfg.GeminiAccountDir, "default-session")
|
||||
cleanLockFiles(userDataDir)
|
||||
|
||||
// 確保目錄存在
|
||||
if err := os.MkdirAll(userDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user data dir: %w", err)
|
||||
}
|
||||
|
||||
return &PlaywrightProvider{
|
||||
cfg: cfg,
|
||||
pw: playwrightInstance,
|
||||
userDataDir: userDataDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getName 返回 Provider 名稱
|
||||
func (p *PlaywrightProvider) Name() string {
|
||||
return "gemini-web"
|
||||
}
|
||||
|
||||
// launchIfNeeded 如果需要則啟動瀏覽器
|
||||
func (p *PlaywrightProvider) launchIfNeeded() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.context != nil && p.page != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("[GeminiWeb] Launching Chromium...")
|
||||
|
||||
// 使用 LaunchPersistentContext(自動保存 session)
|
||||
context, err := p.pw.Chromium.LaunchPersistentContext(p.userDataDir,
|
||||
playwright.BrowserTypeLaunchPersistentContextOptions{
|
||||
Headless: playwright.Bool(!p.cfg.GeminiBrowserVisible),
|
||||
Args: []string{
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-extensions",
|
||||
"--disable-plugins",
|
||||
"--disable-sync",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to launch persistent context: %w", err)
|
||||
}
|
||||
|
||||
p.context = context
|
||||
|
||||
// 取得或建立頁面
|
||||
pages := context.Pages()
|
||||
if len(pages) > 0 {
|
||||
p.page = pages[0]
|
||||
} else {
|
||||
page, err := context.NewPage()
|
||||
if err != nil {
|
||||
_ = context.Close()
|
||||
return fmt.Errorf("failed to create page: %w", err)
|
||||
}
|
||||
p.page = page
|
||||
}
|
||||
|
||||
fmt.Println("[GeminiWeb] Browser launched")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate 生成回應
|
||||
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 {
|
||||
fmt.Println("[GeminiWeb] Error occurred, saving diagnostics...")
|
||||
_ = p.saveDiagnostics()
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("[GeminiWeb] Starting generation with model: %s\n", model)
|
||||
|
||||
// 1. 確保瀏覽器已啟動
|
||||
if err := p.launchIfNeeded(); err != nil {
|
||||
return fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
|
||||
// 2. 導航到 Gemini(如果需要)
|
||||
currentURL := p.page.URL()
|
||||
if !strings.Contains(currentURL, "gemini.google.com") {
|
||||
fmt.Println("[GeminiWeb] Navigating to Gemini...")
|
||||
if _, err := p.page.Goto("https://gemini.google.com/app", playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
Timeout: playwright.Float(60000),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to navigate: %w", err)
|
||||
}
|
||||
// 額外等待 JavaScript 載入
|
||||
fmt.Println("[GeminiWeb] Waiting for page to initialize...")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
// 3. 調試模式:等待用戶確認
|
||||
if p.cfg.GeminiBrowserVisible {
|
||||
fmt.Println("\n" + strings.Repeat("=", 70))
|
||||
fmt.Println("🔍 調試模式:瀏覽器已開啟")
|
||||
fmt.Println("請檢查瀏覽器畫面,然後按 ENTER 繼續...")
|
||||
fmt.Println("如果有問題,請查看: /tmp/gemini-debug.*")
|
||||
fmt.Println(strings.Repeat("=", 70))
|
||||
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
}
|
||||
|
||||
// 4. 等待頁面完全載入(project-golem 策略)
|
||||
fmt.Println("[GeminiWeb] Waiting for page to be ready...")
|
||||
if err := p.waitForPageReady(); err != nil {
|
||||
fmt.Printf("[GeminiWeb] Warning: %v\n", err)
|
||||
|
||||
// 額外調試:輸出頁面 HTML 結構
|
||||
if p.cfg.GeminiBrowserVisible {
|
||||
html, _ := p.page.Content()
|
||||
debugPath := "/tmp/gemini-debug.html"
|
||||
if err := os.WriteFile(debugPath, []byte(html), 0644); err == nil {
|
||||
fmt.Printf("[GeminiWeb] HTML saved to: %s\n", debugPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 檢查登入狀態
|
||||
fmt.Println("[GeminiWeb] Checking login status...")
|
||||
loggedIn := p.isLoggedIn()
|
||||
if !loggedIn {
|
||||
fmt.Println("[GeminiWeb] Not logged in, continuing anyway")
|
||||
if p.cfg.GeminiBrowserVisible {
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("Browser is open. You can:")
|
||||
fmt.Println("1. Log in to Gemini now")
|
||||
fmt.Println("2. Continue without login")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("[GeminiWeb] ✓ Logged in")
|
||||
}
|
||||
|
||||
// 5. 選擇模型(如果支援)
|
||||
if err := p.selectModel(model); err != nil {
|
||||
fmt.Printf("[GeminiWeb] Warning: model selection failed: %v\n", err)
|
||||
}
|
||||
|
||||
// 6. 建構提示詞
|
||||
prompt := buildPromptFromMessagesPlaywright(messages)
|
||||
fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt))
|
||||
|
||||
// 7. 輸入文字(使用 Playwright 的 Auto-wait)
|
||||
if err := p.typeInput(prompt); err != nil {
|
||||
return fmt.Errorf("failed to type: %w", err)
|
||||
}
|
||||
|
||||
// 7. 發送訊息
|
||||
fmt.Println("[GeminiWeb] Sending message...")
|
||||
if err := p.sendMessage(); err != nil {
|
||||
return fmt.Errorf("failed to send: %w", err)
|
||||
}
|
||||
|
||||
// 8. 提取回應
|
||||
fmt.Println("[GeminiWeb] Waiting for response...")
|
||||
response, err := p.extractResponse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract response: %w", err)
|
||||
}
|
||||
|
||||
// 9. 回調
|
||||
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
|
||||
}
|
||||
|
||||
// Close 關閉 Provider
|
||||
func (p *PlaywrightProvider) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.context != nil {
|
||||
if err := p.context.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.context = nil
|
||||
p.page = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveDiagnostics 保存診斷信息
|
||||
func (p *PlaywrightProvider) saveDiagnostics() error {
|
||||
if p.page == nil {
|
||||
return fmt.Errorf("no page available")
|
||||
}
|
||||
|
||||
// 截圖
|
||||
screenshotPath := "/tmp/gemini-debug.png"
|
||||
if _, err := p.page.Screenshot(playwright.PageScreenshotOptions{
|
||||
Path: playwright.String(screenshotPath),
|
||||
}); err == nil {
|
||||
fmt.Printf("[GeminiWeb] Screenshot saved: %s\n", screenshotPath)
|
||||
}
|
||||
|
||||
// HTML
|
||||
htmlPath := "/tmp/gemini-debug.html"
|
||||
if html, err := p.page.Content(); err == nil {
|
||||
if err := os.WriteFile(htmlPath, []byte(html), 0644); err == nil {
|
||||
fmt.Printf("[GeminiWeb] HTML saved: %s\n", htmlPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 輸出頁面信息
|
||||
url := p.page.URL()
|
||||
title, _ := p.page.Title()
|
||||
fmt.Printf("[GeminiWeb] Diagnostics: URL=%s, Title=%s\n", url, title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForPageReady 等待頁面完全就緒(project-golem 策略)
|
||||
func (p *PlaywrightProvider) waitForPageReady() error {
|
||||
fmt.Println("[GeminiWeb] Checking for ready state...")
|
||||
|
||||
// 1. 等待停止按鈕消失(如果存在)
|
||||
_, _ = p.page.WaitForSelector("button[aria-label*='Stop'], button[aria-label*='停止']", playwright.PageWaitForSelectorOptions{
|
||||
State: playwright.WaitForSelectorStateDetached,
|
||||
Timeout: playwright.Float(5000),
|
||||
})
|
||||
|
||||
// 2. 嘗試多種等待策略
|
||||
inputSelectors := []string{
|
||||
".ql-editor.ql-blank",
|
||||
".ql-editor",
|
||||
"div[contenteditable='true'][role='textbox']",
|
||||
"div[contenteditable='true']",
|
||||
".ProseMirror",
|
||||
"rich-textarea",
|
||||
"textarea",
|
||||
}
|
||||
|
||||
// 策略 A: 等待任一輸入框出現
|
||||
for i, sel := range inputSelectors {
|
||||
fmt.Printf(" [%d/%d] Waiting for: %s\n", i+1, len(inputSelectors), sel)
|
||||
locator := p.page.Locator(sel)
|
||||
if err := locator.WaitFor(playwright.LocatorWaitForOptions{
|
||||
Timeout: playwright.Float(5000),
|
||||
State: playwright.WaitForSelectorStateVisible,
|
||||
}); err == nil {
|
||||
fmt.Printf(" ✓ Input field found: %s\n", sel)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 B: 等待頁面完全載入
|
||||
fmt.Println("[GeminiWeb] Waiting for page load...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// 策略 C: 使用 JavaScript 檢查
|
||||
fmt.Println("[GeminiWeb] Checking with JavaScript...")
|
||||
result, err := p.page.Evaluate(`
|
||||
() => {
|
||||
// 檢查所有可能的輸入元素
|
||||
const selectors = [
|
||||
'.ql-editor.ql-blank',
|
||||
'.ql-editor',
|
||||
'div[contenteditable="true"][role="textbox"]',
|
||||
'div[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'rich-textarea',
|
||||
'textarea'
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
return {
|
||||
found: true,
|
||||
selector: sel,
|
||||
tagName: el.tagName,
|
||||
className: el.className,
|
||||
visible: el.offsetParent !== null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false };
|
||||
}
|
||||
`)
|
||||
|
||||
if err == nil {
|
||||
if m, ok := result.(map[string]interface{}); ok {
|
||||
if found, _ := m["found"].(bool); found {
|
||||
sel, _ := m["selector"].(string)
|
||||
fmt.Printf(" ✓ JavaScript found: %s\n", sel)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 D: 調試模式 - 輸出頁面結構
|
||||
if p.cfg.GeminiBrowserVisible {
|
||||
fmt.Println("[GeminiWeb].dump: Page structure analysis")
|
||||
_, _ = p.page.Evaluate(`
|
||||
() => {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const inputLike = [];
|
||||
for (const el of allElements) {
|
||||
if (el.contentEditable === 'true' ||
|
||||
el.role === 'textbox' ||
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.tagName === 'INPUT') {
|
||||
inputLike.push({
|
||||
tag: el.tagName,
|
||||
class: el.className,
|
||||
id: el.id,
|
||||
role: el.role,
|
||||
contentEditable: el.contentEditable
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('Input-like elements:', inputLike);
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
return fmt.Errorf("no input field found after all strategies")
|
||||
}
|
||||
|
||||
// isLoggedIn 檢查是否已登入
|
||||
func (p *PlaywrightProvider) isLoggedIn() bool {
|
||||
// 嘗試找輸入框(登入狀態的主要特徵)
|
||||
selectors := []string{
|
||||
".ProseMirror",
|
||||
"rich-textarea",
|
||||
"div[role='textbox']",
|
||||
"div[contenteditable='true']",
|
||||
"textarea",
|
||||
}
|
||||
|
||||
for _, sel := range selectors {
|
||||
locator := p.page.Locator(sel)
|
||||
if count, _ := locator.Count(); count > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// typeInput 輸入文字(使用 Playwright 的 Auto-wait)
|
||||
func (p *PlaywrightProvider) typeInput(text string) error {
|
||||
fmt.Println("[GeminiWeb] Looking for input field...")
|
||||
|
||||
selectors := []string{
|
||||
".ql-editor.ql-blank",
|
||||
".ql-editor",
|
||||
"div[contenteditable='true'][role='textbox']",
|
||||
"div[contenteditable='true']",
|
||||
".ProseMirror",
|
||||
"rich-textarea",
|
||||
"textarea",
|
||||
}
|
||||
|
||||
var inputLocator playwright.Locator
|
||||
var found bool
|
||||
|
||||
for _, sel := range selectors {
|
||||
fmt.Printf(" Trying: %s\n", sel)
|
||||
locator := p.page.Locator(sel)
|
||||
if err := locator.WaitFor(playwright.LocatorWaitForOptions{
|
||||
Timeout: playwright.Float(3000),
|
||||
}); err == nil {
|
||||
inputLocator = locator
|
||||
found = true
|
||||
fmt.Printf(" ✓ Found with: %s\n", sel)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// 錯誤會被 Generate 的 defer 捕獲並保存診斷
|
||||
url := p.page.URL()
|
||||
title, _ := p.page.Title()
|
||||
return fmt.Errorf("input field not found (URL=%s, Title=%s). Diagnostics will be saved to /tmp/", url, title)
|
||||
}
|
||||
|
||||
// Focus 並填充(Playwright 自動等待)
|
||||
fmt.Printf("[GeminiWeb] Typing %d chars...\n", len(text))
|
||||
if err := inputLocator.Fill(text); err != nil {
|
||||
return fmt.Errorf("failed to fill: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("[GeminiWeb] Input complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMessage 發送訊息
|
||||
func (p *PlaywrightProvider) sendMessage() error {
|
||||
// 方法 1: 按 Enter(最可靠)
|
||||
if err := p.page.Keyboard().Press("Enter"); err != nil {
|
||||
return fmt.Errorf("failed to press Enter: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// 方法 2: 嘗試點擊發送按鈕(補強)
|
||||
_, _ = p.page.Evaluate(`
|
||||
() => {
|
||||
const keywords = ['發送', 'Send', '傳送'];
|
||||
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
|
||||
|
||||
for (const btn of buttons) {
|
||||
const text = (btn.innerText || btn.textContent || '').trim();
|
||||
const label = (btn.getAttribute('aria-label') || '').trim();
|
||||
|
||||
// 跳過停止按鈕
|
||||
if (['停止', 'Stop', '中斷'].includes(text) || label.toLowerCase().includes('stop')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keywords.some(kw => text.includes(kw) || label.includes(kw))) {
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractResponse 提取回應
|
||||
func (p *PlaywrightProvider) extractResponse() (string, error) {
|
||||
var lastText string
|
||||
var stableCount int
|
||||
lastUpdate := time.Now()
|
||||
timeout := 120 * time.Second
|
||||
startTime := time.Now()
|
||||
|
||||
for time.Since(startTime) < timeout {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 使用 JavaScript 提取回應文字(更精確)
|
||||
result, err := p.page.Evaluate(`
|
||||
() => {
|
||||
// 尋找所有可能的回應容器
|
||||
const selectors = [
|
||||
'model-response',
|
||||
'.model-response',
|
||||
'message-content',
|
||||
'.message-content'
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
// 嘗試找markdown內容
|
||||
const markdown = el.querySelector('.markdown, .prose, [class*="markdown"]');
|
||||
if (markdown && markdown.innerText.trim()) {
|
||||
let text = markdown.innerText.trim();
|
||||
// 移除常見的標籤前綴
|
||||
text = text.replace(/^Gemini said\s*\n*/i, '').replace(/^Gemini\s*[::]\s*\n*/i, '').trim();
|
||||
return { text: text, source: sel + ' .markdown' };
|
||||
}
|
||||
|
||||
// 嘗試找純文字內容(排除標籤)
|
||||
let textContent = el.innerText.trim();
|
||||
if (textContent) {
|
||||
// 移除常見的標籤前綴
|
||||
textContent = textContent.replace(/^Gemini said\s*\n*/i, '').replace(/^Gemini\s*[::]\s*\n*/i, '').trim();
|
||||
return { text: textContent, source: sel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: '', source: 'none' };
|
||||
}
|
||||
`)
|
||||
|
||||
if err == nil {
|
||||
if m, ok := result.(map[string]interface{}); ok {
|
||||
text, _ := m["text"].(string)
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text != "" && len(text) > len(lastText) {
|
||||
lastText = text
|
||||
lastUpdate = time.Now()
|
||||
stableCount = 0
|
||||
fmt.Printf("[GeminiWeb] Response: %d chars\n", len(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查是否完成(需要連續 3 次穩定)
|
||||
if time.Since(lastUpdate) > 500*time.Millisecond && lastText != "" {
|
||||
stableCount++
|
||||
if stableCount >= 3 {
|
||||
// 最終檢查:停止按鈕是否還存在
|
||||
stopBtn := p.page.Locator("button[aria-label*='Stop'], button[aria-label*='停止'], button[data-test-id='stop-button']")
|
||||
count, _ := stopBtn.Count()
|
||||
|
||||
if count == 0 {
|
||||
fmt.Println("[GeminiWeb] ✓ Response complete")
|
||||
return lastText, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastText != "" {
|
||||
fmt.Println("[GeminiWeb] ✓ Response complete (timeout)")
|
||||
return lastText, nil
|
||||
}
|
||||
return "", fmt.Errorf("response timeout")
|
||||
}
|
||||
|
||||
// selectModel 選擇 Gemini 模型
|
||||
// Gemini Web 只有三種模型:fast, thinking, pro
|
||||
func (p *PlaywrightProvider) selectModel(model string) error {
|
||||
// 映射模型名稱到 Gemini Web 的模型選擇器
|
||||
modelMap := map[string]string{
|
||||
"fast": "Fast",
|
||||
"thinking": "Thinking",
|
||||
"pro": "Pro",
|
||||
"gemini-fast": "Fast",
|
||||
"gemini-thinking": "Thinking",
|
||||
"gemini-pro": "Pro",
|
||||
"gemini-2.0-fast": "Fast",
|
||||
"gemini-2.0-flash": "Fast", // 相容舊名稱
|
||||
"gemini-2.5-pro": "Pro",
|
||||
"gemini-2.5-pro-thinking": "Thinking",
|
||||
}
|
||||
|
||||
// 從完整模型名稱中提取類型
|
||||
modelType := ""
|
||||
modelLower := strings.ToLower(model)
|
||||
for key, value := range modelMap {
|
||||
if strings.Contains(modelLower, strings.ToLower(key)) || modelLower == strings.ToLower(key) {
|
||||
modelType = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if modelType == "" {
|
||||
// 默認使用 Fast
|
||||
fmt.Printf("[GeminiWeb] Unknown model '%s', defaulting to Fast\n", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("[GeminiWeb] Selecting model: %s\n", modelType)
|
||||
|
||||
// 點擊模型選擇器
|
||||
modelSelector := p.page.Locator("button[aria-label*='Model'], button[aria-label*='模型'], [data-test-id='model-selector']")
|
||||
if count, _ := modelSelector.Count(); count > 0 {
|
||||
if err := modelSelector.First().Click(); err != nil {
|
||||
fmt.Printf("[GeminiWeb] Warning: could not click model selector: %v\n", err)
|
||||
} else {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 選擇對應的模型選項
|
||||
optionSelector := p.page.Locator(fmt.Sprintf("button:has-text('%s'), [role='menuitem']:has-text('%s')", modelType, modelType))
|
||||
if count, _ := optionSelector.Count(); count > 0 {
|
||||
if err := optionSelector.First().Click(); err != nil {
|
||||
fmt.Printf("[GeminiWeb] Warning: could not select model: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[GeminiWeb] ✓ Model selected: %s\n", modelType)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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: " + content + "\n\n"
|
||||
case "user":
|
||||
prompt += content + "\n\n"
|
||||
case "assistant":
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package geminiweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeminiSession struct {
|
||||
Name string `json:"name"`
|
||||
CookieFile string `json:"cookie_file"`
|
||||
LastUsed int64 `json:"last_used"`
|
||||
ActiveCount int `json:"active_count"`
|
||||
RateLimitEnd int64 `json:"rate_limit_end"`
|
||||
}
|
||||
|
||||
type SessionPool struct {
|
||||
mu sync.Mutex
|
||||
sessions []*GeminiSession
|
||||
dir string
|
||||
maxCount int
|
||||
}
|
||||
|
||||
func NewSessionPool(dir string, maxSessions int) (*SessionPool, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create session dir: %w", err)
|
||||
}
|
||||
|
||||
sessions, err := loadSessions(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load sessions: %w", err)
|
||||
}
|
||||
|
||||
return &SessionPool{
|
||||
sessions: sessions,
|
||||
dir: dir,
|
||||
maxCount: maxSessions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadSessions(dir string) ([]*GeminiSession, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sessions []*GeminiSession
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
metaPath := filepath.Join(dir, name, "session.json")
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var s GeminiSession
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, &s)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (p *SessionPool) Count() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.sessions)
|
||||
}
|
||||
|
||||
func (p *SessionPool) GetAvailable() *GeminiSession {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
var available []*GeminiSession
|
||||
for _, s := range p.sessions {
|
||||
if s.RateLimitEnd < now {
|
||||
available = append(available, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(available) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var best *GeminiSession
|
||||
for _, s := range available {
|
||||
if best == nil || s.ActiveCount < best.ActiveCount {
|
||||
best = s
|
||||
} else if s.ActiveCount == best.ActiveCount && s.LastUsed < best.LastUsed {
|
||||
best = s
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
func (p *SessionPool) StartSession(s *GeminiSession) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s.ActiveCount++
|
||||
s.LastUsed = time.Now().UnixMilli()
|
||||
p.saveSession(s)
|
||||
}
|
||||
|
||||
func (p *SessionPool) EndSession(s *GeminiSession) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if s.ActiveCount > 0 {
|
||||
s.ActiveCount--
|
||||
}
|
||||
p.saveSession(s)
|
||||
}
|
||||
|
||||
func (p *SessionPool) RateLimitSession(s *GeminiSession, durationMs int64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s.RateLimitEnd = time.Now().UnixMilli() + durationMs
|
||||
p.saveSession(s)
|
||||
}
|
||||
|
||||
func (p *SessionPool) saveSession(s *GeminiSession) {
|
||||
metaPath := filepath.Join(p.dir, s.Name, "session.json")
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(metaPath, data, 0644)
|
||||
}
|
||||
|
||||
func (p *SessionPool) CreateSession(name string) (*GeminiSession, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
sessionDir := filepath.Join(p.dir, name)
|
||||
if err := os.MkdirAll(sessionDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create session dir: %w", err)
|
||||
}
|
||||
|
||||
s := &GeminiSession{
|
||||
Name: name,
|
||||
CookieFile: filepath.Join(sessionDir, "cookies.json"),
|
||||
LastUsed: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
p.sessions = append(p.sessions, s)
|
||||
p.saveSession(s)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (p *SessionPool) GetSessionNames() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
names := make([]string, len(p.sessions))
|
||||
for i, s := range p.sessions {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
package geminiweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/domain/entity"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Provider 使用持久化瀏覽器管理器
|
||||
type Provider struct {
|
||||
cfg config.BridgeConfig
|
||||
managerOnce sync.Once
|
||||
manager *BrowserManager
|
||||
managerErr error
|
||||
}
|
||||
|
||||
// NewProvider 建立新的 Provider
|
||||
func NewProvider(cfg config.BridgeConfig) *Provider {
|
||||
return &Provider{cfg: cfg}
|
||||
}
|
||||
|
||||
// getName 返回 Provider 名稱
|
||||
func (p *Provider) Name() string {
|
||||
return "gemini-web"
|
||||
}
|
||||
|
||||
// Close 關閉瀏覽器
|
||||
func (p *Provider) Close() error {
|
||||
if p.manager != nil {
|
||||
return p.manager.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getManager 獲取或初始化瀏覽器管理器(單例)
|
||||
func (p *Provider) getManager() (*BrowserManager, error) {
|
||||
p.managerOnce.Do(func() {
|
||||
sessionDir := p.getSessionDir()
|
||||
p.manager, p.managerErr = GetBrowserManager(sessionDir, p.cfg.GeminiBrowserVisible)
|
||||
})
|
||||
return p.manager, p.managerErr
|
||||
}
|
||||
|
||||
// getSessionDir 獲取 session 目錄
|
||||
func (p *Provider) getSessionDir() string {
|
||||
// 使用單一 session 目錄(簡化設計)
|
||||
return filepath.Join(p.cfg.GeminiAccountDir, "default-session")
|
||||
}
|
||||
|
||||
// Generate 生成回應
|
||||
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. 獲取瀏覽器管理器
|
||||
manager, err := p.getManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get browser manager: %w", err)
|
||||
}
|
||||
|
||||
// 2. 啟動瀏覽器(如果尚未啟動)
|
||||
if !manager.IsRunning() {
|
||||
fmt.Printf("[GeminiWeb] Launching browser...\n")
|
||||
if err := manager.Launch(); err != nil {
|
||||
return fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 獲取頁面
|
||||
page, err := manager.GetPage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get page: %w", err)
|
||||
}
|
||||
|
||||
// 4. 檢查當前 URL,如果不是 Gemini 則導航
|
||||
currentURL, _ := page.Info()
|
||||
if !strings.Contains(currentURL.URL, "gemini.google.com") {
|
||||
fmt.Printf("[GeminiWeb] Navigating to Gemini...\n")
|
||||
if err := NavigateToGemini(page); err != nil {
|
||||
return fmt.Errorf("failed to navigate: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
// 5. 檢查登入狀態
|
||||
fmt.Printf("[GeminiWeb] Checking login status...\n")
|
||||
if !IsLoggedIn(page) {
|
||||
fmt.Printf("[GeminiWeb] Not logged in, continuing anyway\n")
|
||||
|
||||
if p.cfg.GeminiBrowserVisible {
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("Browser is open. You can:")
|
||||
fmt.Println("1. Log in to Gemini now")
|
||||
fmt.Println("2. Continue without login")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[GeminiWeb] Logged in\n")
|
||||
}
|
||||
|
||||
// 6. 等待頁面就緒
|
||||
if err := WaitForReady(page); err != nil {
|
||||
fmt.Printf("[GeminiWeb] Warning: %v\n", err)
|
||||
}
|
||||
|
||||
// 7. 建構提示詞
|
||||
prompt := buildPromptFromMessages(messages)
|
||||
fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt))
|
||||
|
||||
// 8. 輸入文字
|
||||
if err := TypeInput(page, prompt); err != nil {
|
||||
return fmt.Errorf("failed to type input: %w", err)
|
||||
}
|
||||
|
||||
// 9. 發送
|
||||
fmt.Printf("[GeminiWeb] Sending message...\n")
|
||||
if err := ClickSend(page); err != nil {
|
||||
return fmt.Errorf("failed to send: %w", err)
|
||||
}
|
||||
|
||||
// 10. 提取回應
|
||||
fmt.Printf("[GeminiWeb] Waiting for response...\n")
|
||||
response, err := ExtractResponse(page)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract response: %w", err)
|
||||
}
|
||||
|
||||
// 11. 串流回調
|
||||
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 []entity.Message) string {
|
||||
var prompt string
|
||||
for _, m := range messages {
|
||||
content := messageContentToString(m.Content)
|
||||
switch m.Role {
|
||||
case "system":
|
||||
prompt += "System: " + content + "\n\n"
|
||||
case "user":
|
||||
prompt += content + "\n\n"
|
||||
case "assistant":
|
||||
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 == "" {
|
||||
sessionName = "default-session"
|
||||
}
|
||||
|
||||
sessionDir := filepath.Join(cfg.GeminiAccountDir, sessionName)
|
||||
if err := os.MkdirAll(sessionDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create session dir: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting browser for login. Session: %s\n", sessionName)
|
||||
fmt.Printf("Session directory: %s\n", sessionDir)
|
||||
fmt.Println("Please log in to your Gemini account in the browser window.")
|
||||
fmt.Println("Press Ctrl+C when you have completed the login...")
|
||||
|
||||
manager, err := NewBrowserManager(sessionDir, true) // visible=true
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create browser manager: %w", err)
|
||||
}
|
||||
|
||||
if err := manager.Launch(); err != nil {
|
||||
return fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
page, err := manager.GetPage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get page: %w", err)
|
||||
}
|
||||
|
||||
if err := NavigateToGemini(page); err != nil {
|
||||
return fmt.Errorf("failed to navigate: %w", err)
|
||||
}
|
||||
|
||||
// 等待用戶手動登入...
|
||||
// 使用 Ctrl+C 退出,瀏覽器資料會自動保存在 userDataDir
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
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 AccountPool struct {
|
||||
mu sync.Mutex
|
||||
accounts []*accountStatus
|
||||
}
|
||||
|
||||
func NewAccountPool(configDirs []string) *AccountPool {
|
||||
accounts := make([]*accountStatus, 0, len(configDirs))
|
||||
for _, dir := range configDirs {
|
||||
accounts = append(accounts, &accountStatus{configDir: dir})
|
||||
}
|
||||
return &AccountPool{accounts: accounts}
|
||||
}
|
||||
|
||||
func (p *AccountPool) GetNextConfigDir() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if len(p.accounts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
available := make([]*accountStatus, 0, len(p.accounts))
|
||||
for _, a := range p.accounts {
|
||||
if a.rateLimitUntil < now {
|
||||
available = append(available, a)
|
||||
}
|
||||
}
|
||||
|
||||
target := available
|
||||
if len(target) == 0 {
|
||||
target = make([]*accountStatus, len(p.accounts))
|
||||
copy(target, p.accounts)
|
||||
// sort by earliest recovery
|
||||
for i := 1; i < len(target); i++ {
|
||||
for j := i; j > 0 && target[j].rateLimitUntil < target[j-1].rateLimitUntil; j-- {
|
||||
target[j], target[j-1] = target[j-1], target[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pick least busy then least recently used
|
||||
best := target[0]
|
||||
for _, a := range target[1:] {
|
||||
if a.activeRequests < best.activeRequests {
|
||||
best = a
|
||||
} else if a.activeRequests == best.activeRequests && a.lastUsed < best.lastUsed {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
best.lastUsed = now
|
||||
return best.configDir
|
||||
}
|
||||
|
||||
func (p *AccountPool) find(configDir string) *accountStatus {
|
||||
for _, a := range p.accounts {
|
||||
if a.configDir == configDir {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AccountPool) ReportRequestStart(configDir string) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if a := p.find(configDir); a != nil {
|
||||
a.activeRequests++
|
||||
a.totalRequests++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccountPool) ReportRequestEnd(configDir string) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if a := p.find(configDir); a != nil && a.activeRequests > 0 {
|
||||
a.activeRequests--
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccountPool) ReportRequestSuccess(configDir string, latencyMs int64) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if a := p.find(configDir); a != nil {
|
||||
a.totalSuccess++
|
||||
a.totalLatencyMs += latencyMs
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccountPool) ReportRequestError(configDir string, latencyMs int64) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if a := p.find(configDir); a != nil {
|
||||
a.totalErrors++
|
||||
a.totalLatencyMs += latencyMs
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccountPool) ReportRateLimit(configDir string, penaltyMs int64) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
if penaltyMs <= 0 {
|
||||
penaltyMs = 60000
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if a := p.find(configDir); a != nil {
|
||||
a.rateLimitUntil = time.Now().UnixMilli() + penaltyMs
|
||||
a.totalRateLimits++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccountPool) GetStats() []entity.AccountStat {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now().UnixMilli()
|
||||
stats := make([]entity.AccountStat, len(p.accounts))
|
||||
for i, a := range p.accounts {
|
||||
stats[i] = entity.AccountStat{
|
||||
ConfigDir: a.configDir,
|
||||
ActiveRequests: a.activeRequests,
|
||||
TotalRequests: a.totalRequests,
|
||||
TotalSuccess: a.totalSuccess,
|
||||
TotalErrors: a.totalErrors,
|
||||
TotalRateLimits: a.totalRateLimits,
|
||||
TotalLatencyMs: a.totalLatencyMs,
|
||||
IsRateLimited: a.rateLimitUntil > now,
|
||||
RateLimitUntil: a.rateLimitUntil,
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func (p *AccountPool) Count() int {
|
||||
return len(p.accounts)
|
||||
}
|
||||
|
||||
// ─── PoolHandle interface ──────────────────────────────────────────────────
|
||||
// PoolHandle 讓 handler 可以注入獨立的 pool 實例,避免多 port 模式共用全域 pool。
|
||||
|
||||
type PoolHandle 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
|
||||
}
|
||||
|
||||
// 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) 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() []entity.AccountStat { return GetAccountStats() }
|
||||
|
||||
// ─── Global pool ───────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
globalPool *AccountPool
|
||||
globalMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitAccountPool(configDirs []string) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
globalPool = NewAccountPool(configDirs)
|
||||
}
|
||||
|
||||
func GetNextAccountConfigDir() string {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.GetNextConfigDir()
|
||||
}
|
||||
|
||||
func ReportRequestStart(configDir string) {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p != nil {
|
||||
p.ReportRequestStart(configDir)
|
||||
}
|
||||
}
|
||||
|
||||
func ReportRequestEnd(configDir string) {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p != nil {
|
||||
p.ReportRequestEnd(configDir)
|
||||
}
|
||||
}
|
||||
|
||||
func ReportRequestSuccess(configDir string, latencyMs int64) {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p != nil {
|
||||
p.ReportRequestSuccess(configDir, latencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func ReportRequestError(configDir string, latencyMs int64) {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p != nil {
|
||||
p.ReportRequestError(configDir, latencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func ReportRateLimit(configDir string, penaltyMs int64) {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p != nil {
|
||||
p.ReportRateLimit(configDir, penaltyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAccountStats() []entity.AccountStat {
|
||||
globalMu.Lock()
|
||||
p := globalPool
|
||||
globalMu.Unlock()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.GetStats()
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEmptyPool(t *testing.T) {
|
||||
p := NewAccountPool(nil)
|
||||
if got := p.GetNextConfigDir(); got != "" {
|
||||
t.Fatalf("expected empty string for empty pool, got %q", got)
|
||||
}
|
||||
if p.Count() != 0 {
|
||||
t.Fatalf("expected count 0, got %d", p.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleDir(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1"})
|
||||
if got := p.GetNextConfigDir(); got != "/dir1" {
|
||||
t.Fatalf("expected /dir1, got %q", got)
|
||||
}
|
||||
if got := p.GetNextConfigDir(); got != "/dir1" {
|
||||
t.Fatalf("expected /dir1 again, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundRobin(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/a", "/b", "/c"})
|
||||
got := []string{
|
||||
p.GetNextConfigDir(),
|
||||
p.GetNextConfigDir(),
|
||||
p.GetNextConfigDir(),
|
||||
p.GetNextConfigDir(),
|
||||
}
|
||||
want := []string{"/a", "/b", "/c", "/a"}
|
||||
for i, w := range want {
|
||||
if got[i] != w {
|
||||
t.Fatalf("call %d: expected %q, got %q", i, w, got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeastBusy(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1", "/dir2", "/dir3"})
|
||||
p.ReportRequestStart("/dir1")
|
||||
p.ReportRequestStart("/dir2")
|
||||
|
||||
if got := p.GetNextConfigDir(); got != "/dir3" {
|
||||
t.Fatalf("expected /dir3 (least busy), got %q", got)
|
||||
}
|
||||
|
||||
p.ReportRequestStart("/dir3")
|
||||
p.ReportRequestEnd("/dir1")
|
||||
|
||||
if got := p.GetNextConfigDir(); got != "/dir1" {
|
||||
t.Fatalf("expected /dir1 after end, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipsRateLimited(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1", "/dir2"})
|
||||
p.ReportRateLimit("/dir1", 60000)
|
||||
|
||||
if got := p.GetNextConfigDir(); got != "/dir2" {
|
||||
t.Fatalf("expected /dir2, got %q", got)
|
||||
}
|
||||
if got := p.GetNextConfigDir(); got != "/dir2" {
|
||||
t.Fatalf("expected /dir2 again, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackToSoonestRecovery(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1", "/dir2"})
|
||||
p.ReportRateLimit("/dir1", 60000)
|
||||
p.ReportRateLimit("/dir2", 30000)
|
||||
|
||||
// dir2 recovers sooner — should be selected
|
||||
if got := p.GetNextConfigDir(); got != "/dir2" {
|
||||
t.Fatalf("expected /dir2 (sooner recovery), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveRequestsDoesNotGoNegative(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1"})
|
||||
p.ReportRequestEnd("/dir1")
|
||||
p.ReportRequestEnd("/dir1")
|
||||
if got := p.GetNextConfigDir(); got != "/dir1" {
|
||||
t.Fatalf("pool should still work, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreUnknownConfigDir(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1"})
|
||||
p.ReportRequestStart("/nonexistent")
|
||||
p.ReportRequestEnd("/nonexistent")
|
||||
p.ReportRateLimit("/nonexistent", 60000)
|
||||
if got := p.GetNextConfigDir(); got != "/dir1" {
|
||||
t.Fatalf("expected /dir1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitExpires(t *testing.T) {
|
||||
p := NewAccountPool([]string{"/dir1", "/dir2"})
|
||||
p.ReportRateLimit("/dir1", 50)
|
||||
|
||||
if got := p.GetNextConfigDir(); got != "/dir2" {
|
||||
t.Fatalf("immediately expected /dir2, got %q", got)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if got := p.GetNextConfigDir(); got != "/dir1" {
|
||||
t.Fatalf("after expiry expected /dir1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalPool(t *testing.T) {
|
||||
InitAccountPool([]string{"/g1", "/g2"})
|
||||
if got := GetNextAccountConfigDir(); got != "/g1" {
|
||||
t.Fatalf("expected /g1, got %q", got)
|
||||
}
|
||||
if got := GetNextAccountConfigDir(); got != "/g2" {
|
||||
t.Fatalf("expected /g2, got %q", got)
|
||||
}
|
||||
if got := GetNextAccountConfigDir(); got != "/g1" {
|
||||
t.Fatalf("expected /g1 again, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalPoolEmpty(t *testing.T) {
|
||||
InitAccountPool(nil)
|
||||
if got := GetNextAccountConfigDir(); got != "" {
|
||||
t.Fatalf("expected empty string for empty global pool, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalPoolReinit(t *testing.T) {
|
||||
InitAccountPool([]string{"/old1", "/old2"})
|
||||
GetNextAccountConfigDir()
|
||||
InitAccountPool([]string{"/new1"})
|
||||
if got := GetNextAccountConfigDir(); got != "/new1" {
|
||||
t.Fatalf("expected /new1 after reinit, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalPoolFunctionsNoopBeforeInit(t *testing.T) {
|
||||
InitAccountPool(nil)
|
||||
ReportRequestStart("/dir1")
|
||||
ReportRequestEnd("/dir1")
|
||||
ReportRateLimit("/dir1", 1000)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package usecase
|
||||
|
||||
import "cursor-api-proxy/internal/config"
|
||||
|
||||
func BuildAgentFixedArgs(cfg config.BridgeConfig, workspaceDir, model string, stream bool) []string {
|
||||
args := []string{"--print"}
|
||||
if cfg.ApproveMcps {
|
||||
args = append(args, "--approve-mcps")
|
||||
}
|
||||
if cfg.Force {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
if cfg.ChatOnlyWorkspace {
|
||||
args = append(args, "--trust")
|
||||
}
|
||||
args = append(args, "--workspace", workspaceDir)
|
||||
args = append(args, "--model", model)
|
||||
if stream {
|
||||
args = append(args, "--stream-partial-output", "--output-format", "stream-json")
|
||||
} else {
|
||||
args = append(args, "--output-format", "text")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func BuildAgentCmdArgs(cfg config.BridgeConfig, workspaceDir, model, prompt string, stream bool) []string {
|
||||
return append(BuildAgentFixedArgs(cfg, workspaceDir, model, stream), prompt)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func getCandidates(agentScriptPath, configDirOverride string) []string {
|
||||
if configDirOverride != "" {
|
||||
return []string{filepath.Join(configDirOverride, "cli-config.json")}
|
||||
}
|
||||
|
||||
var result []string
|
||||
|
||||
if dir := os.Getenv("CURSOR_CONFIG_DIR"); dir != "" {
|
||||
result = append(result, filepath.Join(dir, "cli-config.json"))
|
||||
}
|
||||
|
||||
if agentScriptPath != "" {
|
||||
agentDir := filepath.Dir(agentScriptPath)
|
||||
result = append(result, filepath.Join(agentDir, "..", "data", "config", "cli-config.json"))
|
||||
}
|
||||
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
home = os.Getenv("USERPROFILE")
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
local := os.Getenv("LOCALAPPDATA")
|
||||
if local == "" {
|
||||
local = filepath.Join(home, "AppData", "Local")
|
||||
}
|
||||
result = append(result, filepath.Join(local, "cursor-agent", "cli-config.json"))
|
||||
case "darwin":
|
||||
result = append(result, filepath.Join(home, "Library", "Application Support", "cursor-agent", "cli-config.json"))
|
||||
default:
|
||||
xdg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdg == "" {
|
||||
xdg = filepath.Join(home, ".config")
|
||||
}
|
||||
result = append(result, filepath.Join(xdg, "cursor-agent", "cli-config.json"))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func RunMaxModePreflight(agentScriptPath, configDirOverride string) {
|
||||
for _, candidate := range getCandidates(agentScriptPath, configDirOverride) {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip BOM if present
|
||||
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
|
||||
data = data[3:]
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if raw == nil || len(raw) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
raw["maxMode"] = true
|
||||
if model, ok := raw["model"].(map[string]interface{}); ok {
|
||||
model["maxMode"] = true
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(candidate, out, 0644); err != nil {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor-api-proxy/internal/config"
|
||||
"cursor-api-proxy/pkg/infrastructure/process"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
process.MaxModeFn = RunMaxModePreflight
|
||||
}
|
||||
|
||||
func cacheTokenForAccount(configDir string) {
|
||||
if configDir == "" {
|
||||
return
|
||||
}
|
||||
token := ReadKeychainToken()
|
||||
if token != "" {
|
||||
WriteCachedToken(configDir, token)
|
||||
}
|
||||
}
|
||||
|
||||
func AccountsDir() string {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
home = os.Getenv("USERPROFILE")
|
||||
}
|
||||
return filepath.Join(home, ".cursor-api-proxy", "accounts")
|
||||
}
|
||||
|
||||
func RunAgentSync(cfg config.BridgeConfig, workspaceDir string, cmdArgs []string, tempDir, configDir string, ctx context.Context) (process.RunResult, error) {
|
||||
opts := process.RunOptions{
|
||||
Cwd: workspaceDir,
|
||||
TimeoutMs: cfg.TimeoutMs,
|
||||
MaxMode: cfg.MaxMode,
|
||||
ConfigDir: configDir,
|
||||
Ctx: ctx,
|
||||
}
|
||||
|
||||
result, err := process.Run(cfg.AgentBin, cmdArgs, opts)
|
||||
|
||||
cacheTokenForAccount(configDir)
|
||||
if tempDir != "" {
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func RunAgentStreamWithContext(cfg config.BridgeConfig, workspaceDir string, cmdArgs []string, onLine func(string), tempDir, configDir string, ctx context.Context) (process.StreamResult, error) {
|
||||
opts := process.RunStreamingOptions{
|
||||
RunOptions: process.RunOptions{
|
||||
Cwd: workspaceDir,
|
||||
TimeoutMs: cfg.TimeoutMs,
|
||||
MaxMode: cfg.MaxMode,
|
||||
ConfigDir: configDir,
|
||||
Ctx: ctx,
|
||||
},
|
||||
OnLine: onLine,
|
||||
}
|
||||
|
||||
result, err := process.RunStreaming(cfg.AgentBin, cmdArgs, opts)
|
||||
|
||||
cacheTokenForAccount(configDir)
|
||||
if tempDir != "" {
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package usecase
|
||||
|
||||
import "regexp"
|
||||
|
||||
type rule struct {
|
||||
pattern *regexp.Regexp
|
||||
replacement string
|
||||
}
|
||||
|
||||
var rules = []rule{
|
||||
{regexp.MustCompile(`(?i)x-anthropic-billing-header:[^\n]*\n?`), ""},
|
||||
{regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""},
|
||||
{regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""},
|
||||
{regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""},
|
||||
{regexp.MustCompile(`\bClaude Code\b`), "Cursor"},
|
||||
{regexp.MustCompile(`(?i)Anthropic['']s official CLI for Claude`), "Cursor AI assistant"},
|
||||
{regexp.MustCompile(`\bAnthropic\b`), "Cursor"},
|
||||
{regexp.MustCompile(`(?i)anthropic\.com`), "cursor.com"},
|
||||
{regexp.MustCompile(`(?i)claude\.ai`), "cursor.sh"},
|
||||
{regexp.MustCompile(`^[;,\s]+`), ""},
|
||||
}
|
||||
|
||||
func SanitizeText(text string) string {
|
||||
for _, r := range rules {
|
||||
text = r.pattern.ReplaceAllString(text, r.replacement)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func SanitizeMessages(messages []interface{}) []interface{} {
|
||||
result := make([]interface{}, len(messages))
|
||||
for i, raw := range messages {
|
||||
if raw == nil {
|
||||
result[i] = raw
|
||||
continue
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
result[i] = raw
|
||||
continue
|
||||
}
|
||||
newMsg := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
newMsg[k] = v
|
||||
}
|
||||
switch c := m["content"].(type) {
|
||||
case string:
|
||||
newMsg["content"] = SanitizeText(c)
|
||||
case []interface{}:
|
||||
newParts := make([]interface{}, len(c))
|
||||
for j, p := range c {
|
||||
if pm, ok := p.(map[string]interface{}); ok && pm["type"] == "text" {
|
||||
if t, ok := pm["text"].(string); ok {
|
||||
newPart := make(map[string]interface{}, len(pm))
|
||||
for k, v := range pm {
|
||||
newPart[k] = v
|
||||
}
|
||||
newPart["text"] = SanitizeText(t)
|
||||
newParts[j] = newPart
|
||||
continue
|
||||
}
|
||||
}
|
||||
newParts[j] = p
|
||||
}
|
||||
newMsg["content"] = newParts
|
||||
}
|
||||
result[i] = newMsg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func SanitizeSystem(system interface{}) interface{} {
|
||||
switch v := system.(type) {
|
||||
case string:
|
||||
return SanitizeText(v)
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
for i, p := range v {
|
||||
if pm, ok := p.(map[string]interface{}); ok && pm["type"] == "text" {
|
||||
if t, ok := pm["text"].(string); ok {
|
||||
newPart := make(map[string]interface{}, len(pm))
|
||||
for k, val := range pm {
|
||||
newPart[k] = val
|
||||
}
|
||||
newPart["text"] = SanitizeText(t)
|
||||
result[i] = newPart
|
||||
continue
|
||||
}
|
||||
}
|
||||
result[i] = p
|
||||
}
|
||||
return result
|
||||
}
|
||||
return system
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue