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
11 changes: 11 additions & 0 deletions changelog/unreleased/upstream-qoder-codex-align.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- Align Qoder Cosy fingerprint with hub v1.7.3: CLI 1.1.75, desktop Cosy-Version 0.4.8, undici User-Agent, business/scene/clientip headers, lowercase `agree` data-policy; machineos on all COSY requests, hostname SSE-only.
- Bump Codex outbound cloaking User-Agent to official stable `0.164.0`.
- Pass through inbound `X-Codex-Turn-Metadata`, `X-Codex-Turn-State`, and `X-Codex-Window-Id` on Codex OAuth (non-SIWC) upstream requests, matching CLIProxyAPI’s narrow EnsureHeader set.

### 中文

- Qoder Cosy 指纹对齐 hub v1.7.3:CLI 1.1.75、桌面签到 Cosy-Version 0.4.8、undici User-Agent、business/scene/clientip 头、data-policy 小写 `agree`;machineos 全 COSY 请求携带,hostname 仅 SSE。
- Codex 出站伪装 User-Agent 升至官方稳定版 `0.164.0`。
- Codex OAuth(非 SIWC)上游透传入站 `X-Codex-Turn-Metadata` / `X-Codex-Turn-State` / `X-Codex-Window-Id`,范围与 CLIProxyAPI EnsureHeader 一致、不扩大。
12 changes: 12 additions & 0 deletions internal/executor/client_ua.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package executor

