Skip to content

Commit 56e9792

Browse files
committed
ccm/ocm: Make detour per-credential
1 parent cbb276c commit 56e9792

11 files changed

Lines changed: 124 additions & 91 deletions

File tree

docs/configuration/service/ccm.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Conflict with `credentials`.
5959

6060
List of credential configurations for multi-credential mode.
6161

62-
When set, top-level `credential_path` and `usages_path` are forbidden. Each user must specify a `credential` tag.
62+
When set, top-level `credential_path`, `usages_path`, and `detour` are forbidden. Each user must specify a `credential` tag.
6363

6464
Each credential has a `type` field (`default`, `balancer`, or `fallback`) and a required `tag` field.
6565

@@ -70,6 +70,7 @@ Each credential has a `type` field (`default`, `balancer`, or `fallback`) and a
7070
"tag": "a",
7171
"credential_path": "/path/to/.credentials.json",
7272
"usages_path": "/path/to/usages.json",
73+
"detour": "",
7374
"reserve_5h": 20,
7475
"reserve_weekly": 20
7576
}
@@ -79,6 +80,7 @@ A single OAuth credential file. The `type` field can be omitted (defaults to `de
7980

8081
- `credential_path`: Path to the credentials file. Same defaults as top-level `credential_path`.
8182
- `usages_path`: Optional usage tracking file for this credential.
83+
- `detour`: Outbound tag for connecting to the Claude API with this credential.
8284
- `reserve_5h`: Reserve threshold (1-99) for 5-hour window. Credential pauses at (100-N)% utilization.
8385
- `reserve_weekly`: Reserve threshold (1-99) for weekly window. Credential pauses at (100-N)% utilization.
8486

@@ -163,6 +165,8 @@ These headers will override any existing headers with the same name.
163165

164166
Outbound tag for connecting to the Claude API.
165167

168+
Conflict with `credentials`. In multi-credential mode, use `detour` on individual default credentials.
169+
166170
#### tls
167171

168172
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).

docs/configuration/service/ccm.zh.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Claude Code OAuth 凭据文件的路径。
5959

6060
多凭据模式的凭据配置列表。
6161

62-
设置后,顶层 `credential_path``usages_path` 被禁止。每个用户必须指定 `credential` 标签。
62+
设置后,顶层 `credential_path``usages_path``detour` 被禁止。每个用户必须指定 `credential` 标签。
6363

6464
每个凭据有一个 `type` 字段(`default``balancer``fallback`)和一个必填的 `tag` 字段。
6565

@@ -70,6 +70,7 @@ Claude Code OAuth 凭据文件的路径。
7070
"tag": "a",
7171
"credential_path": "/path/to/.credentials.json",
7272
"usages_path": "/path/to/usages.json",
73+
"detour": "",
7374
"reserve_5h": 20,
7475
"reserve_weekly": 20
7576
}
@@ -79,6 +80,7 @@ Claude Code OAuth 凭据文件的路径。
7980

8081
- `credential_path`:凭据文件的路径。默认值与顶层 `credential_path` 相同。
8182
- `usages_path`:此凭据的可选使用跟踪文件。
83+
- `detour`:此凭据用于连接 Claude API 的出站标签。
8284
- `reserve_5h`:5 小时窗口的保留阈值(1-99)。凭据在利用率达到 (100-N)% 时暂停。
8385
- `reserve_weekly`:每周窗口的保留阈值(1-99)。凭据在利用率达到 (100-N)% 时暂停。
8486

@@ -163,6 +165,8 @@ Claude Code OAuth 凭据文件的路径。
163165

164166
用于连接 Claude API 的出站标签。
165167

168+
`credentials` 冲突。在多凭据模式下,在各个默认凭据上使用 `detour`
169+
166170
#### tls
167171

168172
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)

docs/configuration/service/ocm.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Conflict with `credentials`.
5757

5858
List of credential configurations for multi-credential mode.
5959

60-
When set, top-level `credential_path` and `usages_path` are forbidden. Each user must specify a `credential` tag.
60+
When set, top-level `credential_path`, `usages_path`, and `detour` are forbidden. Each user must specify a `credential` tag.
6161

6262
Each credential has a `type` field (`default`, `balancer`, or `fallback`) and a required `tag` field.
6363

@@ -68,6 +68,7 @@ Each credential has a `type` field (`default`, `balancer`, or `fallback`) and a
6868
"tag": "a",
6969
"credential_path": "/path/to/auth.json",
7070
"usages_path": "/path/to/usages.json",
71+
"detour": "",
7172
"reserve_5h": 20,
7273
"reserve_weekly": 20
7374
}
@@ -77,6 +78,7 @@ A single OAuth credential file. The `type` field can be omitted (defaults to `de
7778

7879
- `credential_path`: Path to the credentials file. Same defaults as top-level `credential_path`.
7980
- `usages_path`: Optional usage tracking file for this credential.
81+
- `detour`: Outbound tag for connecting to the OpenAI API with this credential.
8082
- `reserve_5h`: Reserve threshold (1-99) for primary rate limit window. Credential pauses at (100-N)% utilization.
8183
- `reserve_weekly`: Reserve threshold (1-99) for secondary (weekly) rate limit window. Credential pauses at (100-N)% utilization.
8284

@@ -161,6 +163,8 @@ These headers will override any existing headers with the same name.
161163

162164
Outbound tag for connecting to the OpenAI API.
163165

166+
Conflict with `credentials`. In multi-credential mode, use `detour` on individual default credentials.
167+
164168
#### tls
165169

166170
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).

