Skip to content

Commit 51116fd

Browse files
committed
feat: add multi-account support with load balancing and persistent conversation handling
1 parent 8576f8a commit 51116fd

27 files changed

Lines changed: 1208 additions & 650 deletions

‎.dockerignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ captured
88
.env
99
.env.*
1010
README.tmp
11+
accounts.txt
12+
claude.ai.har

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
.env.*
44
captured/
55
.claude/
6-
6+
.workbuddy/
77
# Binaries
88
*.exe
99
*.exe~

‎README.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
- 支持非流式与 SSE 流式返回
1919
- 支持 `conversation_id` 持久会话模式,保持多轮对话连续性
2020
- 支持完整浏览器 Cookie 模式,更接近 claude.ai 浏览器请求环境
21+
- 支持 `accounts.txt` 多账号池,按当前活跃请求数进行最小负载分发
22+
- 账号级复用 TLS Client、CookieJar、浏览器身份和组织信息,避免每请求重复初始化
23+
- 持久会话按账号隔离,并串行化同一 `conversation_id` 的并发轮次,避免会话串扰
2124
- 支持 Bearer sessionKey 模式,便于本地简单调用
2225
- Bearer 模式下会自动生成可由前端生成的浏览器环境 Cookie/Header;签名或 Cloudflare 类 Cookie 不伪造、不传递
2326
- completion 请求会携带从真实浏览器请求逆向得到的 claude.ai web `tools` 字段
@@ -77,6 +80,15 @@ CLAUDE_SESSION_KEY='你的-sessionKey' PORT=8080 ./claude2api.exe
7780
CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' PORT=8080 ./claude2api.exe
7881
```
7982

83+
使用多账号池:在工作目录创建 `accounts.txt`,每行放一个 sessionKey 或一条完整 Cookie,空行和 `#` 注释会被忽略:
84+
85+
```text
86+
sk-ant-sid01-...
87+
sessionKey=sk-ant-sid02-...; sessionKeyLC=...; anthropic-device-id=...; ...
88+
```
89+
90+
也可通过 `CLAUDE_ACCOUNTS_FILE` 指定其他账号文件。未显式携带认证 Header 的请求会从账号池选择当前活跃请求最少的账号;显式传入 `Authorization` 或 `X-Claude-Cookie` 时仍固定使用该账号。
91+
8092
服务地址:
8193

8294
```text
@@ -179,6 +191,7 @@ CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' d
179191
| `CLAUDE_BASE_URL` | `https://claude.ai` | claude.ai 上游地址。 |
180192
| `CLAUDE_SESSION_KEY` | 空 | claude.ai 的 `sessionKey`。配置后请求端可以不传 Bearer。 |
181193
| `CLAUDE_COOKIE` | 空 | 从浏览器复制的完整 claude.ai Cookie。推荐用于更接近浏览器环境。 |
194+
| `CLAUDE_ACCOUNTS_FILE` | `accounts.txt` | 多账号文件路径;每行一个 sessionKey 或完整 Cookie。 |
182195
| `CLAUDE_TIMEZONE` | `Asia/Singapore` | 发送给 claude.ai 的时区。 |
183196
| `CLAUDE_LOCALE` | `en-US` | 发送给 claude.ai 的语言区域。 |
184197
| `DEFAULT_MODEL` | `claude-sonnet-5` | 请求未指定模型时使用的默认模型。必须在支持模型列表内。 |
@@ -242,7 +255,7 @@ X-Claude-Cookie: <从 claude.ai 浏览器请求中复制的完整 Cookie>
242255

243256
## 注意事项
244257

245-
- 每次 completion 请求都会创建一个临时 claude.ai 会话,请求结束后会尝试删除。
258+
- 未传 `conversation_id` 时,每次 completion 会创建临时 claude.ai 会话,并在响应完成后异步尝试删除。
246259
- `usage` 中的 token 数是近似值,目前主要根据输出文本长度估算。
247260
- 如果 Bearer sessionKey 模式和浏览器行为不一致,建议使用完整 Cookie 模式。
248261
- 如果上游返回 `429`,说明 claude.ai 当前账号或会话触发了速率限制。