import (
"context"
"net/http"

"github.com/caigee-cmd/cli2api/internal/providers"
)
Expand All @@ -15,3 +16,14 @@ func WithClientUserAgent(ctx context.Context, ua string) context.Context {
func ClientUserAgentFromContext(ctx context.Context) string {
return providers.ClientUserAgentFromContext(ctx)
}

// WithCodexClientHeaders records inbound Codex turn/window headers for the
// Codex provider to pass through on OAuth (non-SIWC) upstream requests.
func WithCodexClientHeaders(ctx context.Context, h http.Header) context.Context {
return providers.WithCodexClientHeaders(ctx, h)
}

// CodexClientHeadersFromContext returns inbound Codex passthrough headers.
func CodexClientHeadersFromContext(ctx context.Context) http.Header {
return providers.CodexClientHeadersFromContext(ctx)
}
1 change: 1 addition & 0 deletions internal/gateway/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (h *Handler) prepareExecution(r *http.Request, request translate.ChatReques
sessionHeader = r.Header.Get("X-CLI2API-Session")
prefer = h.requestedAccount(r)
ctx = executor.WithClientUserAgent(r.Context(), r.Header.Get("User-Agent"))
ctx = executor.WithCodexClientHeaders(ctx, r.Header)
}
var logs executor.RequestStarter
if h != nil && h.Logs != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/providers/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ func (c *Client) chatRequest(ctx context.Context, credential Credential, req tra
SetChatHeaders(httpReq.Header, credential, sessionID, true)
if !credential.UsesChatGPT() {
applyCodexRequestHeaders(httpReq.Header, req.Model, usesResponsesLite(req.Model))
applyCodexPassthroughHeaders(httpReq.Header, providers.CodexClientHeadersFromContext(ctx))
}
return httpReq, resolved, nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/codex/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const (
pathWhamUsage = "/wham/usage"

// Cloaking: upstream gates on the official codex CLI UA/originator.
UserAgent = "codex-tui/0.154.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.154.0)"
UserAgent = "codex-tui/0.164.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.164.0)"
Originator = "codex-tui"

QuotaUnit = "rate_limit_window"
Expand Down
16 changes: 16 additions & 0 deletions internal/providers/codex/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package codex
import (
"net/http"
"strings"

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

// SetChatHeaders applies the upstream headers the ChatGPT codex backend expects.
Expand Down Expand Up @@ -59,3 +61,17 @@ func applyCodexRequestHeaders(h http.Header, model string, lite bool) {
h.Set(codexResponsesLiteHeader, "true")
}
}

// applyCodexPassthroughHeaders copies inbound turn/window headers onto the
// upstream request (CLIProxyAPI EnsureHeader for Turn-Metadata/State/Window-Id).
// Empty values are skipped. Do not call on the SIWC path.
func applyCodexPassthroughHeaders(dst, src http.Header) {
if dst == nil || src == nil {
return
}
for _, name := range providers.CodexPassthroughHeaderNames {
if v := strings.TrimSpace(src.Get(name)); v != "" {
dst.Set(name, v)
}
}
}
64 changes: 64 additions & 0 deletions internal/providers/codex/headers_passthrough_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package codex

import (
"net/http"
"strings"
"testing"

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

func TestApplyCodexPassthroughHeaders(t *testing.T) {
src := http.Header{}
src.Set("X-Codex-Turn-State", " opaque-state ")
src.Set("X-Codex-Turn-Metadata", `{"turn_id":"t1"}`)
src.Set("X-Codex-Window-Id", "win-1")
src.Set("X-Codex-Routing-Hint", "must-not-passthrough")
src.Set("Authorization", "secret")

dst := http.Header{}
applyCodexPassthroughHeaders(dst, src)

if got := dst.Get("X-Codex-Turn-State"); got != "opaque-state" {
t.Fatalf("Turn-State=%q", got)
}
if got := dst.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"t1"}` {
t.Fatalf("Turn-Metadata=%q", got)
}
if got := dst.Get("X-Codex-Window-Id"); got != "win-1" {
t.Fatalf("Window-Id=%q", got)
}
if dst.Get("X-Codex-Routing-Hint") != "" || dst.Get("Authorization") != "" {
t.Fatal("must not widen passthrough scope")
}
}

func TestApplyCodexPassthroughHeadersSkipsEmpty(t *testing.T) {
dst := http.Header{}
applyCodexPassthroughHeaders(dst, http.Header{"X-Codex-Turn-State": {" "}})
if dst.Get("X-Codex-Turn-State") != "" {
t.Fatal("blank values must be skipped")
}
applyCodexPassthroughHeaders(dst, nil)
applyCodexPassthroughHeaders(nil, http.Header{"X-Codex-Turn-State": {"x"}})
}

func TestWithCodexClientHeadersFilters(t *testing.T) {
src := http.Header{}
src.Set("X-Codex-Turn-State", "s")
src.Set("X-Other", "nope")
ctx := providers.WithCodexClientHeaders(nil, src)
got := providers.CodexClientHeadersFromContext(ctx)
if got.Get("X-Codex-Turn-State") != "s" || got.Get("X-Other") != "" {
t.Fatalf("got=%v", got)
}
if providers.CodexClientHeadersFromContext(nil) != nil {
t.Fatal("nil ctx")
}
}

func TestUserAgentPinnedToOfficialStable(t *testing.T) {
if !strings.Contains(UserAgent, "0.164.0") {
t.Fatalf("UserAgent=%q want 0.164.0", UserAgent)
}
}
1 change: 1 addition & 0 deletions internal/providers/codex/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (c *Client) ResponsesStream(ctx context.Context, accountID string, req *tra
SetChatHeaders(httpReq.Header, credential, sessionID, true)
if !credential.UsesChatGPT() {
applyCodexRequestHeaders(httpReq.Header, model, usesResponsesLite(model))
applyCodexPassthroughHeaders(httpReq.Header, providers.CodexClientHeadersFromContext(ctx))
}
client, err := c.httpClient(ctx, accountID)
if err != nil {
Expand Down
50 changes: 50 additions & 0 deletions internal/providers/codex_headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package providers

import (
"context"
"net/http"
"strings"
)

type codexClientHeadersKey struct{}

// CodexPassthroughHeaderNames are inbound headers CLIProxyAPI EnsureHeader
// forwards onto ChatGPT Codex OAuth requests for turn continuity. Kept
// narrow: Routing-Hint / Responses-Lite are set by the adapter itself;
// Session-Id is managed locally; SIWC does not receive these.
var CodexPassthroughHeaderNames = []string{
"X-Codex-Turn-Metadata",
"X-Codex-Turn-State",
"X-Codex-Window-Id",
}

// WithCodexClientHeaders records selected inbound Codex headers so the
// Codex adapter can pass them through on non-SIWC upstream requests.
func WithCodexClientHeaders(ctx context.Context, src http.Header) context.Context {
if ctx == nil {
ctx = context.Background()
}
if src == nil {
return ctx
}
out := http.Header{}
for _, name := range CodexPassthroughHeaderNames {
if v := strings.TrimSpace(src.Get(name)); v != "" {
out.Set(name, v)
}
}
if len(out) == 0 {
return ctx
}
return context.WithValue(ctx, codexClientHeadersKey{}, out)
}

// CodexClientHeadersFromContext returns the inbound Codex passthrough
// headers set by WithCodexClientHeaders, or nil when unset.
func CodexClientHeadersFromContext(ctx context.Context) http.Header {
if ctx == nil {
return nil
}
h, _ := ctx.Value(codexClientHeadersKey{}).(http.Header)
return h
}
2 changes: 1 addition & 1 deletion internal/providers/qoder/checkin.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (b *Bridge) Checkin(ctx context.Context) (providers.CheckinResult, error) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Qoder")
req.Header.Set("Cosy-ClientType", "10")
req.Header.Set("Cosy-Version", "0.3.4")
req.Header.Set("Cosy-Version", qauth.DesktopCosyVersion)
req.Header.Set("Cosy-MachineId", machineID)
req.Header.Set("Cosy-MachineToken", machineToken)
req.Header.Set("Cosy-MachineType", machineType)
Expand Down
4 changes: 2 additions & 2 deletions internal/providers/qoder/checkin_inprocess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ func TestBridgeCheckinHappyPath(t *testing.T) {
if got := gotHeaders.Get("Cosy-ClientType"); got != "10" {
t.Fatalf("Cosy-ClientType = %q want 10", got)
}
if got := gotHeaders.Get("Cosy-Version"); got != "0.3.4" {
t.Fatalf("Cosy-Version = %q want 0.3.4", got)
if got := gotHeaders.Get("Cosy-Version"); got != "0.4.8" {
t.Fatalf("Cosy-Version = %q want 0.4.8", got)
}
for _, key := range []string{"Cosy-Machineid", "Cosy-Machinetoken", "Cosy-Machinetype", "Cosy-Machinecode"} {
if gotHeaders.Get(key) == "" {
Expand Down
44 changes: 24 additions & 20 deletions internal/providers/qoder/cosy.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,26 +164,30 @@ func (s *Session) Sign(body, rawURL string, now time.Time) (headers map[string]s
sum := md5.Sum([]byte(raw))
bearer := "Bearer COSY." + payload + "." + hex.EncodeToString(sum[:])
return map[string]string{
"Accept": "text/event-stream",
"Authorization": bearer,
"Cache-Control": "no-cache",
"Content-Type": "application/json",
"Cosy-ClientType": "5",
"Cosy-Data-Policy": "AGREE",
"Cosy-Date": date,
"Cosy-Key": s.cosyKey,
"Cosy-MachineId": s.MachineID,
"Cosy-MachineToken": s.MachineToken,
"Cosy-MachineType": s.MachineType,
"Cosy-User": s.User.uid(),
"Cosy-Version": COSYVersion,
"Login-Version": "v2",
"Accept-Encoding": "identity",
// Sign is only used for inference SSE (direct chat), where qodercli
// also sends machine OS/hostname.
"User-Agent": qauth.UserAgentForURL(rawURL),
"Cosy-MachineOS": qauth.CosyMachineOS,
"Cosy-MachineHostname": qauth.MachineHostname(s.User.uid()),
"Accept": "text/event-stream",
"Authorization": bearer,
"Cache-Control": "no-cache",
"Content-Type": "application/json",
"Cosy-ClientType": "5",
"Cosy-Data-Policy": "agree",
"Cosy-Date": date,
"Cosy-Key": s.cosyKey,
"Cosy-MachineId": s.MachineID,
"Cosy-MachineToken": s.MachineToken,
"Cosy-MachineType": s.MachineType,
"Cosy-User": s.User.uid(),
"Cosy-Version": COSYVersion,
"Login-Version": "v2",
"Accept-Encoding": "identity",
"Cosy-Business-Product": "cli",
"Cosy-Business-Type": "agent",
"Cosy-Scene": "assistant",
"Cosy-Clientip": qauth.CosyClientIP,
// Inference SSE: undici UA + machine OS always; hostname SSE-only
// (applyClientFingerprint mirrors this on the bearer path).
"User-Agent": qauth.CosyUserAgent,
"Cosy-MachineOS": qauth.CosyMachineOS,
"Cosy-MachineHostname": qauth.MachineHostname(s.User.uid()),
}, nil
}

Expand Down
17 changes: 12 additions & 5 deletions internal/providers/qoder/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,23 @@ func TestDirectSignCarriesClientFingerprint(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ region, ua string }{{"global", "Qoder/"}, {"cn", "QoderWork/"}} {
h, err := s.Sign("b", ChatEndpoint(tc.region), time.Unix(1, 0))
for _, region := range []string{"global", "cn"} {
h, err := s.Sign("b", ChatEndpoint(region), time.Unix(1, 0))
if err != nil {
t.Fatal(err)
}
if h["User-Agent"] != tc.ua+qauth.CosyVersion || h["Cosy-Version"] != qauth.CosyVersion {
t.Fatalf("%s ua=%q ver=%q", tc.region, h["User-Agent"], h["Cosy-Version"])
if h["User-Agent"] != qauth.CosyUserAgent || h["Cosy-Version"] != qauth.CosyVersion {
t.Fatalf("%s ua=%q ver=%q", region, h["User-Agent"], h["Cosy-Version"])
}
if h["Cosy-Data-Policy"] != "agree" {
t.Fatalf("%s data-policy=%q", region, h["Cosy-Data-Policy"])
}
if h["Cosy-Business-Product"] != "cli" || h["Cosy-Business-Type"] != "agent" ||
h["Cosy-Scene"] != "assistant" || h["Cosy-Clientip"] != qauth.CosyClientIP {
t.Fatalf("%s business headers incomplete: %+v", region, h)
}
if h["Cosy-MachineOS"] != qauth.CosyMachineOS || h["Cosy-MachineHostname"] != qauth.MachineHostname("uid-fp") {
t.Fatalf("%s os/host=%q/%q", tc.region, h["Cosy-MachineOS"], h["Cosy-MachineHostname"])
t.Fatalf("%s os/host=%q/%q", region, h["Cosy-MachineOS"], h["Cosy-MachineHostname"])
}
}
}
61 changes: 40 additions & 21 deletions internal/providers/qoder/qauth/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,49 @@ import (

// CosyVersion is the single client/protocol version cli2api presents to
// Qoder: the cosy-version header, the bearer payload's cosyVersion, the chat
// body version and the User-Agent all derive from it. It mirrors the pinned
// qodercli build cli2api emulates (session_type "qodercli", product "cli").
const CosyVersion = "1.1.32"
// body version and the OpenAPI User-Agent all derive from it. It mirrors the
// pinned qodercli build (1.1.75; hub PR #75 / v1.7.3).
const CosyVersion = "1.1.75"

// DesktopCosyVersion is the Cosy-Version the official desktop app sends on
// campaign / check-in requests (hub desktop 0.4.8). Distinct from CosyVersion
// (CLI / reasoning).
const DesktopCosyVersion = "0.4.8"

// CosyUserAgent is the User-Agent official qodercli 1.1.75 leaves unset so the
// bundled undici fetch supplies its default ("undici").
const CosyUserAgent = "undici"

// CosyMachineOS mirrors qodercli's `${arch}_${platform}` value for a Linux
// x86_64 host, which is how cli2api is deployed.
const CosyMachineOS = "x86_64_linux"

// User-Agent product names per edition. The China edition ships as
// QoderWork, the international edition as Qoder.
const (
userAgentProductCN = "QoderWork"
userAgentProductGlobal = "Qoder"
)
// CosyClientIP is the link-local address official qodercli puts in
// cosy-clientip on outbound COSY requests.
const CosyClientIP = "169.254.198.161"

// UserAgent returns the edition-specific User-Agent. Anything that is not the
// international region is treated as the China edition (the default region).
// applyCosyBusinessHeaders adds the business / scene / client-ip headers
// qodercli 1.1.75 sends on every COSY request.
func applyCosyBusinessHeaders(h http.Header) {
h.Set("cosy-business-product", "cli")
h.Set("cosy-business-type", "agent")
h.Set("cosy-scene", "assistant")
h.Set("cosy-clientip", CosyClientIP)
}

// UserAgent returns the COSY reasoning User-Agent. Official qodercli does not
// set UA itself; undici supplies "undici". Edition no longer splits the UA
// (hub PR #75). Region is accepted for call-site compatibility.
func UserAgent(region *RegionConfig) string {
if region != nil && region.Name == Global.Name {
return userAgentProductGlobal + "/" + CosyVersion
}
return userAgentProductCN + "/" + CosyVersion
_ = region
return CosyUserAgent
}

// UserAgentForURL returns the edition User-Agent for a request URL.
func UserAgentForURL(rawURL string) string { return UserAgent(regionForURL(rawURL)) }
// UserAgentForURL returns the COSY User-Agent for a request URL.
func UserAgentForURL(rawURL string) string {
_ = rawURL
return CosyUserAgent
}

// regionForURL infers the edition from a request URL's host.
func regionForURL(rawURL string) *RegionConfig {
Expand All @@ -59,12 +76,14 @@ func MachineHostname(uid string) string {
return "DESKTOP-" + strings.ToUpper(hex.EncodeToString(sum[:])[:7])
}

// applyClientFingerprint sets the edition User-Agent and, on inference SSE
// requests only (as qodercli does), cosy-machineos/cosy-machinehostname.
// applyClientFingerprint sets the undici User-Agent, always sends
// cosy-machineos (qodercli 1.1.75), and on inference SSE requests only also
// sends cosy-machinehostname.
func applyClientFingerprint(h http.Header, rawURL, uid string, inferSSE bool) {
h.Set("user-agent", UserAgentForURL(rawURL))
_ = rawURL
h.Set("user-agent", CosyUserAgent)
h.Set("cosy-machineos", CosyMachineOS)
if inferSSE {
h.Set("cosy-machineos", CosyMachineOS)
h.Set("cosy-machinehostname", MachineHostname(uid))
}
}
Loading
Loading