docs/configuration/service/ocm.zh.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ OpenAI OAuth 凭据文件的路径。
5757

5858
多凭据模式的凭据配置列表。
5959

60-
设置后,顶层 `credential_path``usages_path` 被禁止。每个用户必须指定 `credential` 标签。
60+
设置后,顶层 `credential_path``usages_path``detour` 被禁止。每个用户必须指定 `credential` 标签。
6161

6262
每个凭据有一个 `type` 字段(`default``balancer``fallback`)和一个必填的 `tag` 字段。
6363

@@ -68,6 +68,7 @@ OpenAI OAuth 凭据文件的路径。
6868
"tag": "a",
6969
"credential_path": "/path/to/auth.json",
7070
"usages_path": "/path/to/usages.json",
71+
"detour": "",
7172
"reserve_5h": 20,
7273
"reserve_weekly": 20
7374
}
@@ -77,6 +78,7 @@ OpenAI OAuth 凭据文件的路径。
7778

7879
- `credential_path`:凭据文件的路径。默认值与顶层 `credential_path` 相同。
7980
- `usages_path`:此凭据的可选使用跟踪文件。
81+
- `detour`:此凭据用于连接 OpenAI API 的出站标签。
8082
- `reserve_5h`:主要速率限制窗口的保留阈值(1-99)。凭据在利用率达到 (100-N)% 时暂停。
8183
- `reserve_weekly`:次要(每周)速率限制窗口的保留阈值(1-99)。凭据在利用率达到 (100-N)% 时暂停。
8284

@@ -161,6 +163,8 @@ OpenAI OAuth 凭据文件的路径。
161163

162164
用于连接 OpenAI API 的出站标签。
163165

166+
`credentials` 冲突。在多凭据模式下,在各个默认凭据上使用 `detour`
167+
164168
#### tls
165169

166170
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)

