From c15dbf5d3a1fa3e21f6b53950dfb71c6ccbe78bb Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:24:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Codex=20SIWC=C3=97UA=20=E5=88=86?= =?UTF-8?q?=E6=B5=81=E3=80=81WorkBuddy=2011-128=20=E6=B8=85=E6=B4=97?= =?UTF-8?q?=E3=80=81parallel=5Ftool=5Fcalls=20=E4=BF=9D=E7=95=99=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex:保留 outer+SIWC;Codex 客户端 UA 忽略 use_siwc,用 outer OAuth,缺 token 401,刷新只动 outer(对齐 CLIProxyAPI d946c4f) - WorkBuddy:移植 identity / DSH GUI / brand 11-128 规则,作用于 system/developer/assistant - Codex:有 tools 时保留调用方显式 parallel_tool_calls:false --- .../codex-parallel-tool-calls-false.md | 7 + changelog/unreleased/codex-siwc-ua-routing.md | 7 + .../unreleased/workbuddy-11128-fingerprint.md | 7 + internal/executor/client_ua.go | 17 ++ internal/gateway/prepare.go | 2 +- internal/providers/client_ua.go | 31 +++ internal/providers/codex/client.go | 91 +++++--- internal/providers/codex/client_ua.go | 29 +++ internal/providers/codex/client_ua_test.go | 194 ++++++++++++++++++ internal/providers/codex/credential.go | 82 +++++++- internal/providers/codex/native_test.go | 4 +- .../providers/codex/s04_interfaces_test.go | 7 +- internal/providers/codex/upstream.go | 13 +- internal/providers/workbuddy/payload.go | 83 +++++++- .../workbuddy/waf_fingerprint_test.go | 97 +++++++++ 15 files changed, 620 insertions(+), 51 deletions(-) create mode 100644 changelog/unreleased/codex-parallel-tool-calls-false.md create mode 100644 changelog/unreleased/codex-siwc-ua-routing.md create mode 100644 changelog/unreleased/workbuddy-11128-fingerprint.md create mode 100644 internal/executor/client_ua.go create mode 100644 internal/providers/client_ua.go create mode 100644 internal/providers/codex/client_ua.go create mode 100644 internal/providers/codex/client_ua_test.go create mode 100644 internal/providers/workbuddy/waf_fingerprint_test.go diff --git a/changelog/unreleased/codex-parallel-tool-calls-false.md b/changelog/unreleased/codex-parallel-tool-calls-false.md new file mode 100644 index 00000000..b853510d --- /dev/null +++ b/changelog/unreleased/codex-parallel-tool-calls-false.md @@ -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`。 diff --git a/changelog/unreleased/codex-siwc-ua-routing.md b/changelog/unreleased/codex-siwc-ua-routing.md new file mode 100644 index 00000000..e1c63f52 --- /dev/null +++ b/changelog/unreleased/codex-siwc-ua-routing.md @@ -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。 diff --git a/changelog/unreleased/workbuddy-11128-fingerprint.md b/changelog/unreleased/workbuddy-11128-fingerprint.md new file mode 100644 index 00000000..1edd086a --- /dev/null +++ b/changelog/unreleased/workbuddy-11128-fingerprint.md @@ -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 消息保持原样。 diff --git a/internal/executor/client_ua.go b/internal/executor/client_ua.go new file mode 100644 index 00000000..415cd8ee --- /dev/null +++ b/internal/executor/client_ua.go @@ -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) +} diff --git a/internal/gateway/prepare.go b/internal/gateway/prepare.go index 1fb0e849..cbf4a8ce 100644 --- a/internal/gateway/prepare.go +++ b/internal/gateway/prepare.go @@ -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 { diff --git a/internal/providers/client_ua.go b/internal/providers/client_ua.go new file mode 100644 index 00000000..46cf2dc0 --- /dev/null +++ b/internal/providers/client_ua.go @@ -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) +} diff --git a/internal/providers/codex/client.go b/internal/providers/codex/client.go index 35070467..8fb7c2ff 100644 --- a/internal/providers/codex/client.go +++ b/internal/providers/codex/client.go @@ -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 { @@ -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}, } } @@ -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 } diff --git a/internal/providers/codex/client_ua.go b/internal/providers/codex/client_ua.go new file mode 100644 index 00000000..851885ff --- /dev/null +++ b/internal/providers/codex/client_ua.go @@ -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) +} diff --git a/internal/providers/codex/client_ua_test.go b/internal/providers/codex/client_ua_test.go new file mode 100644 index 00000000..b9f2ab9e --- /dev/null +++ b/internal/providers/codex/client_ua_test.go @@ -0,0 +1,194 @@ +package codex + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/providers" + "github.com/caigee-cmd/cli2api/internal/translate" +) + +func TestIsCodexClientUserAgent(t *testing.T) { + for _, ua := range []string{ + "codex-desktop/0.159.0 (Mac OS 26.5.2; arm64)", + "Codex Desktop/26.803.41515", + "codex-rs/0.154.0 (Mac OS 26.5.2; arm64)", + "codex_cli_rs/0.154.0 (Mac OS 26.5.2; arm64)", + "codex-tui/0.154.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.154.0)", + "codex_exec/0.153.2 (Mac OS 26.6.2; arm64)", + } { + if !IsCodexClientUserAgent(ua) { + t.Fatalf("expected Codex client UA: %q", ua) + } + } + for _, ua := range []string{"", "curl/8.7.1", "Mozilla/5.0", "openai-python/1.0"} { + if IsCodexClientUserAgent(ua) { + t.Fatalf("unexpected Codex client UA: %q", ua) + } + } +} + +func dualSIWCCredential() Credential { + return Credential{ + AccessToken: "siwc-token", + RefreshToken: "siwc-refresh", + AccountID: "acct-1", + Email: "u@example.com", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + AuthMethod: AuthMethodChatGPT, + ClientID: "issued-client", + DeviceID: "dev-1", + UseSIWC: true, + Outer: &OuterPartition{ + AccessToken: "outer-token", + RefreshToken: "outer-refresh", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }, + } +} + +func TestEffectiveForRequestCodexClientUsesOuter(t *testing.T) { + cred := dualSIWCCredential() + got, err := cred.EffectiveForRequest("codex-desktop/0.159.0") + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "outer-token" || got.UsesChatGPT() || got.ClientID != "" { + t.Fatalf("codex client effective = %+v", got) + } + if !got.UseSIWC || got.Outer == nil || got.Outer.AccessToken != "outer-token" { + t.Fatalf("dual metadata lost: %+v", got) + } + + siwc, err := cred.EffectiveForRequest("curl/8.7.1") + if err != nil { + t.Fatal(err) + } + if siwc.AccessToken != "siwc-token" || !siwc.UsesChatGPT() { + t.Fatalf("non-codex effective = %+v", siwc) + } +} + +func TestEffectiveForRequestCodexClientMissingOuterFailsClosed(t *testing.T) { + cred := dualSIWCCredential() + cred.Outer = nil + _, err := cred.EffectiveForRequest("codex-tui/0.154.0") + if err == nil { + t.Fatal("expected 401 when outer access_token missing") + } + if !strings.Contains(err.Error(), "outer OAuth access_token") { + t.Fatalf("error = %v", err) + } + e, ok := err.(*providers.Error) + if !ok || e.Status != 401 { + t.Fatalf("want providers.Error 401, got %T %v", err, err) + } +} + +func TestChatRequestCodexClientUAIgnoresUseSIWC(t *testing.T) { + payload, err := dualSIWCCredential().Encode() + if err != nil { + t.Fatal(err) + } + client := NewClient(testStore{items: map[string][]byte{"a1": payload}}) + + codexCtx := providers.WithClientUserAgent(context.Background(), "codex-desktop/0.159.0") + cred, err := client.credential(codexCtx, "a1") + if err != nil { + t.Fatal(err) + } + req := translate.ChatRequest{Model: "gpt-5.5", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}} + httpReq, _, err := client.chatRequest(codexCtx, cred, req, "a1") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(httpReq.URL.String(), ChatBase) { + t.Fatalf("codex client url = %s", httpReq.URL) + } + if httpReq.Header.Get("Authorization") != "Bearer outer-token" { + t.Fatalf("Authorization = %q", httpReq.Header.Get("Authorization")) + } + if httpReq.Header.Get("Chatgpt-Account-Id") != "acct-1" { + t.Fatalf("Chatgpt-Account-Id = %q", httpReq.Header.Get("Chatgpt-Account-Id")) + } + if httpReq.Header.Get("Originator") == "" { + t.Fatal("Originator should be set for non-SIWC Codex client request") + } + + curlCtx := providers.WithClientUserAgent(context.Background(), "curl/8.7.1") + cred, err = client.credential(curlCtx, "a1") + if err != nil { + t.Fatal(err) + } + httpReq, _, err = client.chatRequest(curlCtx, cred, req, "a1") + if err != nil { + t.Fatal(err) + } + if httpReq.URL.String() != SIWCBaseURL+pathResponses { + t.Fatalf("non-codex url = %s", httpReq.URL) + } + if httpReq.Header.Get("Authorization") != "Bearer siwc-token" { + t.Fatalf("non-codex Authorization = %q", httpReq.Header.Get("Authorization")) + } + if httpReq.Header.Get("Chatgpt-Account-Id") != "" { + t.Fatalf("SIWC should not send Chatgpt-Account-Id, got %q", httpReq.Header.Get("Chatgpt-Account-Id")) + } +} + +func TestDecodeCredentialRoundTripKeepsOuter(t *testing.T) { + cred := dualSIWCCredential() + raw, err := cred.Encode() + if err != nil { + t.Fatal(err) + } + got, err := DecodeCredential(raw) + if err != nil { + t.Fatal(err) + } + if !got.UseSIWC || got.Outer == nil || got.Outer.AccessToken != "outer-token" || got.AccessToken != "siwc-token" { + t.Fatalf("round-trip dropped outer: %+v", got) + } +} + +func TestNormalizeCodexUpstreamPreservesExplicitParallelFalse(t *testing.T) { + fields := map[string]json.RawMessage{ + "input": json.RawMessage(`[]`), + "tools": json.RawMessage(`[{"type":"function","name":"lookup"}]`), + "parallel_tool_calls": json.RawMessage(`false`), + } + if err := normalizeCodexUpstream(fields, "gpt-5.5", false, ""); err != nil { + t.Fatal(err) + } + if string(fields["parallel_tool_calls"]) != "false" { + t.Fatalf("parallel_tool_calls = %s", fields["parallel_tool_calls"]) + } + + // Absent defaults to true when tools are present. + fields = map[string]json.RawMessage{ + "input": json.RawMessage(`[]`), + "tools": json.RawMessage(`[{"type":"function","name":"lookup"}]`), + } + if err := normalizeCodexUpstream(fields, "gpt-5.5", false, ""); err != nil { + t.Fatal(err) + } + if string(fields["parallel_tool_calls"]) != "true" { + t.Fatalf("default parallel_tool_calls = %s", fields["parallel_tool_calls"]) + } +} + +func TestSetChatHeadersUsesEffectiveCredential(t *testing.T) { + cred, err := dualSIWCCredential().EffectiveForRequest("codex-rs/0.1") + if err != nil { + t.Fatal(err) + } + h := make(http.Header) + SetChatHeaders(h, cred, "sess", true) + if h.Get("Authorization") != "Bearer outer-token" || h.Get("Originator") == "" { + t.Fatalf("headers = %v", h) + } +} + diff --git a/internal/providers/codex/credential.go b/internal/providers/codex/credential.go index 1469ca2d..9fde2281 100644 --- a/internal/providers/codex/credential.go +++ b/internal/providers/codex/credential.go @@ -9,6 +9,9 @@ import ( "fmt" "strings" "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" ) const ( @@ -63,6 +66,16 @@ const ( // Credential is the canonical storage payload shape. Field names match the // CodexTokenStorage JSON CLIProxyAPI writes so import/export round-trips. +// OuterPartition holds the ChatGPT backend (non-SIWC) OAuth tokens kept +// alongside a Sign in with ChatGPT partition when use_siwc was imported. +type OuterPartition struct { + IDToken string `json:"id_token,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + LastRefresh string `json:"last_refresh,omitempty"` +} + type Credential struct { IDToken string `json:"id_token,omitempty"` AccessToken string `json:"access_token"` @@ -80,6 +93,13 @@ type Credential struct { // closed without them rather than falling back to the CLI client. ClientID string `json:"client_id,omitempty"` DeviceID string `json:"device_id,omitempty"` + // UseSIWC is true when this credential prefers the nested Sign in with + // ChatGPT partition for non-Codex clients. Codex client User-Agents ignore + // it and must use Outer (aligned with CLIProxyAPI d946c4f). + UseSIWC bool `json:"use_siwc,omitempty"` + // Outer is the non-SIWC ChatGPT OAuth partition preserved on import so + // Codex client requests can use it without discarding SIWC tokens. + Outer *OuterPartition `json:"outer,omitempty"` } func DecodeCredential(payload []byte) (Credential, error) { @@ -88,6 +108,9 @@ func DecodeCredential(payload []byte) (Credential, error) { (flat.AccessToken != "" || flat.RefreshToken != "") { flat.ExpiresAt = unixSeconds(flat.ExpiresAt) flat.AuthMethod = normalizeAuthMethod(flat.AuthMethod) + if flat.Outer != nil { + flat.Outer.ExpiresAt = unixSeconds(flat.Outer.ExpiresAt) + } return flat, nil } // Accept CLIProxyAPI's nested token_data shape, including a Sign in with @@ -116,7 +139,7 @@ func DecodeCredential(payload []byte) (Credential, error) { if err := json.Unmarshal(payload, &nested); err == nil { if nested.UseSIWC != nil && *nested.UseSIWC && (nested.SIWC.AccessToken != "" || nested.SIWC.RefreshToken != "") { - return Credential{ + cred := Credential{ IDToken: nested.SIWC.IDToken, AccessToken: nested.SIWC.AccessToken, RefreshToken: nested.SIWC.RefreshToken, @@ -127,7 +150,20 @@ func DecodeCredential(payload []byte) (Credential, error) { AuthMethod: AuthMethodChatGPT, ClientID: nested.SIWC.ClientID, DeviceID: nested.SIWC.DeviceID, - }, nil + UseSIWC: true, + } + // Keep the outer ChatGPT OAuth partition so Codex client requests can + // ignore use_siwc and still authenticate without falling back to SIWC. + if nested.TokenData.AccessToken != "" || nested.TokenData.RefreshToken != "" { + cred.Outer = &OuterPartition{ + IDToken: nested.TokenData.IDToken, + AccessToken: nested.TokenData.AccessToken, + RefreshToken: nested.TokenData.RefreshToken, + ExpiresAt: parseExpire(nested.TokenData.Expire), + LastRefresh: nested.LastRefresh, + } + } + return cred, nil } if nested.TokenData.AccessToken != "" || nested.TokenData.RefreshToken != "" { return Credential{ @@ -174,6 +210,48 @@ func normalizeAuthMethod(method string) string { } } + +// UseSIWCForRequest reports whether this request should use the nested SIWC +// partition. Codex client User-Agents always ignore use_siwc (CLIProxyAPI d946c4f). +func (c Credential) UseSIWCForRequest(userAgent string) bool { + if IsCodexClientUserAgent(userAgent) { + return false + } + return c.UseSIWC +} + +// EffectiveForRequest selects the OAuth partition for the inbound User-Agent. +// Codex clients require the outer (non-SIWC) access_token and never fall back +// to SIWC; other clients keep the SIWC primary when UseSIWC is set. +func (c Credential) EffectiveForRequest(userAgent string) (Credential, error) { + if !c.UseSIWC || !IsCodexClientUserAgent(userAgent) { + return c, nil + } + if c.Outer == nil || strings.TrimSpace(c.Outer.AccessToken) == "" { + return Credential{}, &providers.Error{ + Kind: accounts.KindAuth, + Status: 401, + Message: "codex client requests require the outer OAuth access_token when use_siwc is enabled", + } + } + out := c + out.IDToken = c.Outer.IDToken + out.AccessToken = c.Outer.AccessToken + out.RefreshToken = c.Outer.RefreshToken + out.ExpiresAt = c.Outer.ExpiresAt + out.LastRefresh = c.Outer.LastRefresh + out.AuthMethod = AuthMethodCLI + out.ClientID = "" + out.DeviceID = "" + return out, nil +} + +// refreshingOuter reports that EffectiveForRequest selected the outer partition +// on a dual SIWC credential, so token refresh must only update Outer. +func (c Credential) refreshingOuter() bool { + return c.UseSIWC && !c.UsesChatGPT() && c.Outer != nil +} + func ValidateCredential(payload []byte) error { credential, err := DecodeCredential(payload) if err != nil { diff --git a/internal/providers/codex/native_test.go b/internal/providers/codex/native_test.go index b2c0ba04..be789127 100644 --- a/internal/providers/codex/native_test.go +++ b/internal/providers/codex/native_test.go @@ -110,8 +110,8 @@ func TestBuildNativeBodyAppliesCodexUpstreamConstraints(t *testing.T) { if _, ok := got["service_tier"]; ok { t.Fatalf("unsupported service tier forwarded: %v", got["service_tier"]) } - if got["parallel_tool_calls"] != true { - t.Fatalf("parallel_tool_calls = %v", got["parallel_tool_calls"]) + if got["parallel_tool_calls"] != false { + t.Fatalf("explicit parallel_tool_calls=false was not preserved: %v", got["parallel_tool_calls"]) } tools, _ := json.Marshal(got["tools"]) if !strings.Contains(string(tools), `"type":"web_search"`) || strings.Contains(string(tools), "web_search_preview") || strings.Contains(string(tools), `\p{L}`) { diff --git a/internal/providers/codex/s04_interfaces_test.go b/internal/providers/codex/s04_interfaces_test.go index d88f64e1..0a5cc2ef 100644 --- a/internal/providers/codex/s04_interfaces_test.go +++ b/internal/providers/codex/s04_interfaces_test.go @@ -177,14 +177,17 @@ func TestDecodeCredentialNestedTokenData(t *testing.T) { } func TestDecodeCredentialNestedSIWC(t *testing.T) { - payload := []byte(`{"use_siwc":true,"token_data":{"account_id":"acct","email":"e@x"},"siwc":{"access_token":"sat","refresh_token":"srt","client_id":"issued-client","device_id":"dev-1","expired":"2027-01-01T00:00:00Z"}}`) + payload := []byte(`{"use_siwc":true,"token_data":{"access_token":"outer-at","refresh_token":"outer-rt","account_id":"acct","email":"e@x","expired":"2027-01-01T00:00:00Z"},"siwc":{"access_token":"sat","refresh_token":"srt","client_id":"issued-client","device_id":"dev-1","expired":"2027-01-01T00:00:00Z"}}`) cred, err := DecodeCredential(payload) if err != nil { t.Fatal(err) } - if !cred.UsesChatGPT() || cred.AccessToken != "sat" || cred.ClientID != "issued-client" || cred.DeviceID != "dev-1" || !cred.Ready() { + if !cred.UsesChatGPT() || cred.AccessToken != "sat" || cred.ClientID != "issued-client" || cred.DeviceID != "dev-1" || !cred.Ready() || !cred.UseSIWC { t.Fatalf("siwc nested decode %+v", cred) } + if cred.Outer == nil || cred.Outer.AccessToken != "outer-at" || cred.Outer.RefreshToken != "outer-rt" { + t.Fatalf("outer partition dropped: %+v", cred.Outer) + } } func chatgptCredentialPayload() []byte { diff --git a/internal/providers/codex/upstream.go b/internal/providers/codex/upstream.go index 110a01e1..ea09717c 100644 --- a/internal/providers/codex/upstream.go +++ b/internal/providers/codex/upstream.go @@ -104,10 +104,11 @@ func normalizeCodexUpstream(fields map[string]json.RawMessage, model string, lit fields["tool_choice"] = choice } - // Non-lite Codex requests require parallel_tool_calls=true whenever tools are - // present; the caller's value is not accepted. Lite models reject the true - // value, so the field is forced false. Without tools the member is omitted, - // except on lite where the backend still expects the explicit false. + // Lite models reject parallel_tool_calls=true, so the field is forced false. + // Non-lite with tools: preserve the caller's explicit value (including false), + // matching CLIProxyAPI normalizeCodexParallelToolCallsForTools; default true + // only when the member is absent. Without tools the member is omitted, except + // on lite where the backend still expects the explicit false. if lite { fields["parallel_tool_calls"] = json.RawMessage(`false`) // The lite header also requires reasoning.context=all_turns (#300). @@ -117,7 +118,9 @@ func normalizeCodexUpstream(fields map[string]json.RawMessage, model string, lit } fields["reasoning"] = reasoning } else if !emptyJSONArray(fields["tools"]) { - fields["parallel_tool_calls"] = json.RawMessage(`true`) + if _, ok := fields["parallel_tool_calls"]; !ok { + fields["parallel_tool_calls"] = json.RawMessage(`true`) + } } else { delete(fields, "parallel_tool_calls") } diff --git a/internal/providers/workbuddy/payload.go b/internal/providers/workbuddy/payload.go index fd480313..0faf8c66 100644 --- a/internal/providers/workbuddy/payload.go +++ b/internal/providers/workbuddy/payload.go @@ -2,6 +2,7 @@ package workbuddy import ( "encoding/json" + "regexp" "strings" "github.com/caigee-cmd/cli2api/internal/translate" @@ -337,11 +338,66 @@ var wafFingerprintReplacements = []struct { }, } +// Identity-scoped 11-128 rules ported from workbuddy2api-hub (~v1.7.25–26, +// #570/#579/#574). Applied only to system / developer / assistant; user and +// tool content is left untouched as caller data. +var ( + // Start of an identity sentence through the attribution verb. The rest of + // the sentence (including mid-token dots like v4.1) is consumed by + // rewriteIdentitySentences because RE2 has no lookahead. + wafIdentityStartRe = regexp.MustCompile(`(?i)You are\s+` + + `(?:an?\s+|the\s+)?(?:[\w.-]+\s+){0,3}?` + + `(?:agent|assistant|model|AI|LLM)?[\s,]*` + + `(?:powered by|built on|built by|created by|developed by|made by|trained by)`) + wafIdentityReplacement = "You are a helpful AI assistant." + + wafDSHGUIRe = regexp.MustCompile(`(?i)(You are interacting with the user through the ` + + `DeepSeek[ \t]+Harness[ \t]+Web[ \t]+GUI)` + + ` whose URL the runtime context names\.`) + wafDSHGUIReplacement = `$1 whose URL is named in the runtime context.` + + wafBrandRe = regexp.MustCompile(`(?i)DeepSeek[ \t]+Harness`) + wafBrandReplacement = "DeepSeek" +) + +// rewriteIdentitySentences replaces confirmed self-intro sentences. Sentence +// end is a period followed by whitespace/EOS, a newline, or EOS — so dots +// inside model ids (deepseek-v4.1-flash) stay inside the match. +func rewriteIdentitySentences(text string) string { + var b strings.Builder + remaining := text + for { + loc := wafIdentityStartRe.FindStringIndex(remaining) + if loc == nil { + b.WriteString(remaining) + return b.String() + } + b.WriteString(remaining[:loc[0]]) + rest := remaining[loc[1]:] + end := len(rest) + for i := 0; i < len(rest); i++ { + switch rest[i] { + case '\n': + end = i + goto replaced + case '.': + if i+1 == len(rest) || rest[i+1] == ' ' || rest[i+1] == '\t' || rest[i+1] == '\n' { + end = i + 1 + goto replaced + } + } + } + replaced: + b.WriteString(wafIdentityReplacement) + remaining = rest[end:] + } +} + // sanitizeWAFKeywords rewrites known client fingerprint phrases that trigger -// upstream WorkBuddy WAF rules (code 11128: "Illegal API invocation from an -// unapproved channel"). For example, ZCode/Claude Code desktop environments -// inject git status headers containing "Main branch (you will usually use this for PRs)", -// which the upstream content filter detects and rejects. +// upstream WorkBuddy WAF rules (code 11128 / 11-128: "Illegal API invocation +// from an unapproved channel"). Literal Main-branch rewrites apply to every +// role; identity / DSH GUI / brand rules follow upstream and only touch +// system, developer, and assistant. func sanitizeWAFKeywords(body map[string]any) { raw, ok := body["messages"] if !ok { @@ -356,24 +412,35 @@ func sanitizeWAFKeywords(body map[string]any) { if !ok { continue } + role, _ := message["role"].(string) + identity := role == "system" || role == "developer" || role == "assistant" if content, ok := message["content"].(string); ok { - message["content"] = sanitizeFingerprintText(content) + message["content"] = sanitizeFingerprintText(content, identity) } else if parts, ok := message["content"].([]any); ok { for _, part := range parts { if partMap, ok := part.(map[string]any); ok { if text, ok := partMap["text"].(string); ok { - partMap["text"] = sanitizeFingerprintText(text) + partMap["text"] = sanitizeFingerprintText(text, identity) } } } } + // reasoning_content is model scratch, not an identity surface. if reasoning, ok := message["reasoning_content"].(string); ok { - message["reasoning_content"] = sanitizeFingerprintText(reasoning) + message["reasoning_content"] = sanitizeFingerprintText(reasoning, false) } } } -func sanitizeFingerprintText(text string) string { +func sanitizeFingerprintText(text string, identity bool) string { + if text == "" { + return text + } + if identity { + text = rewriteIdentitySentences(text) + text = wafDSHGUIRe.ReplaceAllString(text, wafDSHGUIReplacement) + text = wafBrandRe.ReplaceAllString(text, wafBrandReplacement) + } for _, rule := range wafFingerprintReplacements { if strings.Contains(text, rule.pattern) { text = strings.ReplaceAll(text, rule.pattern, rule.replacement) diff --git a/internal/providers/workbuddy/waf_fingerprint_test.go b/internal/providers/workbuddy/waf_fingerprint_test.go new file mode 100644 index 00000000..51660833 --- /dev/null +++ b/internal/providers/workbuddy/waf_fingerprint_test.go @@ -0,0 +1,97 @@ +package workbuddy + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSanitizeWAFIdentityAndGUIRules(t *testing.T) { + guiA := "You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names." + guiB := `When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI.` + gui := guiA + " " + guiB + wantGUI := "You are interacting with the user through the DeepSeek Web GUI whose URL is named in the runtime context. " + guiB + + body := map[string]any{ + "messages": []any{ + map[string]any{"role": "system", "content": "You are an AI agent powered by DeepSeek Harness."}, + map[string]any{"role": "developer", "content": "You are Codex Agent, built by OpenAI."}, + map[string]any{"role": "assistant", "content": "You are a coding agent powered by the deepseek-v4.1-flash model."}, + map[string]any{"role": "user", "content": "You are an AI agent powered by DeepSeek Harness."}, + map[string]any{"role": "tool", "content": gui}, + map[string]any{"role": "system", "content": gui}, + }, + } + sanitizeWAFKeywords(body) + msgs := body["messages"].([]any) + + if got := msgs[0].(map[string]any)["content"]; got != "You are a helpful AI assistant." { + t.Fatalf("system identity = %q", got) + } + if got := msgs[1].(map[string]any)["content"]; got != "You are a helpful AI assistant." { + t.Fatalf("developer identity = %q", got) + } + if got := msgs[2].(map[string]any)["content"]; got != "You are a helpful AI assistant." { + t.Fatalf("assistant identity = %q", got) + } + if got := msgs[3].(map[string]any)["content"]; got != "You are an AI agent powered by DeepSeek Harness." { + t.Fatalf("user must stay untouched: %q", got) + } + if got := msgs[4].(map[string]any)["content"]; got != gui { + t.Fatalf("tool must stay untouched: %q", got) + } + if got := msgs[5].(map[string]any)["content"]; got != wantGUI { + t.Fatalf("system GUI = %q want %q", got, wantGUI) + } +} + +func TestSanitizeWAFBrandLeavesHyphenatedIdentifiers(t *testing.T) { + for _, text := range []string{ + "github.com/deepseek-ai/deepseek-harness/tree/main", + "import deepseek_harness as dsh", + "/opt/deepseek-harness/app", + } { + if got := sanitizeFingerprintText(text, true); got != text { + t.Fatalf("identifier rewritten: %q -> %q", text, got) + } + } + if got := sanitizeFingerprintText("the DeepSeek Harness project", true); got != "the DeepSeek project" { + t.Fatalf("brand = %q", got) + } +} + +func TestSanitizeWAFIdentityDoesNotOverMatch(t *testing.T) { + for _, text := range []string{ + "You are an AI agent.", + "You are free to use tools built by the user.", + } { + if got := sanitizeFingerprintText(text, true); got != text { + t.Fatalf("over-matched %q -> %q", text, got) + } + } +} + +func TestPrepareBodyAppliesWAFIdentityRules(t *testing.T) { + src, _ := json.Marshal(map[string]any{ + "model": "wb", + "messages": []any{ + map[string]any{"role": "system", "content": "You are an AI assistant built on DeepSeek."}, + map[string]any{"role": "user", "content": "hi"}, + }, + }) + out := PrepareBody(src) + var body map[string]any + if err := json.Unmarshal(out, &body); err != nil { + t.Fatal(err) + } + msgs := body["messages"].([]any) + if got := msgs[0].(map[string]any)["content"].(string); got != "You are a helpful AI assistant." { + t.Fatalf("prepared system = %q", got) + } + if got := msgs[1].(map[string]any)["content"].(string); got != "hi" { + t.Fatalf("prepared user = %q", got) + } + if strings.Contains(string(out), "built on DeepSeek") { + t.Fatalf("identity fingerprint survived: %s", out) + } +}