Skip to content

Commit 37d16cb

Browse files
committed
Merge upstream/main (post-v6.9.42): claude codex/web-search fixes, gemini-cli usage variants, GPT-5.5, Codex/PI session affinity, antigravity logging
# Conflicts: # README.md # README_CN.md # README_JA.md
2 parents d87cdbc + 359ec30 commit 37d16cb

14 files changed

Lines changed: 688 additions & 61 deletions

File tree

config.example.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@ quota-exceeded:
109109
routing:
110110
strategy: "round-robin" # round-robin (default), fill-first
111111
# Enable universal session-sticky routing for all clients.
112-
# Session IDs are extracted from: X-Session-ID header, Idempotency-Key,
113-
# metadata.user_id, conversation_id, or first few messages hash.
112+
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
113+
# X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
114+
# X-Client-Request-Id (PI), conversation_id, or first few messages hash.
114115
# Automatic failover is always enabled when bound auth becomes unavailable.
115116
session-affinity: false # default: false
116117
# How long session-to-auth bindings are retained. Default: 1h

internal/config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,9 @@ type RoutingConfig struct {
243243

244244
// SessionAffinity enables universal session-sticky routing for all clients.
245245
// Session IDs are extracted from multiple sources:
246-
// X-Session-ID header, Idempotency-Key, metadata.user_id, conversation_id, or message hash.
246+
// metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex),
247+
// X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id,
248+
// conversation_id, or message hash.
247249
// Automatic failover is always enabled when bound auth becomes unavailable.
248250
SessionAffinity bool `yaml:"session-affinity,omitempty" json:"session-affinity,omitempty"`
249251

internal/logging/gin_logger.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ var aiAPIPrefixes = []string{
2727
"/api/provider/",
2828
}
2929

30-
const skipGinLogKey = "__gin_skip_request_logging__"
30+
const (
31+
skipGinLogKey = "__gin_skip_request_logging__"
32+
creditsUsedKey = "__antigravity_credits_used__"
33+
)
3134

3235
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
3336
// using logrus. It captures request details including method, path, status code, latency,
@@ -79,6 +82,9 @@ func GinLogrusLogger() gin.HandlerFunc {
7982
requestID = "--------"
8083
}
8184
logLine := fmt.Sprintf("%3d | %13v | %15s | %-7s \"%s\"", statusCode, latency, clientIP, method, path)
85+
if creditsUsed(c) {
86+
logLine += " [credits]"
87+
}
8288
if errorMessage != "" {
8389
logLine = logLine + " | " + errorMessage
8490
}
@@ -149,3 +155,15 @@ func shouldSkipGinRequestLogging(c *gin.Context) bool {
149155
flag, ok := val.(bool)
150156
return ok && flag
151157
}
158+
159+
func creditsUsed(c *gin.Context) bool {
160+
if c == nil {
161+
return false
162+
}
163+
val, exists := c.Get(creditsUsedKey)
164+
if !exists {
165+
return false
166+
}
167+
flag, ok := val.(bool)
168+
return ok && flag
169+
}

internal/registry/models/models.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,29 @@
12931293
]
12941294
}
12951295
},
1296+
{
1297+
"id": "gpt-5.5",
1298+
"object": "model",
1299+
"created": 1776902400,
1300+
"owned_by": "openai",
1301+
"type": "openai",
1302+
"display_name": "GPT 5.5",
1303+
"version": "gpt-5.5",
1304+
"description": "Frontier model for complex coding, research, and real-world work.",
1305+
"context_length": 272000,
1306+
"max_completion_tokens": 128000,
1307+
"supported_parameters": [
1308+
"tools"
1309+
],
1310+
"thinking": {
1311+
"levels": [
1312+
"low",
1313+
"medium",
1314+
"high",
1315+
"xhigh"
1316+
]
1317+
}
1318+
},
12961319
{
12971320
"id": "codex-auto-review",
12981321
"object": "model",

internal/runtime/executor/antigravity_executor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2268,9 +2268,9 @@ var antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string {
22682268
return []string{base}
22692269
}
22702270
return []string{
2271-
antigravityBaseURLProd,
22722271
antigravityBaseURLDaily,
2273-
antigravitySandboxBaseURLDaily,
2272+
antigravityBaseURLProd,
2273+
// antigravitySandboxBaseURLDaily,
22742274
}
22752275
}
22762276

internal/runtime/executor/gemini_cli_executor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
422422
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
423423
reporter.PublishFailure(ctx)
424424
out <- cliproxyexecutor.StreamChunk{Err: errScan}
425+
return
425426
}
427+
reporter.EnsurePublished(ctx)
426428
return
427429
}
428430

internal/runtime/executor/helps/usage_helpers.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -389,12 +389,22 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
389389
return detail
390390
}
391391

