Skip to content

Commit 8a79bcb

Browse files
committed
Merge upstream changes (v7.2.115) with local modifications
- Preserve client-specific Codex refresh deduplication while applying the upstream refresh timeout - Sync translator, executor, and model configuration fixes
2 parents 44be8ef + ffdb9c9 commit 8a79bcb

36 files changed

Lines changed: 1619 additions & 185 deletions

‎cmd/server/main.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,11 @@ func main() {
285285
homeCfg.DisableClusterDiscovery = true
286286
}
287287
homeClient = home.New(homeCfg)
288-
defer homeClient.Close()
288+
defer func() {
289+
if homeClient != nil {
290+
homeClient.Close()
291+
}
292+
}()
289293

290294
ctxHomeConfig, cancelHomeConfig := context.WithTimeout(context.Background(), 30*time.Second)
291295
raw, errGetConfig := homeClient.GetConfig(ctxHomeConfig)
@@ -615,6 +619,12 @@ func main() {
615619
return
616620
}
617621
}
622+
if homeClient != nil {
623+
// The bootstrap client is not owned by the runtime service. Close it after
624+
// the final startup report so it cannot retain an idle RESP connection.
625+
homeClient.Close()
626+
homeClient = nil
627+
}
618628
if pluginHost.HasTriggeredCommandLineFlags() {
619629
if exitCode, handled := pluginHost.ExecuteCommandLine(context.Background(), os.Args[0], os.Args[1:], configFilePath, flag.CommandLine); handled {
620630
if exitCode != 0 {

‎config.example.yaml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ nonstream-keepalive-interval: 0
330330
# - name: "gemini-2.5-flash" # upstream model name
331331
# alias: "gemini-flash" # client alias mapped to the upstream model
332332
# display-name: "Gemini Flash" # optional catalog display name
333+
# max-context-length: 1048576 # optional: override Codex client context window metadata
333334
# thinking: # optional: exact thinking capability for this configured model
334335
# levels: ["high", "medium", "low", "none", "auto"]
335336
# excluded-models:
@@ -355,6 +356,7 @@ nonstream-keepalive-interval: 0
355356
# models:
356357
# - name: "gemini-2.5-flash" # upstream model name
357358
# alias: "native-gemini-flash" # client alias mapped to the upstream model
359+
# max-context-length: 1048576 # optional: override Codex client context window metadata
358360
# thinking: # optional: exact thinking capability for this configured model
359361
# levels: ["high", "medium", "low", "none", "auto"]
360362
# excluded-models:
@@ -376,6 +378,7 @@ nonstream-keepalive-interval: 0
376378
# - name: "gpt-5-codex" # upstream model name
377379
# alias: "codex-latest" # client alias mapped to the upstream model
378380
# display-name: "Codex Latest" # optional catalog display name
381+
# max-context-length: 1048576 # optional: override Codex client context window metadata
379382
# force-mapping: true # optional: rewrite response model fields back to the alias
380383
# thinking: # optional: exact thinking capability for this configured model
381384
# levels: ["xhigh", "high", "medium", "low"]
@@ -402,6 +405,7 @@ nonstream-keepalive-interval: 0
402405
# - name: "grok-4.5" # upstream model name
403406
# alias: "grok-latest" # client alias mapped to the upstream model
404407
# display-name: "Grok Latest" # optional catalog display name
408+
# max-context-length: 1048576 # optional: override Codex client context window metadata
405409
# force-mapping: true # optional: rewrite response model fields back to the alias
406410
# thinking: # optional: exact thinking capability for this configured model
407411
# levels: ["xhigh", "high", "medium", "low"]
@@ -425,6 +429,7 @@ nonstream-keepalive-interval: 0
425429
# - name: "claude-3-5-sonnet-20241022" # upstream model name
426430
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
427431
# display-name: "Claude Sonnet" # optional catalog display name
432+
# max-context-length: 1048576 # optional: override Codex client context window metadata
428433
# force-mapping: true # optional: rewrite response model fields back to the alias
429434
# thinking: # optional: exact thinking capability for this configured model
430435
# levels: ["max", "xhigh", "high", "medium", "low", "minimal", "none", "auto"]
@@ -494,8 +499,9 @@ nonstream-keepalive-interval: 0
494499
# - name: "moonshotai/kimi-k2:free" # The actual model name.
495500
# alias: "kimi-k2" # The alias used in the API.
496501
# display-name: "Kimi K2" # optional catalog display name
502+
# max-context-length: 1048576 # optional: override Codex client context window metadata
497503
# image: false # optional: set true to allow this model on /v1/images/generations and /v1/images/edits (not chat/responses image input)
498-
# input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients
504+
# input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients. Use [text] for upstreams that reject multimodal tool result content.
499505
# output-modalities: [text] # optional: declare output modalities when known
500506
# thinking: # optional: omit to default to levels ["low","medium","high"]
501507
# levels: ["low", "medium", "high"]

‎internal/auth/claude/anthropic_auth.go‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ const (
2727
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
2828
RedirectURI = "http://localhost:54545/callback"
2929

30-
claudeRefreshMinBackoff = 5 * time.Second
31-
claudeRefreshMaxBackoff = 5 * time.Minute
30+
claudeRefreshMinBackoff = 5 * time.Second
31+
claudeRefreshMaxBackoff = 5 * time.Minute
32+
claudeRefreshTimeout = 30 * time.Second
33+
claudeRefreshHandshakeTimeout = 10 * time.Second
3234
)
3335

3436
var (
@@ -331,6 +333,9 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
331333
if refreshToken == "" {
332334
return nil, fmt.Errorf("refresh token is required")
333335
}
336+
if ctx == nil {
337+
ctx = context.Background()
338+
}
334339
if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
335340
return nil, &refreshHTTPError{
336341
status: http.StatusTooManyRequests,
@@ -340,7 +345,10 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
340345
}
341346

342347
result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) {
343-
return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken)
348+
refreshCtx, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), claudeRefreshTimeout)
349+
defer cancelRefresh()
350+
refreshCtx = context.WithValue(refreshCtx, claudeRefreshHandshakeTimeoutContextKey{}, claudeRefreshHandshakeTimeout)
351+
return o.refreshTokensSingleFlight(refreshCtx, refreshToken)
344352
})
345353
if err != nil {
346354
return nil, err

‎internal/auth/claude/anthropic_auth_test.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,49 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
1717
return f(req)
1818
}
1919

