Skip to content

Commit e48d4bc

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix/codex-mcp-tools-bridge
2 parents f1082bb + deff312 commit e48d4bc

124 files changed

Lines changed: 5593 additions & 573 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan
1+
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
22

33
FRONTEND_CRITICAL_VITEST := \
44
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
@@ -19,10 +19,6 @@ build-backend:
1919
build-frontend:
2020
@pnpm --dir frontend run build
2121

22-
# 编译 datamanagementd(宿主机数据管理进程)
23-
build-datamanagementd:
24-
@cd datamanagement && go build -o datamanagementd ./cmd/datamanagementd
25-
2622
# 运行测试(后端 + 前端)
2723
test: test-backend test-frontend
2824

@@ -36,9 +32,3 @@ test-frontend:
3632

3733
test-frontend-critical:
3834
@pnpm --dir frontend exec vitest run $(FRONTEND_CRITICAL_VITEST)
39-
40-
test-datamanagementd:
41-
@cd datamanagement && go test ./...
42-
43-
secret-scan:
44-
@python3 tools/secret_scan.py

backend/cmd/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.149
1+
0.1.150

backend/internal/handler/admin/admin_helpers_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,12 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
265265
ServiceTier: "PRIORITY",
266266
Action: "filter",
267267
Scope: "all",
268+
UserIDs: []int64{42},
268269
}},
269270
}
270271
out := openaiFastPolicySettingsFromDTO(in)
271272
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
273+
require.Equal(t, []int64{42}, out.Rules[0].UserIDs)
272274
})
273275