392+
func hasGeminiFamilyUsageTokenFields(node gjson.Result) bool {
393+
return node.Get("promptTokenCount").Exists() ||
394+
node.Get("candidatesTokenCount").Exists() ||
395+
node.Get("thoughtsTokenCount").Exists() ||
396+
node.Get("totalTokenCount").Exists() ||
397+
node.Get("cachedContentTokenCount").Exists()
398+
}
399+
392400
func ParseGeminiCLIUsage(data []byte) usage.Detail {
393401
usageNode := gjson.ParseBytes(data)
394-
node := usageNode.Get("response.usageMetadata")
395-
if !node.Exists() {
396-
node = usageNode.Get("response.usage_metadata")
397-
}
402+
node := firstExistingUsageNode(usageNode,
403+
"response.usageMetadata",
404+
"response.usage_metadata",
405+
"usageMetadata",
406+
"usage_metadata",
407+
)
398408
if !node.Exists() {
399409
return usage.Detail{}
400410
}
@@ -433,16 +443,32 @@ func ParseGeminiCLIStreamUsage(line []byte) (usage.Detail, bool) {
433443
if len(payload) == 0 || !gjson.ValidBytes(payload) {
434444
return usage.Detail{}, false
435445
}
436-
node := gjson.GetBytes(payload, "response.usageMetadata")
446+
root := gjson.ParseBytes(payload)
447+
node := firstExistingUsageNode(root,
448+
"response.usageMetadata",
449+
"response.usage_metadata",
450+
"usageMetadata",
451+
"usage_metadata",
452+
)
437453
if !node.Exists() {
438-
node = gjson.GetBytes(payload, "usage_metadata")
454+
return usage.Detail{}, false
439455
}
440-
if !node.Exists() {
456+
if !hasGeminiFamilyUsageTokenFields(node) {
441457
return usage.Detail{}, false
442458
}
443459
return parseGeminiFamilyUsageDetail(node), true
444460
}
445461

462+
func firstExistingUsageNode(root gjson.Result, paths ...string) gjson.Result {
463+
for _, path := range paths {
464+
node := root.Get(path)
465+
if node.Exists() {
466+
return node
467+
}
468+
}
469+
return gjson.Result{}
470+
}
471+
446472
func ParseAntigravityUsage(data []byte) usage.Detail {
447473
usageNode := gjson.ParseBytes(data)
448474
node := usageNode.Get("response.usageMetadata")

internal/runtime/executor/helps/usage_helpers_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,50 @@ func TestParseOpenAIUsageResponses(t *testing.T) {
4747
}
4848
}
4949

50+
func TestParseGeminiCLIUsage_TopLevelUsageMetadata(t *testing.T) {
51+
data := []byte(`{"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":7,"thoughtsTokenCount":3,"totalTokenCount":21,"cachedContentTokenCount":5}}`)
52+
detail := ParseGeminiCLIUsage(data)
53+
if detail.InputTokens != 11 {
54+
t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 11)
55+
}
56+
if detail.OutputTokens != 7 {
57+
t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 7)
58+
}
59+
if detail.ReasoningTokens != 3 {
60+
t.Fatalf("reasoning tokens = %d, want %d", detail.ReasoningTokens, 3)
61+
}
62+
if detail.TotalTokens != 21 {
63+
t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 21)
64+
}
65+
if detail.CachedTokens != 5 {
66+
t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 5)
67+
}
68+
}
69+
70+
func TestParseGeminiCLIStreamUsage_ResponseSnakeCaseUsageMetadata(t *testing.T) {
71+
line := []byte(`data: {"response":{"usage_metadata":{"promptTokenCount":13,"candidatesTokenCount":2,"totalTokenCount":15}}}`)
72+
detail, ok := ParseGeminiCLIStreamUsage(line)
73+
if !ok {
74+
t.Fatal("ParseGeminiCLIStreamUsage() ok = false, want true")
75+
}
76+
if detail.InputTokens != 13 {
77+
t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 13)
78+
}
79+
if detail.OutputTokens != 2 {
80+
t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 2)
81+
}
82+
if detail.TotalTokens != 15 {
83+
t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 15)
84+
}
85+
}
86+
87+
func TestParseGeminiCLIStreamUsage_IgnoresTrafficTypeOnlyUsageMetadata(t *testing.T) {
88+
line := []byte(`data: {"response":{"usageMetadata":{"trafficType":"ON_DEMAND"}}}`)
89+
if detail, ok := ParseGeminiCLIStreamUsage(line); ok {
90+
t.Fatalf("ParseGeminiCLIStreamUsage() = (%+v, true), want false for traffic-only usage metadata", detail)
91+
}
92+
}
93+
5094
func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) {
5195
reporter := &UsageReporter{
5296
provider: "openai",

internal/translator/codex/claude/codex_claude_request.go

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
4040
template := []byte(`{"model":"","instructions":"","input":[]}`)
4141

4242
rootResult := gjson.ParseBytes(rawJSON)
43+
toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
4344
template, _ = sjson.SetBytes(template, "model", modelName)
4445

4546
// Process system messages and convert them to input content format.
@@ -174,8 +175,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
174175
functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", messageContentResult.Get("id").String())
175176
{
176177
name := messageContentResult.Get("name").String()
177-
toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
178-
if short, ok := toolMap[name]; ok {
178+
if short, ok := toolNameMap[name]; ok {
179179
name = short
180180
} else {
181181
name = shortenNameIfNeeded(name)
@@ -249,31 +249,22 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
249249
toolsResult := rootResult.Get("tools")
250250
if toolsResult.IsArray() {
251251
template, _ = sjson.SetRawBytes(template, "tools", []byte(`[]`))
252-
template, _ = sjson.SetBytes(template, "tool_choice", `auto`)
252+
webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult)
253+
template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames))
253254
toolResults := toolsResult.Array()
254-
// Build short name map from declared tools
255-
var names []string
256-
for i := 0; i < len(toolResults); i++ {
257-
n := toolResults[i].Get("name").String()
258-
if n != "" {
259-
names = append(names, n)
260-
}
261-
}
262-
shortMap := buildShortNameMap(names)
263255
for i := 0; i < len(toolResults); i++ {
264256
toolResult := toolResults[i]
265257
// Special handling: map Claude web search tool to Codex web_search
266-
if toolResult.Get("type").String() == "web_search_20250305" {
267-
// Replace the tool content entirely with {"type":"web_search"}
268-
template, _ = sjson.SetRawBytes(template, "tools.-1", []byte(`{"type":"web_search"}`))
258+
if isClaudeWebSearchToolType(toolResult.Get("type").String()) {
259+
template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult))
269260
continue
270261
}
271262
tool := []byte(toolResult.Raw)
272263
tool, _ = sjson.SetBytes(tool, "type", "function")
273264
// Apply shortened name if needed
274265
if v := toolResult.Get("name"); v.Exists() {
275266
name := v.String()
276-
if short, ok := shortMap[name]; ok {
267+
if short, ok := toolNameMap[name]; ok {
277268
name = short
278269
} else {
279270
name = shortenNameIfNeeded(name)
@@ -370,6 +361,81 @@ func isFernetLikeReasoningSignature(signature string) bool {
370361
return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0
371362
}
372363

364+
func isClaudeWebSearchToolType(toolType string) bool {
365+
return toolType == "web_search_20250305" || toolType == "web_search_20260209"
366+
}
367+
368+
func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} {
369+
names := map[string]struct{}{}
370+
if !tools.IsArray() {
371+
return names
372+
}
373+
374+
tools.ForEach(func(_, tool gjson.Result) bool {
375+
toolType := tool.Get("type").String()
376+
if !isClaudeWebSearchToolType(toolType) {
377+
return true
378+
}
379+
380+
if name := tool.Get("name").String(); name != "" {
381+
names[name] = struct{}{}
382+
}
383+
return true
384+
})
385+
386+
return names
387+
}
388+
389+
func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte {
390+
if !toolChoice.Exists() || toolChoice.Type == gjson.Null {
391+
return []byte(`"auto"`)
392+
}
393+
394+
choiceType := toolChoice.Get("type").String()
395+
if choiceType == "" && toolChoice.Type == gjson.String {
396+
choiceType = toolChoice.String()
397+
}
398+
399+
switch choiceType {
400+
case "auto", "":
401+
return []byte(`"auto"`)
402+
case "any":
403+
return []byte(`"required"`)
404+
case "none":
405+
return []byte(`"none"`)
406+
case "tool":
407+
name := toolChoice.Get("name").String()
408+
if _, ok := webSearchToolNames[name]; ok {
409+
return []byte(`{"type":"web_search"}`)
410+
}
411+
if short, ok := toolNameMap[name]; ok {
412+
name = short
413+
} else {
414+
name = shortenNameIfNeeded(name)
415+
}
416+
if name == "" {
417+
return []byte(`"auto"`)
418+
}
419+
420+
choice := []byte(`{"type":"function","name":""}`)
421+
choice, _ = sjson.SetBytes(choice, "name", name)
422+
return choice
423+
default:
424+
return []byte(`"auto"`)
425+
}
426+
}
427+
428+
func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte {
429+
out := []byte(`{"type":"web_search"}`)
430+
if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
431+
out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw))
432+
}
433+
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
434+
out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw))
435+
}
436+
return out
437+
}
438+
373439
// shortenNameIfNeeded applies a simple shortening rule for a single name.
374440
func shortenNameIfNeeded(name string) string {
375441
const limit = 64

0 commit comments

Comments
 (0)