option/ccm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func (c *CCMCredential) UnmarshalJSON(bytes []byte) error {
7676
type CCMDefaultCredentialOptions struct {
7777
CredentialPath string `json:"credential_path,omitempty"`
7878
UsagesPath string `json:"usages_path,omitempty"`
79+
Detour string `json:"detour,omitempty"`
7980
Reserve5h uint8 `json:"reserve_5h,omitempty"`
8081
ReserveWeekly uint8 `json:"reserve_weekly,omitempty"`
8182
}

option/ocm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func (c *OCMCredential) UnmarshalJSON(bytes []byte) error {
7676
type OCMDefaultCredentialOptions struct {
7777
CredentialPath string `json:"credential_path,omitempty"`
7878
UsagesPath string `json:"usages_path,omitempty"`
79+
Detour string `json:"detour,omitempty"`
7980
Reserve5h uint8 `json:"reserve_5h,omitempty"`
8081
ReserveWeekly uint8 `json:"reserve_weekly,omitempty"`
8182
}

service/ccm/credential_state.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@ package ccm
33
import (
44
"bytes"
55
"context"
6+
stdTLS "crypto/tls"
67
"encoding/json"
78
"io"
9+
"net"
810
"net/http"
911
"strconv"
1012
"strings"
1113
"sync"
1214
"time"
1315

16+
"github.com/sagernet/sing-box/adapter"
17+
"github.com/sagernet/sing-box/common/dialer"
1418
"github.com/sagernet/sing-box/log"
1519
"github.com/sagernet/sing-box/option"
1620
E "github.com/sagernet/sing/common/exceptions"
21+
M "github.com/sagernet/sing/common/metadata"
22+
"github.com/sagernet/sing/common/ntp"
1723
)
1824

1925
const defaultPollInterval = 60 * time.Second
@@ -44,7 +50,29 @@ type defaultCredential struct {
4450
logger log.ContextLogger
4551
}
4652

47-
func newDefaultCredential(tag string, options option.CCMDefaultCredentialOptions, httpClient *http.Client, logger log.ContextLogger) *defaultCredential {
53+
func newDefaultCredential(ctx context.Context, tag string, options option.CCMDefaultCredentialOptions, logger log.ContextLogger) (*defaultCredential, error) {
54+
credentialDialer, err := dialer.NewWithOptions(dialer.Options{
55+
Context: ctx,
56+
Options: option.DialerOptions{
57+
Detour: options.Detour,
58+
},
59+
RemoteIsDomain: true,
60+
})
61+
if err != nil {
62+
return nil, E.Cause(err, "create dialer for credential ", tag)
63+
}
64+
httpClient := &http.Client{
65+
Transport: &http.Transport{
66+
ForceAttemptHTTP2: true,
67+
TLSClientConfig: &stdTLS.Config{
68+
RootCAs: adapter.RootPoolFromContext(ctx),
69+
Time: ntp.TimeFuncFromContext(ctx),
70+
},
71+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
72+
return credentialDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
73+
},
74+
},
75+
}
4876
credential := &defaultCredential{
4977
tag: tag,
5078
credentialPath: options.CredentialPath,
@@ -61,7 +89,7 @@ func newDefaultCredential(tag string, options option.CCMDefaultCredentialOptions
6189
logger: logger,
6290
}
6391
}
64-
return credential
92+
return credential, nil
6593
}
6694

6795
func (c *defaultCredential) start() error {
@@ -548,8 +576,8 @@ func extractCCMSessionID(bodyBytes []byte) string {
548576
}
549577

550578
func buildCredentialProviders(
579+
ctx context.Context,
551580
options option.CCMServiceOptions,
552-
httpClient *http.Client,
553581
logger log.ContextLogger,
554582
) (map[string]credentialProvider, []*defaultCredential, error) {
555583
defaultCredentials := make(map[string]*defaultCredential)
@@ -559,7 +587,10 @@ func buildCredentialProviders(
559587
for _, credOpt := range options.Credentials {
560588
switch credOpt.Type {
561589
case "default":
562-
credential := newDefaultCredential(credOpt.Tag, credOpt.DefaultOptions, httpClient, logger)
590+
credential, err := newDefaultCredential(ctx, credOpt.Tag, credOpt.DefaultOptions, logger)
591+
if err != nil {
592+
return nil, nil, err
593+
}
563594
defaultCredentials[credOpt.Tag] = credential
564595
allDefaults = append(allDefaults, credential)
565596
providers[credOpt.Tag] = &singleCredentialProvider{credential: credential}
@@ -645,13 +676,17 @@ func validateCCMOptions(options option.CCMServiceOptions) error {
645676
hasCredentials := len(options.Credentials) > 0
646677
hasLegacyPath := options.CredentialPath != ""
647678
hasLegacyUsages := options.UsagesPath != ""
679+
hasLegacyDetour := options.Detour != ""
648680

649681
if hasCredentials && hasLegacyPath {
650682
return E.New("credential_path and credentials are mutually exclusive")
651683
}
652684
if hasCredentials && hasLegacyUsages {
653685
return E.New("usages_path and credentials are mutually exclusive; use usages_path on individual credentials")
654686
}
687+
if hasCredentials && hasLegacyDetour {
688+
return E.New("detour and credentials are mutually exclusive; use detour on individual credentials")
689+
}
655690

656691
if hasCredentials {
657692
tags := make(map[string]bool)
@@ -685,7 +720,6 @@ func validateCCMOptions(options option.CCMServiceOptions) error {
685720

686721
// retryRequestWithBody re-sends a buffered request body using a different credential.
687722
func retryRequestWithBody(
688-
httpClient *http.Client,
689723
originalRequest *http.Request,
690724
bodyBytes []byte,
691725
credential *defaultCredential,
@@ -726,7 +760,7 @@ func retryRequestWithBody(
726760
}
727761
retryRequest.Header.Set("Authorization", "Bearer "+accessToken)
728762

729-
return httpClient.Do(retryRequest)
763+
return credential.httpClient.Do(retryRequest)
730764
}
731765

732766
// credentialForUser finds the credential provider for a user.

service/ccm/service.go

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ package ccm
33
import (
44
"bytes"
55
"context"
6-
stdTLS "crypto/tls"
76
"encoding/json"
87
"errors"
98
"io"
109
"mime"
11-
"net"
1210
"net/http"
1311
"strconv"
1412
"strings"
@@ -17,7 +15,6 @@ import (
1715

1816
"github.com/sagernet/sing-box/adapter"
1917
boxService "github.com/sagernet/sing-box/adapter/service"
20-
"github.com/sagernet/sing-box/common/dialer"
2118
"github.com/sagernet/sing-box/common/listener"
2219
"github.com/sagernet/sing-box/common/tls"
2320
C "github.com/sagernet/sing-box/constant"
@@ -26,9 +23,7 @@ import (
2623
"github.com/sagernet/sing/common"
2724
"github.com/sagernet/sing/common/buf"
2825
E "github.com/sagernet/sing/common/exceptions"
29-
M "github.com/sagernet/sing/common/metadata"
3026
N "github.com/sagernet/sing/common/network"
31-
"github.com/sagernet/sing/common/ntp"
3227
aTLS "github.com/sagernet/sing/common/tls"
3328

3429
"github.com/anthropics/anthropic-sdk-go"
@@ -114,7 +109,6 @@ type Service struct {
114109
ctx context.Context
115110
logger log.ContextLogger
116111
options option.CCMServiceOptions
117-
httpClient *http.Client
118112
httpHeaders http.Header
119113
listener *listener.Listener
120114
tlsConfig tls.ServerConfig
@@ -139,30 +133,6 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
139133
return nil, E.Cause(err, "validate options")
140134
}
141135

142-
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
143-
Context: ctx,
144-
Options: option.DialerOptions{
145-
Detour: options.Detour,
146-
},
147-
RemoteIsDomain: true,
148-
})
149-
if err != nil {
150-
return nil, E.Cause(err, "create dialer")
151-
}
152-
153-
httpClient := &http.Client{
154-
Transport: &http.Transport{
155-
ForceAttemptHTTP2: true,
156-
TLSClientConfig: &stdTLS.Config{
157-
RootCAs: adapter.RootPoolFromContext(ctx),
158-
Time: ntp.TimeFuncFromContext(ctx),
159-
},
160-
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
161-
return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
162-
},
163-
},
164-
}
165-
166136
userManager := &UserManager{
167137
tokenMap: make(map[string]string),
168138
}
@@ -172,7 +142,6 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
172142
ctx: ctx,
173143
logger: logger,
174144
options: options,
175-
httpClient: httpClient,
176145
httpHeaders: options.Headers.Build(),
177146
listener: listener.New(listener.Options{
178147
Context: ctx,
@@ -184,7 +153,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
184153
}
185154

186155
if len(options.Credentials) > 0 {
187-
providers, allDefaults, err := buildCredentialProviders(options, httpClient, logger)
156+
providers, allDefaults, err := buildCredentialProviders(ctx, options, logger)
188157
if err != nil {
189158
return nil, E.Cause(err, "build credential providers")
190159
}
@@ -197,10 +166,14 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
197166
}
198167
service.userCredentialMap = userCredentialMap
199168
} else {
200-
credential := newDefaultCredential("default", option.CCMDefaultCredentialOptions{
169+
credential, err := newDefaultCredential(ctx, "default", option.CCMDefaultCredentialOptions{
201170
CredentialPath: options.CredentialPath,
202171
UsagesPath: options.UsagesPath,
203-
}, httpClient, logger)
172+
Detour: options.Detour,
173+
}, logger)
174+
if err != nil {
175+
return nil, err
176+
}
204177
service.legacyCredential = credential
205178
service.legacyProvider = &singleCredentialProvider{credential: credential}
206179
service.allDefaults = []*defaultCredential{credential}
@@ -402,7 +375,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
402375

403376
proxyRequest.Header.Set("Authorization", "Bearer "+accessToken)
404377

405-
response, err := s.httpClient.Do(proxyRequest)
378+
response, err := credential.httpClient.Do(proxyRequest)
406379
if err != nil {
407380
writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error())
408381
return
@@ -417,7 +390,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
417390
}
418391
response.Body.Close()
419392
s.logger.Info("retrying with credential ", nextCredential.tag, " after 429 from ", credential.tag)
420-
retryResponse, retryErr := retryRequestWithBody(s.httpClient, r, bodyBytes, nextCredential, s.httpHeaders)
393+
retryResponse, retryErr := retryRequestWithBody(r, bodyBytes, nextCredential, s.httpHeaders)
421394
if retryErr != nil {
422395
s.logger.Error("retry request: ", retryErr)
423396
writeJSONError(w, r, http.StatusBadGateway, "api_error", retryErr.Error())

0 commit comments

Comments
 (0)