Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/codex-parallel-tool-calls-false.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Non-lite Codex requests that include tools now preserve an explicit `parallel_tool_calls: false` from the caller instead of forcing `true` (aligned with CLIProxyAPI). Absent values still default to `true`.

### 中文

- 非 lite 且带 tools 的 Codex 请求会保留调用方显式传入的 `parallel_tool_calls: false`,不再强制改成 `true`(与 CLIProxyAPI 对齐);未传时仍默认 `true`。
7 changes: 7 additions & 0 deletions changelog/unreleased/codex-siwc-ua-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Codex credentials that enable `use_siwc` now keep both the outer ChatGPT OAuth and SIWC partitions. Requests whose User-Agent identifies an official Codex client (`codex-desktop`, `Codex Desktop`, `codex-rs`, `codex_cli_rs`, `codex-tui`, `codex_exec`) ignore `use_siwc`, use the outer token, and fail closed with 401 when the outer `access_token` is missing; refresh on that path only updates the outer partition.

### 中文

- 启用 `use_siwc` 的 Codex 凭据会同时保留 outer ChatGPT OAuth 与 SIWC 分区。入站 User-Agent 识别为官方 Codex 客户端(`codex-desktop` / `Codex Desktop` / `codex-rs` / `codex_cli_rs` / `codex-tui` / `codex_exec`)时忽略 `use_siwc`、改用 outer token;缺少 outer `access_token` 则直接 401,且该路径下的刷新只更新 outer。
7 changes: 7 additions & 0 deletions changelog/unreleased/workbuddy-11128-fingerprint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- WorkBuddy payload sanitization now rewrites DeepSeek Harness identity sentences and the DSH Web GUI paragraph (and reduces the `DeepSeek Harness` brand prose) on system / developer / assistant messages, matching upstream 11-128 channel-fingerprint fixes. User and tool messages stay untouched.

### 中文

- WorkBuddy 出站清洗对齐上游 11-128 渠道指纹修复:对 system / developer / assistant 改写 DeepSeek Harness 自我介绍句与 DSH Web GUI 描述段,并将散文中的 `DeepSeek Harness` 缩为 `DeepSeek`;user / tool 消息保持原样。
17 changes: 17 additions & 0 deletions internal/executor/client_ua.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package executor

import (
"context"

"github.com/caigee-cmd/cli2api/internal/providers"
)

// WithClientUserAgent records the inbound User-Agent for provider routing.
func WithClientUserAgent(ctx context.Context, ua string) context.Context {
return providers.WithClientUserAgent(ctx, ua)
}

