Skip to content

Commit b0c8c54

Browse files
committed
ccm/ocm: Make detour per-credential
1 parent 2a714ed commit b0c8c54

11 files changed

Lines changed: 135 additions & 99 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: 51 additions & 14 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 {
@@ -244,6 +272,7 @@ func (c *defaultCredential) pollUsage(ctx context.Context) {
244272
}
245273
request.Header.Set("Authorization", "Bearer "+accessToken)
246274
request.Header.Set("Content-Type", "application/json")
275+
request.Header.Set("anthropic-beta", anthropicBetaOAuthValue)
247276

248277
httpClient := &http.Client{
249278
Transport: c.httpClient.Transport,
@@ -264,12 +293,12 @@ func (c *defaultCredential) pollUsage(ctx context.Context) {
264293

265294
var usageResponse struct {
266295
FiveHour struct {
267-
Utilization float64 `json:"utilization"`
268-
ResetsAt int64 `json:"resets_at"`
296+
Utilization float64 `json:"utilization"`
297+
ResetsAt json.Number `json:"resets_at"`
269298
} `json:"five_hour"`
270299
SevenDay struct {
271-
Utilization float64 `json:"utilization"`
272-
ResetsAt int64 `json:"resets_at"`
300+
Utilization float64 `json:"utilization"`
301+
ResetsAt json.Number `json:"resets_at"`
273302
} `json:"seven_day"`
274303
}
275304
err = json.NewDecoder(response.Body).Decode(&usageResponse)
@@ -281,12 +310,14 @@ func (c *defaultCredential) pollUsage(ctx context.Context) {
281310
c.stateMutex.Lock()
282311
defer c.stateMutex.Unlock()
283312
c.state.fiveHourUtilization = usageResponse.FiveHour.Utilization * 100
284-
if usageResponse.FiveHour.ResetsAt > 0 {
285-
c.state.fiveHourReset = time.Unix(usageResponse.FiveHour.ResetsAt, 0)
313+
fiveHourReset, _ := usageResponse.FiveHour.ResetsAt.Int64()
314+
if fiveHourReset > 0 {
315+
c.state.fiveHourReset = time.Unix(fiveHourReset, 0)
286316
}
287317
c.state.weeklyUtilization = usageResponse.SevenDay.Utilization * 100
288-
if usageResponse.SevenDay.ResetsAt > 0 {
289-
c.state.weeklyReset = time.Unix(usageResponse.SevenDay.ResetsAt, 0)
318+
weeklyReset, _ := usageResponse.SevenDay.ResetsAt.Int64()
319+
if weeklyReset > 0 {
320+
c.state.weeklyReset = time.Unix(weeklyReset, 0)
290321
}
291322
if c.state.hardRateLimited && time.Now().After(c.state.rateLimitResetAt) {
292323
c.state.hardRateLimited = false
@@ -548,8 +579,8 @@ func extractCCMSessionID(bodyBytes []byte) string {
548579
}
549580

550581
func buildCredentialProviders(
582+
ctx context.Context,
551583
options option.CCMServiceOptions,
552-
httpClient *http.Client,
553584
logger log.ContextLogger,
554585
) (map[string]credentialProvider, []*defaultCredential, error) {
555586
defaultCredentials := make(map[string]*defaultCredential)
@@ -559,7 +590,10 @@ func buildCredentialProviders(
559590
for _, credOpt := range options.Credentials {
560591
switch credOpt.Type {
561592
case "default":
562-
credential := newDefaultCredential(credOpt.Tag, credOpt.DefaultOptions, httpClient, logger)
593+
credential, err := newDefaultCredential(ctx, credOpt.Tag, credOpt.DefaultOptions, logger)
594+
if err != nil {
595+
return nil, nil, err
596+
}
563597
defaultCredentials[credOpt.Tag] = credential
564598
allDefaults = append(allDefaults, credential)
565599
providers[credOpt.Tag] = &singleCredentialProvider{credential: credential}
@@ -645,13 +679,17 @@ func validateCCMOptions(options option.CCMServiceOptions) error {
645679
hasCredentials := len(options.Credentials) > 0
646680
hasLegacyPath := options.CredentialPath != ""
647681
hasLegacyUsages := options.UsagesPath != ""
682+
hasLegacyDetour := options.Detour != ""
648683

649684
if hasCredentials && hasLegacyPath {
650685
return E.New("credential_path and credentials are mutually exclusive")
651686
}
652687
if hasCredentials && hasLegacyUsages {
653688
return E.New("usages_path and credentials are mutually exclusive; use usages_path on individual credentials")
654689
}
690+
if hasCredentials && hasLegacyDetour {
691+
return E.New("detour and credentials are mutually exclusive; use detour on individual credentials")
692+
}
655693

656694
if hasCredentials {
657695
tags := make(map[string]bool)
@@ -685,7 +723,6 @@ func validateCCMOptions(options option.CCMServiceOptions) error {
685723

686724
// retryRequestWithBody re-sends a buffered request body using a different credential.
687725
func retryRequestWithBody(
688-
httpClient *http.Client,
689726
originalRequest *http.Request,
690727
bodyBytes []byte,
691728
credential *defaultCredential,
@@ -726,7 +763,7 @@ func retryRequestWithBody(
726763
}
727764
retryRequest.Header.Set("Authorization", "Bearer "+accessToken)
728765

729-
return httpClient.Do(retryRequest)
766+
return credential.httpClient.Do(retryRequest)
730767
}
731768

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

0 commit comments

Comments
 (0)