20+
func TestNewAnthropicHttpClientDoesNotSetRequestTimeout(t *testing.T) {
21+
if got := NewAnthropicHttpClient(nil).Timeout; got != 0 {
22+
t.Fatalf("HTTP client timeout = %s, want zero", got)
23+
}
24+
}
25+
26+
func TestRefreshTokens_UsesIndependentTimeout(t *testing.T) {
27+
resetClaudeRefreshState()
28+
defer resetClaudeRefreshState()
29+
30+
callerCtx, cancelCaller := context.WithCancel(context.Background())
31+
cancelCaller()
32+
var requestDeadline time.Time
33+
auth := &ClaudeAuth{
34+
httpClient: &http.Client{
35+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
36+
var ok bool
37+
requestDeadline, ok = req.Context().Deadline()
38+
if !ok {
39+
t.Fatal("refresh request has no deadline")
40+
}
41+
if errContext := req.Context().Err(); errContext != nil {
42+
t.Fatalf("refresh request context is already done: %v", errContext)
43+
}
44+
return &http.Response{
45+
StatusCode: http.StatusBadRequest,
46+
Body: io.NopCloser(strings.NewReader(`{"error":"probe"}`)),
47+
Header: make(http.Header),
48+
Request: req,
49+
}, nil
50+
}),
51+
},
52+
}
53+
54+
_, err := auth.RefreshTokens(callerCtx, "independent-timeout-token")
55+
if err == nil {
56+
t.Fatal("expected refresh error")
57+
}
58+
if requestDeadline.IsZero() || !requestDeadline.After(time.Now()) {
59+
t.Fatalf("refresh deadline = %v, want a future deadline", requestDeadline)
60+
}
61+
}
62+
2063
func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) {
2164
resetClaudeRefreshState()
2265
defer resetClaudeRefreshState()

‎internal/auth/claude/utls_transport.go‎

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
package claude
44

55
import (
6+
"fmt"
67
"net/http"
78
"strings"
89
"sync"
10+
"time"
911

1012
tls "github.com/refraction-networking/utls"
1113
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
@@ -15,6 +17,8 @@ import (
1517
"golang.org/x/net/proxy"
1618
)
1719

20+
type claudeRefreshHandshakeTimeoutContextKey struct{}
21+
1822
// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint
1923
// to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
2024
type utlsRoundTripper struct {
@@ -50,7 +54,7 @@ func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
5054
// getOrCreateConnection gets an existing connection or creates a new one.
5155
// It uses a per-host locking mechanism to prevent multiple goroutines from
5256
// creating connections to the same host simultaneously.
53-
func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) {
57+
func (t *utlsRoundTripper) getOrCreateConnection(host, addr string, handshakeTimeout time.Duration) (*http2.ClientConn, error) {
5458
t.mu.Lock()
5559

5660
// Check if connection exists and is usable
@@ -77,7 +81,7 @@ func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.Clie
7781
t.mu.Unlock()
7882

7983
// Create connection outside the lock
80-
h2Conn, err := t.createConnection(host, addr)
84+
h2Conn, err := t.createConnection(host, addr, handshakeTimeout)
8185

8286
t.mu.Lock()
8387
defer t.mu.Unlock()
@@ -98,25 +102,38 @@ func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.Clie
98102
// createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint.
99103
// Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses)
100104
// than Firefox, reducing the mismatch between TLS layer and HTTP headers.
101-
func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) {
102-
conn, err := t.dialer.Dial("tcp", addr)
103-
if err != nil {
104-
return nil, err
105+
func (t *utlsRoundTripper) createConnection(host, addr string, handshakeTimeout time.Duration) (*http2.ClientConn, error) {
106+
conn, errDial := t.dialer.Dial("tcp", addr)
107+
if errDial != nil {
108+
return nil, errDial
109+
}
110+
111+
if handshakeTimeout > 0 {
112+
if errSetDeadline := conn.SetDeadline(time.Now().Add(handshakeTimeout)); errSetDeadline != nil {
113+
_ = conn.Close()
114+
return nil, fmt.Errorf("failed to set TLS handshake deadline: %w", errSetDeadline)
115+
}
105116
}
106117

107118
tlsConfig := &tls.Config{ServerName: host}
108119
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
109120

110-
if err := tlsConn.Handshake(); err != nil {
111-
conn.Close()
112-
return nil, err
121+
if errHandshake := tlsConn.Handshake(); errHandshake != nil {
122+
_ = conn.Close()
123+
return nil, errHandshake
124+
}
125+
if handshakeTimeout > 0 {
126+
if errClearDeadline := conn.SetDeadline(time.Time{}); errClearDeadline != nil {
127+
_ = conn.Close()
128+
return nil, fmt.Errorf("failed to clear TLS handshake deadline: %w", errClearDeadline)
129+
}
113130
}
114131

115132
tr := &http2.Transport{}
116-
h2Conn, err := tr.NewClientConn(tlsConn)
117-
if err != nil {
118-
tlsConn.Close()
119-
return nil, err
133+
h2Conn, errClientConn := tr.NewClientConn(tlsConn)
134+
if errClientConn != nil {
135+
_ = tlsConn.Close()
136+
return nil, errClientConn
120137
}
121138

122139
return h2Conn, nil
@@ -133,7 +150,8 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
133150
// Get hostname without port for TLS ServerName
134151
hostname := req.URL.Hostname()
135152

136-
h2Conn, err := t.getOrCreateConnection(hostname, addr)
153+
handshakeTimeout, _ := req.Context().Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration)
154+
h2Conn, err := t.getOrCreateConnection(hostname, addr, handshakeTimeout)
137155
if err != nil {
138156
return nil, err
139157
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package claude
2+
3+
import (
4+
"errors"
5+
"net"
6+
"testing"
7+
"time"
8+
)
9+
10+
type claudeTestDialer struct {
11+
conn net.Conn
12+
}
13+
14+
func (d claudeTestDialer) Dial(_, _ string) (net.Conn, error) {
15+
return d.conn, nil
16+
}
17+
18+
func TestUtlsRoundTripperBoundsTLSHandshake(t *testing.T) {
19+
clientConn, serverConn := net.Pipe()
20+
defer func() {
21+
if errClose := serverConn.Close(); errClose != nil {
22+
t.Errorf("server connection close returned error: %v", errClose)
23+
}
24+
}()
25+
26+
transport := &utlsRoundTripper{dialer: claudeTestDialer{conn: clientConn}}
27+
startedAt := time.Now()
28+
_, err := transport.createConnection("example.com", "unused", 20*time.Millisecond)
29+
if err == nil {
30+
t.Fatal("expected TLS handshake timeout")
31+
}
32+
var netErr net.Error
33+
if !errors.As(err, &netErr) || !netErr.Timeout() {
34+
t.Fatalf("error = %v, want timeout error", err)
35+
}
36+
if elapsed := time.Since(startedAt); elapsed > time.Second {
37+
t.Fatalf("TLS handshake took %s, want less than one second", elapsed)
38+
}
39+
}

‎internal/auth/codex/openai_auth.go‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ import (
2222

2323
// OAuth configuration constants for OpenAI Codex
2424
const (
25-
AuthURL = "https://auth.openai.com/oauth/authorize"
26-
TokenURL = "https://auth.openai.com/oauth/token"
27-
ClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
28-
RedirectURI = "http://localhost:1455/auth/callback"
25+
AuthURL = "https://auth.openai.com/oauth/authorize"
26+
TokenURL = "https://auth.openai.com/oauth/token"
27+
ClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
28+
RedirectURI = "http://localhost:1455/auth/callback"
29+
codexRefreshTimeout = 30 * time.Second
2930
)
3031

3132
// CodexAuth handles the OpenAI OAuth2 authentication flow.
@@ -206,7 +207,9 @@ func (o *CodexAuth) RefreshTokensWithClientID(ctx context.Context, refreshToken,
206207

207208
refreshKey := clientID + "\x00" + refreshToken
208209
result, err, _ := codexRefreshGroup.Do(refreshKey, func() (interface{}, error) {
209-
return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken, clientID)
210+
refreshCtx, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), codexRefreshTimeout)
211+
defer cancelRefresh()
212+
return o.refreshTokensSingleFlight(refreshCtx, refreshToken, clientID)
210213
})
211214
if err != nil {
212215
return nil, err

‎internal/auth/codex/openai_auth_test.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,49 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
2020
return f(req)
2121
}
2222

23+
func TestNewCodexAuthDoesNotSetRequestTimeout(t *testing.T) {
24+
if got := NewCodexAuth(nil).httpClient.Timeout; got != 0 {
25+
t.Fatalf("HTTP client timeout = %s, want zero", got)
26+
}
27+
}
28+
29+
func TestRefreshTokens_UsesIndependentTimeout(t *testing.T) {
30+
resetCodexRefreshGroupForTest()
31+
defer resetCodexRefreshGroupForTest()
32+
33+
callerCtx, cancelCaller := context.WithCancel(context.Background())
34+
cancelCaller()
35+
var requestDeadline time.Time
36+
auth := &CodexAuth{
37+
httpClient: &http.Client{
38+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
39+
var ok bool
40+
requestDeadline, ok = req.Context().Deadline()
41+
if !ok {
42+
t.Fatal("refresh request has no deadline")
43+
}
44+
if errContext := req.Context().Err(); errContext != nil {
45+
t.Fatalf("refresh request context is already done: %v", errContext)
46+
}
47+
return &http.Response{
48+
StatusCode: http.StatusBadRequest,
49+
Body: io.NopCloser(strings.NewReader(`{"error":"probe"}`)),
50+
Header: make(http.Header),
51+
Request: req,
52+
}, nil
53+
}),
54+
},
55+
}
56+
57+
_, err := auth.RefreshTokens(callerCtx, "independent-timeout-token")
58+
if err == nil {
59+
t.Fatal("expected refresh error")
60+
}
61+
if requestDeadline.IsZero() || !requestDeadline.After(time.Now()) {
62+
t.Fatalf("refresh deadline = %v, want a future deadline", requestDeadline)
63+
}
64+
}
65+
2366
func resetCodexRefreshGroupForTest() {
2467
codexRefreshGroup = singleflight.Group{}
2568
}

0 commit comments

Comments
 (0)