// ClientUserAgentFromContext returns the inbound User-Agent, or empty when unset.
func ClientUserAgentFromContext(ctx context.Context) string {
return providers.ClientUserAgentFromContext(ctx)
}
2 changes: 1 addition & 1 deletion internal/gateway/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (h *Handler) prepareExecution(r *http.Request, request translate.ChatReques
if r != nil {
sessionHeader = r.Header.Get("X-CLI2API-Session")
prefer = h.requestedAccount(r)
ctx = r.Context()
ctx = executor.WithClientUserAgent(r.Context(), r.Header.Get("User-Agent"))
}
var logs executor.RequestStarter
if h != nil && h.Logs != nil {
Expand Down
31 changes: 31 additions & 0 deletions internal/providers/client_ua.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package providers

import (
"context"
"strings"
)

type clientUserAgentKey struct{}

// WithClientUserAgent records the inbound User-Agent so providers can branch
// on the calling client (for example Codex SIWC vs outer OAuth).
func WithClientUserAgent(ctx context.Context, ua string) context.Context {
if ctx == nil {
ctx = context.Background()
}
ua = strings.TrimSpace(ua)
if ua == "" {
return ctx
}
return context.WithValue(ctx, clientUserAgentKey{}, ua)
}

// ClientUserAgentFromContext returns the inbound User-Agent set by
// WithClientUserAgent, or empty when unset.
func ClientUserAgentFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
ua, _ := ctx.Value(clientUserAgentKey{}).(string)
return strings.TrimSpace(ua)
}
91 changes: 60 additions & 31 deletions internal/providers/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ func parseJWTClaims(token string) *jwtClaims {
// ---------------------------------------------------------------------------

// credential loads and refreshes the account credential when needed.
// The inbound User-Agent selects SIWC vs outer OAuth on dual use_siwc credentials.
func (c *Client) credential(ctx context.Context, accountID string) (Credential, error) {
_, payload, err := c.store.LoadCredentialPayload(ctx, accountID)
if err != nil {
Expand All @@ -612,63 +613,76 @@ func (c *Client) credential(ctx context.Context, accountID string) (Credential,
}
}
}
if !credential.needsRefresh(time.Now()) {
return credential, nil
ua := ClientUserAgentFromContext(ctx)
effective, err := credential.EffectiveForRequest(ua)
if err != nil {
return Credential{}, err
}
if !effective.needsRefresh(time.Now()) {
return effective, nil
}
refreshedStored, err := c.refreshCredential(ctx, accountID, credential, effective)
if err != nil {
return Credential{}, err
}
return c.refreshCredential(ctx, accountID, credential)
return refreshedStored.EffectiveForRequest(ua)
}

func (c *Client) refreshCredential(ctx context.Context, accountID string, credential Credential) (Credential, error) {
func (c *Client) refreshCredential(ctx context.Context, accountID string, stored, effective Credential) (Credential, error) {
flightKey := accountID
if effective.refreshingOuter() {
flightKey = accountID + ":outer"
}
c.mu.Lock()
if call, ok := c.refresh[accountID]; ok {
if call, ok := c.refresh[flightKey]; ok {
c.mu.Unlock()
select {
case <-call.done:
return call.cred, call.err
case <-ctx.Done():
return credential, ctx.Err()
return stored, ctx.Err()
}
}
call := &refreshCall{done: make(chan struct{})}
c.refresh[accountID] = call
c.refresh[flightKey] = call
c.mu.Unlock()

refreshed, err := c.refreshTokens(ctx, accountID, credential)
refreshed, err := c.refreshTokens(ctx, accountID, stored, effective)
if err == nil {
if encoded, encErr := refreshed.Encode(); encErr == nil {
_ = c.store.SaveCredentialPayload(ctx, accountID, CredentialFormat, encoded)
}
} else {
_ = c.store.Observe(ctx, accountID, credential.AccountID, "login_required", err.Error(), accounts.KindAuth)
_ = c.store.Observe(ctx, accountID, stored.AccountID, "login_required", err.Error(), accounts.KindAuth)
}
call.cred, call.err = refreshed, err
close(call.done)
c.mu.Lock()
delete(c.refresh, accountID)
delete(c.refresh, flightKey)
c.mu.Unlock()
return refreshed, err
}

func (c *Client) refreshTokens(ctx context.Context, accountID string, credential Credential) (Credential, error) {
if strings.TrimSpace(credential.RefreshToken) == "" {
return credential, fmt.Errorf("no refresh_token; re-login required")
func (c *Client) refreshTokens(ctx context.Context, accountID string, stored, effective Credential) (Credential, error) {
if strings.TrimSpace(effective.RefreshToken) == "" {
return stored, fmt.Errorf("no refresh_token; re-login required")
}
tokenURL := TokenURL
form := url.Values{
"grant_type": {"refresh_token"},
"client_id": {ClientID},
"refresh_token": {credential.RefreshToken},
"refresh_token": {effective.RefreshToken},
"scope": {AuthScope},
}
if credential.UsesChatGPT() {
if strings.TrimSpace(credential.ClientID) == "" {
return credential, fmt.Errorf("chatgpt login is missing its issued client_id; sign in again")
if effective.UsesChatGPT() {
if strings.TrimSpace(effective.ClientID) == "" {
return stored, fmt.Errorf("chatgpt login is missing its issued client_id; sign in again")
}
tokenURL = SIWCTokenURL
form = url.Values{
"grant_type": {"refresh_token"},
"client_id": {credential.ClientID},
"refresh_token": {credential.RefreshToken},
"client_id": {effective.ClientID},
"refresh_token": {effective.RefreshToken},
"resource": {SIWCResource},
}
}
Expand All @@ -677,33 +691,48 @@ func (c *Client) refreshTokens(ctx context.Context, accountID string, credential
h.Set("Accept", "application/json")
})
if err != nil {
return credential, err
return stored, err
}
if status != http.StatusOK {
return credential, fmt.Errorf("token refresh failed with status %d: %s", status, strings.TrimSpace(string(payload)))
return stored, fmt.Errorf("token refresh failed with status %d: %s", status, strings.TrimSpace(string(payload)))
}
refreshed, err := parseTokenResponse(payload)
if err != nil {
return credential, err
return stored, err
}
// Preserve fields the refresh response may omit.
if refreshed.RefreshToken == "" {
refreshed.RefreshToken = credential.RefreshToken
refreshed.RefreshToken = effective.RefreshToken
}
if refreshed.AccountID == "" {
refreshed.AccountID = credential.AccountID
refreshed.AccountID = stored.AccountID
}
if refreshed.Email == "" {
refreshed.Email = credential.Email
refreshed.Email = stored.Email
}
// Codex-client path on a dual credential: only mutate Outer; keep SIWC.
if effective.refreshingOuter() {
out := stored
out.Outer = &OuterPartition{
IDToken: refreshed.IDToken,
AccessToken: refreshed.AccessToken,
RefreshToken: refreshed.RefreshToken,
ExpiresAt: refreshed.ExpiresAt,
LastRefresh: refreshed.LastRefresh,
}
return out, nil
}
refreshed.AuthMethod = credential.AuthMethod
refreshed.ClientID = credential.ClientID
refreshed.DeviceID = credential.DeviceID
if credential.UsesChatGPT() {
refreshed.AuthMethod = effective.AuthMethod
refreshed.ClientID = effective.ClientID
refreshed.DeviceID = effective.DeviceID
if effective.UsesChatGPT() {
if err := requireSIWCGrant(payload); err != nil {
return credential, err
return stored, err
}
}
if stored.UseSIWC {
refreshed.UseSIWC = true
refreshed.Outer = stored.Outer
}
return refreshed, nil
}

Expand Down
29 changes: 29 additions & 0 deletions internal/providers/codex/client_ua.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package codex

import (
"context"
"strings"

"github.com/caigee-cmd/cli2api/internal/providers"
)

// IsCodexClientUserAgent reports whether userAgent identifies an official Codex
// client. Aligned with CLIProxyAPI IsCodexClientUserAgent (d946c4f).
func IsCodexClientUserAgent(userAgent string) bool {
ua := strings.ToLower(strings.TrimSpace(userAgent))
if ua == "" {
return false
}
return strings.Contains(ua, "codex-desktop") ||
strings.Contains(ua, "codex desktop") ||
strings.Contains(ua, "codex-rs") ||
strings.Contains(ua, "codex_cli_rs") ||
strings.Contains(ua, "codex-tui") ||
strings.Contains(ua, "codex_exec")
}

// ClientUserAgentFromContext returns the inbound User-Agent recorded by the
// gateway, or empty when unset.
func ClientUserAgentFromContext(ctx context.Context) string {
return providers.ClientUserAgentFromContext(ctx)
}
Loading
Loading