From ba35c618c0538d41adc092f6e344d151caf7807f Mon Sep 17 00:00:00 2001 From: 404QAQ <91465187+404QAQ@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:57:16 +0800 Subject: [PATCH 01/20] fix(images): pass content-moderation refusals through as 400 instead of retrying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When gpt-image upstream returns response.completed with no image but a text refusal (content moderation, e.g. the model replies "this request was judged unsafe to generate"), the soft-failure path treated it as a probabilistic upstream failure and returned a retryable UpstreamFailoverError (502). Retrying or switching accounts is futile for a content-policy block — it just burns other accounts' quota and still surfaces an opaque 502 to the client. Distinguish the two no-image cases: (A) model text refusal -> 400 content_policy_violation, no retry, refusal reason passed through to the client. (B) truly empty response -> unchanged: retryable UpstreamFailoverError (502). Adds extractOpenAIImagesModelRefusal (extracts the refusal text from output_text.delta / message output_text, capped at 600 chars) and two unit tests; the existing empty-response retry test is unaffected. (cherry picked from commit 9491de0a39a35ff9a58752c1e26e56f5c03d7336) --- .../service/openai_images_incomplete_test.go | 47 ++++++++++ .../service/openai_images_responses.go | 89 +++++++++++++++++-- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/openai_images_incomplete_test.go b/backend/internal/service/openai_images_incomplete_test.go index 60e441286..6a8ba5622 100644 --- a/backend/internal/service/openai_images_incomplete_test.go +++ b/backend/internal/service/openai_images_incomplete_test.go @@ -121,3 +121,50 @@ func TestImagesOAuthNonStreaming_CompletedNoImageTriggersSameAccountRetry(t *tes t.Fatal("soft-failure should prefer same-account retry (probabilistic upstream failure)") } } + +// 内容审核拒绝(模型未出图但输出文字拒绝)应返回 400 content_policy 错误且不重试, +// 而非可重试的 UpstreamFailoverError。 +func TestImagesOAuthNonStreaming_ContentRefusalReturns400NoRetry(t *testing.T) { + // 上游:completed 无图,但模型输出了文字拒绝(内容审核场景)。 + upstreamSSE := "event: response.created\n" + + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\",\"status\":\"in_progress\",\"model\":\"gpt-5.4-mini\",\"output\":[]}}\n\n" + + "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"抱歉,这个请求因涉及违规内容被安全系统判定为不适合生成。\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"抱歉,这个请求因涉及违规内容被安全系统判定为不适合生成。\"}]}],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(upstreamSSE))} + + svc := &OpenAIGatewayService{} + _, _, _, err := svc.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, "b64_json", "gpt-image-2") + + if err == nil { + t.Fatal("content refusal should return an error") + } + // 应是不可重试的内容策略错误(400),而非 UpstreamFailoverError。 + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) { + t.Fatalf("content refusal must NOT be a retryable failover error, got %v", failoverErr) + } + var imgErr *OpenAIImagesUpstreamError + if !errors.As(err, &imgErr) { + t.Fatalf("expected *OpenAIImagesUpstreamError, got %T: %v", err, err) + } + if imgErr.StatusCode != http.StatusBadRequest { + t.Fatalf("content refusal should be 400, got %d", imgErr.StatusCode) + } + if !strings.Contains(imgErr.Message, "安全系统") && !strings.Contains(imgErr.Message, "违规") { + t.Fatalf("refusal message should carry model's reason, got %q", imgErr.Message) + } +} + +// extractOpenAIImagesModelRefusal:真空响应(无文字)返回空串。 +func TestExtractModelRefusal_EmptyWhenNoText(t *testing.T) { + body := "data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + if r := extractOpenAIImagesModelRefusal([]byte(body)); r != "" { + t.Fatalf("empty response should yield no refusal, got %q", r) + } +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index c98e8008c..226cbf8e8 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -607,6 +607,62 @@ func openAIImagesUpstreamErrorFromSSEPayload(payload []byte) *OpenAIImagesUpstre } } +// extractOpenAIImagesModelRefusal 从上游 SSE 响应体提取「模型未出图、改用文字拒绝」 +// 的拒绝文本(内容审核场景)。 +// +// 上游 response.completed 无图时,模型常以 output_text / message 形式输出拒绝说明 +// (如“被安全系统判定为不适合生成”)。这类失败是内容策略拦截,重试/换账号均无效, +// 应把该文本作为内容策略错误透传给客户端。返回空串表示无文字输出(真空响应)。 +func extractOpenAIImagesModelRefusal(body []byte) string { + var b strings.Builder + collect := func(s string) { + if s = strings.TrimSpace(s); s != "" { + if b.Len() > 0 { + _ = b.WriteByte(' ') + } + _, _ = b.WriteString(s) + } + } + forEachOpenAISSEDataPayload(string(body), func(payload []byte) { + if !gjson.ValidBytes(payload) { + return + } + switch gjson.GetBytes(payload, "type").String() { + case "response.output_text.delta": + // 流式文本增量。 + collect(gjson.GetBytes(payload, "delta").String()) + case "response.completed", "response.output_item.done": + // 终态里的 message/output_text。 + gjson.GetBytes(payload, "response.output").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + collect(part.Get("text").String()) + } + return true + }) + } + return true + }) + if item := gjson.GetBytes(payload, "item"); item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + collect(part.Get("text").String()) + } + return true + }) + } + } + }) + refusal := strings.TrimSpace(b.String()) + // 截断过长文本,避免把整段模型输出塞进错误响应。 + const maxRefusal = 600 + if len(refusal) > maxRefusal { + refusal = refusal[:maxRefusal] + } + return refusal +} + // summarizeOpenAIImagesNoOutputBody 从上游 SSE 响应体提取诊断摘要,用于软失败时 // 记录到 ops 日志(上游无图、无标准错误的场景)。提取最终事件类型、response.status、 // incomplete_details.reason,并附 body 截断片段,便于事后定位上游到底返回了什么。 @@ -1037,14 +1093,31 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( } return OpenAIUsage{}, 0, nil, upstreamErr } - // 软失败兜底:上游既无图、又无任何可识别的 error/failed/incomplete 事件 - // (实测:上游偶发把请求路由到 gpt-5.x-mini,返回 response.completed 但 output 为空、 - // image_gen 工具未执行)。这是上游的概率性失败——同账号有时成功有时失败。 - // 处理:① 记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于排查; - // ② 返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 - // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图), - // 用尽后由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率 - // 又不无谓消耗其他账号配额。 + // 软失败兜底:上游无图。先区分两种情形(实测真因,见下): + // + // (A) 内容审核拒绝:模型未出图,但输出了文字拒绝(response.completed 里带 + // output_text / message,内容如“被安全系统判定为不适合生成”)。这是用户 + // prompt 触发 OpenAI 内容策略,模型主动拒绝改用文字回应。**换账号/重试均无效** + // (内容层拦截,与账号/承载模型无关),应把拒绝理由作为 400 透传给客户端, + // 避免无谓地重试 + 消耗其它账号配额,且让客户端拿到可读的拒绝原因。 + // (B) 真空响应:既无图也无任何文字输出(罕见,如偶发路由到 gpt-5.x-mini、 + // image_gen 工具未执行)。这是上游的概率性失败,此时才按可重试处理。 + if refusal := extractOpenAIImagesModelRefusal(body); refusal != "" { + refusalErr := &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadRequest, + ErrorType: "image_generation_user_error", + Code: "content_policy_violation", + Message: sanitizeUpstreamErrorMessage(refusal), + } + setOpsUpstreamError(c, http.StatusBadRequest, refusalErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(body)) + writeOpenAIImagesUpstreamErrorResponse(c, refusalErr) + return OpenAIUsage{}, 0, nil, refusalErr + } + // (B) 真空响应:记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于 + // 排查,并返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 + // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图),用尽后 + // 由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率又不无谓 + // 消耗其它账号配额。 setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(body)) return OpenAIUsage{}, 0, nil, &UpstreamFailoverError{ StatusCode: http.StatusBadGateway, From ecbe076055be69ff26f8c81200621f82126b1e79 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 23 Jun 2026 18:27:49 +0800 Subject: [PATCH 02/20] fix(keys): add CLAUDE_CODE_ATTRIBUTION_HEADER=0 to Claude Code terminal templates (cherry picked from commit 28e7adef095f5512922a3c45b224dea7e67bd68e) --- frontend/src/components/keys/UseKeyModal.vue | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index be12e8fc2..6123a38a1 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -442,19 +442,22 @@ function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] { path = 'Terminal' content = `export ANTHROPIC_BASE_URL="${baseUrl}" export ANTHROPIC_AUTH_TOKEN="${apiKey}" -export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export CLAUDE_CODE_ATTRIBUTION_HEADER=0` break case 'cmd': path = 'Command Prompt' content = `set ANTHROPIC_BASE_URL=${baseUrl} set ANTHROPIC_AUTH_TOKEN=${apiKey} -set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` +set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +set CLAUDE_CODE_ATTRIBUTION_HEADER=0` break case 'powershell': path = 'PowerShell' content = `$env:ANTHROPIC_BASE_URL="${baseUrl}" $env:ANTHROPIC_AUTH_TOKEN="${apiKey}" -$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` +$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +$env:CLAUDE_CODE_ATTRIBUTION_HEADER=0` break default: path = 'Terminal' From 645740916776a59c1863c901363f6f89eebde17b Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 23 Jun 2026 18:38:52 +0800 Subject: [PATCH 03/20] fix(gateway): enforce codex_cli_only restriction on /v1/chat/completions (cherry picked from commit ae5e980dd12f7f8061ba8644788b156565e704cb) --- .../service/openai_gateway_chat_completions.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a1051afe5..17cba4afa 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,6 +61,19 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + restrictionResult := s.detectCodexClientRestriction(c, account) + logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) + if restrictionResult.Enabled && !restrictionResult.Matched { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "forbidden_error", + "message": "This account only allows Codex official clients", + }, + }) + return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") + } + // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { From dc85d07ac34583605fc1b4786e8260e7197b7e0f Mon Sep 17 00:00:00 2001 From: zichuanwangcloud-gif Date: Tue, 23 Jun 2026 10:46:17 +0000 Subject: [PATCH 04/20] fix(ops): prevent monitoring trend cards from growing unbounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency / switch-rate / throughput cards on the ops dashboard sit in grid cells that only set `min-h-[360px]` (no definite height). Their inner card uses `h-full`, which resolves to `auto` when the parent height is `auto`. Combined with the Chart.js `responsive` + `maintainAspectRatio: false` charts, this forms a height feedback loop: the canvas reads the parent height to size itself, the content then grows, the next ResizeObserver tick reads an even larger height, and the cards stretch downward without bound. On wide screens (`lg:grid-cols-4`) a sibling card usually fixes the row height via `align-items: stretch`, masking the issue. It surfaces when no sibling bounds the row height — e.g. the single-column (`grid-cols-1`) stacked layout on narrow viewports, or when the concurrency card collapses to little content. `min-h` only sets a floor, not a ceiling. Fix: give the two Chart.js canvas cells a definite height (`h-[360px]`) so the responsive resize has a fixed reference and the loop cannot run. The concurrency card is not a responsive canvas, so it keeps `min-h-[360px]` to avoid clipping its content. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 9707dedca2f8c57b7f36a015f8577a403a3e7372) --- frontend/src/views/admin/ops/OpsDashboard.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue index 9c512db6f..8bb42f3b6 100644 --- a/frontend/src/views/admin/ops/OpsDashboard.vue +++ b/frontend/src/views/admin/ops/OpsDashboard.vue @@ -44,7 +44,7 @@
-
+
-
+
Date: Tue, 23 Jun 2026 19:29:17 +0800 Subject: [PATCH 05/20] fix(auth): stop swallowing email auth identity create error via shadowed err (cherry picked from commit 82576e0a389d76d4384d68f8edb508ed8725667c) --- backend/internal/service/auth_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index b7a7fc8de..1d8944e08 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -499,7 +499,7 @@ func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, s } if !existed { - if err := client.AuthIdentity.Create(). + if err = client.AuthIdentity.Create(). SetUserID(user.ID). SetProviderType("email"). SetProviderKey("email"). From 73da15da73124ee84b441a445fcfff8bb9099168 Mon Sep 17 00:00:00 2001 From: hsn Date: Tue, 23 Jun 2026 22:53:20 +0800 Subject: [PATCH 06/20] fix(openai): fail over on chat transport errors (cherry picked from commit 65fa728921f8de8d177c020143f8ead8599b032f) --- .../openai_gateway_chat_completions.go | 13 +---- .../openai_gateway_chat_completions_raw.go | 13 +---- ...ai_upstream_transport_error_handle_test.go | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 17cba4afa..bb461edf5 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -254,18 +254,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") - return nil, fmt.Errorf("upstream request failed: %s", safeErr) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index d03c7b663..eef980128 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -171,18 +171,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") - return nil, fmt.Errorf("upstream request failed: %s", safeErr) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_upstream_transport_error_handle_test.go b/backend/internal/service/openai_upstream_transport_error_handle_test.go index 35bd56a19..3ea8b4318 100644 --- a/backend/internal/service/openai_upstream_transport_error_handle_test.go +++ b/backend/internal/service/openai_upstream_transport_error_handle_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -37,6 +39,21 @@ func newOpenAITransportErrTestContext() (*gin.Context, *httptest.ResponseRecorde return c, rec } +type failingOpenAIHTTPUpstream struct { + err error + calls int +} + +func (u *failingOpenAIHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.calls++ + return nil, u.err +} + +func (u *failingOpenAIHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + u.calls++ + return nil, u.err +} + // A durable proxy/credential failure must (a) temporarily unschedule the account // so it stops being hammered, and (b) return a failover error so the handler // switches to a healthy account instead of writing a hard 502 itself. @@ -159,3 +176,37 @@ func TestHandleOpenAIUpstreamTransportError_DeadlineExceeded_StillFailsOver(t *t var fo *UpstreamFailoverError require.True(t, errors.As(err, &fo), "context.DeadlineExceeded must still return *UpstreamFailoverError") } + +func TestForwardAsRawChatCompletions_TransportErrorFailsOver(t *testing.T) { + repo := &openaiTransportAccountRepoStub{} + upstream := &failingOpenAIHTTPUpstream{ + err: errors.New(`Post "https://opencode.ai/zen/v1/chat/completions": EOF`), + } + svc := &OpenAIGatewayService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + } + account := &Account{ + ID: 81, + Name: "oc-20053", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://opencode.ai/zen/v1"}, + } + c, rec := newOpenAITransportErrTestContext() + body := []byte(`{"model":"deepseek-v4-flash-free","messages":[{"role":"user","content":"hello"}]}`) + + _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + + require.Equal(t, 1, upstream.calls) + var fo *UpstreamFailoverError + require.True(t, errors.As(err, &fo), "transport error must trigger account failover") + require.Equal(t, http.StatusBadGateway, fo.StatusCode) + require.Empty(t, repo.tempUnschedCalls, "plain EOF is transient: fail over but do not evict") + require.Equal(t, 0, rec.Body.Len(), "service must not write a hard 502 before handler can fail over") +} From 83a9bd9042379bcbf34b05f0d481047c98af1a0f Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 24 Jun 2026 23:43:03 +0800 Subject: [PATCH 07/20] fix(admin/usage): populate cache creation/read token breakdown in stats (cherry picked from commit 063454ae9668b2efe12475034186d237013a703a) --- backend/internal/repository/usage_log_repo.go | 4 ++++ .../repository/usage_log_repo_stats_integration_test.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 635685ee6..aa01b173d 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -3489,6 +3489,8 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(actual_cost), 0) as total_actual_cost, COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost, @@ -3519,6 +3521,8 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us &stats.TotalInputTokens, &stats.TotalOutputTokens, &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, &stats.TotalCost, &stats.TotalActualCost, &totalAccountCost, diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go index 09ac2aee1..44052d07c 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -29,6 +29,7 @@ func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { _, err := repo.Create(ctx, &service.UsageLog{ UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3", InputTokens: 2, OutputTokens: 3, + CacheCreationTokens: 4, CacheReadTokens: 5, TotalCost: 0.5, ActualCost: 0.4, CreatedAt: now, InboundEndpoint: &inboundEndpoint, UpstreamEndpoint: &upstreamEndpoint, }) @@ -44,6 +45,9 @@ func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { require.Equal(t, int64(3), stats.TotalRequests) require.Equal(t, int64(6), stats.TotalInputTokens) require.Equal(t, int64(9), stats.TotalOutputTokens) + require.Equal(t, int64(27), stats.TotalCacheTokens) + require.Equal(t, int64(12), stats.TotalCacheCreationTokens) + require.Equal(t, int64(15), stats.TotalCacheReadTokens) require.InDelta(t, 1.2, stats.TotalActualCost, 1e-9) require.NotEmpty(t, stats.Endpoints) require.NotEmpty(t, stats.UpstreamEndpoints) From 9c019c0fcb82b46a0cb220cb08ed3b174914d11d Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 24 Jun 2026 23:59:22 +0800 Subject: [PATCH 08/20] test(admin/usage): update sqlmock rows for cache breakdown columns (cherry picked from commit 4567f6582b5d61b39c715eef739dd32c43f73c80) --- .../repository/usage_log_repo_request_type_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 793dee217..eed93a700 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -389,11 +389,13 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T) "total_input_tokens", "total_output_tokens", "total_cache_tokens", + "total_cache_creation_tokens", + "total_cache_read_tokens", "total_cost", "total_actual_cost", "total_account_cost", "avg_duration_ms", - }).AddRow(int64(1), int64(2), int64(3), int64(4), 1.2, 1.0, 1.2, 20.0)) + }).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) @@ -481,9 +483,10 @@ func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testin mock.ExpectQuery("FROM usage_logs"). WillReturnRows(sqlmock.NewRows([]string{ "total_requests", "total_input_tokens", "total_output_tokens", - "total_cache_tokens", "total_cost", "total_actual_cost", + "total_cache_tokens", "total_cache_creation_tokens", "total_cache_read_tokens", + "total_cost", "total_actual_cost", "total_account_cost", "avg_duration_ms", - }).AddRow(int64(50), int64(1000), int64(2000), int64(100), 15.0, 12.5, 11.0, 100.0)) + }).AddRow(int64(50), int64(1000), int64(2000), int64(100), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0)) mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\)"). WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\)"). From 3d293bc674887249f92e32e517352852a6cf561a Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Thu, 25 Jun 2026 02:32:15 +0800 Subject: [PATCH 09/20] fix: avoid default codex instructions for chat bridge (cherry picked from commit dbdbfb11225d2f036c34287ccc8f6028fe2289ed) --- .../openai_gateway_chat_completions.go | 7 +++- .../openai_gateway_chat_completions_test.go | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index bb461edf5..50135dd0c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -185,7 +185,12 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) } - codexResult := applyCodexOAuthTransform(reqBody, false, false) + codexResult := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + SkipDefaultInstructions: !isResponsesShape, + }) + if !isResponsesShape { + ensureCodexOAuthInstructionsField(reqBody) + } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 071308a57..b9933e757 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -181,6 +181,47 @@ func TestForwardAsChatCompletions_APIKeyPropagatesPromptCacheKeyInResponsesBody( require.Equal(t, generateSessionUUID(isolateOpenAISessionID(99, "cache-key-123")), upstream.lastReq.Header.Get("session_id")) } +func TestForwardAsChatCompletions_OAuthDoesNotInjectDefaultInstructions(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_no_default_instructions"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 3, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4") + require.Error(t, err) + require.Nil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) + require.Equal(t, "", gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.NotContains(t, string(upstream.lastBody), "Communicate with the user by streaming thinking") +} + func TestForwardAsChatCompletions_ClientDisconnectDrainsUpstreamUsage(t *testing.T) { gin.SetMode(gin.TestMode) From e84926cd90dba99ec31c4327110734aede1b72b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AE=80=E7=AE=80aw?= <2801819967@qq.com> Date: Thu, 25 Jun 2026 15:12:21 +0800 Subject: [PATCH 10/20] fix(token-refresh): treat refresh_token_invalidated as non-retryable (cherry picked from commit 0a97a5f461557d26cf89fb21da9f191e96de4da8) --- .../internal/service/token_refresh_service.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index 13bdae326..cf71c3773 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -432,14 +432,15 @@ func isNonRetryableRefreshError(err error) bool { } msg := strings.ToLower(err.Error()) nonRetryable := []string{ - "invalid_grant", // refresh_token 已失效 - "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 - "app_session_terminated", // refresh_token team 账号工作区被删除 - "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 - "invalid_client", // 客户端配置错误 - "unauthorized_client", // 客户端未授权 - "access_denied", // 访问被拒绝 - "missing_project_id", // 缺少 project_id + "invalid_grant", // refresh_token 已失效 + "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 + "app_session_terminated", // refresh_token team 账号工作区被删除 + "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 + "refresh_token_invalidated", // OpenAI session ended; refresh token invalidated + "invalid_client", // 客户端配置错误 + "unauthorized_client", // 客户端未授权 + "access_denied", // 访问被拒绝 + "missing_project_id", // 缺少 project_id "no refresh token available", } for _, needle := range nonRetryable { From bcc91e5d5c7beb1e45c62a6d2a28d49864e331ff Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 25 Jun 2026 16:29:33 +0800 Subject: [PATCH 11/20] fix(antigravity): add project fallback for standard tier (cherry picked from commit 650c50e34bc8754471be629c0689ba69b6e8d682) --- .../service/antigravity_gateway_service.go | 44 ++++- .../antigravity_gateway_service_test.go | 175 ++++++++++++++++++ .../components/account/CreateAccountModal.vue | 22 ++- .../components/account/EditAccountModal.vue | 30 ++- .../__tests__/EditAccountModal.spec.ts | 64 +++++++ .../__tests__/credentialsBuilder.spec.ts | 40 +++- .../components/account/credentialsBuilder.ts | 15 ++ frontend/src/i18n/locales/en.ts | 4 + frontend/src/i18n/locales/zh.ts | 4 + 9 files changed, 389 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 7bdda2e50..aa4cab22d 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -90,6 +90,10 @@ const ( antigravityFallbackSecondsEnv = "GATEWAY_ANTIGRAVITY_FALLBACK_COOLDOWN_SECONDS" ) +const antigravityProjectIDFallbackCredentialKey = "antigravity_project_id" + +var errAntigravityProjectIDRequired = errors.New("该 standard-tier Antigravity 账号需配置 project_id") + // AntigravityAccountSwitchError 账号切换信号 // 当账号限流时间超过阈值时,通知上层切换账号 type AntigravityAccountSwitchError struct { @@ -1029,6 +1033,22 @@ func (s *AntigravityGatewayService) getMappedModel(account *Account, requestedMo return mapAntigravityModel(account, requestedModel) } +func resolveAntigravityProjectID(account *Account) (string, error) { + if account == nil { + return "", errAntigravityProjectIDRequired + } + if projectID := strings.TrimSpace(account.GetCredential("project_id")); projectID != "" { + return projectID, nil + } + if projectID := strings.TrimSpace(account.GetCredential(antigravityProjectIDFallbackCredentialKey)); projectID != "" { + return projectID, nil + } + if projectID := strings.TrimSpace(account.GetExtraString(antigravityProjectIDFallbackCredentialKey)); projectID != "" { + return projectID, nil + } + return "", errAntigravityProjectIDRequired +} + // applyThinkingModelSuffix 根据 thinking 配置调整模型名 // 当映射结果是 claude-sonnet-4-5 且请求开启了 thinking 时,改为 claude-sonnet-4-5-thinking func applyThinkingModelSuffix(mappedModel string, thinkingEnabled bool) string { @@ -1068,8 +1088,10 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account return nil, fmt.Errorf("获取 access_token 失败: %w", err) } - // 获取 project_id(部分账户类型可能没有) - projectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + return nil, err + } // 模型映射 mappedModel := s.getMappedModel(account, modelID) @@ -1326,6 +1348,10 @@ func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model strin if err := json.Unmarshal(originalBody, &request); err != nil { return nil, fmt.Errorf("解析请求体失败: %w", err) } + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return nil, errAntigravityProjectIDRequired + } wrapped := map[string]any{ "project": projectID, @@ -1403,8 +1429,11 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } } - // 获取 project_id(部分账户类型可能没有) - projectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + _ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } // 代理 URL proxyURL := "" @@ -2171,8 +2200,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co } } - // 获取 project_id(部分账户类型可能没有) - projectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + _ = s.writeGoogleError(c, http.StatusBadRequest, err.Error()) + return nil, err + } // 代理 URL proxyURL := "" diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 0fac7a1eb..02c2d85e3 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -185,6 +185,21 @@ func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, a return s.Do(req, proxyURL, accountID, concurrency) } +type recordingInternal500CounterCache struct { + incrementCalls []int64 + resetCalls []int64 +} + +func (c *recordingInternal500CounterCache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) { + c.incrementCalls = append(c.incrementCalls, accountID) + return int64(len(c.incrementCalls)), nil +} + +func (c *recordingInternal500CounterCache) ResetInternal500Count(_ context.Context, accountID int64) error { + c.resetCalls = append(c.resetCalls, accountID) + return nil +} + type antigravitySettingRepoStub struct{} func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -215,6 +230,157 @@ func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) err panic("unexpected Delete call") } +func TestResolveAntigravityProjectID(t *testing.T) { + tests := []struct { + name string + account *Account + want string + wantErr bool + }{ + { + name: "uses onboard project_id first", + account: &Account{Credentials: map[string]any{ + "project_id": " onboard-project ", + antigravityProjectIDFallbackCredentialKey: " configured-project ", + }}, + want: "onboard-project", + }, + { + name: "uses configured credentials fallback", + account: &Account{Credentials: map[string]any{ + antigravityProjectIDFallbackCredentialKey: " configured-project ", + }}, + want: "configured-project", + }, + { + name: "uses configured extra fallback", + account: &Account{Extra: map[string]any{ + antigravityProjectIDFallbackCredentialKey: " extra-project ", + }}, + want: "extra-project", + }, + { + name: "missing project", + account: &Account{Credentials: map[string]any{}}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveAntigravityProjectID(tc.account) + if tc.wantErr { + require.ErrorIs(t, err, errAntigravityProjectIDRequired) + require.Empty(t, got) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + + body, err := json.Marshal(map[string]any{ + "contents": []map[string]any{ + {"role": "user", "parts": []map[string]any{{"text": "hello"}}}, + }, + }) + require.NoError(t, err) + c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body)) + + upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n") + upstream := &queuedHTTPUpstreamStub{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(bytes.NewReader(upstreamBody)), + }, + }, + } + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 101, + Name: "acc-configured-project", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "token", + antigravityProjectIDFallbackCredentialKey: "configured-project", + "model_mapping": map[string]any{ + "gemini-2.5-flash": "gemini-2.5-flash", + }, + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requestBodies, 1) + + var wrapped map[string]any + require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped)) + require.Equal(t, "configured-project", wrapped["project"]) +} + +func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + + body, err := json.Marshal(map[string]any{ + "contents": []map[string]any{ + {"role": "user", "parts": []map[string]any{{"text": "hello"}}}, + }, + }) + require.NoError(t, err) + c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body)) + + upstream := &queuedHTTPUpstreamStub{} + internal500Cache := &recordingInternal500CounterCache{} + svc := &AntigravityGatewayService{ + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + internal500Cache: internal500Cache, + } + + account := &Account{ + ID: 102, + Name: "acc-missing-project", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "token", + "model_mapping": map[string]any{ + "gemini-2.5-flash": "gemini-2.5-flash", + }, + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false) + require.Nil(t, result) + require.ErrorIs(t, err, errAntigravityProjectIDRequired) + require.Equal(t, http.StatusBadRequest, writer.Code) + require.Empty(t, upstream.requestBodies) + require.Empty(t, internal500Cache.incrementCalls) + require.Contains(t, writer.Body.String(), "project_id") + require.NotContains(t, writer.Body.String(), `"project":""`) +} + func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) { gin.SetMode(gin.TestMode) writer := httptest.NewRecorder() @@ -255,6 +421,7 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) { Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, } @@ -313,6 +480,7 @@ func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *tes Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -369,6 +537,7 @@ func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover( Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -423,6 +592,7 @@ func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *tes Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -478,6 +648,7 @@ func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling( Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -623,6 +794,7 @@ func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) { Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ "claude-sonnet-4-5": mappedModel, }, @@ -676,6 +848,7 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ "gemini-2.5-flash": mappedModel, }, @@ -747,6 +920,7 @@ func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignatur Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ originalModel: mappedModel, }, @@ -805,6 +979,7 @@ func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailove Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ originalModel: mappedModel, }, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index cfff36163..1d9a778db 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -774,6 +774,18 @@
+
+ + +

