feat(codex): pass through codex client identity headers
This commit is contained in:
@@ -28,8 +28,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexClientVersion = "0.101.0"
|
codexUserAgent = "codex_cli_rs/0.116.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
|
||||||
codexUserAgent = "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
|
codexOriginator = "codex_cli_rs"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dataTag = []byte("data:")
|
var dataTag = []byte("data:")
|
||||||
@@ -645,8 +645,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
ginHeaders = ginCtx.Request.Header
|
ginHeaders = ginCtx.Request.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion)
|
misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
|
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-Metadata", "")
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
|
||||||
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
||||||
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
||||||
|
|
||||||
@@ -663,8 +665,12 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
isAPIKey = true
|
isAPIKey = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
|
||||||
|
r.Header.Set("Originator", originator)
|
||||||
|
} else if !isAPIKey {
|
||||||
|
r.Header.Set("Originator", codexOriginator)
|
||||||
|
}
|
||||||
if !isAPIKey {
|
if !isAPIKey {
|
||||||
r.Header.Set("Originator", "codex_cli_rs")
|
|
||||||
if auth != nil && auth.Metadata != nil {
|
if auth != nil && auth.Metadata != nil {
|
||||||
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
||||||
r.Header.Set("Chatgpt-Account-Id", accountID)
|
r.Header.Set("Chatgpt-Account-Id", accountID)
|
||||||
|
|||||||
@@ -814,9 +814,10 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
|
ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
|
||||||
|
misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
|
||||||
|
misc.EnsureHeader(headers, ginHeaders, "Version", "")
|
||||||
|
|
||||||
misc.EnsureHeader(headers, ginHeaders, "Version", codexClientVersion)
|
|
||||||
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
|
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
|
||||||
if betaHeader == "" && ginHeaders != nil {
|
if betaHeader == "" && ginHeaders != nil {
|
||||||
betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
|
betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
|
||||||
@@ -834,8 +835,12 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
isAPIKey = true
|
isAPIKey = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
|
||||||
|
headers.Set("Originator", originator)
|
||||||
|
} else if !isAPIKey {
|
||||||
|
headers.Set("Originator", codexOriginator)
|
||||||
|
}
|
||||||
if !isAPIKey {
|
if !isAPIKey {
|
||||||
headers.Set("Originator", "codex_cli_rs")
|
|
||||||
if auth != nil && auth.Metadata != nil {
|
if auth != nil && auth.Metadata != nil {
|
||||||
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
||||||
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
|
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
|
||||||
|
|||||||
@@ -41,9 +41,46 @@ func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T)
|
|||||||
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
||||||
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
||||||
}
|
}
|
||||||
|
if got := headers.Get("Version"); got != "" {
|
||||||
|
t.Fatalf("Version = %q, want empty", got)
|
||||||
|
}
|
||||||
if got := headers.Get("x-codex-beta-features"); got != "" {
|
if got := headers.Get("x-codex-beta-features"); got != "" {
|
||||||
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
||||||
}
|
}
|
||||||
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != "" {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Client-Request-Id"); got != "" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
ctx := contextWithGinHeaders(map[string]string{
|
||||||
|
"Originator": "Codex Desktop",
|
||||||
|
"Version": "0.115.0-alpha.27",
|
||||||
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
||||||
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
||||||
|
})
|
||||||
|
|
||||||
|
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
|
||||||
|
|
||||||
|
if got := headers.Get("Originator"); got != "Codex Desktop" {
|
||||||
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
||||||
|
}
|
||||||
|
if got := headers.Get("Version"); got != "0.115.0-alpha.27" {
|
||||||
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
|
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
|
||||||
@@ -177,6 +214,57 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
req = req.WithContext(contextWithGinHeaders(map[string]string{
|
||||||
|
"Originator": "Codex Desktop",
|
||||||
|
"Version": "0.115.0-alpha.27",
|
||||||
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
||||||
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
||||||
|
}))
|
||||||
|
|
||||||
|
applyCodexHeaders(req, auth, "oauth-token", true, nil)
|
||||||
|
|
||||||
|
if got := req.Header.Get("Originator"); got != "Codex Desktop" {
|
||||||
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("Version"); got != "0.115.0-alpha.27" {
|
||||||
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersDoesNotInjectClientOnlyHeadersByDefault(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCodexHeaders(req, nil, "oauth-token", true, nil)
|
||||||
|
|
||||||
|
if got := req.Header.Get("Version"); got != "" {
|
||||||
|
t.Fatalf("Version = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != "" {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Client-Request-Id"); got != "" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contextWithGinHeaders(headers map[string]string) context.Context {
|
func contextWithGinHeaders(headers map[string]string) context.Context {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -104,59 +104,59 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
|
|
||||||
// Always try cached signature first (more reliable than client-provided)
|
// Always try cached signature first (more reliable than client-provided)
|
||||||
// Client may send stale or invalid signatures from different sessions
|
// Client may send stale or invalid signatures from different sessions
|
||||||
signature := ""
|
signature := ""
|
||||||
if thinkingText != "" {
|
if thinkingText != "" {
|
||||||
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
|
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
|
||||||
signature = cachedSig
|
signature = cachedSig
|
||||||
// log.Debugf("Using cached signature for thinking block")
|
// log.Debugf("Using cached signature for thinking block")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to client signature only if cache miss and client signature is valid
|
|
||||||
if signature == "" {
|
|
||||||
signatureResult := contentResult.Get("signature")
|
|
||||||
clientSignature := ""
|
|
||||||
if signatureResult.Exists() && signatureResult.String() != "" {
|
|
||||||
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
|
|
||||||
if len(arrayClientSignatures) == 2 {
|
|
||||||
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
|
|
||||||
clientSignature = arrayClientSignatures[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cache.HasValidSignature(modelName, clientSignature) {
|
|
||||||
signature = clientSignature
|
// Fallback to client signature only if cache miss and client signature is valid
|
||||||
|
if signature == "" {
|
||||||
|
signatureResult := contentResult.Get("signature")
|
||||||
|
clientSignature := ""
|
||||||
|
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||||
|
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
|
||||||
|
if len(arrayClientSignatures) == 2 {
|
||||||
|
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
|
||||||
|
clientSignature = arrayClientSignatures[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cache.HasValidSignature(modelName, clientSignature) {
|
||||||
|
signature = clientSignature
|
||||||
|
}
|
||||||
|
// log.Debugf("Using client-provided signature for thinking block")
|
||||||
}
|
}
|
||||||
// log.Debugf("Using client-provided signature for thinking block")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store for subsequent tool_use in the same message
|
// Store for subsequent tool_use in the same message
|
||||||
if cache.HasValidSignature(modelName, signature) {
|
if cache.HasValidSignature(modelName, signature) {
|
||||||
currentMessageThinkingSignature = signature
|
currentMessageThinkingSignature = signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip trailing unsigned thinking blocks on last assistant message
|
// Skip trailing unsigned thinking blocks on last assistant message
|
||||||
isUnsigned := !cache.HasValidSignature(modelName, signature)
|
isUnsigned := !cache.HasValidSignature(modelName, signature)
|
||||||
|
|
||||||
// If unsigned, skip entirely (don't convert to text)
|
// If unsigned, skip entirely (don't convert to text)
|
||||||
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
||||||
// Converting to text would break this requirement
|
// Converting to text would break this requirement
|
||||||
if isUnsigned {
|
if isUnsigned {
|
||||||
// log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
// log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
||||||
enableThoughtTranslate = false
|
enableThoughtTranslate = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid signature, send as thought block
|
// Valid signature, send as thought block
|
||||||
// Always include "text" field — Google Antigravity API requires it
|
// Always include "text" field — Google Antigravity API requires it
|
||||||
// even for redacted thinking where the text is empty.
|
// even for redacted thinking where the text is empty.
|
||||||
partJSON := []byte(`{}`)
|
partJSON := []byte(`{}`)
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
|
partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
|
partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
|
||||||
if signature != "" {
|
if signature != "" {
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
|
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
|
||||||
}
|
}
|
||||||
clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
|
clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
||||||
prompt := contentResult.Get("text").String()
|
prompt := contentResult.Get("text").String()
|
||||||
// Skip empty text parts to avoid Gemini API error:
|
// Skip empty text parts to avoid Gemini API error:
|
||||||
|
|||||||
Reference in New Issue
Block a user