‎README_EN.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ It reverse-proxies requests to `https://claude.ai` using a browser-like TLS/clie
1313
- Streaming and non-streaming responses
1414
- Persistent `conversation_id` mode for multi-turn conversation continuity
1515
- Browser Cookie mode to match a real claude.ai browser session
16+
- Multi-account pool via `accounts.txt`, routed by the lowest active request count
17+
- Account-scoped TLS client, CookieJar, browser identity, and organization cache reuse
18+
- Persistent conversations isolated by account, with concurrent turns serialized per `conversation_id`
1619
- Bearer session key mode for simple local use
1720
- Dedicated local `tlsclient` module wrapping the Chrome-profile `github.com/bogdanfinn/tls-client` client, CookieJar, and common browser headers
1821
- Completion requests include the claude.ai web `tools` payload reverse-engineered from a real browser request
@@ -64,6 +67,7 @@ The service is configured with environment variables.
6467
| `CLAUDE_BASE_URL` | `https://claude.ai` | Upstream claude.ai base URL. |
6568
| `CLAUDE_SESSION_KEY` | empty | Optional claude.ai `sessionKey`. If set, API requests do not need a Bearer token. |
6669
| `CLAUDE_COOKIE` | empty | Optional full browser Cookie header from claude.ai. Recommended when you want behavior closest to the browser. |
70+
| `CLAUDE_ACCOUNTS_FILE` | `accounts.txt` | Multi-account file; one session key or full Cookie header per line. |
6771
| `CLAUDE_TIMEZONE` | `Asia/Singapore` | Timezone sent to claude.ai completion requests. |
6872
| `CLAUDE_LOCALE` | `en-US` | Locale sent to claude.ai completion requests. |
6973
| `DEFAULT_MODEL` | `claude-sonnet-5` | Model used when a request omits `model`. Must be one of the supported models. |
@@ -104,6 +108,15 @@ Full browser Cookie mode:
104108
CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' PORT=8080 ./claude2api.exe
105109
```
106110

111+
Multi-account mode: create `accounts.txt` in the working directory, with one session key or full Cookie header per line. Blank lines and `#` comments are ignored:
112+
113+
```text
114+
sk-ant-sid01-...
115+
sessionKey=sk-ant-sid02-...; sessionKeyLC=...; anthropic-device-id=...; ...
116+
```
117+
118+
Use `CLAUDE_ACCOUNTS_FILE` to select another path. Requests without explicit authentication headers are routed to the least-loaded configured account; requests with `Authorization` or `X-Claude-Cookie` remain pinned to those credentials.
119+
107120
Then use the local base URL:
108121