{{ t('admin.accounts.antigravityProjectIdHint') }}

+
+
@@ -3238,7 +3250,10 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' -import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' +import { + applyAntigravityProjectID, + applyInterceptWarmup +} from '@/components/account/credentialsBuilder' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' @@ -3440,6 +3455,7 @@ loadQuotaNotifyGlobal() const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream +const antigravityProjectId = ref('') const upstreamBaseUrl = ref('') // For upstream type: base URL const upstreamApiKey = ref('') // For upstream type: API key const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') @@ -3813,6 +3829,7 @@ watch( antigravityAccountType.value = 'oauth' } else { allowOverages.value = false + antigravityProjectId.value = '' antigravityWhitelistModels.value = [] antigravityModelMappings.value = [] antigravityModelRestrictionMode.value = 'mapping' @@ -4271,6 +4288,7 @@ const resetForm = () => { customBaseUrl.value = '' allowOverages.value = false antigravityAccountType.value = 'oauth' + antigravityProjectId.value = '' upstreamBaseUrl.value = '' upstreamApiKey.value = '' vertexServiceAccountJson.value = '' @@ -5133,6 +5151,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { } const credentials = antigravityOAuth.buildCredentials(tokenInfo) + applyAntigravityProjectID(credentials, antigravityProjectId.value, 'create') // Generate account name with index for batch const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name @@ -5249,6 +5268,7 @@ const handleAntigravityExchange = async (authCode: string) => { if (!tokenInfo) return const credentials = antigravityOAuth.buildCredentials(tokenInfo) + applyAntigravityProjectID(credentials, antigravityProjectId.value, 'create') applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create') // Antigravity 只使用映射模式 const antigravityModelMapping = buildModelMappingObject( diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8dc85d0eb..cabcc0316 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1000,6 +1000,21 @@
+
+ + +

{{ t('admin.accounts.antigravityProjectIdHint') }}

+
+
@@ -2398,7 +2413,10 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' -import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' +import { + applyAntigravityProjectID, + applyInterceptWarmup +} from '@/components/account/credentialsBuilder' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' @@ -2531,6 +2549,7 @@ const autoPause5hDisabled = ref(false) const autoPause7dDisabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages +const antigravityProjectId = ref('') const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const antigravityWhitelistModels = ref([]) const antigravityModelMappings = ref([]) @@ -2940,6 +2959,12 @@ const syncFormFromAccount = (newAccount: Account | null) => { editVertexProjectId.value = '' editVertexClientEmail.value = '' editVertexLocation.value = 'us-central1' + antigravityProjectId.value = + newAccount.platform === 'antigravity' && + newAccount.type === 'oauth' && + typeof credentials?.antigravity_project_id === 'string' + ? credentials.antigravity_project_id.trim() + : '' // Load mixed scheduling setting (only for antigravity accounts) mixedScheduling.value = false @@ -3930,6 +3955,9 @@ const handleSubmit = async () => { const currentCredentials = (updatePayload.credentials as Record) || ((props.account.credentials as Record) || {}) const newCredentials: Record = { ...currentCredentials } + if (props.account.type === 'oauth') { + applyAntigravityProjectID(newCredentials, antigravityProjectId.value, 'edit') + } // 移除旧字段 delete newCredentials.model_whitelist diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index f4865de9e..77c333fb2 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -167,6 +167,31 @@ function buildVertexAccount() { } as any } +function buildAntigravityAccount(projectId = 'configured-project') { + return { + id: 3, + name: 'Antigravity OAuth', + notes: '', + platform: 'antigravity', + type: 'oauth', + credentials: { + antigravity_project_id: projectId, + model_mapping: { + 'gemini-2.5-flash': 'gemini-2.5-flash' + } + }, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + rate_multiplier: 1, + status: 'active', + group_ids: [], + expires_at: null, + auto_pause_on_expired: false + } as any +} + function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { @@ -579,4 +604,43 @@ describe('EditAccountModal', () => { expect(updateAccountMock).not.toHaveBeenCalled() }) + + it('loads and submits Antigravity configured project fallback', async () => { + const account = buildAntigravityAccount('configured-project') + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const input = wrapper.get('[data-testid="antigravity-project-id-input"]') + expect(input.element.value).toBe('configured-project') + + await input.setValue(' updated-project ') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.antigravity_project_id).toBe( + 'updated-project' + ) + }) + + it('clears Antigravity configured project fallback when input is empty', async () => { + const account = buildAntigravityAccount('configured-project') + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const input = wrapper.get('[data-testid="antigravity-project-id-input"]') + + await input.setValue('') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty( + 'antigravity_project_id' + ) + }) }) diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index be2a8d521..665b1732e 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { applyInterceptWarmup } from '../credentialsBuilder' +import { + ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY, + applyAntigravityProjectID, + applyInterceptWarmup +} from '../credentialsBuilder' describe('applyInterceptWarmup', () => { it('create + enabled=true: should set intercept_warmup_requests to true', () => { @@ -44,3 +48,37 @@ describe('applyInterceptWarmup', () => { expect('intercept_warmup_requests' in creds).toBe(false) }) }) + +describe('applyAntigravityProjectID', () => { + it('create + project id: trims and stores configured project fallback', () => { + const creds: Record = { access_token: 'tok' } + applyAntigravityProjectID(creds, ' configured-project ', 'create') + expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project') + }) + + it('create + empty project id: should not add the field', () => { + const creds: Record = { access_token: 'tok' } + applyAntigravityProjectID(creds, ' ', 'create') + expect(ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY in creds).toBe(false) + }) + + it('edit + empty project id: deletes existing fallback', () => { + const creds: Record = { + access_token: 'tok', + [ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]: 'old-project' + } + applyAntigravityProjectID(creds, '', 'edit') + expect(ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY in creds).toBe(false) + }) + + it('does not affect onboard project_id or other credentials', () => { + const creds: Record = { + project_id: 'onboard-project', + model_mapping: { 'gemini-*': 'gemini-2.5-flash' } + } + applyAntigravityProjectID(creds, 'configured-project', 'edit') + expect(creds.project_id).toBe('onboard-project') + expect(creds.model_mapping).toEqual({ 'gemini-*': 'gemini-2.5-flash' }) + expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project') + }) +}) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index b8008e8bf..f13897651 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -9,3 +9,18 @@ export function applyInterceptWarmup( delete credentials.intercept_warmup_requests } } + +export const ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY = 'antigravity_project_id' + +export function applyAntigravityProjectID( + credentials: Record, + projectId: string, + mode: 'create' | 'edit' +): void { + const trimmed = projectId.trim() + if (trimmed) { + credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY] = trimmed + } else if (mode === 'edit') { + delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY] + } +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 20196d472..5829377af 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2532,6 +2532,10 @@ export default { // Home Page upstream: 'Upstream', upstreamDesc: 'Connect via Base URL + API Key' }, + antigravityProjectIdLabel: 'GCP Project ID (optional)', + antigravityProjectIdPlaceholder: 'your-gcp-project-id', + antigravityProjectIdHint: + 'Antigravity standard-tier accounts that do not receive an automatic project_id need a user-owned GCP project.', status: { active: 'Active', inactive: 'Inactive', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8270eba8a..83325b859 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2724,6 +2724,10 @@ export default { // Home Page api_key: 'API Key', cookie: 'Cookie' }, + antigravityProjectIdLabel: 'GCP Project ID(可选)', + antigravityProjectIdPlaceholder: 'your-gcp-project-id', + antigravityProjectIdHint: + 'standard-tier 且未自动返回 project_id 的 Antigravity 账号需要填写用户自带 GCP project。', status: { active: '正常', inactive: '停用', From 271ef4367da1eda5cee48d2d6378854c19956193 Mon Sep 17 00:00:00 2001 From: Elsa Date: Thu, 25 Jun 2026 21:17:13 +0800 Subject: [PATCH 12/20] Detect OpenAI overloaded error codes (cherry picked from commit cc7612bdbd622d4504321ff3af3f4d04e929640a) --- .../service/openai_gateway_service.go | 15 ++++++++ ...nai_gateway_service_codex_cli_only_test.go | 12 ++++++ .../service/openai_gateway_service_test.go | 38 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0d4cadab9..2b1b26173 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1136,6 +1136,21 @@ func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg strin } func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { + if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + return false + } + + hasOpenAIServerOverloadedCode := func(payload []byte) bool { + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + } + return code == "server_is_overloaded" || code == "slow_down" + } + + if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { + return true + } if upstreamStatusCode != http.StatusBadRequest { return false } diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 10d586540..e245e89c0 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -228,6 +228,18 @@ func TestIsOpenAITransientProcessingError(t *testing.T) { []byte(`{"error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`), )) + require.True(t, isOpenAITransientProcessingError( + http.StatusBadRequest, + "", + []byte(`{"error":{"code":"server_is_overloaded","message":"Please retry later.","type":"invalid_request_error"}}`), + )) + + require.True(t, isOpenAITransientProcessingError( + http.StatusServiceUnavailable, + "", + []byte(`{"error":{"code":"slow_down","message":"Please retry later."}}`), + )) + require.True(t, isOpenAITransientProcessingError( http.StatusBadRequest, "", diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 51c528cb8..66cfddea6 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1330,6 +1330,44 @@ func TestOpenAIStreamingResponseFailedBeforeOutputCapacityErrorReturnsFailover(t require.Empty(t, rec.Body.String()) } +func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1"}}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_1","error":{"code":"server_is_overloaded","message":"Please retry later."}}}`, + "", + }, "\n"))), + Header: http.Header{"X-Request-Id": []string{"rid-overloaded-failed"}}, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "model", "model") + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Contains(t, string(failoverErr.ResponseBody), "Please retry later") + require.False(t, c.Writer.Written()) + require.Empty(t, rec.Body.String()) +} + func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{ From 97b758e2a4bee9ff7107350a43ff5e06b16cd34e Mon Sep 17 00:00:00 2001 From: visa2 Date: Fri, 26 Jun 2026 00:04:23 +0800 Subject: [PATCH 13/20] fix(apicompat): avoid doubling tool_call arguments from single-chunk upstreams When converting a Chat Completions stream into Responses events, the first tool_call delta chunk was copied wholesale into stream state (including function.arguments), then the same chunk's arguments were accumulated again by the shared `+=` block. For OpenAI this is harmless because its first tool_call chunk carries empty arguments, but upstreams that pack id+name+arguments into a single chunk (e.g. GLM/Zhipu) end up with doubled arguments such as {"cmd":"ls"}{"cmd":"ls"}. Codex then fails to parse the tool call with "trailing characters", breaking every tool invocation. Reset the copied arguments so the shared accumulator counts them exactly once, keeping the emitted delta and the final done/arguments consistent. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 29122e30514f9af95ed8b7c9f8b6aa5984b0b55f) --- .../chatcompletions_responses_bridge.go | 7 ++++ ...letions_responses_stream_lifecycle_test.go | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f03e0bdce..f0570b58e 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -737,6 +737,13 @@ func ChatCompletionsChunkToResponsesEvents( copyCall.ID = generateItemID() } copyCall.Type = "function" + // Arguments are accumulated by the shared block below so the + // emitted delta and the stored value stay in sync. Some upstreams + // (e.g. GLM/Zhipu) pack id+name+arguments into the first tool_call + // chunk; without this reset the first chunk's arguments would be + // counted twice (once from this copy, once from the += below), + // producing a doubled, invalid JSON like {"a":1}{"a":1}. + copyCall.Function.Arguments = "" state.ToolCalls[idx] = ©Call stored = ©Call itemID := generateItemID() diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go index b71d1ba78..baa4e4b2a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go @@ -179,6 +179,41 @@ func TestStream_ToolCallLifecycleComplete(t *testing.T) { require.True(t, sawItemDone, "function_call output_item.done missing") } +// TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape +// where a single tool_call delta chunk carries id+name+arguments together. +// Earlier code copied the whole tool_call (including arguments) into state and +// then accumulated the same chunk's arguments again, producing a doubled, +// invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing +// ("trailing characters"). +func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`, + `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`, + `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + }) + + var argsDelta strings.Builder + var sawArgsDone, sawItemDone bool + for _, e := range events { + switch e.Type { + case "response.function_call_arguments.delta": + _, _ = argsDelta.WriteString(e.Delta) + case "response.function_call_arguments.done": + sawArgsDone = true + require.Equal(t, `{"cmd":"ls"}`, e.Arguments) + case "response.output_item.done": + if e.Item != nil && e.Item.Type == "function_call" { + sawItemDone = true + require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments) + } + } + } + require.True(t, sawArgsDone, "function_call_arguments.done missing") + require.True(t, sawItemDone, "function_call output_item.done missing") + // Accumulated deltas must equal the final arguments exactly (no duplication). + require.Equal(t, `{"cmd":"ls"}`, argsDelta.String()) +} + // TestStream_SSEWireComplete drives the full stream through SSE encoding and // asserts the function_call events carry complete fields on the wire. func TestStream_SSEWireComplete(t *testing.T) { From efb6cf066c96ec6001ec204225102a3bd07fdbcf Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 26 Jun 2026 09:34:20 +0800 Subject: [PATCH 14/20] fix(lint): gofmt token_refresh_service.go after refresh_token_invalidated addition (cherry picked from commit c9f42e1f77b924289dcfb2011e16e836af004b18) --- .../internal/service/token_refresh_service.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index cf71c3773..96f5f010d 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -432,15 +432,15 @@ func isNonRetryableRefreshError(err error) bool { } msg := strings.ToLower(err.Error()) nonRetryable := []string{ - "invalid_grant", // refresh_token 已失效 - "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 - "app_session_terminated", // refresh_token team 账号工作区被删除 - "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 + "invalid_grant", // refresh_token 已失效 + "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 + "app_session_terminated", // refresh_token team 账号工作区被删除 + "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 "refresh_token_invalidated", // OpenAI session ended; refresh token invalidated - "invalid_client", // 客户端配置错误 - "unauthorized_client", // 客户端未授权 - "access_denied", // 访问被拒绝 - "missing_project_id", // 缺少 project_id + "invalid_client", // 客户端配置错误 + "unauthorized_client", // 客户端未授权 + "access_denied", // 访问被拒绝 + "missing_project_id", // 缺少 project_id "no refresh token available", } for _, needle := range nonRetryable { From ec6bc0ab3934fd0c7510b264f9c0d8940242dc3f Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 26 Jun 2026 11:21:18 +0800 Subject: [PATCH 15/20] fix(ci): stabilize internal fork checks --- .github/workflows/backend-ci.yml | 4 +- .github/workflows/release.yml | 4 +- .github/workflows/security-scan.yml | 4 +- Makefile | 11 +++-- backend/ent/schema/user.go | 2 +- .../handler/auth_oauth_pending_flow.go | 8 ---- .../handler/auth_oauth_test_helpers_test.go | 47 ------------------- backend/internal/handler/auth_oidc_oauth.go | 40 +++++++++++++--- .../repository/github_release_service_test.go | 1 + .../internal/repository/migrations_runner.go | 41 ---------------- .../migrations_runner_checksum_test.go | 16 ------- .../internal/repository/usage_cleanup_repo.go | 2 - .../usage_log_repo_integration_test.go | 14 +++--- .../usage_log_repo_request_type_test.go | 2 +- backend/internal/repository/user_repo.go | 5 -- .../middleware/security_headers_test.go | 3 +- backend/internal/server/routes/admin.go | 3 ++ .../service/auth_email_oauth_auto_test.go | 2 +- backend/internal/service/auth_service.go | 10 ---- .../auth_service_platform_quota_test.go | 3 +- backend/internal/service/gateway_service.go | 3 ++ .../internal/service/ops_settings_models.go | 24 +++++----- backend/internal/service/pricing_service.go | 1 + .../service/setting_service_update_test.go | 2 +- backend/internal/service/settings_view.go | 11 ----- 25 files changed, 79 insertions(+), 184 deletions(-) delete mode 100644 backend/internal/repository/migrations_runner_checksum_test.go diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index a9cf34993..fba34d490 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -35,11 +35,11 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: - version: 9 + version: 9.15.9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version-file: .nvmrc cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - name: Install frontend dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8c5dcfbf..839614743 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,12 +60,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: - version: 9 + version: 9.15.9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version-file: .nvmrc cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index ab3305ab6..2ec5f73c0 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -37,11 +37,11 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v6 with: - version: 9 + version: 9.15.9 - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version-file: .nvmrc cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - name: Install dependencies diff --git a/Makefile b/Makefile index d00d0c4f5..e88dda9d2 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ .PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan FRONTEND_CRITICAL_VITEST := \ - src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ - src/views/auth/__tests__/WechatCallbackView.spec.ts \ - src/views/user/__tests__/PaymentView.spec.ts \ - src/views/user/__tests__/PaymentResultView.spec.ts \ - src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \ + src/components/__tests__/ApiKeyCreate.spec.ts \ + src/components/__tests__/Dashboard.spec.ts \ + src/components/__tests__/LoginForm.spec.ts \ + src/views/auth/__tests__/OidcCallbackView.spec.ts \ + src/views/user/__tests__/UsageView.spec.ts \ + src/views/admin/__tests__/UsersView.spec.ts \ src/views/admin/__tests__/SettingsView.spec.ts # 一键编译前后端 diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index d9a3f4b70..acaa4e20e 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -56,7 +56,7 @@ func (User) Fields() []ent.Field { field.String("username"). MaxLen(100). Default(""), - field.String("notes"). + field.String("notes"). SchemaType(map[string]string{dialect.Postgres: "text"}). Default(""), diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 2081cbe6f..cca6c6c45 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -427,14 +427,6 @@ func cloneOAuthMetadata(values map[string]any) map[string]any { return cloned } -func mergeOAuthMetadata(base map[string]any, overlay map[string]any) map[string]any { - merged := cloneOAuthMetadata(base) - for key, value := range overlay { - merged[key] = value - } - return merged -} - func normalizeAdoptedOAuthDisplayName(value string) string { value = strings.TrimSpace(value) if len([]rune(value)) > 100 { diff --git a/backend/internal/handler/auth_oauth_test_helpers_test.go b/backend/internal/handler/auth_oauth_test_helpers_test.go index 5022ffe8a..e42277610 100644 --- a/backend/internal/handler/auth_oauth_test_helpers_test.go +++ b/backend/internal/handler/auth_oauth_test_helpers_test.go @@ -2,28 +2,11 @@ package handler import ( "net/http" - "net/http/httptest" - "net/url" "testing" "github.com/stretchr/testify/require" ) -func buildEncodedOAuthBindUserCookie(t *testing.T, userID int64, secret string) string { - t.Helper() - value, err := buildOAuthBindUserCookieValue(userID, secret) - require.NoError(t, err) - return value -} - -func encodedCookie(name, value string) *http.Cookie { - return &http.Cookie{ - Name: name, - Value: encodeCookieValue(value), - Path: "/", - } -} - func findCookie(cookies []*http.Cookie, name string) *http.Cookie { for _, cookie := range cookies { if cookie.Name == name { @@ -33,39 +16,9 @@ func findCookie(cookies []*http.Cookie, name string) *http.Cookie { return nil } -func requireCookieCleared(t *testing.T, recorder *httptest.ResponseRecorder, name string) { - t.Helper() - cookie := findCookie(recorder.Result().Cookies(), name) - require.NotNil(t, cookie) - require.Equal(t, -1, cookie.MaxAge) -} - func decodeCookieValueForTest(t *testing.T, value string) string { t.Helper() decoded, err := decodeCookieValue(value) require.NoError(t, err) return decoded } - -func assertOAuthRedirectError(t *testing.T, location string, errorCode string, errorMessage string) { - t.Helper() - values := parseOAuthRedirectFragment(t, location) - require.Equal(t, errorCode, values.Get("error")) - require.Equal(t, errorMessage, values.Get("error_message")) -} - -func parseOAuthRedirectFragment(t *testing.T, location string) url.Values { - t.Helper() - require.NotEmpty(t, location) - - parsed, err := url.Parse(location) - require.NoError(t, err) - - rawValues := parsed.RawQuery - if rawValues == "" { - rawValues = parsed.Fragment - } - values, err := url.ParseQuery(rawValues) - require.NoError(t, err) - return values -} diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 792a8c10a..af4985c35 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" @@ -672,10 +673,7 @@ func (h *AuthHandler) CompleteOIDCOAuthAccountCreation(c *gin.Context) { respondPendingOAuthBindingApplyError(c, err) return } - decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{ - AdoptDisplayName: req.AdoptDisplayName, - AdoptAvatar: req.AdoptAvatar, - }) + decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest(req)) if err != nil { response.ErrorFrom(c, err) return @@ -1113,13 +1111,21 @@ func (k oidcJWK) publicKey() (any, error) { return &rsa.PublicKey{N: n, E: e}, nil case "EC": var curve elliptic.Curve + var ecdhCurve ecdh.Curve + var coordinateSize int switch strings.TrimSpace(k.Crv) { case "P-256": curve = elliptic.P256() + ecdhCurve = ecdh.P256() + coordinateSize = 32 case "P-384": curve = elliptic.P384() + ecdhCurve = ecdh.P384() + coordinateSize = 48 case "P-521": curve = elliptic.P521() + ecdhCurve = ecdh.P521() + coordinateSize = 66 default: return nil, fmt.Errorf("unsupported ec curve: %s", k.Crv) } @@ -1131,8 +1137,12 @@ func (k oidcJWK) publicKey() (any, error) { if err != nil { return nil, fmt.Errorf("decode ec y: %w", err) } - if !curve.IsOnCurve(x, y) { - return nil, errors.New("ec point is not on curve") + point, err := encodeOIDCECDHPoint(x, y, coordinateSize) + if err != nil { + return nil, err + } + if _, err := ecdhCurve.NewPublicKey(point); err != nil { + return nil, fmt.Errorf("invalid ec public key: %w", err) } return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil default: @@ -1151,6 +1161,24 @@ func decodeBase64URLBigInt(raw string) (*big.Int, error) { return new(big.Int).SetBytes(buf), nil } +func encodeOIDCECDHPoint(x, y *big.Int, coordinateSize int) ([]byte, error) { + if x == nil || y == nil { + return nil, errors.New("missing ec coordinate") + } + if x.Sign() < 0 || y.Sign() < 0 { + return nil, errors.New("negative ec coordinate") + } + if len(x.Bytes()) > coordinateSize || len(y.Bytes()) > coordinateSize { + return nil, errors.New("ec coordinate is too large") + } + + point := make([]byte, 1+coordinateSize*2) + point[0] = 4 + x.FillBytes(point[1 : 1+coordinateSize]) + y.FillBytes(point[1+coordinateSize:]) + return point, nil +} + func containsString(values []string, target string) bool { target = strings.TrimSpace(target) for _, v := range values { diff --git a/backend/internal/repository/github_release_service_test.go b/backend/internal/repository/github_release_service_test.go index d375a1930..7948f80c0 100644 --- a/backend/internal/repository/github_release_service_test.go +++ b/backend/internal/repository/github_release_service_test.go @@ -29,6 +29,7 @@ type testTransport struct { func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Rewrite the URL to point to our test server testURL := t.testServerURL + req.URL.Path + // #nosec G704 -- testServerURL is created by httptest.Server in this test. newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body) if err != nil { return nil, err diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index c8841ee3a..5ecce49b5 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -37,14 +37,6 @@ const nonTransactionalMigrationSuffix = "_notx.sql" const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_dedup_key_index_notx.sql" const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key" -type migrationChecksumCompatibilityRule struct { - fileChecksum string - acceptedDBChecksum map[string]struct{} - acceptedChecksums map[string]struct{} -} - -var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibilityRule{} - // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 // // 该函数可以在每次应用启动时安全调用: @@ -136,10 +128,6 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error { if rowErr == nil { // 迁移已应用,验证校验和是否匹配 if existing != checksum { - // 兼容特定历史误改场景(仅白名单规则),其余仍保持严格不可变约束。 - if isMigrationChecksumCompatible(name, existing, checksum) { - continue - } // 校验和不匹配意味着迁移文件在应用后被修改,这是危险的。 // 正确的做法是创建新的迁移文件来进行变更。 return fmt.Errorf( @@ -257,35 +245,6 @@ func indexIsInvalid(ctx context.Context, db *sql.DB, indexName string) (bool, er return invalid, err } -func checksumSet(values ...string) map[string]struct{} { - out := make(map[string]struct{}, len(values)) - for _, value := range values { - out[value] = struct{}{} - } - return out -} - -func newMigrationChecksumCompatibilityRule(fileChecksum string, acceptedDBChecksums ...string) migrationChecksumCompatibilityRule { - return migrationChecksumCompatibilityRule{ - fileChecksum: fileChecksum, - acceptedDBChecksum: checksumSet(acceptedDBChecksums...), - acceptedChecksums: checksumSet(append([]string{fileChecksum}, acceptedDBChecksums...)...), - } -} - -func isMigrationChecksumCompatible(name, dbChecksum, fileChecksum string) bool { - rule, ok := migrationChecksumCompatibilityRules[name] - if !ok { - return false - } - _, dbOK := rule.acceptedChecksums[dbChecksum] - if !dbOK { - return false - } - _, fileOK := rule.acceptedChecksums[fileChecksum] - return fileOK -} - func validateMigrationExecutionMode(name, content string) (bool, error) { normalizedName := strings.ToLower(strings.TrimSpace(name)) upperContent := strings.ToUpper(content) diff --git a/backend/internal/repository/migrations_runner_checksum_test.go b/backend/internal/repository/migrations_runner_checksum_test.go deleted file mode 100644 index 1f32836fc..000000000 --- a/backend/internal/repository/migrations_runner_checksum_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package repository - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIsMigrationChecksumCompatible_InternalBaselineHasNoLegacyExceptions(t *testing.T) { - require.Empty(t, migrationChecksumCompatibilityRules) - require.False(t, isMigrationChecksumCompatible( - "001_internal_core_baseline.sql", - "db-checksum", - "file-checksum", - )) -} diff --git a/backend/internal/repository/usage_cleanup_repo.go b/backend/internal/repository/usage_cleanup_repo.go index 737914060..d80e37cc7 100644 --- a/backend/internal/repository/usage_cleanup_repo.go +++ b/backend/internal/repository/usage_cleanup_repo.go @@ -366,11 +366,9 @@ func buildUsageCleanupWhere(filters service.UsageCleanupFilters) (string, []any) condition, conditionArgs := buildRequestTypeFilterCondition(idx, *filters.RequestType) conditions = append(conditions, condition) args = append(args, conditionArgs...) - idx += len(conditionArgs) } else if filters.Stream != nil { conditions = append(conditions, fmt.Sprintf("stream = $%d", idx)) args = append(args, *filters.Stream) - idx++ } return strings.Join(conditions, " AND "), args } diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index ed3050d89..34221b482 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -1369,17 +1369,17 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters() { endTime := base.Add(48 * time.Hour) // Test with user filter - trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, 0, 0, 0, "", nil, nil, nil) + trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, 0, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters user filter") s.Require().Len(trend, 2) // Test with apiKey filter - trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", 0, apiKey.ID, 0, 0, "", nil, nil, nil) + trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", 0, apiKey.ID, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters apiKey filter") s.Require().Len(trend, 2) // Test with both filters - trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, apiKey.ID, 0, 0, "", nil, nil, nil) + trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, apiKey.ID, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters both filters") s.Require().Len(trend, 2) } @@ -1396,7 +1396,7 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters_HourlyGranularity() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(3 * time.Hour) - trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "hour", user.ID, 0, 0, 0, "", nil, nil, nil) + trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "hour", user.ID, 0, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters hourly") s.Require().Len(trend, 2) } @@ -1442,17 +1442,17 @@ func (s *UsageLogRepoSuite) TestGetModelStatsWithFilters() { endTime := base.Add(2 * time.Hour) // Test with user filter - stats, err := s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, user.ID, 0, 0, 0, nil, nil, nil) + stats, err := s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, user.ID, 0, 0, 0, nil, nil) s.Require().NoError(err, "GetModelStatsWithFilters user filter") s.Require().Len(stats, 2) // Test with apiKey filter - stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, apiKey.ID, 0, 0, nil, nil, nil) + stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, apiKey.ID, 0, 0, nil, nil) s.Require().NoError(err, "GetModelStatsWithFilters apiKey filter") s.Require().Len(stats, 2) // Test with account filter - stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, 0, account.ID, 0, nil, nil, nil) + stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, 0, account.ID, 0, nil, nil) s.Require().NoError(err, "GetModelStatsWithFilters account filter") s.Require().Len(stats, 2) } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index eed93a700..65e2b03ea 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -584,7 +584,7 @@ func (s usageLogScannerStub) Scan(dest ...any) error { } for i := range dest { dv := reflect.ValueOf(dest[i]) - if dv.Kind() != reflect.Ptr { + if dv.Kind() != reflect.Pointer { return fmt.Errorf("dest[%d] is not pointer", i) } dv.Elem().Set(reflect.ValueOf(s.values[i])) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index ad30dc3f0..b86bb0e4a 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -928,11 +928,6 @@ func userSignupSourceOrDefault(signupSource string) string { } } -// marshalExtraEmails serializes notify email entries to JSON for storage. -func marshalExtraEmails(entries []service.NotifyEmailEntry) string { - return service.MarshalNotifyEmails(entries) -} - // UpdateTotpSecret 更新用户的 TOTP 加密密钥 func (r *userRepository) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { client := clientFromContext(ctx, r.client) diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index 701630be3..657a4a829 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -331,8 +331,7 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Contains(t, enhanced, "'nonce-existing'") }) - } - +} func TestAddToDirective(t *testing.T) { t.Run("adds_to_existing_directive", func(t *testing.T) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 94b4344be..a15c2f639 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -68,6 +68,9 @@ func RegisterAdminRoutes( // 错误透传规则管理 registerErrorPassthroughRoutes(admin, h) + // 风控管理 + registerContentModerationRoutes(admin, h) + // TLS 指纹模板管理 registerTLSFingerprintProfileRoutes(admin, h) diff --git a/backend/internal/service/auth_email_oauth_auto_test.go b/backend/internal/service/auth_email_oauth_auto_test.go index 9f414644d..65e5d0834 100644 --- a/backend/internal/service/auth_email_oauth_auto_test.go +++ b/backend/internal/service/auth_email_oauth_auto_test.go @@ -49,7 +49,7 @@ func TestEmailOAuthAuto_SnapshotsPlatformQuotaDefaults(t *testing.T) { svc := newEmailOAuthAutoAuthService( userRepo, map[string]string{ - SettingKey SettingKeyDefaultPlatformQuotas: `{"gemini": {"monthly": 100.0}}`, + SettingKeyDefaultPlatformQuotas: `{"gemini": {"monthly": 100.0}}`, }, quotaRepo, ) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 1d8944e08..668b44c1f 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -538,16 +538,6 @@ func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, s return identity, !existed } -func inferLegacySignupSource(email string) string { - normalized := strings.ToLower(strings.TrimSpace(email)) - switch { - case strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain): - return "oidc" - default: - return "email" - } -} - func (s *AuthService) validateAccountCreationEmailPolicy(ctx context.Context, email string) error { if s.settingService == nil { return nil diff --git a/backend/internal/service/auth_service_platform_quota_test.go b/backend/internal/service/auth_service_platform_quota_test.go index 9f1ebf5f6..b20bf8fcf 100644 --- a/backend/internal/service/auth_service_platform_quota_test.go +++ b/backend/internal/service/auth_service_platform_quota_test.go @@ -118,7 +118,7 @@ func TestSnapshotPlatformQuotaDefaults_NilRepoIsNoop(t *testing.T) { func TestResolveSignupGrantPlan_GlobalQuotaLoadedBeforeAuthSource(t *testing.T) { // 全局 quota JSON key(新格式) settings := map[string]string{ - SettingKey SettingKeyDefaultPlatformQuotas: `{ + SettingKeyDefaultPlatformQuotas: `{ "anthropic": {"daily": 10, "weekly": 50, "monthly": 200}, "openai": {"daily": 5, "weekly": 25, "monthly": 100}, "gemini": {"daily": 5, "weekly": 25, "monthly": 100}, @@ -143,7 +143,6 @@ func TestResolveSignupGrantPlan_GlobalQuotaLoadedBeforeAuthSource(t *testing.T) // !enabled 早退路径仍携带全局 quota(GetDefaultPlatformQuotas 在 ResolveAuthSourceGrantSettings 之前)。 func TestResolveSignupGrantPlan_DisabledAuthSourceStillCarriesGlobalQuota(t *testing.T) { settings := map[string]string{ - SettingKey // auth source 不配置(=> !enabled 路径) SettingKeyDefaultPlatformQuotas: `{"anthropic": {"daily": 10, "weekly": 50, "monthly": 200}}`, } svc := newAuthService(nil, settings, nil, nil) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index de7f34930..62f8dc112 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -10430,18 +10430,21 @@ func (s *GatewayService) initDebugGatewayBodyFile(path string) { } // 如果 path 指向一个已存在的目录,自动追加默认文件名 + // #nosec G703 -- path comes from operator-controlled debug configuration. if info, err := os.Stat(path); err == nil && info.IsDir() { path = filepath.Join(path, debugGatewayBodyDefaultFilename) } // 确保父目录存在 if dir := filepath.Dir(path); dir != "." { + // #nosec G703 -- dir is derived from operator-controlled debug configuration. if err := os.MkdirAll(dir, 0755); err != nil { slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err) return } } + // #nosec G703 -- path comes from operator-controlled debug configuration. f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { slog.Error("failed to open gateway debug log file", "path", path, "error", err) diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index d4c5b0f47..ac2198ca7 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -92,18 +92,18 @@ type OpsAlertRuntimeSettings struct { // OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation). type OpsAdvancedSettings struct { - DataRetention OpsDataRetentionSettings `json:"data_retention"` - Aggregation OpsAggregationSettings `json:"aggregation"` - OpenAIAccountQuotaAutoPause OpsOpenAIAccountQuotaAutoPauseSettings `json:"openai_account_quota_auto_pause"` - IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"` - IgnoreContextCanceled bool `json:"ignore_context_canceled"` - IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"` - IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"` - IgnoreUpstreamCreditErrors bool `json:"ignore_upstream_credit_errors"` - DisplayOpenAITokenStats bool `json:"display_openai_token_stats"` - DisplayAlertEvents bool `json:"display_alert_events"` - AutoRefreshEnabled bool `json:"auto_refresh_enabled"` - AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` + DataRetention OpsDataRetentionSettings `json:"data_retention"` + Aggregation OpsAggregationSettings `json:"aggregation"` + OpenAIAccountQuotaAutoPause OpsOpenAIAccountQuotaAutoPauseSettings `json:"openai_account_quota_auto_pause"` + IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"` + IgnoreContextCanceled bool `json:"ignore_context_canceled"` + IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"` + IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"` + IgnoreUpstreamCreditErrors bool `json:"ignore_upstream_credit_errors"` + DisplayOpenAITokenStats bool `json:"display_openai_token_stats"` + DisplayAlertEvents bool `json:"display_alert_events"` + AutoRefreshEnabled bool `json:"auto_refresh_enabled"` + AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` } type OpsOpenAIAccountQuotaAutoPauseSettings struct { diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index bd0c30df4..ad6abfa7e 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -479,6 +479,7 @@ func (s *PricingService) useFallbackPricing() error { } pricingFile := s.getPricingFilePath() + // #nosec G703 -- pricingFile is derived from operator-controlled service configuration. if err := os.WriteFile(pricingFile, data, 0644); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err) } diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 5d43ce46c..fa47e0f90 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -102,7 +102,7 @@ func TestSettingService_UpdateSettings_AccountCreationEmailSuffixWhitelist_Inval AccountCreationEmailSuffixWhitelist: []string{"@invalid_domain"}, }) require.Error(t, err) - require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err)) + require.Equal(t, "INVALID_ACCOUNT_CREATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err)) } func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a9be8a84d..a900e02d7 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -1,16 +1,5 @@ package service -import "strings" - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - type SystemSettings struct { EmailVerifyEnabled bool AccountCreationEmailSuffixWhitelist []string From 112cbf4bc0df778c0af4ecd2ee06b879bc3f35cf Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 26 Jun 2026 11:38:00 +0800 Subject: [PATCH 16/20] fix(ci): align internal baseline with repositories --- backend/internal/repository/usage_log_repo.go | 31 ++++++++++++------- .../user_repo_sort_integration_test.go | 5 +-- .../migrations/001_internal_core_baseline.sql | 21 +++++++++---- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index aa01b173d..8f94f54ae 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -377,7 +377,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -407,7 +408,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -816,7 +817,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -894,7 +896,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -943,7 +946,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1032,7 +1036,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1107,7 +1112,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1156,7 +1162,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1213,7 +1220,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1243,7 +1251,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1321,7 +1329,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { log.TotalCost, log.ActualCost, rateMultiplier, - log.AccountRateMultiplier, requestType, + log.AccountRateMultiplier, + requestType, log.Stream, log.OpenAIWSMode, duration, diff --git a/backend/internal/repository/user_repo_sort_integration_test.go b/backend/internal/repository/user_repo_sort_integration_test.go index 05f10641f..9125a767a 100644 --- a/backend/internal/repository/user_repo_sort_integration_test.go +++ b/backend/internal/repository/user_repo_sort_integration_test.go @@ -18,11 +18,12 @@ func (s *UserRepoSuite) mustInsertUsageLog(userID int64, createdAt time.Time) { _, err := integrationDB.ExecContext( s.ctx, - `INSERT INTO usage_logs (user_id, api_key_id, account_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at) - VALUES ($1, $2, $3, 'gpt-test', 1, 1, 0.01, 0.01, $4)`, + `INSERT INTO usage_logs (user_id, api_key_id, account_id, request_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at) + VALUES ($1, $2, $3, $4, 'gpt-test', 1, 1, 0.01, 0.01, $5)`, userID, apiKey.ID, account.ID, + "user-sort-"+createdAt.UTC().Format(time.RFC3339Nano), createdAt.UTC(), ) s.Require().NoError(err) diff --git a/backend/migrations/001_internal_core_baseline.sql b/backend/migrations/001_internal_core_baseline.sql index cde962b4e..2c372bb84 100644 --- a/backend/migrations/001_internal_core_baseline.sql +++ b/backend/migrations/001_internal_core_baseline.sql @@ -261,7 +261,7 @@ CREATE INDEX IF NOT EXISTS pending_auth_sessions_completion_code_hash ON pending CREATE TABLE IF NOT EXISTS identity_adoption_decisions ( id BIGSERIAL PRIMARY KEY, pending_auth_session_id BIGINT NOT NULL UNIQUE REFERENCES pending_auth_sessions(id) ON DELETE CASCADE, - identity_id BIGINT NOT NULL REFERENCES auth_identities(id) ON DELETE CASCADE, + identity_id BIGINT REFERENCES auth_identities(id) ON DELETE CASCADE, adopt_display_name BOOLEAN NOT NULL DEFAULT FALSE, adopt_avatar BOOLEAN NOT NULL DEFAULT FALSE, decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -332,14 +332,23 @@ CREATE INDEX IF NOT EXISTS user_attribute_values_value ON user_attribute_values CREATE TABLE IF NOT EXISTS user_platform_quotas ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - platform VARCHAR(50) NOT NULL, - used NUMERIC(20,8) NOT NULL DEFAULT 0, - limit_value NUMERIC(20,8) NOT NULL DEFAULT 0, - window_start TIMESTAMPTZ, + platform VARCHAR(32) NOT NULL, + daily_limit_usd NUMERIC(20,10), + weekly_limit_usd NUMERIC(20,10), + monthly_limit_usd NUMERIC(20,10), + daily_usage_usd NUMERIC(20,10) NOT NULL DEFAULT 0, + weekly_usage_usd NUMERIC(20,10) NOT NULL DEFAULT 0, + monthly_usage_usd NUMERIC(20,10) NOT NULL DEFAULT 0, + daily_window_start TIMESTAMPTZ, + weekly_window_start TIMESTAMPTZ, + monthly_window_start TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, platform) + deleted_at TIMESTAMPTZ ); +CREATE UNIQUE INDEX IF NOT EXISTS user_platform_quotas_user_id_platform_active_key + ON user_platform_quotas (user_id, platform) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS user_platform_quotas_user_id ON user_platform_quotas (user_id); CREATE INDEX IF NOT EXISTS user_platform_quotas_platform ON user_platform_quotas (platform); CREATE TABLE IF NOT EXISTS user_group_rate_multipliers ( From d80297c0432158dd3b505c52e61e95c8776beefc Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 26 Jun 2026 11:46:47 +0800 Subject: [PATCH 17/20] fix(ci): restore baseline conflict indexes --- backend/migrations/001_internal_core_baseline.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/migrations/001_internal_core_baseline.sql b/backend/migrations/001_internal_core_baseline.sql index 2c372bb84..c8b7f51d1 100644 --- a/backend/migrations/001_internal_core_baseline.sql +++ b/backend/migrations/001_internal_core_baseline.sql @@ -424,7 +424,7 @@ CREATE INDEX IF NOT EXISTS usage_logs_request_id ON usage_logs (request_id); CREATE INDEX IF NOT EXISTS usage_logs_user_created_at ON usage_logs (user_id, created_at); CREATE INDEX IF NOT EXISTS usage_logs_api_key_created_at ON usage_logs (api_key_id, created_at); CREATE INDEX IF NOT EXISTS usage_logs_group_created_at ON usage_logs (group_id, created_at) WHERE group_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_usage_logs_request_api_key ON usage_logs (request_id, api_key_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_logs_request_api_key ON usage_logs (request_id, api_key_id); CREATE INDEX IF NOT EXISTS idx_usage_logs_upstream_model ON usage_logs (upstream_model); CREATE INDEX IF NOT EXISTS idx_usage_logs_requested_model ON usage_logs (requested_model); CREATE INDEX IF NOT EXISTS idx_usage_logs_inbound_endpoint ON usage_logs (inbound_endpoint); @@ -462,7 +462,7 @@ CREATE TABLE IF NOT EXISTS scheduler_outbox ( CREATE INDEX IF NOT EXISTS idx_scheduler_outbox_created_at ON scheduler_outbox (created_at); CREATE UNIQUE INDEX IF NOT EXISTS idx_scheduler_outbox_pending_dedup_key ON scheduler_outbox (dedup_key) - WHERE dedup_key IS NOT NULL AND delivered_at IS NULL; + WHERE dedup_key IS NOT NULL; CREATE TABLE IF NOT EXISTS channels ( id BIGSERIAL PRIMARY KEY, From 36e23694460402ba1c10471c3c7f9b061b700dac Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 26 Jun 2026 11:55:34 +0800 Subject: [PATCH 18/20] fix(ci): complete internal baseline schema contracts --- .../migrations/001_internal_core_baseline.sql | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/backend/migrations/001_internal_core_baseline.sql b/backend/migrations/001_internal_core_baseline.sql index c8b7f51d1..383a14dce 100644 --- a/backend/migrations/001_internal_core_baseline.sql +++ b/backend/migrations/001_internal_core_baseline.sql @@ -170,8 +170,8 @@ CREATE TABLE IF NOT EXISTS api_keys ( group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', last_used_at TIMESTAMPTZ, - ip_whitelist JSONB NOT NULL DEFAULT '[]'::jsonb, - ip_blacklist JSONB NOT NULL DEFAULT '[]'::jsonb, + ip_whitelist JSONB DEFAULT '[]'::jsonb, + ip_blacklist JSONB DEFAULT '[]'::jsonb, quota NUMERIC(20,8) NOT NULL DEFAULT 0, quota_used NUMERIC(20,8) NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ, @@ -367,7 +367,7 @@ CREATE TABLE IF NOT EXISTS usage_logs ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, api_key_id BIGINT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - request_id VARCHAR(255) NOT NULL, + request_id VARCHAR(255), model VARCHAR(100) NOT NULL, requested_model VARCHAR(100), upstream_model VARCHAR(100), @@ -405,7 +405,7 @@ CREATE TABLE IF NOT EXISTS usage_logs ( image_input_size VARCHAR(32), image_output_size VARCHAR(32), image_size_source VARCHAR(16), - image_size_breakdown JSONB NOT NULL DEFAULT '{}'::jsonb, + image_size_breakdown JSONB DEFAULT '{}'::jsonb, service_tier TEXT, reasoning_effort TEXT, inbound_endpoint TEXT, @@ -1050,18 +1050,29 @@ CREATE INDEX IF NOT EXISTS usage_cleanup_tasks_created_at ON usage_cleanup_tasks CREATE TABLE IF NOT EXISTS scheduled_test_plans ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, + account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + model_id VARCHAR(200) NOT NULL, + cron_expression VARCHAR(100) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, - config JSONB NOT NULL DEFAULT '{}'::jsonb, + max_results INT NOT NULL DEFAULT 50, + auto_recover BOOLEAN NOT NULL DEFAULT FALSE, + last_run_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS scheduled_test_plans_account_id ON scheduled_test_plans (account_id); +CREATE INDEX IF NOT EXISTS scheduled_test_plans_due ON scheduled_test_plans (enabled, next_run_at); CREATE TABLE IF NOT EXISTS scheduled_test_results ( id BIGSERIAL PRIMARY KEY, plan_id BIGINT REFERENCES scheduled_test_plans(id) ON DELETE CASCADE, status VARCHAR(20) NOT NULL, - result JSONB NOT NULL DEFAULT '{}'::jsonb, + response_text TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + latency_ms BIGINT NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS scheduled_test_results_plan_created ON scheduled_test_results (plan_id, created_at DESC); From e006d4d1ee8d9fc161def450c7d9ed88fce6ab53 Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 26 Jun 2026 12:06:56 +0800 Subject: [PATCH 19/20] fix(ci): align internal baseline schema --- .../migrations/001_internal_core_baseline.sql | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/backend/migrations/001_internal_core_baseline.sql b/backend/migrations/001_internal_core_baseline.sql index 383a14dce..2d85c1f54 100644 --- a/backend/migrations/001_internal_core_baseline.sql +++ b/backend/migrations/001_internal_core_baseline.sql @@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS groups ( claude_code_only BOOLEAN NOT NULL DEFAULT FALSE, fallback_group_id BIGINT, fallback_group_id_on_invalid_request BIGINT, - model_routing JSONB NOT NULL DEFAULT '{}'::jsonb, + model_routing JSONB DEFAULT '{}'::jsonb, model_routing_enabled BOOLEAN NOT NULL DEFAULT FALSE, mcp_xml_inject BOOLEAN NOT NULL DEFAULT TRUE, supported_model_scopes JSONB NOT NULL DEFAULT '["claude","gemini_text","gemini_image"]'::jsonb, @@ -77,6 +77,21 @@ CREATE INDEX IF NOT EXISTS groups_platform ON groups (platform); CREATE INDEX IF NOT EXISTS groups_is_exclusive ON groups (is_exclusive); CREATE INDEX IF NOT EXISTS groups_sort_order ON groups (sort_order); CREATE INDEX IF NOT EXISTS groups_deleted_at ON groups (deleted_at); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'anthropic-default', 'Auto-created default group', 'anthropic', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'anthropic-default' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'openai-default', 'Auto-created default group', 'openai', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'openai-default' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'gemini-default', 'Auto-created default group', 'gemini', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'gemini-default' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'antigravity-default-1', 'Auto-created default group', 'antigravity', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'antigravity-default-1' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'antigravity-default-2', 'Auto-created default group', 'antigravity', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'antigravity-default-2' AND deleted_at IS NULL); CREATE TABLE IF NOT EXISTS proxies ( id BIGSERIAL PRIMARY KEY, @@ -722,6 +737,8 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( client_ip INET, platform VARCHAR(32), model VARCHAR(100), + requested_model VARCHAR(100), + upstream_model VARCHAR(100), request_path VARCHAR(256), inbound_endpoint TEXT, upstream_endpoint TEXT, @@ -743,6 +760,7 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( upstream_status_code INT, upstream_error_message TEXT, upstream_error_detail TEXT, + upstream_errors JSONB, provider_error_code VARCHAR(64), provider_error_type VARCHAR(64), network_error_type VARCHAR(50), @@ -762,6 +780,10 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( attempted_key_prefix VARCHAR(32), deleted_key_owner_user_id BIGINT, deleted_key_name VARCHAR(100), + api_key_prefix VARCHAR(32), + resolved BOOLEAN NOT NULL DEFAULT FALSE, + resolved_at TIMESTAMPTZ, + resolved_by_user_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_ops_error_logs_created_at ON ops_error_logs (created_at DESC); @@ -773,6 +795,7 @@ CREATE INDEX IF NOT EXISTS idx_ops_error_logs_phase_time ON ops_error_logs (erro CREATE INDEX IF NOT EXISTS idx_ops_error_logs_type_time ON ops_error_logs (error_type, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ops_error_logs_request_id ON ops_error_logs (request_id); CREATE INDEX IF NOT EXISTS idx_ops_error_logs_client_request_id ON ops_error_logs (client_request_id); +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_resolved_time ON ops_error_logs (resolved, created_at DESC); CREATE TABLE IF NOT EXISTS ops_system_metrics ( id BIGSERIAL PRIMARY KEY, @@ -990,19 +1013,21 @@ CREATE INDEX IF NOT EXISTS deletedapikeyaudit_user_id ON deleted_api_key_audits CREATE TABLE IF NOT EXISTS idempotency_records ( id BIGSERIAL PRIMARY KEY, - scope VARCHAR(64) NOT NULL, - key VARCHAR(255) NOT NULL, - request_hash VARCHAR(64) NOT NULL, - status VARCHAR(20) NOT NULL, + scope VARCHAR(128) NOT NULL, + idempotency_key_hash VARCHAR(64) NOT NULL, + request_fingerprint VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, response_status INT, response_body TEXT, + error_reason VARCHAR(128), locked_until TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (scope, key) + UNIQUE (scope, idempotency_key_hash) ); CREATE INDEX IF NOT EXISTS idempotency_records_expires_at ON idempotency_records (expires_at); +CREATE INDEX IF NOT EXISTS idempotency_records_status_locked_until ON idempotency_records (status, locked_until); CREATE TABLE IF NOT EXISTS error_passthrough_rules ( id BIGSERIAL PRIMARY KEY, From 08f458cfb0ed23bb837729917e9036c6f411fc50 Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 26 Jun 2026 12:16:30 +0800 Subject: [PATCH 20/20] fix(ci): tolerate seeded default groups --- ...le_mode_default_groups_integration_test.go | 27 ++++++++++++------- .../migrations/001_internal_core_baseline.sql | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/backend/internal/repository/simple_mode_default_groups_integration_test.go b/backend/internal/repository/simple_mode_default_groups_integration_test.go index 90417169a..0894bc9c7 100644 --- a/backend/internal/repository/simple_mode_default_groups_integration_test.go +++ b/backend/internal/repository/simple_mode_default_groups_integration_test.go @@ -43,18 +43,25 @@ func TestEnsureSimpleModeDefaultGroups_IgnoresSoftDeletedGroups(t *testing.T) { seedCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - // Create and then soft-delete an anthropic default group. - g, err := client.Group.Create(). - SetName(service.PlatformAnthropic + "-default"). - SetPlatform(service.PlatformAnthropic). - SetStatus(service.StatusActive). - SetRateMultiplier(1.0). - SetIsExclusive(false). - Save(seedCtx) + // Seed baseline may already provide this default group. Soft-delete the + // active default first, or create one if the test database starts empty. + deleted, err := client.Group.Delete(). + Where(group.NameEQ(service.PlatformAnthropic+"-default"), group.DeletedAtIsNil()). + Exec(seedCtx) require.NoError(t, err) + if deleted == 0 { + g, err := client.Group.Create(). + SetName(service.PlatformAnthropic + "-default"). + SetPlatform(service.PlatformAnthropic). + SetStatus(service.StatusActive). + SetRateMultiplier(1.0). + SetIsExclusive(false). + Save(seedCtx) + require.NoError(t, err) - _, err = client.Group.Delete().Where(group.IDEQ(g.ID)).Exec(seedCtx) - require.NoError(t, err) + _, err = client.Group.Delete().Where(group.IDEQ(g.ID)).Exec(seedCtx) + require.NoError(t, err) + } require.NoError(t, ensureSimpleModeDefaultGroups(seedCtx, client)) diff --git a/backend/migrations/001_internal_core_baseline.sql b/backend/migrations/001_internal_core_baseline.sql index 2d85c1f54..5e3bfbb22 100644 --- a/backend/migrations/001_internal_core_baseline.sql +++ b/backend/migrations/001_internal_core_baseline.sql @@ -742,7 +742,7 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( request_path VARCHAR(256), inbound_endpoint TEXT, upstream_endpoint TEXT, - request_type SMALLINT NOT NULL DEFAULT 0, + request_type SMALLINT, stream BOOLEAN NOT NULL DEFAULT FALSE, openai_ws_mode BOOLEAN NOT NULL DEFAULT FALSE, user_agent TEXT,