274276
t.Run("non-empty values pass through (lowercased)", func(t *testing.T) {

backend/internal/handler/admin/dashboard_handler.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -657,11 +657,14 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
657657
dim.AccountID = id
658658
}
659659
}
660-
if v := c.Query("request_type"); v != "" {
661-
if rt, err := strconv.ParseInt(v, 10, 16); err == nil {
662-
rtVal := int16(rt)
663-
dim.RequestType = &rtVal
660+
if v := strings.TrimSpace(c.Query("request_type")); v != "" {
661+
parsed, err := service.ParseUsageRequestType(v)
662+
if err != nil {
663+
response.BadRequest(c, err.Error())
664+
return
664665
}
666+
rtVal := int16(parsed)
667+
dim.RequestType = &rtVal
665668
}
666669
if v := c.Query("stream"); v != "" {
667670
if s, err := strconv.ParseBool(v); err == nil {

backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,42 @@ func TestGetUserBreakdown_NoFilters(t *testing.T) {
241241
require.Empty(t, repo.capturedDim.Model)
242242
require.Empty(t, repo.capturedDim.Endpoint)
243243
}
244+
245+
func TestGetUserBreakdown_RequestTypeStringFilter(t *testing.T) {
246+
cases := []struct {
247+
name string
248+
value string
249+
want int16
250+
}{
251+
{"ws_v2", "ws_v2", int16(service.RequestTypeWSV2)},
252+
{"stream", "stream", int16(service.RequestTypeStream)},
253+
{"sync", "sync", int16(service.RequestTypeSync)},
254+
}
255+
for _, tc := range cases {
256+
t.Run(tc.name, func(t *testing.T) {
257+
repo := &userBreakdownRepoCapture{}
258+
router := newUserBreakdownRouter(repo)
259+
260+
req := httptest.NewRequest(http.MethodGet,
261+
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&request_type="+tc.value, nil)
262+
w := httptest.NewRecorder()
263+
router.ServeHTTP(w, req)
264+
265+
require.Equal(t, http.StatusOK, w.Code)
266+
require.NotNil(t, repo.capturedDim.RequestType, "request_type=%s should set filter", tc.value)
267+
require.Equal(t, tc.want, *repo.capturedDim.RequestType)
268+
})
269+
}
270+
}
271+
272+
func TestGetUserBreakdown_InvalidRequestType(t *testing.T) {
273+
repo := &userBreakdownRepoCapture{}
274+
router := newUserBreakdownRouter(repo)
275+
276+
req := httptest.NewRequest(http.MethodGet,
277+
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&request_type=bogus", nil)
278+
w := httptest.NewRecorder()
279+
router.ServeHTTP(w, req)
280+
281+
require.Equal(t, http.StatusBadRequest, w.Code)
282+
}

backend/internal/handler/dto/settings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ type OpenAIFastPolicyRule struct {
422422
ServiceTier string `json:"service_tier"`
423423
Action string `json:"action"`
424424
Scope string `json:"scope"`
425+
UserIDs []int64 `json:"user_ids,omitempty"`
425426
ErrorMessage string `json:"error_message,omitempty"`
426427
ModelWhitelist []string `json:"model_whitelist,omitempty"`
427428
FallbackAction string `json:"fallback_action,omitempty"`

backend/internal/handler/openai_gateway_handler.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
205205
if !ok {
206206
return
207207
}
208+
// body-signal compact:上游 unary 等待期间向下游发 SSE 注释行心跳,防止
209+
// 反向代理空闲超时掐断长压缩连接(#3887)。首拍延迟一个心跳间隔,快速
210+
// 失败仍走 JSON+状态码链路;未标记客户端流式或间隔为 0 时是 no-op。
211+
stopCompactKeepalive := service.StartOpenAICompactSSEKeepalive(c, h.openAICompactKeepaliveInterval())
212+
defer stopCompactKeepalive()
208213

209214
// 校验请求体 JSON 合法性
210215
if !gjson.ValidBytes(body) {
@@ -402,7 +407,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
402407
// Forward request
403408
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
404409
forwardStart := time.Now()
405-
writerSizeBeforeForward := c.Writer.Size()
410+
// 用扣除 compact 心跳字节的口径快照:心跳注释不构成语义响应,
411+
// 不能因心跳字节变化而放弃 failover 换号(#3887)。
412+
writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c)
406413
result, err := func() (*service.OpenAIForwardResult, error) {
407414
defer func() {
408415
if accountReleaseFunc != nil {
@@ -436,7 +443,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
436443
} else {
437444
var failoverErr *service.UpstreamFailoverError
438445
if errors.As(err, &failoverErr) {
439-
if c.Writer.Size() != writerSizeBeforeForward {
446+
if service.OpenAICompactKeepaliveAdjustedWrittenSize(c) != writerSizeBeforeForward {
440447
h.handleFailoverExhausted(c, failoverErr, true)
441448
return
442449
}
@@ -637,6 +644,13 @@ func (h *OpenAIGatewayHandler) logOpenAIRemoteCompactOutcome(c *gin.Context, sta
637644
if status >= 200 && status < 300 {
638645
outcome = "succeeded"
639646
}
647+
// compact 心跳提交后失败的 wire 状态码固化为 200,真实结局以流内错误
648+
// 标记为准(response.failed 降级路径会 MarkOpsStreamError)。
649+
if outcome == "succeeded" && c != nil {
650+
if _, hasStreamErr := service.GetOpsStreamError(c); hasStreamErr {
651+
outcome = "failed"
652+
}
653+
}
640654
latencyMs := time.Since(startedAt).Milliseconds()
641655
if latencyMs < 0 {
642656
latencyMs = 0
@@ -1943,6 +1957,11 @@ func (h *OpenAIGatewayHandler) mapUpstreamError(statusCode int) (int, string, st
19431957

19441958
// handleStreamingAwareError handles errors that may occur after streaming has started
19451959
func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
1960+
// body-signal compact 心跳可能已把响应头提交为 200:先停心跳(建立
1961+
// happens-before,接管 ResponseWriter),并升级为流内错误处理。
1962+
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
1963+
streamStarted = true
1964+
}
19461965
if streamStarted {
19471966
// /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于
19481967
// response.completed/failed/incomplete/cancelled 集合。
@@ -1975,6 +1994,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
19751994
if c == nil || c.Writer == nil {
19761995
return false
19771996
}
1997+
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
1998+
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
1999+
streamStarted = true
2000+
}
19782001
if service.IsResponseCommitted(c) {
19792002
return false
19802003
}
@@ -2010,7 +2033,9 @@ func openAIForwardErrorAlreadyCommunicated(c *gin.Context, writerSizeBeforeForwa
20102033
if err == nil || c == nil || c.Writer == nil {
20112034
return false
20122035
}
2013-
if c.Writer.Size() == writerSizeBeforeForward {
2036+
// 与快照同口径:排除 compact 心跳字节,避免"仅心跳写出"被误判为
2037+
// 响应已写出(#3887)。
2038+
if service.OpenAICompactKeepaliveAdjustedWrittenSize(c) == writerSizeBeforeForward {
20142039
return false
20152040
}
20162041

@@ -2036,6 +2061,14 @@ func openAIForwardErrorAlreadyCommunicated(c *gin.Context, writerSizeBeforeForwa
20362061

20372062
// errorResponse returns OpenAI API format error response
20382063
func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
2064+
// body-signal compact 心跳可能已把响应头提交为 200:JSON 错误体会与已
2065+
// 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。
2066+
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
2067+
service.MarkOpsStreamError(c, errType, message, status)
2068+
if writeResponsesFailedSSE(c, errType, message) {
2069+
return
2070+
}
2071+
}
20392072
c.JSON(status, gin.H{
20402073
"error": gin.H{
20412074
"type": errType,
@@ -2044,6 +2077,15 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
20442077
})
20452078
}
20462079

2080+
// openAICompactKeepaliveInterval 复用流式 keepalive 配置作为 compact 下游
2081+
// 心跳间隔;0 表示禁用(与流式路径语义一致)。
2082+
func (h *OpenAIGatewayHandler) openAICompactKeepaliveInterval() time.Duration {
2083+
if h.cfg == nil || h.cfg.Gateway.StreamKeepaliveInterval <= 0 {
2084+
return 0
2085+
}
2086+
return time.Duration(h.cfg.Gateway.StreamKeepaliveInterval) * time.Second
2087+
}
2088+
20472089
func setOpenAIClientTransportHTTP(c *gin.Context) {
20482090
service.SetOpenAIClientTransport(c, service.OpenAIClientTransportHTTP)
20492091
}
@@ -2306,6 +2348,16 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe
23062348
if !h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), key) {
23072349
return false
23082350
}
2351+
// body-signal compact 心跳可能已把响应头提交为 200(cyber 检查在用户槽位
2352+
// 长等待之后执行):以 response.failed 终止事件回传;未提交时停拍后照常
2353+
// 写 JSON(#3887)。
2354+
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
2355+
service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden)
2356+
if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) {
2357+
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key)
2358+
return true
2359+
}
2360+
}
23092361
switch format {
23102362
case cyberBlockFormatAnthropic:
23112363
c.JSON(http.StatusForbidden, gin.H{"type": "error", "error": gin.H{

backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
3030
TopP: req.TopP,
3131
Stream: req.Stream,
3232
ServiceTier: req.ServiceTier,
33+
ParallelToolCalls: req.ParallelToolCalls,
3334
}
3435
if req.Reasoning != nil {
3536
out.ReasoningEffort = req.Reasoning.Effort
@@ -934,9 +935,17 @@ func ChatUsageToResponsesUsage(usage *ChatUsage) *ResponsesUsage {
934935
if out.TotalTokens == 0 {
935936
out.TotalTokens = out.InputTokens + out.OutputTokens
936937
}
937-
if usage.PromptTokensDetails != nil && usage.PromptTokensDetails.CachedTokens > 0 {
938+
if usage.PromptTokensDetails != nil && (usage.PromptTokensDetails.CachedTokens > 0 ||
939+
usage.PromptTokensDetails.CacheCreationTokens > 0 || usage.PromptTokensDetails.CacheWriteTokens > 0) {
938940
out.InputTokensDetails = &ResponsesInputTokensDetails{
939-
CachedTokens: usage.PromptTokensDetails.CachedTokens,
941+
CachedTokens: usage.PromptTokensDetails.CachedTokens,
942+
CacheCreationTokens: usage.PromptTokensDetails.CacheCreationTokens,
943+
CacheWriteTokens: usage.PromptTokensDetails.CacheWriteTokens,
944+
}
945+
if usage.PromptTokensDetails.CacheWriteTokens > 0 {
946+
out.CacheCreationInputTokens = usage.PromptTokensDetails.CacheWriteTokens
947+
} else {
948+
out.CacheCreationInputTokens = usage.PromptTokensDetails.CacheCreationTokens
940949
}
941950
}
942951
return out

backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,26 @@ func TestResponsesToChatCompletionsRequest_TextFormatJsonSchema(t *testing.T) {
127127
}`, string(out.ResponseFormat))
128128
}
129129

130+
func TestResponsesToChatCompletionsRequest_ParallelToolCalls(t *testing.T) {
131+
parallel := false
132+
req := &ResponsesRequest{
133+
Model: "gpt-4o",
134+
Input: json.RawMessage(`[
135+
{"role":"user","content":"Use tools"}
136+
]`),
137+
ParallelToolCalls: &parallel,
138+
}
139+
140+
out, err := ResponsesToChatCompletionsRequest(req)
141+
require.NoError(t, err)
142+
require.NotNil(t, out.ParallelToolCalls)
143+
assert.False(t, *out.ParallelToolCalls)
144+
145+
payload, err := json.Marshal(out)
146+
require.NoError(t, err)
147+
assert.Contains(t, string(payload), `"parallel_tool_calls":false`)
148+
}
149+
130150
func chatMessageRoles(messages []ChatMessage) []string {
131151
roles := make([]string, 0, len(messages))
132152
for _, message := range messages {

backend/internal/pkg/apicompat/chatcompletions_responses_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,47 @@ func TestChatCompletionsToResponses_BasicText(t *testing.T) {
3232
assert.Equal(t, "user", items[0].Role)
3333
}
3434

35+
func TestUsageConversionsPreserveCacheWriteTokens(t *testing.T) {
36+
var responsesUsage ResponsesUsage
37+
require.NoError(t, json.Unmarshal([]byte(`{
38+
"input_tokens":1000,
39+
"output_tokens":50,
40+
"input_tokens_details":{"cached_tokens":100,"cache_write_tokens":200}
41+
}`), &responsesUsage))
42+
require.NotNil(t, responsesUsage.InputTokensDetails)
43+
require.Equal(t, 200, responsesUsage.InputTokensDetails.CacheWriteTokens)
44+
45+
chatUsage := chatUsageFromResponsesUsage(&responsesUsage)
46+
require.NotNil(t, chatUsage.PromptTokensDetails)
47+
require.Equal(t, 100, chatUsage.PromptTokensDetails.CachedTokens)
48+
require.Equal(t, 200, chatUsage.PromptTokensDetails.CacheWriteTokens)
49+
50+
roundTrip := ChatUsageToResponsesUsage(chatUsage)
51+
require.NotNil(t, roundTrip.InputTokensDetails)
52+
require.Equal(t, 200, roundTrip.CacheCreationInputTokens)
53+
require.Equal(t, 200, roundTrip.InputTokensDetails.CacheWriteTokens)
54+
}
55+
56+
func TestResponsesUsageNestedCacheWritePresenceOverridesTopLevelAlias(t *testing.T) {
57+
tests := []struct {
58+
name string
59+
nestedJSON string
60+
want int
61+
}{
62+
{name: "explicit zero", nestedJSON: `{"cache_write_tokens":0}`, want: 0},
63+
{name: "nonzero", nestedJSON: `{"cache_write_tokens":7}`, want: 7},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
var usage ResponsesUsage
69+
payload := []byte(`{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":` + tt.nestedJSON + `}`)
70+
require.NoError(t, json.Unmarshal(payload, &usage))
71+
require.Equal(t, tt.want, usage.CacheCreationInputTokens)
72+
})
73+
}
74+
}
75+
3576
func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
3677
req := &ChatCompletionsRequest{
3778
Model: "gpt-4o",
@@ -531,6 +572,25 @@ func TestChatCompletionsToResponses_ServiceTier(t *testing.T) {
531572
assert.Equal(t, "flex", resp.ServiceTier)
532573
}
533574

575+
func TestChatCompletionsToResponses_ParallelToolCalls(t *testing.T) {
576+
for _, value := range []bool{false, true} {
577+
req := &ChatCompletionsRequest{
578+
Model: "gpt-4o",
579+
ParallelToolCalls: &value,
580+
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
581+
}
582+
583+
resp, err := ChatCompletionsToResponses(req)
584+
require.NoError(t, err)
585+
require.NotNil(t, resp.ParallelToolCalls)
586+
assert.Equal(t, value, *resp.ParallelToolCalls)
587+
588+
payload, err := json.Marshal(resp)
589+
require.NoError(t, err)
590+
assert.Contains(t, string(payload), `"parallel_tool_calls":`+string(mustMarshalJSON(t, value)))
591+
}
592+
}
593+
534594
// ---------------------------------------------------------------------------
535595
// temperature / top_p stripping for reasoning models
536596
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)