109122
```text
@@ -188,7 +201,7 @@ See [API_EN.md](API_EN.md) for endpoint details and examples.
188201

189202
## Notes
190203

191-
- The proxy creates a temporary claude.ai conversation for each completion and deletes it after the request.
204+
- Without `conversation_id`, the proxy creates a temporary claude.ai conversation for each completion and asynchronously deletes it after the response.
192205
- Token usage values are approximate and currently based on output text length.
193206
- If browser and Bearer modes behave differently, prefer full Cookie mode because it carries the same request environment as the browser.
194207
- A `429` response is returned by claude.ai when the upstream account/session is rate-limited.

‎claude/client.go‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"io"
1111
"strings"
12+
"sync"
1213
"time"
1314

1415
"claude2api/models"
@@ -24,6 +25,7 @@ type Client struct {
2425
baseURL string
2526
sessionKey string
2627
claudeCookie string
28+
orgMu sync.Mutex
2729
orgID string // cached org UUID
2830
deviceID string // anthropic-device-id
2931
sessionKeyLC string
@@ -205,16 +207,15 @@ func (c *Client) doRequest(ctx context.Context, method, path string, body io.Rea
205207
req.AddCookie(&http.Cookie{Name: "_gcl_au", Value: c.gclAU})
206208
req.AddCookie(&http.Cookie{Name: "ion-vk", Value: c.ionVK})
207209
req.AddCookie(&http.Cookie{Name: "_dd_s", Value: c.generatedDDSCookie()})
208-
if c.orgID != "" {
209-
req.AddCookie(&http.Cookie{Name: "lastActiveOrg", Value: c.orgID})
210-
}
211210
}
212211

213212
return c.httpClient.Do(req)
214213
}
215214

216215
// GetOrganization fetches the user's organization UUID
217216
func (c *Client) GetOrganization(ctx context.Context) (string, error) {
217+
c.orgMu.Lock()
218+
defer c.orgMu.Unlock()
218219
if c.orgID != "" {
219220
return c.orgID, nil
220221
}
@@ -328,7 +329,7 @@ func (c *Client) SendMessage(ctx context.Context, convID string, req *models.Cla
328329
defer resp.Body.Close()
329330

330331
scanner := bufio.NewScanner(resp.Body)
331-
scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024)
332+
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
332333

333334
var eventType, data string
334335
for scanner.Scan() {
@@ -342,7 +343,11 @@ func (c *Client) SendMessage(ctx context.Context, convID string, req *models.Cla
342343

343344
if line == "" {
344345
if data != "" {
345-
events <- parseCompletionEvent(eventType, data)
346+
select {
347+
case events <- parseCompletionEvent(eventType, data):
348+
case <-ctx.Done():
349+
return
350+
}
346351
}
347352
eventType = ""
348353
data = ""
@@ -361,7 +366,11 @@ func (c *Client) SendMessage(ctx context.Context, convID string, req *models.Cla
361366
}
362367

363368
if data != "" {
364-
events <- parseCompletionEvent(eventType, data)
369+
select {
370+
case events <- parseCompletionEvent(eventType, data):
371+
case <-ctx.Done():
372+
return
373+
}
365374
}
366375
}()
367376

‎config/config.go‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"bufio"
45
"encoding/json"
56
"os"
67
"strings"
@@ -17,6 +18,14 @@ type Config struct {
1718
DefaultModel string
1819
Effort string
1920
Thinking interface{}
21+
Accounts []Account
22+
}
23+
24+
// Account contains credentials for one claude.ai account. Cookie may contain
25+
// the full browser Cookie header; SessionKey is used for Bearer-only mode.
26+
type Account struct {
27+
SessionKey string
28+
Cookie string
2029
}
2130

2231
// New creates a Config from environment variables with sane defaults
@@ -38,7 +47,7 @@ func New() *Config {
3847

3948
locale := os.Getenv("CLAUDE_LOCALE")
4049
if locale == "" {
41-
locale = "en-US"
50+
locale = "en-US"
4251
}
4352

4453
timezone := os.Getenv("CLAUDE_TIMEZONE")
@@ -62,17 +71,77 @@ func New() *Config {
6271
thinkingRaw := os.Getenv("CLAUDE_THINKING")
6372
thinking := parseThinkingEnv(thinkingRaw)
6473

74+
sessionKey := os.Getenv("CLAUDE_SESSION_KEY")
75+
claudeCookie := os.Getenv("CLAUDE_COOKIE")
76+
accounts := loadAccounts(os.Getenv("CLAUDE_ACCOUNTS_FILE"), sessionKey, claudeCookie)
77+
6578
return &Config{
6679
Port: port,
6780
ClaudeBaseURL: baseURL,
68-
SessionKey: os.Getenv("CLAUDE_SESSION_KEY"),
69-
ClaudeCookie: os.Getenv("CLAUDE_COOKIE"),
81+
SessionKey: sessionKey,
82+
ClaudeCookie: claudeCookie,
7083
Timezone: timezone,
7184
Locale: locale,
7285
DefaultModel: model,
7386
Effort: effort,
7487
Thinking: thinking,
88+
Accounts: accounts,
89+
}
90+
}
91+
92+
func loadAccounts(path, fallbackSessionKey, fallbackCookie string) []Account {
93+
if strings.TrimSpace(path) == "" {
94+
path = "accounts.txt"
95+
}
96+
97+
accounts := make([]Account, 0)
98+
seen := make(map[string]struct{})
99+
add := func(account Account) {
100+
account.SessionKey = strings.TrimSpace(account.SessionKey)
101+
account.Cookie = strings.TrimSpace(account.Cookie)
102+
if account.SessionKey == "" {
103+
account.SessionKey = sessionKeyFromCookie(account.Cookie)
104+
}
105+
if account.SessionKey == "" {
106+
return
107+
}
108+
key := account.SessionKey + "\x00" + account.Cookie
109+
if _, ok := seen[key]; ok {
110+
return
111+
}
112+
seen[key] = struct{}{}
113+
accounts = append(accounts, account)
114+
}
115+
116+
if f, err := os.Open(path); err == nil {
117+
defer f.Close()
118+
scanner := bufio.NewScanner(f)
119+
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
120+
for scanner.Scan() {
121+
line := strings.TrimSpace(scanner.Text())
122+
if line == "" || strings.HasPrefix(line, "#") {
123+
continue
124+
}
125+
if strings.Contains(line, "sessionKey=") {
126+
add(Account{Cookie: line})
127+
} else {
128+
add(Account{SessionKey: line})
129+
}
130+
}
131+
}
132+
133+
add(Account{SessionKey: fallbackSessionKey, Cookie: fallbackCookie})
134+
return accounts
135+
}
136+
137+
func sessionKeyFromCookie(cookie string) string {
138+
for _, part := range strings.Split(cookie, ";") {
139+
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
140+
if len(kv) == 2 && kv[0] == "sessionKey" {
141+
return kv[1]
142+
}
75143
}
144+
return ""
76145
}
77146

78147
// parseThinkingEnv parses the CLAUDE_THINKING env var into a value suitable for

‎config/config_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestLoadAccounts(t *testing.T) {
10+
path := filepath.Join(t.TempDir(), "accounts.txt")
11+
contents := "# comment\naccount-a\n\nsessionKey=account-b; anthropic-device-id=device-b\naccount-a\n"
12+
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
13+
t.Fatalf("write accounts: %v", err)
14+
}
15+
16+
accounts := loadAccounts(path, "account-c", "")
17+
if len(accounts) != 3 {
18+
t.Fatalf("expected 3 unique accounts, got %d: %#v", len(accounts), accounts)
19+
}
20+
if accounts[0].SessionKey != "account-a" || accounts[1].SessionKey != "account-b" || accounts[2].SessionKey != "account-c" {
21+
t.Fatalf("unexpected accounts: %#v", accounts)
22+
}
23+
if accounts[1].Cookie == "" {
24+
t.Fatal("full Cookie line was not preserved")
25+
}
26+
}

‎docker-compose.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ services:
66
restart: unless-stopped
77
ports:
88
- "${PORT:-8080}:8080"
9+
volumes:
10+
- "${CLAUDE_ACCOUNTS_FILE:-./accounts.txt}:/app/accounts.txt:ro"
911
environment:
1012
PORT: "8080"
1113
CLAUDE_BASE_URL: "${CLAUDE_BASE_URL:-https://claude.ai}"
1214
CLAUDE_SESSION_KEY: "${CLAUDE_SESSION_KEY:-}"
1315
CLAUDE_COOKIE: "${CLAUDE_COOKIE:-}"
16+
CLAUDE_ACCOUNTS_FILE: "/app/accounts.txt"
1417
CLAUDE_TIMEZONE: "${CLAUDE_TIMEZONE:-Asia/Singapore}"
1518
CLAUDE_LOCALE: "${CLAUDE_LOCALE:-en-US}"
1619
DEFAULT_MODEL: "${DEFAULT_MODEL:-claude-sonnet-5}"

0 commit comments

Comments
 (0)